From f655adfbea18a9bbd38084e5cee42dd9638de230 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Sun, 17 May 2026 00:17:20 +0200 Subject: [PATCH] feat: add local-first private AI digest workflow Migrate app code into canonical feature slices, add phone-only AI digest scheduling and review, wire local notification/background task support, and cover the flow with tests. --- android/app/src/main/AndroidManifest.xml | 22 +- docs/SETUP.md | 32 + ios/Podfile | 6 +- ios/Podfile.lock | 34 +- ios/Runner.xcodeproj/project.pbxproj | 416 ++- ios/Runner/AppDelegate.swift | 5 + ios/Runner/Info.plist | 24 + ios/Runner/Runner.entitlements | 10 + .../Base.lproj/MainInterface.storyboard | 24 + ios/Share Extension/Info.plist | 38 + .../Share Extension.entitlements | 10 + ios/Share Extension/ShareViewController.swift | 3 + lib/README.md | 11 + lib/app/README.md | 12 + lib/app/data/README.md | 15 + lib/app/data/relationship_repository.dart | 176 + .../relationship_repository_activity.dart | 667 ++++ .../relationship_repository_ai_digest.dart | 161 + .../data/relationship_repository_people.dart | 523 +++ .../data/relationship_repository_share.dart | 883 +++++ .../data/relationship_repository_sync.dart | 486 +++ lib/app/data/storage/README.md | 7 + lib/app/data/storage/local_data_store.dart | 19 + .../data/storage/local_data_store_hive.dart | 54 + .../storage/local_data_store_in_memory.dart | 22 + .../storage/local_data_store_provider.dart | 14 + .../local_data_store_shared_prefs.dart | 44 + lib/app/navigation/README.md | 6 + lib/app/navigation/app_destination.dart | 22 + lib/app/presentation/README.md | 7 + lib/app/presentation/app_shell.dart | 381 ++ lib/app/relationship_saver_app.dart | 63 + lib/app/state/README.md | 7 + lib/app/state/local_data_state.dart | 380 ++ lib/core/README.md | 11 + lib/core/auth/README.md | 4 + lib/core/config/README.md | 4 + lib/core/config/app_config.dart | 8 + lib/core/llm/captured_fact_draft_parser.dart | 73 + lib/core/llm/llm_config.dart | 107 + lib/core/llm/llm_service.dart | 328 ++ lib/core/network/README.md | 4 + lib/core/network/interceptors/README.md | 4 + lib/core/network/reachability/README.md | 4 + .../reachability/network_reachability_io.dart | 24 +- lib/core/observability/sentry_init.dart | 63 + lib/core/presentation/README.md | 7 + lib/core/presentation/frosted_card.dart | 40 + lib/core/utils/README.md | 4 + lib/features/README.md | 13 + .../application/ai_digest_notifier.dart | 105 + .../ai_digest_response_parser.dart | 175 + .../anonymized_llm_context_builder.dart | 285 ++ .../llm_digest_background_scheduler.dart | 125 + .../application/llm_digest_environment.dart | 40 + .../application/llm_digest_orchestrator.dart | 256 ++ .../application/llm_digest_text_client.dart | 31 + .../ai_digest/data/llm_digest_config.dart | 77 + .../ai_digest/data/llm_digest_run_store.dart | 34 + .../ai_digest/domain/ai_digest_models.dart | 207 ++ .../ai_digest_notification_listener.dart | 50 + .../presentation/ai_digest_review_view.dart | 197 + lib/features/auth/README.md | 9 + lib/features/auth/application/README.md | 4 + .../auth/application/session_controller.dart | 76 + lib/features/auth/presentation/README.md | 4 + .../auth/presentation/sign_in_view.dart | 228 ++ lib/features/auth/session_controller.dart | 78 +- lib/features/auth/sign_in_view.dart | 230 +- lib/features/dashboard/README.md | 9 + lib/features/dashboard/dashboard_view.dart | 2584 +------------ lib/features/dashboard/domain/README.md | 4 + .../dashboard/domain/dashboard_models.dart | 73 + lib/features/dashboard/presentation/README.md | 4 + .../presentation/dashboard_view.dart | 2582 +++++++++++++ lib/features/home/README.md | 4 + lib/features/home/app_shell.dart | 393 +- lib/features/ideas/README.md | 9 + lib/features/ideas/domain/README.md | 4 + lib/features/ideas/domain/idea_models.dart | 76 + lib/features/ideas/ideas_view.dart | 561 +-- lib/features/ideas/presentation/README.md | 4 + .../ideas/presentation/ideas_view.dart | 600 +++ lib/features/local/README.md | 8 + lib/features/local/local_models.dart | 1033 +----- lib/features/local/local_repository.dart | 2177 +---------- lib/features/local/storage/README.md | 4 + .../local/storage/local_data_store.dart | 21 +- .../local/storage/local_data_store_hive.dart | 56 +- .../storage/local_data_store_in_memory.dart | 24 +- .../storage/local_data_store_provider.dart | 16 +- .../local_data_store_shared_prefs.dart | 46 +- lib/features/moments/README.md | 9 + lib/features/moments/domain/README.md | 4 + .../moments/domain/moment_models.dart | 63 + lib/features/moments/moments_view.dart | 346 +- lib/features/moments/presentation/README.md | 4 + .../moments/presentation/moments_view.dart | 372 ++ lib/features/people/README.md | 12 + lib/features/people/domain/README.md | 4 + lib/features/people/domain/person_models.dart | 538 +++ lib/features/people/people_view.dart | 3205 +---------------- lib/features/people/presentation/README.md | 14 + .../people/presentation/people_view.dart | 627 ++++ .../presentation/people_view_detail.dart | 1033 ++++++ .../presentation/people_view_dialogs.dart | 1383 +++++++ .../people/presentation/people_view_list.dart | 528 +++ .../people/presentation/providers/README.md | 4 + .../providers/people_ui_state.dart | 70 + lib/features/reminders/README.md | 9 + lib/features/reminders/application/README.md | 4 + .../reminder_notification_intent_bus.dart | 18 + .../reminder_notification_listener.dart | 67 + .../application/reminder_scheduler.dart | 27 + ...eminder_scheduler_local_notifications.dart | 240 ++ .../reminder_scheduler_provider.dart | 30 + lib/features/reminders/domain/README.md | 4 + .../reminders/domain/reminder_models.dart | 71 + lib/features/reminders/presentation/README.md | 4 + .../presentation/reminders_view.dart | 561 +++ lib/features/reminders/reminders_view.dart | 563 +-- lib/features/reminders/scheduling/README.md | 4 + .../reminder_notification_intent_bus.dart | 20 +- .../reminder_notification_listener.dart | 69 +- .../scheduling/reminder_scheduler.dart | 29 +- ...eminder_scheduler_local_notifications.dart | 242 +- .../reminder_scheduler_provider.dart | 32 +- lib/features/settings/README.md | 6 + lib/features/settings/presentation/README.md | 4 + .../settings/presentation/settings_view.dart | 691 ++++ lib/features/settings/settings_view.dart | 388 +- lib/features/share_intake/README.md | 12 + .../share_intake/application/README.md | 4 + .../application/share_capture_flow.dart | 82 + .../application/share_capture_models.dart | 60 + lib/features/share_intake/domain/README.md | 4 + .../domain/share_capture_draft_suggester.dart | 232 ++ .../share_intake/domain/share_models.dart | 456 +++ .../domain/share_payload_parser.dart | 178 + .../domain/signal_share_parser.dart | 72 + .../domain/whatsapp_share_parser.dart | 87 + .../share_intake/presentation/README.md | 4 + .../presentation/incoming_share_listener.dart | 176 + .../share_capture_review_sheet.dart | 574 +++ .../presentation/share_inbox_view.dart | 1236 +++++++ .../presentation/whatsapp_share_listener.dart | 2 + .../share_intake/share_capture_flow.dart | 2 + .../share_intake/share_capture_models.dart | 2 + .../share_capture_review_sheet.dart | 2 + .../share_intake/share_inbox_view.dart | 973 +---- .../share_intake/share_payload_parser.dart | 2 + .../share_intake/whatsapp_share_listener.dart | 194 +- .../share_intake/whatsapp_share_parser.dart | 80 +- lib/features/shared/README.md | 4 + lib/features/shared/frosted_card.dart | 42 +- lib/features/signals/README.md | 14 + .../domain/signals_feed_synthesizer.dart | 369 ++ lib/features/signals/presentation/README.md | 4 + .../signals/presentation/signals_view.dart | 238 ++ lib/features/signals/signals_controller.dart | 191 +- lib/features/signals/signals_view.dart | 213 +- lib/features/sync/README.md | 10 + lib/features/sync/application/README.md | 4 + .../sync_auto_trigger_controller.dart | 84 + .../application/sync_background_runner.dart | 107 + .../sync/application/sync_coordinator.dart | 179 + lib/features/sync/data/README.md | 4 + lib/features/sync/data/storage/README.md | 4 + .../sync/data/storage/sync_state_store.dart | 18 + .../data/storage/sync_state_store_hive.dart | 46 + .../storage/sync_state_store_in_memory.dart | 19 + .../storage/sync_state_store_provider.dart | 14 + .../sync_state_store_shared_prefs.dart | 32 + .../sync/data/sync_queue_repository.dart | 220 ++ lib/features/sync/presentation/README.md | 4 + lib/features/sync/presentation/sync_view.dart | 1393 +++++++ lib/features/sync/storage/README.md | 4 + .../sync/storage/sync_state_store.dart | 20 +- .../sync/storage/sync_state_store_hive.dart | 48 +- .../storage/sync_state_store_in_memory.dart | 21 +- .../storage/sync_state_store_provider.dart | 16 +- .../sync_state_store_shared_prefs.dart | 34 +- .../sync/sync_auto_trigger_controller.dart | 86 +- lib/features/sync/sync_background_runner.dart | 109 +- lib/features/sync/sync_coordinator.dart | 181 +- lib/features/sync/sync_queue_repository.dart | 222 +- lib/features/sync/sync_view.dart | 1395 +------ lib/integrations/backend/README.md | 4 + lib/integrations/backend/models/README.md | 4 + lib/main.dart | 90 +- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 6 + pubspec.lock | 88 + pubspec.yaml | 3 + .../llm/captured_fact_draft_parser_test.dart | 63 + .../ai_digest/ai_digest_repository_test.dart | 114 + .../ai_digest_response_parser_test.dart | 52 + .../anonymized_llm_context_builder_test.dart | 94 + .../llm_digest_orchestrator_test.dart | 168 + test/features/app/app_auth_flow_test.dart | 2 +- .../app/app_mobile_crud_sync_flow_test.dart | 5 +- .../features/local/local_repository_test.dart | 117 + .../local_repository_hive_migration_test.dart | 2 +- .../share_capture_draft_suggester_test.dart | 82 + .../share_intake/share_inbox_view_test.dart | 10 +- .../share_payload_parser_test.dart | 67 + .../signals_feed_synthesizer_test.dart | 139 + .../sync/sync_background_runner_test.dart | 79 +- .../sync/sync_rejection_repair_flow_test.dart | 4 +- .../flutter/generated_plugin_registrant.cc | 6 + windows/flutter/generated_plugins.cmake | 2 + 212 files changed, 24178 insertions(+), 15895 deletions(-) create mode 100644 ios/Runner/Runner.entitlements create mode 100644 ios/Share Extension/Base.lproj/MainInterface.storyboard create mode 100644 ios/Share Extension/Info.plist create mode 100644 ios/Share Extension/Share Extension.entitlements create mode 100644 ios/Share Extension/ShareViewController.swift create mode 100644 lib/README.md create mode 100644 lib/app/README.md create mode 100644 lib/app/data/README.md create mode 100644 lib/app/data/relationship_repository.dart create mode 100644 lib/app/data/relationship_repository_activity.dart create mode 100644 lib/app/data/relationship_repository_ai_digest.dart create mode 100644 lib/app/data/relationship_repository_people.dart create mode 100644 lib/app/data/relationship_repository_share.dart create mode 100644 lib/app/data/relationship_repository_sync.dart create mode 100644 lib/app/data/storage/README.md create mode 100644 lib/app/data/storage/local_data_store.dart create mode 100644 lib/app/data/storage/local_data_store_hive.dart create mode 100644 lib/app/data/storage/local_data_store_in_memory.dart create mode 100644 lib/app/data/storage/local_data_store_provider.dart create mode 100644 lib/app/data/storage/local_data_store_shared_prefs.dart create mode 100644 lib/app/navigation/README.md create mode 100644 lib/app/navigation/app_destination.dart create mode 100644 lib/app/presentation/README.md create mode 100644 lib/app/presentation/app_shell.dart create mode 100644 lib/app/relationship_saver_app.dart create mode 100644 lib/app/state/README.md create mode 100644 lib/app/state/local_data_state.dart create mode 100644 lib/core/README.md create mode 100644 lib/core/auth/README.md create mode 100644 lib/core/config/README.md create mode 100644 lib/core/llm/captured_fact_draft_parser.dart create mode 100644 lib/core/llm/llm_config.dart create mode 100644 lib/core/llm/llm_service.dart create mode 100644 lib/core/network/README.md create mode 100644 lib/core/network/interceptors/README.md create mode 100644 lib/core/network/reachability/README.md create mode 100644 lib/core/observability/sentry_init.dart create mode 100644 lib/core/presentation/README.md create mode 100644 lib/core/presentation/frosted_card.dart create mode 100644 lib/core/utils/README.md create mode 100644 lib/features/README.md create mode 100644 lib/features/ai_digest/application/ai_digest_notifier.dart create mode 100644 lib/features/ai_digest/application/ai_digest_response_parser.dart create mode 100644 lib/features/ai_digest/application/anonymized_llm_context_builder.dart create mode 100644 lib/features/ai_digest/application/llm_digest_background_scheduler.dart create mode 100644 lib/features/ai_digest/application/llm_digest_environment.dart create mode 100644 lib/features/ai_digest/application/llm_digest_orchestrator.dart create mode 100644 lib/features/ai_digest/application/llm_digest_text_client.dart create mode 100644 lib/features/ai_digest/data/llm_digest_config.dart create mode 100644 lib/features/ai_digest/data/llm_digest_run_store.dart create mode 100644 lib/features/ai_digest/domain/ai_digest_models.dart create mode 100644 lib/features/ai_digest/presentation/ai_digest_notification_listener.dart create mode 100644 lib/features/ai_digest/presentation/ai_digest_review_view.dart create mode 100644 lib/features/auth/README.md create mode 100644 lib/features/auth/application/README.md create mode 100644 lib/features/auth/application/session_controller.dart create mode 100644 lib/features/auth/presentation/README.md create mode 100644 lib/features/auth/presentation/sign_in_view.dart create mode 100644 lib/features/dashboard/README.md create mode 100644 lib/features/dashboard/domain/README.md create mode 100644 lib/features/dashboard/domain/dashboard_models.dart create mode 100644 lib/features/dashboard/presentation/README.md create mode 100644 lib/features/dashboard/presentation/dashboard_view.dart create mode 100644 lib/features/home/README.md create mode 100644 lib/features/ideas/README.md create mode 100644 lib/features/ideas/domain/README.md create mode 100644 lib/features/ideas/domain/idea_models.dart create mode 100644 lib/features/ideas/presentation/README.md create mode 100644 lib/features/ideas/presentation/ideas_view.dart create mode 100644 lib/features/local/README.md create mode 100644 lib/features/local/storage/README.md create mode 100644 lib/features/moments/README.md create mode 100644 lib/features/moments/domain/README.md create mode 100644 lib/features/moments/domain/moment_models.dart create mode 100644 lib/features/moments/presentation/README.md create mode 100644 lib/features/moments/presentation/moments_view.dart create mode 100644 lib/features/people/README.md create mode 100644 lib/features/people/domain/README.md create mode 100644 lib/features/people/domain/person_models.dart create mode 100644 lib/features/people/presentation/README.md create mode 100644 lib/features/people/presentation/people_view.dart create mode 100644 lib/features/people/presentation/people_view_detail.dart create mode 100644 lib/features/people/presentation/people_view_dialogs.dart create mode 100644 lib/features/people/presentation/people_view_list.dart create mode 100644 lib/features/people/presentation/providers/README.md create mode 100644 lib/features/people/presentation/providers/people_ui_state.dart create mode 100644 lib/features/reminders/README.md create mode 100644 lib/features/reminders/application/README.md create mode 100644 lib/features/reminders/application/reminder_notification_intent_bus.dart create mode 100644 lib/features/reminders/application/reminder_notification_listener.dart create mode 100644 lib/features/reminders/application/reminder_scheduler.dart create mode 100644 lib/features/reminders/application/reminder_scheduler_local_notifications.dart create mode 100644 lib/features/reminders/application/reminder_scheduler_provider.dart create mode 100644 lib/features/reminders/domain/README.md create mode 100644 lib/features/reminders/domain/reminder_models.dart create mode 100644 lib/features/reminders/presentation/README.md create mode 100644 lib/features/reminders/presentation/reminders_view.dart create mode 100644 lib/features/reminders/scheduling/README.md create mode 100644 lib/features/settings/README.md create mode 100644 lib/features/settings/presentation/README.md create mode 100644 lib/features/settings/presentation/settings_view.dart create mode 100644 lib/features/share_intake/README.md create mode 100644 lib/features/share_intake/application/README.md create mode 100644 lib/features/share_intake/application/share_capture_flow.dart create mode 100644 lib/features/share_intake/application/share_capture_models.dart create mode 100644 lib/features/share_intake/domain/README.md create mode 100644 lib/features/share_intake/domain/share_capture_draft_suggester.dart create mode 100644 lib/features/share_intake/domain/share_models.dart create mode 100644 lib/features/share_intake/domain/share_payload_parser.dart create mode 100644 lib/features/share_intake/domain/signal_share_parser.dart create mode 100644 lib/features/share_intake/domain/whatsapp_share_parser.dart create mode 100644 lib/features/share_intake/presentation/README.md create mode 100644 lib/features/share_intake/presentation/incoming_share_listener.dart create mode 100644 lib/features/share_intake/presentation/share_capture_review_sheet.dart create mode 100644 lib/features/share_intake/presentation/share_inbox_view.dart create mode 100644 lib/features/share_intake/presentation/whatsapp_share_listener.dart create mode 100644 lib/features/share_intake/share_capture_flow.dart create mode 100644 lib/features/share_intake/share_capture_models.dart create mode 100644 lib/features/share_intake/share_capture_review_sheet.dart create mode 100644 lib/features/share_intake/share_payload_parser.dart create mode 100644 lib/features/shared/README.md create mode 100644 lib/features/signals/README.md create mode 100644 lib/features/signals/domain/signals_feed_synthesizer.dart create mode 100644 lib/features/signals/presentation/README.md create mode 100644 lib/features/signals/presentation/signals_view.dart create mode 100644 lib/features/sync/README.md create mode 100644 lib/features/sync/application/README.md create mode 100644 lib/features/sync/application/sync_auto_trigger_controller.dart create mode 100644 lib/features/sync/application/sync_background_runner.dart create mode 100644 lib/features/sync/application/sync_coordinator.dart create mode 100644 lib/features/sync/data/README.md create mode 100644 lib/features/sync/data/storage/README.md create mode 100644 lib/features/sync/data/storage/sync_state_store.dart create mode 100644 lib/features/sync/data/storage/sync_state_store_hive.dart create mode 100644 lib/features/sync/data/storage/sync_state_store_in_memory.dart create mode 100644 lib/features/sync/data/storage/sync_state_store_provider.dart create mode 100644 lib/features/sync/data/storage/sync_state_store_shared_prefs.dart create mode 100644 lib/features/sync/data/sync_queue_repository.dart create mode 100644 lib/features/sync/presentation/README.md create mode 100644 lib/features/sync/presentation/sync_view.dart create mode 100644 lib/features/sync/storage/README.md create mode 100644 lib/integrations/backend/README.md create mode 100644 lib/integrations/backend/models/README.md create mode 100644 test/core/llm/captured_fact_draft_parser_test.dart create mode 100644 test/features/ai_digest/ai_digest_repository_test.dart create mode 100644 test/features/ai_digest/ai_digest_response_parser_test.dart create mode 100644 test/features/ai_digest/anonymized_llm_context_builder_test.dart create mode 100644 test/features/ai_digest/llm_digest_orchestrator_test.dart create mode 100644 test/features/share_intake/share_capture_draft_suggester_test.dart create mode 100644 test/features/share_intake/share_payload_parser_test.dart create mode 100644 test/features/signals/signals_feed_synthesizer_test.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9521e63..f1cfdc3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Share Extension/Info.plist b/ios/Share Extension/Info.plist new file mode 100644 index 0000000..0fd3c40 --- /dev/null +++ b/ios/Share Extension/Info.plist @@ -0,0 +1,38 @@ + + + + + AppGroupId + $(CUSTOM_GROUP_ID) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + NSExtension + + NSExtensionAttributes + + PHSupportedMediaTypes + + Video + Image + + NSExtensionActivationRule + + NSExtensionActivationSupportsText + + NSExtensionActivationSupportsWebURLWithMaxCount + 1 + NSExtensionActivationSupportsImageWithMaxCount + 20 + NSExtensionActivationSupportsMovieWithMaxCount + 10 + NSExtensionActivationSupportsFileWithMaxCount + 5 + + + NSExtensionMainStoryboard + MainInterface + NSExtensionPointIdentifier + com.apple.share-services + + + diff --git a/ios/Share Extension/Share Extension.entitlements b/ios/Share Extension/Share Extension.entitlements new file mode 100644 index 0000000..8b3085f --- /dev/null +++ b/ios/Share Extension/Share Extension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + $(CUSTOM_GROUP_ID) + + + diff --git a/ios/Share Extension/ShareViewController.swift b/ios/Share Extension/ShareViewController.swift new file mode 100644 index 0000000..d70fe91 --- /dev/null +++ b/ios/Share Extension/ShareViewController.swift @@ -0,0 +1,3 @@ +import receive_sharing_intent + +class ShareViewController: RSIShareViewController {} diff --git a/lib/README.md b/lib/README.md new file mode 100644 index 0000000..9001b48 --- /dev/null +++ b/lib/README.md @@ -0,0 +1,11 @@ +# Lib Layout + +This app now uses a prototype-friendly vertical-slice structure. + +Use `lib/app/` for bootstrap, shell navigation, shared app-state wiring, and +local persistence. Use `lib/features//` for product work inside a +single slice. Use `lib/core/` only for cross-cutting primitives that are not +owned by one feature. + +If you are starting work, open the slice README first. Most old top-level +files are now compatibility exports that point to the new canonical location. diff --git a/lib/app/README.md b/lib/app/README.md new file mode 100644 index 0000000..c37461c --- /dev/null +++ b/lib/app/README.md @@ -0,0 +1,12 @@ +# App Layer + +`lib/app/` holds app-level code that is not one product slice: + +- `relationship_saver_app.dart`: root widget and global listeners. +- `navigation/`: typed shell destinations. +- `presentation/`: responsive app shell. +- `data/`: aggregate prototype repository and persistence plumbing. +- `state/`: shared aggregate snapshot used by the current local-first store. + +This is the place to change boot flow, shell behavior, or prototype-wide state +composition. diff --git a/lib/app/data/README.md b/lib/app/data/README.md new file mode 100644 index 0000000..8677dde --- /dev/null +++ b/lib/app/data/README.md @@ -0,0 +1,15 @@ +# App Data + +This folder contains the prototype app store and persistence adapters. + +`relationship_repository.dart` is the single local-first write boundary for the +prototype. It now delegates behavior into part files so the code is grouped by +slice instead of one giant file: + +- `relationship_repository_people.dart`: people CRUD and profile merging. +- `relationship_repository_share.dart`: share intake, inbox, and person resolution. +- `relationship_repository_activity.dart`: captures, facts, ideas, reminders, and task toggles. +- `relationship_repository_sync.dart`: sync envelopes and remote-change application. + +If you are changing persistence behavior, start with the matching part file and +only touch the root repository file when the shared lifecycle changes. diff --git a/lib/app/data/relationship_repository.dart b/lib/app/data/relationship_repository.dart new file mode 100644 index 0000000..3e483ce --- /dev/null +++ b/lib/app/data/relationship_repository.dart @@ -0,0 +1,176 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/app/data/storage/local_data_store.dart'; +import 'package:relationship_saver/app/data/storage/local_data_store_hive.dart'; +import 'package:relationship_saver/app/data/storage/local_data_store_provider.dart'; +import 'package:relationship_saver/app/data/storage/local_data_store_shared_prefs.dart'; +import 'package:relationship_saver/app/state/local_data_state.dart'; +import 'package:relationship_saver/core/config/app_config.dart'; +import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart'; +import 'package:relationship_saver/features/dashboard/domain/dashboard_models.dart'; +import 'package:relationship_saver/features/ideas/domain/idea_models.dart'; +import 'package:relationship_saver/features/local/chat_preference_extractor.dart'; +import 'package:relationship_saver/features/moments/domain/moment_models.dart'; +import 'package:relationship_saver/features/people/domain/person_models.dart'; +import 'package:relationship_saver/features/reminders/application/reminder_scheduler_provider.dart'; +import 'package:relationship_saver/features/reminders/domain/reminder_models.dart'; +import 'package:relationship_saver/features/share_intake/application/share_capture_models.dart'; +import 'package:relationship_saver/features/share_intake/domain/share_capture_draft_suggester.dart'; +import 'package:relationship_saver/features/share_intake/domain/share_models.dart'; +import 'package:relationship_saver/features/sync/data/sync_queue_repository.dart'; +import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; +import 'package:uuid/uuid.dart'; + +part 'relationship_repository_activity.dart'; +part 'relationship_repository_ai_digest.dart'; +part 'relationship_repository_people.dart'; +part 'relationship_repository_share.dart'; +part 'relationship_repository_sync.dart'; + +const int _schemaVersion = 5; +const int _syncSchemaVersion = 1; +const Uuid _uuid = Uuid(); +const ChatPreferenceExtractor _chatPreferenceExtractor = + ChatPreferenceExtractor(); + +/// Persisted local-first app store used by the current prototype. +/// +/// The storage backend is still a single local record because that keeps the +/// prototype stable, but the repository implementation is now split by feature +/// behavior so it reads like vertical slices instead of one monolithic file. +class LocalRepository extends AsyncNotifier { + @override + Future build() async { + final LocalDataStore store = ref.read(localDataStoreProvider); + LocalDataRecord? record = await store.read(); + + if ((record == null || record.rawState.isEmpty) && + AppConfig.useHiveLocalDb && + store is HiveLocalDataStore) { + final SharedPrefsLocalDataStore legacyStore = SharedPrefsLocalDataStore(); + final LocalDataRecord? legacy = await legacyStore.read(); + if (legacy != null && legacy.rawState.isNotEmpty) { + await store.write( + rawState: legacy.rawState, + schemaVersion: legacy.schemaVersion, + ); + await legacyStore.clear(); + record = await store.read(); + } + } + + if (record == null || record.rawState.isEmpty) { + final LocalDataState seeded = LocalDataState.seed(); + await _persist(seeded); + await _reconcileReminderSchedule(seeded); + return seeded; + } + + final LocalDataRecord migrated = await _migrateIfNeeded(record); + try { + final Map json = + jsonDecode(migrated.rawState) as Map; + final LocalDataState loaded = LocalDataState.fromJson(json); + await _reconcileReminderSchedule(loaded); + return loaded; + } on FormatException { + final LocalDataState fallback = LocalDataState.seed(); + await _persist(fallback); + await _reconcileReminderSchedule(fallback); + return fallback; + } + } + + Future _setState(LocalDataState next) async { + state = AsyncData(next); + await _persist(next); + await _reconcileReminderSchedule(next); + } + + LocalDataState _requireState() { + final LocalDataState? value = state.asData?.value; + if (value == null) { + throw StateError('Local data state is not ready yet'); + } + return value; + } + + Future _persist(LocalDataState state) async { + await ref + .read(localDataStoreProvider) + .write( + rawState: jsonEncode(state.toJson()), + schemaVersion: _schemaVersion, + ); + } + + Future _reconcileReminderSchedule(LocalDataState state) async { + try { + await ref.read(reminderSchedulerProvider).reconcile(state.reminders); + } catch (_) { + // Scheduling failures must not block local-first data updates. + } + } + + Future _migrateIfNeeded(LocalDataRecord record) async { + final int currentVersion = record.schemaVersion; + if (currentVersion == _schemaVersion) { + return record; + } + + final LocalDataStore store = ref.read(localDataStoreProvider); + if (currentVersion <= 0) { + final LocalDataState seeded = LocalDataState.seed(); + await store.clear(); + await _persist(seeded); + return LocalDataRecord( + rawState: jsonEncode(seeded.toJson()), + schemaVersion: _schemaVersion, + ); + } + + try { + final Map json = + jsonDecode(record.rawState) as Map; + final LocalDataState migratedState = LocalDataState.fromJson(json); + final String migratedRawState = jsonEncode(migratedState.toJson()); + await store.write( + rawState: migratedRawState, + schemaVersion: _schemaVersion, + ); + return LocalDataRecord( + rawState: migratedRawState, + schemaVersion: _schemaVersion, + ); + } on FormatException { + final LocalDataState seeded = LocalDataState.seed(); + await store.clear(); + await _persist(seeded); + return LocalDataRecord( + rawState: jsonEncode(seeded.toJson()), + schemaVersion: _schemaVersion, + ); + } + } + + Future _enqueueChange(ChangeEnvelope change) async { + await ref.read(syncQueueRepositoryProvider.notifier).enqueue(change); + } + + Future _enqueueChanges(List changes) async { + await ref.read(syncQueueRepositoryProvider.notifier).enqueueAll(changes); + } +} + +String? _trimToNull(String? value) { + if (value == null) { + return null; + } + final String trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; +} + +final AsyncNotifierProvider +localRepositoryProvider = + AsyncNotifierProvider(LocalRepository.new); diff --git a/lib/app/data/relationship_repository_activity.dart b/lib/app/data/relationship_repository_activity.dart new file mode 100644 index 0000000..f23756e --- /dev/null +++ b/lib/app/data/relationship_repository_activity.dart @@ -0,0 +1,667 @@ +part of 'relationship_repository.dart'; + +/// Capture, idea, reminder, and lightweight dashboard state persistence. +extension LocalRepositoryActivityOperations on LocalRepository { + Future addMoment({ + required String personId, + required String summary, + String type = 'capture', + }) async { + final String normalized = summary.trim(); + if (normalized.isEmpty) { + throw ArgumentError('Capture summary cannot be empty'); + } + + final LocalDataState current = _requireState(); + final String payload = _truncate(normalized, 280); + final RelationshipMoment moment = RelationshipMoment( + id: 'm-${_uuid.v4()}', + personId: personId, + title: _titleFromSummary(payload), + summary: payload, + at: DateTime.now(), + type: type, + ); + + await _setState( + current.copyWith( + moments: [moment, ...current.moments], + ), + ); + await _enqueueChange( + _buildEnvelope( + entityType: 'capture', + entityId: moment.id, + op: ChangeOperation.upsert, + payload: _momentPayload(moment), + ), + ); + } + + Future addPersonFact({ + required String personId, + required CapturedFactType type, + required String text, + String? label, + CaptureSourceKind sourceKind = CaptureSourceKind.manual, + String? sourceApp, + String? sourceUrl, + String? sharedMessageId, + String? inboxEntryId, + double? confidence, + bool needsReview = false, + bool isSensitive = false, + DateTime? createdAt, + }) async { + final LocalDataState current = _requireState(); + final String normalizedText = text.trim(); + if (normalizedText.isEmpty) { + throw ArgumentError('Fact text is required'); + } + final DateTime now = createdAt ?? DateTime.now(); + final PersonFact fact = PersonFact( + id: 'pf-${_uuid.v4()}', + personId: personId, + type: type, + text: normalizedText, + label: _trimToNull(label), + sourceKind: sourceKind, + sourceApp: _trimToNull(sourceApp), + sourceUrl: _trimToNull(sourceUrl), + sharedMessageId: sharedMessageId, + inboxEntryId: inboxEntryId, + confidence: confidence, + needsReview: needsReview, + isSensitive: isSensitive, + createdAt: now, + updatedAt: now, + ); + await _setState( + current.copyWith( + personFacts: [fact, ...current.personFacts], + people: _touchPeople( + current.people, + personId: personId, + updatedAt: now, + interactedAt: now, + ), + ), + ); + } + + Future updatePersonFact(PersonFact fact) async { + final LocalDataState current = _requireState(); + final DateTime now = DateTime.now(); + final List updated = current.personFacts + .map( + (PersonFact item) => + item.id == fact.id ? fact.copyWith(updatedAt: now) : item, + ) + .toList(growable: false); + await _setState( + current.copyWith( + personFacts: updated, + people: _touchPeople( + current.people, + personId: fact.personId, + updatedAt: now, + ), + ), + ); + } + + Future deletePersonFact(String factId) async { + final LocalDataState current = _requireState(); + final List updated = current.personFacts + .where((PersonFact item) => item.id != factId) + .toList(growable: false); + await _setState(current.copyWith(personFacts: updated)); + } + + Future addImportantDate({ + required String personId, + required String label, + required DateTime date, + String classification = 'important', + CaptureSourceKind sourceKind = CaptureSourceKind.manual, + String? sourceApp, + String? inboxEntryId, + bool isSensitive = false, + DateTime? createdAt, + }) async { + final LocalDataState current = _requireState(); + final String normalizedLabel = label.trim(); + if (normalizedLabel.isEmpty) { + throw ArgumentError('Date label is required'); + } + final DateTime now = createdAt ?? DateTime.now(); + final PersonImportantDate importantDate = PersonImportantDate( + id: 'pd-${_uuid.v4()}', + personId: personId, + label: normalizedLabel, + date: date, + classification: classification.trim().isEmpty + ? 'important' + : classification.trim(), + sourceKind: sourceKind, + sourceApp: _trimToNull(sourceApp), + inboxEntryId: inboxEntryId, + isSensitive: isSensitive, + createdAt: now, + updatedAt: now, + ); + await _setState( + current.copyWith( + importantDates: [ + importantDate, + ...current.importantDates, + ], + people: _touchPeople( + current.people, + personId: personId, + updatedAt: now, + interactedAt: now, + ), + ), + ); + } + + Future updateImportantDate(PersonImportantDate value) async { + final LocalDataState current = _requireState(); + final DateTime now = DateTime.now(); + final List updated = current.importantDates + .map( + (PersonImportantDate item) => + item.id == value.id ? value.copyWith(updatedAt: now) : item, + ) + .toList(growable: false); + await _setState( + current.copyWith( + importantDates: updated, + people: _touchPeople( + current.people, + personId: value.personId, + updatedAt: now, + ), + ), + ); + } + + Future deleteImportantDate(String dateId) async { + final LocalDataState current = _requireState(); + final List updated = current.importantDates + .where((PersonImportantDate item) => item.id != dateId) + .toList(growable: false); + await _setState(current.copyWith(importantDates: updated)); + } + + Future updateMoment(RelationshipMoment moment) async { + if (moment.summary.trim().isEmpty) { + throw ArgumentError('Capture summary cannot be empty'); + } + + final LocalDataState current = _requireState(); + final List updated = current.moments + .map((RelationshipMoment item) => item.id == moment.id ? moment : item) + .toList(growable: false); + await _setState(current.copyWith(moments: updated)); + await _enqueueChange( + _buildEnvelope( + entityType: 'capture', + entityId: moment.id, + op: ChangeOperation.upsert, + payload: _momentPayload(moment), + ), + ); + } + + Future deleteMoment(String momentId) async { + final LocalDataState current = _requireState(); + final List moments = current.moments + .where((RelationshipMoment moment) => moment.id != momentId) + .toList(growable: false); + await _setState(current.copyWith(moments: moments)); + await _enqueueChange( + _buildEnvelope( + entityType: 'capture', + entityId: momentId, + op: ChangeOperation.delete, + ), + ); + } + + Future addIdea({ + required IdeaType type, + required String title, + required String details, + String? personId, + }) async { + final String normalizedTitle = title.trim(); + if (normalizedTitle.isEmpty) { + throw ArgumentError('Idea title is required'); + } + + final LocalDataState current = _requireState(); + final RelationshipIdea idea = RelationshipIdea( + id: 'i-${_uuid.v4()}', + personId: personId, + type: type, + title: normalizedTitle, + details: details.trim(), + createdAt: DateTime.now(), + ); + + await _setState( + current.copyWith(ideas: [idea, ...current.ideas]), + ); + await _enqueueChange( + _buildEnvelope( + entityType: _ideaEntityType(idea.type), + entityId: idea.id, + op: ChangeOperation.upsert, + payload: _ideaPayload(idea), + ), + ); + } + + Future updateIdea(RelationshipIdea idea) async { + if (idea.title.trim().isEmpty) { + throw ArgumentError('Idea title is required'); + } + + final LocalDataState current = _requireState(); + final List ideas = current.ideas + .map((RelationshipIdea item) => item.id == idea.id ? idea : item) + .toList(growable: false); + await _setState(current.copyWith(ideas: ideas)); + await _enqueueChange( + _buildEnvelope( + entityType: _ideaEntityType(idea.type), + entityId: idea.id, + op: ChangeOperation.upsert, + payload: _ideaPayload(idea), + ), + ); + } + + Future toggleIdeaArchived(String ideaId) async { + final LocalDataState current = _requireState(); + RelationshipIdea? updatedIdea; + final List ideas = current.ideas + .map((RelationshipIdea item) { + if (item.id != ideaId) { + return item; + } + final RelationshipIdea toggled = item.copyWith( + isArchived: !item.isArchived, + ); + updatedIdea = toggled; + return toggled; + }) + .toList(growable: false); + await _setState(current.copyWith(ideas: ideas)); + if (updatedIdea != null) { + await _enqueueChange( + _buildEnvelope( + entityType: _ideaEntityType(updatedIdea!.type), + entityId: updatedIdea!.id, + op: ChangeOperation.upsert, + payload: _ideaPayload(updatedIdea!), + ), + ); + } + } + + Future deleteIdea(String ideaId) async { + final LocalDataState current = _requireState(); + final List ideas = current.ideas + .where((RelationshipIdea idea) => idea.id != ideaId) + .toList(growable: false); + await _setState(current.copyWith(ideas: ideas)); + await _enqueueChange( + _buildEnvelope( + entityType: _ideaEntityTypeFromId(current.ideas, ideaId), + entityId: ideaId, + op: ChangeOperation.delete, + ), + ); + } + + Future addReminder({ + required String title, + required ReminderCadence cadence, + required DateTime nextAt, + String? personId, + }) async { + final String normalizedTitle = title.trim(); + if (normalizedTitle.isEmpty) { + throw ArgumentError('Reminder title is required'); + } + + final LocalDataState current = _requireState(); + final ReminderRule reminder = ReminderRule( + id: 'r-${_uuid.v4()}', + personId: personId, + title: normalizedTitle, + cadence: cadence, + nextAt: nextAt, + enabled: true, + ); + + await _setState( + current.copyWith( + reminders: [reminder, ...current.reminders], + ), + ); + await _enqueueChange( + _buildEnvelope( + entityType: 'reminderRule', + entityId: reminder.id, + op: ChangeOperation.upsert, + payload: _reminderPayload(reminder), + ), + ); + } + + Future updateReminder(ReminderRule reminder) async { + if (reminder.title.trim().isEmpty) { + throw ArgumentError('Reminder title is required'); + } + + final LocalDataState current = _requireState(); + final List reminders = current.reminders + .map((ReminderRule item) => item.id == reminder.id ? reminder : item) + .toList(growable: false); + await _setState(current.copyWith(reminders: reminders)); + await _enqueueChange( + _buildEnvelope( + entityType: 'reminderRule', + entityId: reminder.id, + op: ChangeOperation.upsert, + payload: _reminderPayload(reminder), + ), + ); + } + + Future toggleReminderEnabled(String reminderId) async { + final LocalDataState current = _requireState(); + ReminderRule? updatedReminder; + final List reminders = current.reminders + .map((ReminderRule item) { + if (item.id != reminderId) { + return item; + } + final ReminderRule toggled = item.copyWith(enabled: !item.enabled); + updatedReminder = toggled; + return toggled; + }) + .toList(growable: false); + await _setState(current.copyWith(reminders: reminders)); + if (updatedReminder != null) { + await _enqueueChange( + _buildEnvelope( + entityType: 'reminderRule', + entityId: updatedReminder!.id, + op: ChangeOperation.upsert, + payload: _reminderPayload(updatedReminder!), + ), + ); + } + } + + Future deleteReminder(String reminderId) async { + final LocalDataState current = _requireState(); + final List reminders = current.reminders + .where((ReminderRule reminder) => reminder.id != reminderId) + .toList(growable: false); + await _setState(current.copyWith(reminders: reminders)); + await _enqueueChange( + _buildEnvelope( + entityType: 'reminderRule', + entityId: reminderId, + op: ChangeOperation.delete, + ), + ); + } + + Future upsertInferredPreferenceSignalObservation({ + required String personId, + required String key, + required String label, + required String category, + required PreferenceSignalPolarity polarity, + required double confidence, + String? sourceApp, + String? evidenceMessageId, + String? evidenceSnippet, + DateTime? observedAt, + }) async { + final LocalDataState current = _requireState(); + final DateTime observed = observedAt ?? DateTime.now(); + final String normalizedKey = _normalizePreferenceKey(key); + if (normalizedKey.isEmpty) { + throw ArgumentError('Preference signal key cannot be empty'); + } + if (!_personExists(current.people, personId)) { + throw ArgumentError('Profile does not exist for preference signal'); + } + + final int existingIndex = current.preferenceSignals.indexWhere( + (PersonPreferenceSignal signal) => + signal.personId == personId && + signal.key == normalizedKey && + signal.polarity == polarity, + ); + + final double normalizedConfidence = _clampUnit(confidence); + final String normalizedLabel = label.trim().isEmpty + ? normalizedKey + : label.trim(); + final String normalizedCategory = category.trim().isEmpty + ? 'general' + : category.trim().toLowerCase(); + final String? snippet = _trimToNull(evidenceSnippet); + final String? messageId = _trimToNull(evidenceMessageId); + final String? source = _trimToNull(sourceApp)?.toLowerCase(); + + late final PersonPreferenceSignal nextSignal; + final List nextSignals = current.preferenceSignals + .toList(growable: true); + + if (existingIndex < 0) { + nextSignal = PersonPreferenceSignal( + id: 'ps-${_uuid.v4()}', + personId: personId, + key: normalizedKey, + label: normalizedLabel, + category: normalizedCategory, + polarity: polarity, + confidence: normalizedConfidence, + status: PreferenceSignalStatus.inferred, + firstSeenAt: observed, + lastSeenAt: observed, + occurrenceCount: 1, + sourceApps: source == null ? const [] : [source], + evidenceMessageIds: messageId == null + ? const [] + : [messageId], + evidenceSnippets: snippet == null + ? const [] + : [snippet], + ); + nextSignals.insert(0, nextSignal); + } else { + final PersonPreferenceSignal existing = nextSignals[existingIndex]; + final int currentCount = existing.occurrenceCount < 1 + ? 1 + : existing.occurrenceCount; + final int nextCount = currentCount + 1; + final double mergedConfidence = + ((existing.confidence * currentCount) + normalizedConfidence) / + nextCount; + nextSignal = existing.copyWith( + label: normalizedLabel, + category: normalizedCategory, + confidence: _clampUnit(mergedConfidence), + lastSeenAt: observed, + occurrenceCount: nextCount, + sourceApps: _mergeUniqueStrings(existing.sourceApps, source), + evidenceMessageIds: _mergeUniqueStrings( + existing.evidenceMessageIds, + messageId, + maxItems: 8, + ), + evidenceSnippets: _mergeUniqueStrings( + existing.evidenceSnippets, + snippet == null ? null : _truncate(snippet, 220), + maxItems: 6, + ), + ); + nextSignals[existingIndex] = nextSignal; + } + + await _setState(current.copyWith(preferenceSignals: nextSignals)); + return nextSignal; + } + + Future upsertPreferenceSignal(PersonPreferenceSignal signal) async { + final LocalDataState current = _requireState(); + if (!_personExists(current.people, signal.personId)) { + throw ArgumentError('Profile does not exist for preference signal'); + } + final List nextSignals = _upsertItem( + items: current.preferenceSignals, + item: signal, + idOf: (PersonPreferenceSignal value) => value.id, + ); + await _setState(current.copyWith(preferenceSignals: nextSignals)); + } + + Future setPreferenceSignalStatus({ + required String signalId, + required PreferenceSignalStatus status, + }) async { + final LocalDataState current = _requireState(); + final List nextSignals = current.preferenceSignals + .map((PersonPreferenceSignal signal) { + if (signal.id != signalId) { + return signal; + } + return signal.copyWith(status: status); + }) + .toList(growable: false); + await _setState(current.copyWith(preferenceSignals: nextSignals)); + } + + Future confirmPreferenceSignal(String signalId) { + return setPreferenceSignalStatus( + signalId: signalId, + status: PreferenceSignalStatus.confirmed, + ); + } + + Future dismissPreferenceSignal(String signalId) { + return setPreferenceSignalStatus( + signalId: signalId, + status: PreferenceSignalStatus.dismissed, + ); + } + + Future dismissLocalSignal(String signalId) async { + final String normalizedId = signalId.trim(); + if (normalizedId.isEmpty) { + return; + } + + final LocalDataState current = _requireState(); + if (current.dismissedSignalIds.contains(normalizedId)) { + return; + } + + await _setState( + current.copyWith( + dismissedSignalIds: [ + normalizedId, + ...current.dismissedSignalIds, + ], + ), + ); + } + + Future toggleTaskDone(String taskId) async { + final LocalDataState current = _requireState(); + final List tasks = current.tasks + .map( + (DashboardTask task) => + task.id == taskId ? task.copyWith(done: !task.done) : task, + ) + .toList(growable: false); + + await _setState(current.copyWith(tasks: tasks)); + } +} + +bool _personExists(List people, String personId) { + return people.any((PersonProfile person) => person.id == personId); +} + +String _normalizePreferenceKey(String value) { + return value + .trim() + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9:_ -]+'), '') + .replaceAll(RegExp(r'\s+'), ' '); +} + +double _clampUnit(double value) { + if (value.isNaN) { + return 0; + } + return value.clamp(0.0, 1.0).toDouble(); +} + +List _mergeUniqueStrings( + List existing, + String? incoming, { + int maxItems = 12, +}) { + final List values = []; + final Set seen = {}; + + for (final String raw in existing) { + final String value = raw.trim(); + if (value.isEmpty) { + continue; + } + final String key = value.toLowerCase(); + if (seen.add(key)) { + values.add(value); + } + } + + final String? newValue = _trimToNull(incoming); + if (newValue != null) { + final String key = newValue.toLowerCase(); + if (!seen.contains(key)) { + values.insert(0, newValue); + } + } + + if (values.length <= maxItems) { + return values; + } + return values.take(maxItems).toList(growable: false); +} + +String _truncate(String value, int maxChars) { + if (value.length <= maxChars) { + return value; + } + return value.substring(0, maxChars); +} + +String _titleFromSummary(String summary) { + final List words = summary.split(RegExp(r'\s+')); + final int take = words.length < 5 ? words.length : 5; + return words.take(take).join(' '); +} diff --git a/lib/app/data/relationship_repository_ai_digest.dart b/lib/app/data/relationship_repository_ai_digest.dart new file mode 100644 index 0000000..0cb4cfd --- /dev/null +++ b/lib/app/data/relationship_repository_ai_digest.dart @@ -0,0 +1,161 @@ +part of 'relationship_repository.dart'; + +extension LocalRepositoryAiDigestOperations on LocalRepository { + Future saveAiSuggestionDrafts(List drafts) async { + if (drafts.isEmpty) { + return; + } + + final LocalDataState current = _requireState(); + final Set existingKeys = current.aiSuggestionDrafts + .map(_aiDraftDedupeKey) + .toSet(); + final List uniqueDrafts = drafts + .where((AiSuggestionDraft draft) { + if (!_personExists(current.people, draft.personId)) { + return false; + } + return existingKeys.add(_aiDraftDedupeKey(draft)); + }) + .toList(growable: false); + + if (uniqueDrafts.isEmpty) { + return; + } + + await _setState( + current.copyWith( + aiSuggestionDrafts: [ + ...uniqueDrafts, + ...current.aiSuggestionDrafts, + ], + ), + ); + } + + Future acceptAiSuggestionDraft(String draftId) async { + final LocalDataState current = _requireState(); + final AiSuggestionDraft? draft = current.aiSuggestionDrafts + .where((AiSuggestionDraft item) => item.id == draftId) + .cast() + .firstOrNull; + if (draft == null || draft.status != AiSuggestionStatus.pending) { + return; + } + if (!_personExists(current.people, draft.personId)) { + await _setAiSuggestionStatus(draftId, AiSuggestionStatus.dismissed); + return; + } + + final DateTime now = DateTime.now(); + final List drafts = current.aiSuggestionDrafts + .map( + (AiSuggestionDraft item) => item.id == draftId + ? item.copyWith(status: AiSuggestionStatus.accepted) + : item, + ) + .toList(growable: false); + + switch (draft.kind) { + case AiSuggestionKind.giftIdea: + case AiSuggestionKind.eventIdea: + final RelationshipIdea idea = RelationshipIdea( + id: 'i-${_uuid.v4()}', + personId: draft.personId, + type: draft.kind == AiSuggestionKind.giftIdea + ? IdeaType.gift + : IdeaType.event, + title: draft.title, + details: _aiDraftDetails(draft), + createdAt: now, + ); + await _setState( + current.copyWith( + aiSuggestionDrafts: drafts, + ideas: [idea, ...current.ideas], + ), + ); + await _enqueueChange( + _buildEnvelope( + entityType: _ideaEntityType(idea.type), + entityId: idea.id, + op: ChangeOperation.upsert, + payload: _ideaPayload(idea), + ), + ); + case AiSuggestionKind.checkIn: + final DashboardTask task = DashboardTask( + id: 't-${_uuid.v4()}', + title: draft.title, + description: _aiDraftDetails(draft), + when: draft.suggestedFor ?? now.add(const Duration(days: 1)), + ); + await _setState( + current.copyWith( + aiSuggestionDrafts: drafts, + tasks: [task, ...current.tasks], + ), + ); + case AiSuggestionKind.reminder: + final ReminderRule reminder = ReminderRule( + id: 'r-${_uuid.v4()}', + personId: draft.personId, + title: draft.title, + cadence: ReminderCadence.weekly, + nextAt: draft.suggestedFor ?? now.add(const Duration(days: 7)), + ); + await _setState( + current.copyWith( + aiSuggestionDrafts: drafts, + reminders: [reminder, ...current.reminders], + ), + ); + await _enqueueChange( + _buildEnvelope( + entityType: 'reminderRule', + entityId: reminder.id, + op: ChangeOperation.upsert, + payload: _reminderPayload(reminder), + ), + ); + } + } + + Future dismissAiSuggestionDraft(String draftId) { + return _setAiSuggestionStatus(draftId, AiSuggestionStatus.dismissed); + } + + Future _setAiSuggestionStatus( + String draftId, + AiSuggestionStatus status, + ) async { + final LocalDataState current = _requireState(); + final List drafts = current.aiSuggestionDrafts + .map( + (AiSuggestionDraft item) => + item.id == draftId ? item.copyWith(status: status) : item, + ) + .toList(growable: false); + await _setState(current.copyWith(aiSuggestionDrafts: drafts)); + } +} + +String _aiDraftDedupeKey(AiSuggestionDraft draft) { + return [ + draft.personId, + draft.kind.name, + draft.title.trim().toLowerCase(), + draft.details.trim().toLowerCase(), + ].join('|'); +} + +String _aiDraftDetails(AiSuggestionDraft draft) { + final String reason = draft.reason == null || draft.reason!.trim().isEmpty + ? '' + : '\n\nWhy: ${draft.reason!.trim()}'; + return '${draft.details.trim()}$reason'.trim(); +} + +extension on Iterable { + T? get firstOrNull => isEmpty ? null : first; +} diff --git a/lib/app/data/relationship_repository_people.dart b/lib/app/data/relationship_repository_people.dart new file mode 100644 index 0000000..7db2790 --- /dev/null +++ b/lib/app/data/relationship_repository_people.dart @@ -0,0 +1,523 @@ +part of 'relationship_repository.dart'; + +/// Person-profile persistence and merge operations. +extension LocalRepositoryPeopleOperations on LocalRepository { + Future addPerson({ + required String name, + required String relationship, + required String notes, + required List tags, + DateTime? nextMoment, + String? location, + List aliases = const [], + }) async { + final String normalizedName = name.trim(); + final String normalizedRelationship = relationship.trim(); + if (normalizedName.isEmpty || normalizedRelationship.isEmpty) { + throw ArgumentError('Name and relationship are required'); + } + + final DateTime now = DateTime.now(); + final LocalDataState current = _requireState(); + final PersonProfile person = PersonProfile( + id: 'p-${_uuid.v4()}', + name: normalizedName, + relationship: normalizedRelationship, + affinityScore: 75, + nextMoment: nextMoment ?? now.add(const Duration(days: 3)), + tags: _mergeTags(tags, const []), + notes: notes.trim(), + aliases: _normalizeAliases(aliases), + location: _trimToNull(location), + lastUpdatedAt: now, + ); + + await _setState( + current.copyWith(people: [person, ...current.people]), + ); + await _enqueueChange( + _buildEnvelope( + entityType: 'person', + entityId: person.id, + op: ChangeOperation.upsert, + payload: _personPayload(person), + ), + ); + } + + Future updatePerson(PersonProfile person) async { + final String normalizedName = person.name.trim(); + final String normalizedRelationship = person.relationship.trim(); + if (normalizedName.isEmpty || normalizedRelationship.isEmpty) { + throw ArgumentError('Name and relationship are required'); + } + + final DateTime now = DateTime.now(); + final PersonProfile updatedPerson = person.copyWith( + name: normalizedName, + relationship: normalizedRelationship, + notes: person.notes.trim(), + tags: _mergeTags(person.tags, const []), + aliases: _normalizeAliases(person.aliases), + location: _trimToNull(person.location), + lastUpdatedAt: now, + ); + + final LocalDataState current = _requireState(); + final List updated = current.people + .map( + (PersonProfile item) => + item.id == updatedPerson.id ? updatedPerson : item, + ) + .toList(growable: false); + await _setState(current.copyWith(people: updated)); + await _enqueueChange( + _buildEnvelope( + entityType: 'person', + entityId: updatedPerson.id, + op: ChangeOperation.upsert, + payload: _personPayload(updatedPerson), + ), + ); + } + + Future deletePerson(String personId) async { + final LocalDataState current = _requireState(); + final List removedMoments = current.moments + .where((RelationshipMoment moment) => moment.personId == personId) + .toList(growable: false); + final List removedIdeas = current.ideas + .where((RelationshipIdea idea) => idea.personId == personId) + .toList(growable: false); + final List removedReminders = current.reminders + .where((ReminderRule reminder) => reminder.personId == personId) + .toList(growable: false); + final List removedFacts = current.personFacts + .where((PersonFact fact) => fact.personId == personId) + .toList(growable: false); + final List removedDates = current.importantDates + .where((PersonImportantDate value) => value.personId == personId) + .toList(growable: false); + + await _setState( + current.copyWith( + people: current.people + .where((PersonProfile person) => person.id != personId) + .toList(growable: false), + moments: current.moments + .where((RelationshipMoment moment) => moment.personId != personId) + .toList(growable: false), + ideas: current.ideas + .where((RelationshipIdea idea) => idea.personId != personId) + .toList(growable: false), + reminders: current.reminders + .where((ReminderRule reminder) => reminder.personId != personId) + .toList(growable: false), + preferenceSignals: current.preferenceSignals + .where( + (PersonPreferenceSignal signal) => signal.personId != personId, + ) + .toList(growable: false), + personFacts: current.personFacts + .where((PersonFact fact) => fact.personId != personId) + .toList(growable: false), + aiSuggestionDrafts: current.aiSuggestionDrafts + .where((AiSuggestionDraft draft) => draft.personId != personId) + .toList(growable: false), + importantDates: current.importantDates + .where((PersonImportantDate value) => value.personId != personId) + .toList(growable: false), + sourceLinks: current.sourceLinks + .where((SourceProfileLink link) => link.profileId != personId) + .toList(growable: false), + sharedMessages: current.sharedMessages + .where((SharedMessageEntry entry) => entry.profileId != personId) + .toList(growable: false), + sharedInbox: current.sharedInbox + .map((SharedInboxEntry entry) { + final List nextCandidates = entry.candidateProfileIds + .where((String candidateId) => candidateId != personId) + .toList(growable: false); + return entry.copyWith( + candidateProfileIds: nextCandidates, + targetPersonId: entry.targetPersonId == personId + ? null + : entry.targetPersonId, + ); + }) + .toList(growable: false), + ), + ); + + await _enqueueChanges([ + _buildEnvelope( + entityType: 'person', + entityId: personId, + op: ChangeOperation.delete, + ), + ...removedMoments.map( + (RelationshipMoment moment) => _buildEnvelope( + entityType: 'capture', + entityId: moment.id, + op: ChangeOperation.delete, + ), + ), + ...removedIdeas.map( + (RelationshipIdea idea) => _buildEnvelope( + entityType: _ideaEntityType(idea.type), + entityId: idea.id, + op: ChangeOperation.delete, + ), + ), + ...removedReminders.map( + (ReminderRule reminder) => _buildEnvelope( + entityType: 'reminderRule', + entityId: reminder.id, + op: ChangeOperation.delete, + ), + ), + ...removedFacts.map( + (PersonFact fact) => _buildEnvelope( + entityType: 'personFact', + entityId: fact.id, + op: ChangeOperation.delete, + ), + ), + ...removedDates.map( + (PersonImportantDate value) => _buildEnvelope( + entityType: 'personDate', + entityId: value.id, + op: ChangeOperation.delete, + ), + ), + ]); + } + + Future mergePersonProfiles({ + required String sourcePersonId, + required String targetPersonId, + }) async { + if (sourcePersonId == targetPersonId) { + throw ArgumentError('Source and target profile must be different'); + } + + final LocalDataState current = _requireState(); + final PersonProfile? source = _findPersonById( + current.people, + sourcePersonId, + ); + final PersonProfile? target = _findPersonById( + current.people, + targetPersonId, + ); + if (source == null || target == null) { + throw ArgumentError('Both source and target profiles must exist'); + } + + final DateTime now = DateTime.now(); + final PersonProfile mergedTarget = target.copyWith( + affinityScore: target.affinityScore > source.affinityScore + ? target.affinityScore + : source.affinityScore, + nextMoment: source.nextMoment.isBefore(target.nextMoment) + ? source.nextMoment + : target.nextMoment, + tags: _mergeTags(target.tags, source.tags), + notes: _mergeNotes( + targetNotes: target.notes, + sourceName: source.name, + sourceNotes: source.notes, + ), + aliases: _normalizeAliases([ + ...target.aliases, + ...source.aliases, + ]), + location: _effectiveLocation(target.location, source.location), + lastUpdatedAt: now, + lastInteractedAt: _latestDate( + target.lastInteractedAt, + source.lastInteractedAt, + ), + ); + + final List nextPeople = current.people + .where((PersonProfile person) => person.id != sourcePersonId) + .map( + (PersonProfile person) => + person.id == targetPersonId ? mergedTarget : person, + ) + .toList(growable: false); + + final List updatedMoments = []; + final List nextMoments = current.moments + .map((RelationshipMoment moment) { + if (moment.personId != sourcePersonId) { + return moment; + } + final RelationshipMoment merged = moment.copyWith( + personId: targetPersonId, + ); + updatedMoments.add(merged); + return merged; + }) + .toList(growable: false); + + final List updatedIdeas = []; + final List nextIdeas = current.ideas + .map((RelationshipIdea idea) { + if (idea.personId != sourcePersonId) { + return idea; + } + final RelationshipIdea merged = idea.copyWith( + personId: targetPersonId, + ); + updatedIdeas.add(merged); + return merged; + }) + .toList(growable: false); + + final List updatedReminders = []; + final List nextReminders = current.reminders + .map((ReminderRule reminder) { + if (reminder.personId != sourcePersonId) { + return reminder; + } + final ReminderRule merged = reminder.copyWith( + personId: targetPersonId, + ); + updatedReminders.add(merged); + return merged; + }) + .toList(growable: false); + + final List nextLinks = current.sourceLinks + .map((SourceProfileLink link) { + if (link.profileId != sourcePersonId) { + return link; + } + return link.copyWith(profileId: targetPersonId); + }) + .toList(growable: false); + + final List nextMessages = current.sharedMessages + .map((SharedMessageEntry entry) { + if (entry.profileId != sourcePersonId) { + return entry; + } + return entry.copyWith(profileId: targetPersonId); + }) + .toList(growable: false); + + final List nextInbox = current.sharedInbox + .map((SharedInboxEntry entry) { + if (!entry.candidateProfileIds.contains(sourcePersonId) && + entry.targetPersonId != sourcePersonId) { + return entry; + } + final List candidates = entry.candidateProfileIds + .map( + (String profileId) => + profileId == sourcePersonId ? targetPersonId : profileId, + ) + .toSet() + .toList(growable: false); + return entry.copyWith( + candidateProfileIds: candidates, + targetPersonId: entry.targetPersonId == sourcePersonId + ? targetPersonId + : entry.targetPersonId, + ); + }) + .toList(growable: false); + + final List nextPreferenceSignals = current + .preferenceSignals + .map((PersonPreferenceSignal signal) { + if (signal.personId != sourcePersonId) { + return signal; + } + return signal.copyWith(personId: targetPersonId); + }) + .toList(growable: false); + + final List nextFacts = current.personFacts + .map((PersonFact fact) { + if (fact.personId != sourcePersonId) { + return fact; + } + return fact.copyWith(personId: targetPersonId, updatedAt: now); + }) + .toList(growable: false); + + final List nextImportantDates = current.importantDates + .map((PersonImportantDate value) { + if (value.personId != sourcePersonId) { + return value; + } + return value.copyWith(personId: targetPersonId, updatedAt: now); + }) + .toList(growable: false); + + final List nextAiSuggestionDrafts = current + .aiSuggestionDrafts + .map((AiSuggestionDraft draft) { + if (draft.personId != sourcePersonId) { + return draft; + } + return draft.copyWith(personId: targetPersonId); + }) + .toList(growable: false); + + await _setState( + current.copyWith( + people: nextPeople, + moments: nextMoments, + ideas: nextIdeas, + reminders: nextReminders, + sourceLinks: nextLinks, + sharedMessages: nextMessages, + sharedInbox: nextInbox, + personFacts: nextFacts, + importantDates: nextImportantDates, + preferenceSignals: nextPreferenceSignals, + aiSuggestionDrafts: nextAiSuggestionDrafts, + ), + ); + + await _enqueueChanges([ + _buildEnvelope( + entityType: 'person', + entityId: mergedTarget.id, + op: ChangeOperation.upsert, + payload: _personPayload(mergedTarget), + ), + _buildEnvelope( + entityType: 'person', + entityId: sourcePersonId, + op: ChangeOperation.delete, + ), + ...updatedMoments.map( + (RelationshipMoment moment) => _buildEnvelope( + entityType: 'capture', + entityId: moment.id, + op: ChangeOperation.upsert, + payload: _momentPayload(moment), + ), + ), + ...updatedIdeas.map( + (RelationshipIdea idea) => _buildEnvelope( + entityType: _ideaEntityType(idea.type), + entityId: idea.id, + op: ChangeOperation.upsert, + payload: _ideaPayload(idea), + ), + ), + ...updatedReminders.map( + (ReminderRule reminder) => _buildEnvelope( + entityType: 'reminderRule', + entityId: reminder.id, + op: ChangeOperation.upsert, + payload: _reminderPayload(reminder), + ), + ), + ]); + } +} + +List _mergeTags(List primary, List secondary) { + final List merged = []; + final Set seen = {}; + for (final String raw in [...primary, ...secondary]) { + final String tag = raw.trim(); + if (tag.isEmpty) { + continue; + } + final String normalized = tag.toLowerCase(); + if (seen.add(normalized)) { + merged.add(tag); + } + } + return merged; +} + +String _mergeNotes({ + required String targetNotes, + required String sourceName, + required String sourceNotes, +}) { + final String target = targetNotes.trim(); + final String source = sourceNotes.trim(); + if (target.isEmpty) { + return source; + } + if (source.isEmpty) { + return target; + } + if (target == source) { + return target; + } + return '$target\n\nMerged from $sourceName:\n$source'; +} + +String? _effectiveLocation(String? target, String? source) { + final String? targetValue = _trimToNull(target); + if (targetValue != null) { + return targetValue; + } + return _trimToNull(source); +} + +List _normalizeAliases(List aliases) { + final List values = []; + final Set seen = {}; + for (final String raw in aliases) { + final String value = raw.trim(); + if (value.isEmpty) { + continue; + } + final String key = value.toLowerCase(); + if (seen.add(key)) { + values.add(value); + } + } + return values; +} + +DateTime? _latestDate(DateTime? left, DateTime? right) { + if (left == null) { + return right; + } + if (right == null) { + return left; + } + return left.isAfter(right) ? left : right; +} + +List _touchPeople( + List people, { + required String personId, + DateTime? updatedAt, + DateTime? interactedAt, +}) { + return people + .map((PersonProfile person) { + if (person.id != personId) { + return person; + } + return person.copyWith( + lastUpdatedAt: updatedAt ?? person.lastUpdatedAt ?? DateTime.now(), + lastInteractedAt: + interactedAt ?? person.lastInteractedAt ?? DateTime.now(), + ); + }) + .toList(growable: false); +} + +PersonProfile? _findPersonById(List people, String id) { + for (final PersonProfile person in people) { + if (person.id == id) { + return person; + } + } + return null; +} diff --git a/lib/app/data/relationship_repository_share.dart b/lib/app/data/relationship_repository_share.dart new file mode 100644 index 0000000..3d1f0fe --- /dev/null +++ b/lib/app/data/relationship_repository_share.dart @@ -0,0 +1,883 @@ +part of 'relationship_repository.dart'; + +/// Share capture intake, person resolution, and inbox triage. +extension LocalRepositoryShareOperations on LocalRepository { + Future ingestSharedMessage( + SharedMessageIngestInput input, + ) async { + final DateTime now = DateTime.now(); + final SharedPayload payload = SharedPayload( + sourceApp: input.sourceApp.trim().toLowerCase(), + payloadType: SharedPayloadType.text, + rawText: input.messageText.trim(), + createdAt: input.sharedAt ?? now, + receivedAt: now, + platform: 'legacy', + sourceDisplayName: _trimToNull(input.sourceDisplayName), + sourceUserId: _trimToNull(input.sourceUserId), + sourceThreadId: _trimToNull(input.sourceThreadId), + dedupeKey: _dedupeKeyForShare( + sourceApp: input.sourceApp, + rawText: input.messageText, + url: null, + sourceUserId: input.sourceUserId, + sourceThreadId: input.sourceThreadId, + ), + ); + if (payload.rawText.isEmpty) { + throw ArgumentError('Shared message text cannot be empty'); + } + + final LocalDataState current = _requireState(); + final String sourceApp = payload.sourceApp; + final String? sourceDisplayName = payload.sourceDisplayName; + final String normalizedDisplayName = _normalizeDisplayName( + sourceDisplayName, + ); + final String? sourceUserId = payload.sourceUserId; + final String? sourceThreadId = payload.sourceThreadId; + final String? sourceFingerprint = _buildStableSourceFingerprint( + sourceApp: sourceApp, + sourceUserId: sourceUserId, + sourceThreadId: sourceThreadId, + ); + + final SourceProfileLink? matchedLink = _findExistingSourceLink( + links: current.sourceLinks, + sourceApp: sourceApp, + sourceFingerprint: sourceFingerprint, + sourceUserId: sourceUserId, + sourceThreadId: sourceThreadId, + normalizedDisplayName: normalizedDisplayName, + ); + + PersonProfile? resolvedPerson; + if (matchedLink != null) { + resolvedPerson = _findPersonById(current.people, matchedLink.profileId); + } + + final List matchingPeople = normalizedDisplayName.isEmpty + ? const [] + : _findPeopleByNormalizedName(current.people, normalizedDisplayName); + + if (resolvedPerson == null && matchingPeople.length == 1) { + resolvedPerson = matchingPeople.first; + } + + if (resolvedPerson == null && matchingPeople.length > 1) { + return _queueSharedInbox( + current: current, + payload: payload, + sourceFingerprint: sourceFingerprint, + normalizedDisplayName: normalizedDisplayName, + reason: SharedInboxReason.ambiguousProfileMatch, + candidateProfileIds: matchingPeople + .map((PersonProfile person) => person.id) + .toList(growable: false), + ); + } + + final List nearMatchPeople = resolvedPerson == null + ? _findNearMatchPeople( + current.people, + normalizedDisplayName: normalizedDisplayName, + ) + : const []; + if (resolvedPerson == null && nearMatchPeople.isNotEmpty) { + return _queueSharedInbox( + current: current, + payload: payload, + sourceFingerprint: sourceFingerprint, + normalizedDisplayName: normalizedDisplayName, + reason: SharedInboxReason.nearProfileConflict, + candidateProfileIds: nearMatchPeople + .map((PersonProfile person) => person.id) + .toList(growable: false), + ); + } + + bool createdProfile = false; + final List nextPeople = current.people.toList( + growable: true, + ); + if (resolvedPerson == null) { + final String? inferredName = _deriveAutoProfileName( + sourceDisplayName: sourceDisplayName, + sourceUserId: sourceUserId, + sourceThreadId: sourceThreadId, + ); + if (inferredName == null) { + return _queueSharedInbox( + current: current, + payload: payload, + sourceFingerprint: sourceFingerprint, + normalizedDisplayName: normalizedDisplayName, + reason: SharedInboxReason.missingIdentity, + candidateProfileIds: const [], + ); + } + createdProfile = true; + resolvedPerson = PersonProfile( + id: 'p-${_uuid.v4()}', + name: inferredName, + relationship: 'WhatsApp Contact', + affinityScore: 70, + nextMoment: now.add(const Duration(days: 2)), + tags: const ['whatsapp'], + notes: 'Auto-created from shared message.', + aliases: sourceDisplayName == null || sourceDisplayName == inferredName + ? const [] + : [sourceDisplayName], + lastUpdatedAt: now, + ); + nextPeople.insert(0, resolvedPerson); + } + + return _ingestIntoResolvedProfile( + current: current, + importedAt: now, + payload: payload, + sourceFingerprint: sourceFingerprint, + normalizedDisplayName: normalizedDisplayName, + resolvedPerson: resolvedPerson, + matchedLink: matchedLink, + createdProfile: createdProfile, + people: nextPeople, + resolvedAutomatically: true, + draft: _defaultDraftForPayload(payload), + ); + } + + Future resolveSharedInboxToExistingProfile({ + required String inboxEntryId, + required String profileId, + CapturedFactDraft? draft, + }) async { + final LocalDataState current = _requireState(); + final SharedInboxEntry entry = _requireSharedInboxEntry( + current, + inboxEntryId, + ); + final PersonProfile? resolvedPerson = _findPersonById( + current.people, + profileId, + ); + if (resolvedPerson == null) { + throw ArgumentError('Profile not found for inbox entry'); + } + return captureSharedPayloadToExistingProfile( + payload: entry.payload, + profileId: profileId, + draft: draft ?? entry.draft, + consumedInboxEntryId: entry.id, + normalizedDisplayName: entry.normalizedDisplayName, + sourceFingerprint: entry.sourceFingerprint, + resolvedAutomatically: false, + ); + } + + Future resolveSharedInboxByCreatingProfile({ + required String inboxEntryId, + String? name, + String relationship = 'WhatsApp Contact', + String? location, + List aliases = const [], + CapturedFactDraft? draft, + }) async { + final LocalDataState current = _requireState(); + final SharedInboxEntry entry = _requireSharedInboxEntry( + current, + inboxEntryId, + ); + return captureSharedPayloadByCreatingProfile( + payload: entry.payload, + name: name, + relationship: relationship, + location: location, + aliases: aliases, + draft: draft ?? entry.draft, + consumedInboxEntryId: entry.id, + normalizedDisplayName: entry.normalizedDisplayName, + sourceFingerprint: entry.sourceFingerprint, + resolvedAutomatically: false, + ); + } + + Future saveSharedPayloadToInbox({ + required SharedPayload payload, + SharedInboxReason reason = SharedInboxReason.missingIdentity, + List candidateProfileIds = const [], + String? normalizedDisplayName, + String? targetPersonId, + String? sourceFingerprint, + CapturedFactDraft? draft, + }) async { + final LocalDataState current = _requireState(); + return _queueSharedInbox( + current: current, + payload: payload, + sourceFingerprint: + sourceFingerprint ?? + _buildStableSourceFingerprint( + sourceApp: payload.sourceApp, + sourceUserId: payload.sourceUserId, + sourceThreadId: payload.sourceThreadId, + ), + normalizedDisplayName: + normalizedDisplayName ?? + _normalizeDisplayName(payload.sourceDisplayName), + reason: reason, + candidateProfileIds: candidateProfileIds, + targetPersonId: targetPersonId, + draft: draft, + ); + } + + Future captureSharedPayloadToExistingProfile({ + required SharedPayload payload, + required String profileId, + CapturedFactDraft? draft, + bool resolvedAutomatically = false, + String? consumedInboxEntryId, + String? normalizedDisplayName, + String? sourceFingerprint, + }) async { + final LocalDataState current = _requireState(); + final PersonProfile? resolvedPerson = _findPersonById( + current.people, + profileId, + ); + if (resolvedPerson == null) { + throw ArgumentError('Profile not found'); + } + final String effectiveNormalizedDisplayName = + normalizedDisplayName ?? + _normalizeDisplayName(payload.sourceDisplayName); + final String? effectiveSourceFingerprint = + sourceFingerprint ?? + _buildStableSourceFingerprint( + sourceApp: payload.sourceApp, + sourceUserId: payload.sourceUserId, + sourceThreadId: payload.sourceThreadId, + ); + final SourceProfileLink? matchedLink = _findExistingSourceLink( + links: current.sourceLinks, + sourceApp: payload.sourceApp, + sourceFingerprint: effectiveSourceFingerprint, + sourceUserId: payload.sourceUserId, + sourceThreadId: payload.sourceThreadId, + normalizedDisplayName: effectiveNormalizedDisplayName, + ); + return _ingestIntoResolvedProfile( + current: current, + importedAt: DateTime.now(), + payload: payload, + sourceFingerprint: effectiveSourceFingerprint, + normalizedDisplayName: effectiveNormalizedDisplayName, + resolvedPerson: resolvedPerson, + matchedLink: matchedLink, + createdProfile: false, + people: current.people, + resolvedAutomatically: resolvedAutomatically, + consumedInboxEntryId: consumedInboxEntryId, + draft: draft ?? _defaultDraftForPayload(payload), + ); + } + + Future captureSharedPayloadByCreatingProfile({ + required SharedPayload payload, + String? name, + String relationship = 'Contact', + String? location, + List aliases = const [], + CapturedFactDraft? draft, + bool resolvedAutomatically = false, + String? consumedInboxEntryId, + String? normalizedDisplayName, + String? sourceFingerprint, + }) async { + final LocalDataState current = _requireState(); + final DateTime now = DateTime.now(); + final String profileName = + _trimToNull(name) ?? + _deriveAutoProfileName( + sourceDisplayName: payload.sourceDisplayName, + sourceUserId: payload.sourceUserId, + sourceThreadId: payload.sourceThreadId, + ) ?? + 'New Contact'; + final String normalizedRelationship = relationship.trim().isEmpty + ? 'Contact' + : relationship.trim(); + final PersonProfile createdPerson = PersonProfile( + id: 'p-${_uuid.v4()}', + name: profileName, + relationship: normalizedRelationship, + affinityScore: 70, + nextMoment: now.add(const Duration(days: 2)), + tags: [ + if (payload.sourceApp.trim().isNotEmpty) payload.sourceApp.trim(), + ], + notes: 'Created from shared capture.', + aliases: _normalizeAliases([ + ...aliases, + if (payload.sourceDisplayName != null) payload.sourceDisplayName!, + ]), + location: _trimToNull(location), + lastUpdatedAt: now, + lastInteractedAt: now, + ); + return _ingestIntoResolvedProfile( + current: current, + importedAt: now, + payload: payload, + sourceFingerprint: + sourceFingerprint ?? + _buildStableSourceFingerprint( + sourceApp: payload.sourceApp, + sourceUserId: payload.sourceUserId, + sourceThreadId: payload.sourceThreadId, + ), + normalizedDisplayName: + normalizedDisplayName ?? + _normalizeDisplayName(payload.sourceDisplayName), + resolvedPerson: createdPerson, + matchedLink: null, + createdProfile: true, + people: [createdPerson, ...current.people], + resolvedAutomatically: resolvedAutomatically, + consumedInboxEntryId: consumedInboxEntryId, + draft: draft ?? _defaultDraftForPayload(payload), + ); + } + + Future dismissSharedInboxEntry(String inboxEntryId) async { + final LocalDataState current = _requireState(); + final List nextInbox = current.sharedInbox + .where((SharedInboxEntry entry) => entry.id != inboxEntryId) + .toList(growable: false); + if (nextInbox.length == current.sharedInbox.length) { + return; + } + await _setState(current.copyWith(sharedInbox: nextInbox)); + } + + Future _queueSharedInbox({ + required LocalDataState current, + required SharedPayload payload, + required SharedInboxReason reason, + required List candidateProfileIds, + required String normalizedDisplayName, + String? sourceFingerprint, + String? targetPersonId, + CapturedFactDraft? draft, + }) async { + final SharedPayload storedPayload = payload.copyWith( + rawText: _truncate(payload.rawText, 1800), + dedupeKey: + payload.dedupeKey ?? + _dedupeKeyForShare( + sourceApp: payload.sourceApp, + rawText: payload.rawText, + url: payload.url, + sourceUserId: payload.sourceUserId, + sourceThreadId: payload.sourceThreadId, + ), + ); + final SharedInboxEntry entry = SharedInboxEntry( + id: 'si-${_uuid.v4()}', + payload: storedPayload, + reason: reason, + candidateProfileIds: candidateProfileIds, + normalizedDisplayName: normalizedDisplayName, + assignmentStatus: ShareAssignmentStatus.savedToInbox, + targetPersonId: targetPersonId, + sourceFingerprint: sourceFingerprint, + draft: draft ?? _defaultDraftForPayload(storedPayload), + ); + await _setState( + current.copyWith( + sharedInbox: [entry, ...current.sharedInbox], + ), + ); + return SharedMessageIngestResult.queuedForResolution( + inboxEntryId: entry.id, + ); + } + + Future _ingestIntoResolvedProfile({ + required LocalDataState current, + required SharedPayload payload, + required DateTime importedAt, + required String normalizedDisplayName, + required String? sourceFingerprint, + required PersonProfile resolvedPerson, + required bool createdProfile, + required List people, + required bool resolvedAutomatically, + required CapturedFactDraft draft, + SourceProfileLink? matchedLink, + String? consumedInboxEntryId, + }) async { + final String sourceApp = payload.sourceApp; + final DateTime sharedAt = payload.createdAt; + final String? sourceUserId = payload.sourceUserId; + final String? sourceThreadId = payload.sourceThreadId; + final bool createdSourceLink = matchedLink == null; + final List nextLinks = current.sourceLinks.toList( + growable: true, + ); + if (matchedLink == null) { + nextLinks.insert( + 0, + SourceProfileLink( + id: 'sl-${_uuid.v4()}', + sourceApp: sourceApp, + sourceUserId: sourceUserId, + sourceThreadId: sourceThreadId, + sourceFingerprint: sourceFingerprint, + normalizedDisplayName: normalizedDisplayName, + profileId: resolvedPerson.id, + firstSeenAt: sharedAt, + lastSeenAt: sharedAt, + ), + ); + } else { + final int index = nextLinks.indexWhere( + (SourceProfileLink link) => link.id == matchedLink.id, + ); + if (index >= 0) { + nextLinks[index] = matchedLink.copyWith( + sourceUserId: sourceUserId ?? matchedLink.sourceUserId, + sourceThreadId: sourceThreadId ?? matchedLink.sourceThreadId, + sourceFingerprint: sourceFingerprint ?? matchedLink.sourceFingerprint, + normalizedDisplayName: normalizedDisplayName.isEmpty + ? matchedLink.normalizedDisplayName + : normalizedDisplayName, + profileId: resolvedPerson.id, + lastSeenAt: sharedAt, + ); + } + } + + final String summary = _truncate(payload.rawText, 1800); + final RelationshipMoment moment = RelationshipMoment( + id: 'm-${_uuid.v4()}', + personId: resolvedPerson.id, + title: _titleFromSummary(summary), + summary: summary, + at: sharedAt, + type: sourceApp == 'whatsapp' ? 'whatsapp' : 'shared', + ); + + final SharedMessageEntry sharedEntry = SharedMessageEntry( + id: 'sm-${_uuid.v4()}', + profileId: resolvedPerson.id, + payload: payload.copyWith( + rawText: summary, + dedupeKey: + payload.dedupeKey ?? + _dedupeKeyForShare( + sourceApp: payload.sourceApp, + rawText: payload.rawText, + url: payload.url, + sourceUserId: payload.sourceUserId, + sourceThreadId: payload.sourceThreadId, + ), + ), + importedAt: importedAt, + resolvedAutomatically: resolvedAutomatically, + assignmentStatus: ShareAssignmentStatus.assignedDirectly, + draft: draft, + ); + final List nextInbox = consumedInboxEntryId == null + ? current.sharedInbox + : current.sharedInbox + .where( + (SharedInboxEntry entry) => entry.id != consumedInboxEntryId, + ) + .toList(growable: false); + + await _setState( + current.copyWith( + people: _touchPeople( + people, + personId: resolvedPerson.id, + updatedAt: importedAt, + interactedAt: sharedAt, + ), + moments: [moment, ...current.moments], + sourceLinks: nextLinks, + sharedMessages: [ + sharedEntry, + ...current.sharedMessages, + ], + sharedInbox: nextInbox, + personFacts: _applyFactDraftToFacts( + current.personFacts, + personId: resolvedPerson.id, + sharedEntry: sharedEntry, + draft: draft, + consumedInboxEntryId: consumedInboxEntryId, + observedAt: sharedAt, + ), + importantDates: _applyFactDraftToDates( + current.importantDates, + personId: resolvedPerson.id, + draft: draft, + sourceApp: payload.sourceApp, + consumedInboxEntryId: consumedInboxEntryId, + observedAt: sharedAt, + ), + ), + ); + + final List outbound = [ + if (createdProfile) + _buildEnvelope( + entityType: 'person', + entityId: resolvedPerson.id, + op: ChangeOperation.upsert, + payload: _personPayload(resolvedPerson), + ), + _buildEnvelope( + entityType: 'capture', + entityId: moment.id, + op: ChangeOperation.upsert, + payload: _momentPayload(moment), + ), + ]; + await _enqueueChanges(outbound); + + final List extractedSignals = + _chatPreferenceExtractor.extract(summary); + for (final ExtractedPreferenceSignalCandidate candidate + in extractedSignals) { + await upsertInferredPreferenceSignalObservation( + personId: resolvedPerson.id, + key: candidate.key, + label: candidate.label, + category: candidate.category, + polarity: candidate.polarity, + confidence: candidate.confidence, + sourceApp: sourceApp, + evidenceMessageId: sharedEntry.id, + evidenceSnippet: candidate.evidenceSnippet, + observedAt: sharedAt, + ); + } + + return SharedMessageIngestResult.imported( + profileId: resolvedPerson.id, + profileName: resolvedPerson.name, + createdProfile: createdProfile, + createdSourceLink: createdSourceLink, + momentId: moment.id, + ); + } +} + +CapturedFactDraft _defaultDraftForPayload(SharedPayload payload) { + return ShareCaptureDraftSuggester.suggestForPayload( + payload, + ).copyWith(needsReview: true); +} + +String _dedupeKeyForShare({ + required String sourceApp, + required String rawText, + required String? url, + required String? sourceUserId, + required String? sourceThreadId, +}) { + final String text = rawText.trim().toLowerCase().replaceAll( + RegExp(r'\s+'), + ' ', + ); + return [ + sourceApp.trim().toLowerCase(), + sourceUserId?.trim().toLowerCase() ?? '', + sourceThreadId?.trim().toLowerCase() ?? '', + url?.trim().toLowerCase() ?? '', + text, + ].join('|'); +} + +List _applyFactDraftToFacts( + List currentFacts, { + required String personId, + required SharedMessageEntry sharedEntry, + required CapturedFactDraft draft, + required String? consumedInboxEntryId, + required DateTime observedAt, +}) { + if (draft.type == CapturedFactType.importantDate || + draft.text.trim().isEmpty) { + return currentFacts; + } + final PersonFact fact = PersonFact( + id: 'pf-${_uuid.v4()}', + personId: personId, + type: draft.type, + text: draft.text.trim(), + label: _trimToNull(draft.label), + sourceKind: consumedInboxEntryId == null + ? CaptureSourceKind.shareSheet + : CaptureSourceKind.shareInbox, + sourceApp: sharedEntry.sourceApp, + sourceUrl: sharedEntry.url, + sharedMessageId: sharedEntry.id, + inboxEntryId: consumedInboxEntryId, + confidence: draft.confidence, + needsReview: draft.needsReview, + isSensitive: draft.isSensitive, + createdAt: observedAt, + updatedAt: observedAt, + ); + return [fact, ...currentFacts]; +} + +List _applyFactDraftToDates( + List currentDates, { + required String personId, + required CapturedFactDraft draft, + required String sourceApp, + required String? consumedInboxEntryId, + required DateTime observedAt, +}) { + if (draft.type != CapturedFactType.importantDate || draft.dateValue == null) { + return currentDates; + } + final PersonImportantDate value = PersonImportantDate( + id: 'pd-${_uuid.v4()}', + personId: personId, + label: _trimToNull(draft.label) ?? draft.text.trim(), + date: draft.dateValue!, + classification: 'important', + sourceKind: consumedInboxEntryId == null + ? CaptureSourceKind.shareSheet + : CaptureSourceKind.shareInbox, + sourceApp: sourceApp, + inboxEntryId: consumedInboxEntryId, + isSensitive: draft.isSensitive, + createdAt: observedAt, + updatedAt: observedAt, + ); + return [value, ...currentDates]; +} + +SourceProfileLink? _findExistingSourceLink({ + required List links, + required String sourceApp, + required String? sourceFingerprint, + required String? sourceUserId, + required String? sourceThreadId, + required String normalizedDisplayName, +}) { + if (sourceFingerprint != null && sourceFingerprint.isNotEmpty) { + for (final SourceProfileLink link in links) { + final String? linkFingerprint = + link.sourceFingerprint ?? + _buildStableSourceFingerprint( + sourceApp: link.sourceApp, + sourceUserId: link.sourceUserId, + sourceThreadId: link.sourceThreadId, + ); + if (link.sourceApp == sourceApp && linkFingerprint == sourceFingerprint) { + return link; + } + } + } + + if (sourceUserId != null && sourceUserId.isNotEmpty) { + for (final SourceProfileLink link in links) { + if (link.sourceApp == sourceApp && link.sourceUserId == sourceUserId) { + return link; + } + } + } + + if (sourceThreadId != null && sourceThreadId.isNotEmpty) { + for (final SourceProfileLink link in links) { + if (link.sourceApp == sourceApp && + link.sourceThreadId == sourceThreadId) { + return link; + } + } + } + + if (normalizedDisplayName.isNotEmpty) { + for (final SourceProfileLink link in links) { + if (link.sourceApp == sourceApp && + link.normalizedDisplayName == normalizedDisplayName) { + return link; + } + } + } + + return null; +} + +List _findPeopleByNormalizedName( + List people, + String normalizedDisplayName, +) { + return people + .where((PersonProfile person) { + if (_normalizeDisplayName(person.name) == normalizedDisplayName) { + return true; + } + for (final String alias in person.aliases) { + if (_normalizeDisplayName(alias) == normalizedDisplayName) { + return true; + } + } + return false; + }) + .toList(growable: false); +} + +List _findNearMatchPeople( + List people, { + required String normalizedDisplayName, +}) { + if (normalizedDisplayName.isEmpty) { + return const []; + } + + final Map distanceById = {}; + final List candidates = []; + for (final PersonProfile person in people) { + final List probes = [person.name, ...person.aliases]; + for (final String probe in probes) { + final String personName = _normalizeDisplayName(probe); + if (personName.isEmpty || personName == normalizedDisplayName) { + continue; + } + final int distance = _levenshteinDistance( + normalizedDisplayName, + personName, + ); + if (_isNearDisplayName( + normalizedDisplayName, + personName, + distance: distance, + )) { + distanceById[person.id] = distance; + if (!candidates.any((PersonProfile item) => item.id == person.id)) { + candidates.add(person); + } + } + } + } + + candidates.sort((PersonProfile a, PersonProfile b) { + final int left = distanceById[a.id] ?? 999; + final int right = distanceById[b.id] ?? 999; + if (left != right) { + return left.compareTo(right); + } + return a.name.length.compareTo(b.name.length); + }); + if (candidates.length <= 5) { + return candidates; + } + return candidates.take(5).toList(growable: false); +} + +bool _isNearDisplayName(String left, String right, {required int distance}) { + final int maxLength = left.length > right.length ? left.length : right.length; + final int minLength = left.length < right.length ? left.length : right.length; + if (minLength >= 4 && (left.contains(right) || right.contains(left))) { + return true; + } + final int threshold = maxLength >= 14 ? 3 : 2; + if (distance <= threshold) { + return true; + } + return maxLength >= 8 && (distance / maxLength) <= 0.22; +} + +int _levenshteinDistance(String left, String right) { + if (left == right) { + return 0; + } + if (left.isEmpty) { + return right.length; + } + if (right.isEmpty) { + return left.length; + } + + List previous = List.generate( + right.length + 1, + (int index) => index, + growable: false, + ); + for (int i = 1; i <= left.length; i += 1) { + final List current = List.filled(right.length + 1, 0); + current[0] = i; + for (int j = 1; j <= right.length; j += 1) { + final int substitutionCost = left[i - 1] == right[j - 1] ? 0 : 1; + final int deletion = previous[j] + 1; + final int insertion = current[j - 1] + 1; + final int substitution = previous[j - 1] + substitutionCost; + final int value = deletion < insertion ? deletion : insertion; + current[j] = value < substitution ? value : substitution; + } + previous = current; + } + return previous[right.length]; +} + +SharedInboxEntry _requireSharedInboxEntry( + LocalDataState current, + String inboxEntryId, +) { + for (final SharedInboxEntry entry in current.sharedInbox) { + if (entry.id == inboxEntryId) { + return entry; + } + } + throw ArgumentError('Shared inbox entry not found'); +} + +String? _deriveAutoProfileName({ + required String? sourceDisplayName, + required String? sourceUserId, + required String? sourceThreadId, +}) { + return _trimToNull(sourceDisplayName) ?? + _trimToNull(sourceUserId) ?? + _trimToNull(sourceThreadId); +} + +String? _buildStableSourceFingerprint({ + required String sourceApp, + required String? sourceUserId, + required String? sourceThreadId, +}) { + final String? userId = _trimToNull(sourceUserId); + final String? threadId = _trimToNull(sourceThreadId); + if (userId == null && threadId == null) { + return null; + } + + final List parts = [ + sourceApp.trim().toLowerCase(), + if (userId != null) 'u:${userId.toLowerCase()}', + if (threadId != null) 't:${threadId.toLowerCase()}', + ]; + return parts.join('|'); +} + +String _normalizeDisplayName(String? raw) { + if (raw == null) { + return ''; + } + final String compact = raw + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9]+'), ' ') + .trim(); + return compact.replaceAll(RegExp(r'\s+'), ' '); +} diff --git a/lib/app/data/relationship_repository_sync.dart b/lib/app/data/relationship_repository_sync.dart new file mode 100644 index 0000000..b717050 --- /dev/null +++ b/lib/app/data/relationship_repository_sync.dart @@ -0,0 +1,486 @@ +part of 'relationship_repository.dart'; + +/// Sync queue integration and change-envelope serialization helpers. +extension LocalRepositorySyncOperations on LocalRepository { + Future applyRemoteChanges(List changes) async { + if (changes.isEmpty) { + return; + } + + LocalDataState next = _requireState(); + for (final ChangeEnvelope change in changes) { + next = _applyRemoteChange(next, change); + } + await _setState(next); + } + + LocalDataState _applyRemoteChange( + LocalDataState current, + ChangeEnvelope change, + ) { + switch (change.entityType) { + case 'person': + return _applyPersonChange(current, change); + case 'capture': + return _applyMomentChange(current, change); + case 'giftIdea': + case 'eventIdea': + return _applyIdeaChange(current, change); + case 'reminderRule': + return _applyReminderChange(current, change); + default: + return current; + } + } + + LocalDataState _applyPersonChange( + LocalDataState current, + ChangeEnvelope change, + ) { + if (change.op == ChangeOperation.delete) { + final String personId = change.entityId; + return current.copyWith( + people: current.people + .where((PersonProfile person) => person.id != personId) + .toList(growable: false), + moments: current.moments + .where((RelationshipMoment moment) => moment.personId != personId) + .toList(growable: false), + ideas: current.ideas + .where((RelationshipIdea idea) => idea.personId != personId) + .toList(growable: false), + reminders: current.reminders + .where((ReminderRule reminder) => reminder.personId != personId) + .toList(growable: false), + preferenceSignals: current.preferenceSignals + .where( + (PersonPreferenceSignal signal) => signal.personId != personId, + ) + .toList(growable: false), + personFacts: current.personFacts + .where((PersonFact fact) => fact.personId != personId) + .toList(growable: false), + aiSuggestionDrafts: current.aiSuggestionDrafts + .where((AiSuggestionDraft draft) => draft.personId != personId) + .toList(growable: false), + importantDates: current.importantDates + .where((PersonImportantDate value) => value.personId != personId) + .toList(growable: false), + sourceLinks: current.sourceLinks + .where((SourceProfileLink link) => link.profileId != personId) + .toList(growable: false), + sharedMessages: current.sharedMessages + .where((SharedMessageEntry entry) => entry.profileId != personId) + .toList(growable: false), + ); + } + + final int existingIndex = current.people.indexWhere( + (PersonProfile person) => person.id == change.entityId, + ); + final PersonProfile? existing = existingIndex >= 0 + ? current.people[existingIndex] + : null; + final Map payload = change.payload ?? {}; + final PersonProfile next = PersonProfile( + id: change.entityId, + name: _stringField(payload, 'name', existing?.name ?? 'Unknown'), + relationship: _stringField( + payload, + 'relationship', + existing?.relationship ?? 'Relationship', + ), + affinityScore: _intField( + payload, + 'affinityScore', + existing?.affinityScore ?? 70, + ), + nextMoment: _dateField( + payload, + 'nextMoment', + existing?.nextMoment ?? DateTime.now().add(const Duration(days: 3)), + ), + tags: _stringListField(payload, 'tags', existing?.tags ?? []), + notes: _stringField(payload, 'notes', existing?.notes ?? ''), + aliases: _stringListField( + payload, + 'aliases', + existing?.aliases ?? [], + ), + location: _stringOrNullField(payload, 'location', existing?.location), + lastUpdatedAt: _dateOrNullField( + payload, + 'lastUpdatedAt', + existing?.lastUpdatedAt, + ), + lastInteractedAt: _dateOrNullField( + payload, + 'lastInteractedAt', + existing?.lastInteractedAt, + ), + ); + final List people = _upsertItem( + items: current.people, + item: next, + idOf: (PersonProfile person) => person.id, + ); + return current.copyWith(people: people); + } + + LocalDataState _applyMomentChange( + LocalDataState current, + ChangeEnvelope change, + ) { + if (change.op == ChangeOperation.delete) { + return current.copyWith( + moments: current.moments + .where((RelationshipMoment moment) => moment.id != change.entityId) + .toList(growable: false), + ); + } + + final int existingIndex = current.moments.indexWhere( + (RelationshipMoment moment) => moment.id == change.entityId, + ); + final RelationshipMoment? existing = existingIndex >= 0 + ? current.moments[existingIndex] + : null; + final Map payload = change.payload ?? {}; + final String summary = _stringField( + payload, + 'summary', + existing?.summary ?? '', + ); + final RelationshipMoment next = RelationshipMoment( + id: change.entityId, + personId: _stringField( + payload, + 'personId', + existing?.personId ?? + (current.people.isEmpty + ? 'person-unknown' + : current.people.first.id), + ), + title: _stringField( + payload, + 'title', + existing?.title ?? _titleFromSummary(summary), + ), + summary: summary, + at: _dateField(payload, 'at', existing?.at ?? DateTime.now()), + type: _stringField(payload, 'type', existing?.type ?? 'capture'), + ); + final List moments = _upsertItem( + items: current.moments, + item: next, + idOf: (RelationshipMoment moment) => moment.id, + ); + return current.copyWith(moments: moments); + } + + LocalDataState _applyIdeaChange( + LocalDataState current, + ChangeEnvelope change, + ) { + if (change.op == ChangeOperation.delete) { + return current.copyWith( + ideas: current.ideas + .where((RelationshipIdea idea) => idea.id != change.entityId) + .toList(growable: false), + ); + } + + final int existingIndex = current.ideas.indexWhere( + (RelationshipIdea idea) => idea.id == change.entityId, + ); + final RelationshipIdea? existing = existingIndex >= 0 + ? current.ideas[existingIndex] + : null; + final Map payload = change.payload ?? {}; + final IdeaType fallbackType = _ideaTypeFromEntityType( + change.entityType, + fallback: existing?.type ?? IdeaType.gift, + ); + final IdeaType type = _ideaTypeFromRaw( + payload['type'], + fallback: fallbackType, + ); + final RelationshipIdea next = RelationshipIdea( + id: change.entityId, + personId: payload['personId'] as String? ?? existing?.personId, + type: type, + title: _stringField(payload, 'title', existing?.title ?? 'Untitled idea'), + details: _stringField(payload, 'details', existing?.details ?? ''), + createdAt: _dateField( + payload, + 'createdAt', + existing?.createdAt ?? DateTime.now(), + ), + isArchived: _boolField( + payload, + 'isArchived', + existing?.isArchived ?? false, + ), + ); + final List ideas = _upsertItem( + items: current.ideas, + item: next, + idOf: (RelationshipIdea idea) => idea.id, + ); + return current.copyWith(ideas: ideas); + } + + LocalDataState _applyReminderChange( + LocalDataState current, + ChangeEnvelope change, + ) { + if (change.op == ChangeOperation.delete) { + return current.copyWith( + reminders: current.reminders + .where((ReminderRule reminder) => reminder.id != change.entityId) + .toList(growable: false), + ); + } + + final int existingIndex = current.reminders.indexWhere( + (ReminderRule reminder) => reminder.id == change.entityId, + ); + final ReminderRule? existing = existingIndex >= 0 + ? current.reminders[existingIndex] + : null; + final Map payload = change.payload ?? {}; + final ReminderRule next = ReminderRule( + id: change.entityId, + personId: payload['personId'] as String? ?? existing?.personId, + title: _stringField(payload, 'title', existing?.title ?? 'Reminder'), + cadence: _cadenceFromRaw( + payload['cadence'], + fallback: existing?.cadence ?? ReminderCadence.weekly, + ), + nextAt: _dateField(payload, 'nextAt', existing?.nextAt ?? DateTime.now()), + enabled: _boolField(payload, 'enabled', existing?.enabled ?? true), + ); + final List reminders = _upsertItem( + items: current.reminders, + item: next, + idOf: (ReminderRule reminder) => reminder.id, + ); + return current.copyWith(reminders: reminders); + } + + ChangeEnvelope _buildEnvelope({ + required String entityType, + required String entityId, + required ChangeOperation op, + Map? payload, + }) { + return ChangeEnvelope( + schemaVersion: _syncSchemaVersion, + entityType: entityType, + entityId: entityId, + op: op, + modifiedAt: DateTime.now().toUtc(), + clientMutationId: _uuid.v4(), + payload: payload, + ); + } + + Map _personPayload(PersonProfile person) { + return { + 'name': person.name, + 'relationship': person.relationship, + 'affinityScore': person.affinityScore, + 'nextMoment': person.nextMoment.toUtc().toIso8601String(), + 'tags': person.tags, + 'notes': person.notes, + 'aliases': person.aliases, + 'location': person.location, + 'lastUpdatedAt': person.lastUpdatedAt?.toUtc().toIso8601String(), + 'lastInteractedAt': person.lastInteractedAt?.toUtc().toIso8601String(), + }; + } + + Map _momentPayload(RelationshipMoment moment) { + return { + 'personId': moment.personId, + 'title': moment.title, + 'summary': moment.summary, + 'at': moment.at.toUtc().toIso8601String(), + 'type': moment.type, + }; + } + + Map _ideaPayload(RelationshipIdea idea) { + return { + 'personId': idea.personId, + 'type': idea.type.name, + 'title': idea.title, + 'details': idea.details, + 'createdAt': idea.createdAt.toUtc().toIso8601String(), + 'isArchived': idea.isArchived, + }; + } + + Map _reminderPayload(ReminderRule reminder) { + return { + 'personId': reminder.personId, + 'title': reminder.title, + 'cadence': reminder.cadence.name, + 'nextAt': reminder.nextAt.toUtc().toIso8601String(), + 'enabled': reminder.enabled, + }; + } +} + +String _ideaEntityType(IdeaType type) { + return type == IdeaType.event ? 'eventIdea' : 'giftIdea'; +} + +String _ideaEntityTypeFromId(List ideas, String ideaId) { + final int index = ideas.indexWhere( + (RelationshipIdea idea) => idea.id == ideaId, + ); + final RelationshipIdea? existing = index >= 0 ? ideas[index] : null; + if (existing == null) { + return 'giftIdea'; + } + return _ideaEntityType(existing.type); +} + +IdeaType _ideaTypeFromEntityType( + String entityType, { + required IdeaType fallback, +}) { + switch (entityType) { + case 'eventIdea': + return IdeaType.event; + case 'giftIdea': + return IdeaType.gift; + default: + return fallback; + } +} + +IdeaType _ideaTypeFromRaw(dynamic value, {required IdeaType fallback}) { + if (value is! String || value.isEmpty) { + return fallback; + } + + return IdeaType.values.firstWhere( + (IdeaType type) => type.name == value, + orElse: () => fallback, + ); +} + +ReminderCadence _cadenceFromRaw( + dynamic value, { + required ReminderCadence fallback, +}) { + if (value is! String || value.isEmpty) { + return fallback; + } + + return ReminderCadence.values.firstWhere( + (ReminderCadence cadence) => cadence.name == value, + orElse: () => fallback, + ); +} + +List _upsertItem({ + required List items, + required T item, + required String Function(T value) idOf, +}) { + final int index = items.indexWhere((T value) => idOf(value) == idOf(item)); + if (index < 0) { + return [item, ...items]; + } + + final List copy = items.toList(); + copy[index] = item; + return copy; +} + +String _stringField(Map payload, String key, String fallback) { + final dynamic value = payload[key]; + if (value is String && value.trim().isNotEmpty) { + return value.trim(); + } + return fallback; +} + +String? _stringOrNullField( + Map payload, + String key, + String? fallback, +) { + final dynamic value = payload[key]; + if (value is String) { + final String trimmed = value.trim(); + if (trimmed.isNotEmpty) { + return trimmed; + } + return null; + } + return fallback; +} + +int _intField(Map payload, String key, int fallback) { + final dynamic value = payload[key]; + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + return fallback; +} + +bool _boolField(Map payload, String key, bool fallback) { + final dynamic value = payload[key]; + if (value is bool) { + return value; + } + return fallback; +} + +List _stringListField( + Map payload, + String key, + List fallback, +) { + final dynamic value = payload[key]; + if (value is! List) { + return fallback; + } + + return value.map((dynamic item) => '$item').toList(growable: false); +} + +DateTime _dateField( + Map payload, + String key, + DateTime fallback, +) { + final dynamic value = payload[key]; + if (value is String) { + final DateTime? parsed = DateTime.tryParse(value); + if (parsed != null) { + return parsed.toLocal(); + } + } + return fallback; +} + +DateTime? _dateOrNullField( + Map payload, + String key, + DateTime? fallback, +) { + final dynamic value = payload[key]; + if (value is String) { + final DateTime? parsed = DateTime.tryParse(value); + return parsed?.toLocal(); + } + return fallback; +} diff --git a/lib/app/data/storage/README.md b/lib/app/data/storage/README.md new file mode 100644 index 0000000..55ac298 --- /dev/null +++ b/lib/app/data/storage/README.md @@ -0,0 +1,7 @@ +# App Storage + +This folder owns local persistence adapters for the aggregate app store. + +Use these files when you need to change how the local snapshot is stored. The +contract is `local_data_store.dart`; Hive and shared-preferences adapters are +swap-in implementations behind the provider. diff --git a/lib/app/data/storage/local_data_store.dart b/lib/app/data/storage/local_data_store.dart new file mode 100644 index 0000000..3835104 --- /dev/null +++ b/lib/app/data/storage/local_data_store.dart @@ -0,0 +1,19 @@ +/// Raw local state payload and associated schema version. +class LocalDataRecord { + const LocalDataRecord({required this.rawState, required this.schemaVersion}); + + final String rawState; + final int schemaVersion; +} + +/// Persistence boundary for local app state. +abstract interface class LocalDataStore { + /// Reads latest persisted state payload. + Future read(); + + /// Writes state payload and schema version. + Future write({required String rawState, required int schemaVersion}); + + /// Clears persisted state payload. + Future clear(); +} diff --git a/lib/app/data/storage/local_data_store_hive.dart b/lib/app/data/storage/local_data_store_hive.dart new file mode 100644 index 0000000..ccf787b --- /dev/null +++ b/lib/app/data/storage/local_data_store_hive.dart @@ -0,0 +1,54 @@ +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:relationship_saver/app/data/storage/local_data_store.dart'; + +/// Hive-backed local store for future DB migration path. +class HiveLocalDataStore implements LocalDataStore { + static const String _boxName = 'relationship_saver_local'; + static const String _stateKey = 'state_json'; + static const String _schemaVersionKey = 'schema_version'; + + static bool _initialized = false; + + @override + Future clear() async { + final Box box = await _openBox(); + await box.delete(_stateKey); + await box.delete(_schemaVersionKey); + } + + @override + Future read() async { + final Box box = await _openBox(); + final String? rawState = box.get(_stateKey) as String?; + if (rawState == null || rawState.isEmpty) { + return null; + } + + final int schemaVersion = + (box.get(_schemaVersionKey) as num?)?.toInt() ?? 0; + return LocalDataRecord(rawState: rawState, schemaVersion: schemaVersion); + } + + @override + Future write({ + required String rawState, + required int schemaVersion, + }) async { + final Box box = await _openBox(); + await box.put(_stateKey, rawState); + await box.put(_schemaVersionKey, schemaVersion); + } + + Future> _openBox() async { + if (!_initialized) { + await Hive.initFlutter(); + _initialized = true; + } + + if (!Hive.isBoxOpen(_boxName)) { + return Hive.openBox(_boxName); + } + + return Hive.box(_boxName); + } +} diff --git a/lib/app/data/storage/local_data_store_in_memory.dart b/lib/app/data/storage/local_data_store_in_memory.dart new file mode 100644 index 0000000..03a7042 --- /dev/null +++ b/lib/app/data/storage/local_data_store_in_memory.dart @@ -0,0 +1,22 @@ +import 'package:relationship_saver/app/data/storage/local_data_store.dart'; + +/// In-memory local store for deterministic tests. +class InMemoryLocalDataStore implements LocalDataStore { + LocalDataRecord? _record; + + @override + Future clear() async { + _record = null; + } + + @override + Future read() async => _record; + + @override + Future write({ + required String rawState, + required int schemaVersion, + }) async { + _record = LocalDataRecord(rawState: rawState, schemaVersion: schemaVersion); + } +} diff --git a/lib/app/data/storage/local_data_store_provider.dart b/lib/app/data/storage/local_data_store_provider.dart new file mode 100644 index 0000000..b214990 --- /dev/null +++ b/lib/app/data/storage/local_data_store_provider.dart @@ -0,0 +1,14 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/app/data/storage/local_data_store.dart'; +import 'package:relationship_saver/app/data/storage/local_data_store_hive.dart'; +import 'package:relationship_saver/app/data/storage/local_data_store_shared_prefs.dart'; +import 'package:relationship_saver/core/config/app_config.dart'; + +/// Chooses local persistence backend for app state. +final Provider localDataStoreProvider = + Provider((Ref ref) { + if (AppConfig.useHiveLocalDb) { + return HiveLocalDataStore(); + } + return SharedPrefsLocalDataStore(); + }); diff --git a/lib/app/data/storage/local_data_store_shared_prefs.dart b/lib/app/data/storage/local_data_store_shared_prefs.dart new file mode 100644 index 0000000..bac06ad --- /dev/null +++ b/lib/app/data/storage/local_data_store_shared_prefs.dart @@ -0,0 +1,44 @@ +import 'package:relationship_saver/app/data/storage/local_data_store.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// shared_preferences-backed local store used as stable default. +class SharedPrefsLocalDataStore implements LocalDataStore { + SharedPrefsLocalDataStore({ + this.stateKey = 'local_data_state_v1', + this.schemaVersionKey = 'local_data_schema_version', + }); + + final String stateKey; + final String schemaVersionKey; + + @override + Future clear() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.remove(stateKey); + await prefs.remove(schemaVersionKey); + } + + @override + Future read() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + final String? rawState = prefs.getString(stateKey); + if (rawState == null || rawState.isEmpty) { + return null; + } + + return LocalDataRecord( + rawState: rawState, + schemaVersion: prefs.getInt(schemaVersionKey) ?? 0, + ); + } + + @override + Future write({ + required String rawState, + required int schemaVersion, + }) async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setString(stateKey, rawState); + await prefs.setInt(schemaVersionKey, schemaVersion); + } +} diff --git a/lib/app/navigation/README.md b/lib/app/navigation/README.md new file mode 100644 index 0000000..b0d34e1 --- /dev/null +++ b/lib/app/navigation/README.md @@ -0,0 +1,6 @@ +# App Navigation + +This folder contains typed shell destinations used by the main app shell. + +Change `app_destination.dart` when you add, remove, or rename a top-level tab. +The shell in `../presentation/` maps these destinations to screens. diff --git a/lib/app/navigation/app_destination.dart b/lib/app/navigation/app_destination.dart new file mode 100644 index 0000000..bd77b8d --- /dev/null +++ b/lib/app/navigation/app_destination.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; + +/// Typed shell destinations used by the app navigation shell. +enum AppDestination { + dashboard('Dashboard', Icons.dashboard_rounded), + people('People', Icons.people_alt_rounded), + moments('Moments', Icons.auto_awesome_rounded), + signals('Signals', Icons.tips_and_updates_rounded), + aiReview('AI Review', Icons.rate_review_rounded), + ideas('Ideas', Icons.lightbulb_outline_rounded), + reminders('Reminders', Icons.notifications_active_outlined), + sync('Sync', Icons.sync_rounded), + settings('Settings', Icons.settings_rounded); + + const AppDestination(this.label, this.icon); + + final String label; + final IconData icon; +} + +/// Secondary destinations exposed from the compact shell overflow menu. +enum CompactSecondaryDestination { aiReview, ideas, reminders, sync, settings } diff --git a/lib/app/presentation/README.md b/lib/app/presentation/README.md new file mode 100644 index 0000000..064f6c9 --- /dev/null +++ b/lib/app/presentation/README.md @@ -0,0 +1,7 @@ +# App Presentation + +This folder owns the shared app shell UI. + +`app_shell.dart` is intentionally the only place that knows how wide-layout and +compact-layout navigation work. Feature screens should stay unaware of shell +details and just render their slice. diff --git a/lib/app/presentation/app_shell.dart b/lib/app/presentation/app_shell.dart new file mode 100644 index 0000000..cb10b60 --- /dev/null +++ b/lib/app/presentation/app_shell.dart @@ -0,0 +1,381 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/app/navigation/app_destination.dart'; +import 'package:relationship_saver/core/config/app_theme.dart'; +import 'package:relationship_saver/features/ai_digest/presentation/ai_digest_review_view.dart'; +import 'package:relationship_saver/features/dashboard/presentation/dashboard_view.dart'; +import 'package:relationship_saver/features/ideas/presentation/ideas_view.dart'; +import 'package:relationship_saver/features/moments/presentation/moments_view.dart'; +import 'package:relationship_saver/features/people/presentation/people_view.dart'; +import 'package:relationship_saver/features/reminders/presentation/reminders_view.dart'; +import 'package:relationship_saver/features/settings/presentation/settings_view.dart'; +import 'package:relationship_saver/features/signals/presentation/signals_view.dart'; +import 'package:relationship_saver/features/sync/presentation/sync_view.dart'; + +/// Responsive shell that hosts the main feature slices. +class AppShell extends ConsumerStatefulWidget { + const AppShell({super.key}); + + @override + ConsumerState createState() => _AppShellState(); +} + +class _AppShellState extends ConsumerState { + AppDestination _destination = AppDestination.dashboard; + + static const List _destinations = AppDestination.values; + static const List _compactPrimaryDestinations = + [ + AppDestination.dashboard, + AppDestination.people, + AppDestination.moments, + AppDestination.signals, + ]; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFF3F8FB), + Color(0xFFEAF1F8), + Color(0xFFF8FAFC), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Scaffold( + backgroundColor: Colors.transparent, + body: SafeArea( + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + if (constraints.maxWidth < 1000) { + return _buildCompactLayout(context); + } + + return Row( + children: [ + _Sidebar( + selected: _destination, + destinations: _destinations, + onChange: _selectDestination, + ), + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + child: KeyedSubtree( + key: ValueKey(_destination), + child: _screenFor(_destination), + ), + ), + ), + ], + ); + }, + ), + ), + ), + ); + } + + Widget _buildCompactLayout(BuildContext context) { + final int compactIndex = _compactSelectedIndex(_destination); + final AppDestination active = _compactPrimaryDestinations[compactIndex]; + return _CompactShell( + title: active.label, + selected: active, + body: _screenFor(active), + destinations: _compactPrimaryDestinations, + onChange: _selectDestination, + onOpenSecondary: (CompactSecondaryDestination destination) { + final AppDestination screenDestination = switch (destination) { + CompactSecondaryDestination.ideas => AppDestination.ideas, + CompactSecondaryDestination.aiReview => AppDestination.aiReview, + CompactSecondaryDestination.reminders => AppDestination.reminders, + CompactSecondaryDestination.sync => AppDestination.sync, + CompactSecondaryDestination.settings => AppDestination.settings, + }; + + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return _CompactSecondaryScreen( + title: screenDestination.label, + child: _screenFor(screenDestination), + ); + }, + ), + ); + }, + ); + } + + void _selectDestination(AppDestination destination) { + setState(() { + _destination = destination; + }); + } + + int _compactSelectedIndex(AppDestination destination) { + for (int i = 0; i < _compactPrimaryDestinations.length; i += 1) { + if (_compactPrimaryDestinations[i] == destination) { + return i; + } + } + return 0; + } + + Widget _screenFor(AppDestination destination) { + switch (destination) { + case AppDestination.dashboard: + return const DashboardView(); + case AppDestination.people: + return const PeopleView(); + case AppDestination.moments: + return const MomentsView(); + case AppDestination.signals: + return const SignalsView(); + case AppDestination.aiReview: + return const AiDigestReviewView(); + case AppDestination.ideas: + return const IdeasView(); + case AppDestination.reminders: + return const RemindersView(); + case AppDestination.sync: + return const SyncView(); + case AppDestination.settings: + return const SettingsView(); + } + } +} + +class _Sidebar extends StatelessWidget { + const _Sidebar({ + required this.selected, + required this.destinations, + required this.onChange, + }); + + final AppDestination selected; + final List destinations; + final ValueChanged onChange; + + @override + Widget build(BuildContext context) { + return Container( + width: 290, + margin: const EdgeInsets.fromLTRB(20, 20, 8, 20), + padding: const EdgeInsets.fromLTRB(18, 20, 18, 18), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.78), + borderRadius: BorderRadius.circular(28), + border: Border.all(color: Colors.white.withValues(alpha: 0.7)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + gradient: const LinearGradient( + colors: [Color(0xFF1AB6C8), Color(0xFF286DE0)], + ), + ), + child: const Icon( + Icons.favorite_rounded, + color: Colors.white, + size: 20, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Relationship Saver', + style: Theme.of(context).textTheme.titleMedium, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 22), + ...destinations.map((AppDestination destination) { + final bool isSelected = destination == selected; + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: InkWell( + onTap: () => onChange(destination), + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + decoration: BoxDecoration( + color: isSelected + ? const Color(0xFFEAF7FB) + : Colors.transparent, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Icon( + destination.icon, + size: 20, + color: isSelected + ? AppTheme.primary + : AppTheme.textSecondary, + ), + const SizedBox(width: 10), + Text( + destination.label, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith( + color: isSelected + ? AppTheme.primary + : AppTheme.textSecondary, + ), + ), + ], + ), + ), + ), + ); + }), + const Spacer(), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF2F8FB), + borderRadius: BorderRadius.circular(16), + ), + child: Text( + 'Tip: Use Signals daily and convert at least one into a concrete plan.', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), + ), + ), + ], + ), + ); + } +} + +class _CompactShell extends StatelessWidget { + const _CompactShell({ + required this.title, + required this.selected, + required this.body, + required this.destinations, + required this.onChange, + required this.onOpenSecondary, + }); + + final String title; + final AppDestination selected; + final Widget body; + final List destinations; + final ValueChanged onChange; + final ValueChanged onOpenSecondary; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + PopupMenuButton( + tooltip: 'More', + icon: const Icon(Icons.more_horiz_rounded), + onSelected: onOpenSecondary, + itemBuilder: (BuildContext context) => + >[ + const PopupMenuItem( + value: CompactSecondaryDestination.aiReview, + child: Text('AI Review'), + ), + const PopupMenuItem( + value: CompactSecondaryDestination.ideas, + child: Text('Ideas'), + ), + const PopupMenuItem( + value: CompactSecondaryDestination.reminders, + child: Text('Reminders'), + ), + const PopupMenuItem( + value: CompactSecondaryDestination.sync, + child: Text('Sync'), + ), + const PopupMenuItem( + value: CompactSecondaryDestination.settings, + child: Text('Settings'), + ), + ], + ), + ], + ), + ), + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 220), + child: KeyedSubtree( + key: ValueKey(selected), + child: body, + ), + ), + ), + NavigationBar( + selectedIndex: destinations.indexOf(selected), + onDestinationSelected: (int index) => onChange(destinations[index]), + destinations: destinations + .map( + (AppDestination destination) => NavigationDestination( + icon: Icon(destination.icon), + label: destination.label, + ), + ) + .toList(growable: false), + ), + ], + ); + } +} + +class _CompactSecondaryScreen extends StatelessWidget { + const _CompactSecondaryScreen({required this.title, required this.child}); + + final String title; + final Widget child; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [Color(0xFFF3F8FB), Color(0xFFEAF1F8)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar(title: Text(title)), + body: child, + ), + ); + } +} diff --git a/lib/app/relationship_saver_app.dart b/lib/app/relationship_saver_app.dart new file mode 100644 index 0000000..6ed8ce4 --- /dev/null +++ b/lib/app/relationship_saver_app.dart @@ -0,0 +1,63 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/app/presentation/app_shell.dart'; +import 'package:relationship_saver/core/config/app_theme.dart'; +import 'package:relationship_saver/core/llm/llm_config.dart'; +import 'package:relationship_saver/features/ai_digest/application/llm_digest_background_scheduler.dart'; +import 'package:relationship_saver/features/ai_digest/presentation/ai_digest_notification_listener.dart'; +import 'package:relationship_saver/features/auth/application/session_controller.dart'; +import 'package:relationship_saver/features/auth/presentation/sign_in_view.dart'; +import 'package:relationship_saver/features/reminders/application/reminder_notification_listener.dart'; +import 'package:relationship_saver/features/share_intake/presentation/incoming_share_listener.dart'; +import 'package:relationship_saver/features/sync/application/sync_background_runner.dart'; +import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; + +/// Root app widget that wires auth, shell entry, and global listeners. +class RelationshipSaverApp extends ConsumerStatefulWidget { + const RelationshipSaverApp({super.key}); + + @override + ConsumerState createState() => + _RelationshipSaverAppState(); +} + +class _RelationshipSaverAppState extends ConsumerState { + @override + void initState() { + super.initState(); + unawaited(ref.read(llmConfigProvider.notifier).initialize()); + } + + @override + Widget build(BuildContext context) { + final WidgetRef ref = this.ref; + final AsyncValue sessionState = ref.watch( + sessionControllerProvider, + ); + + return MaterialApp( + title: 'Relationship Saver', + debugShowCheckedModeBanner: false, + theme: AppTheme.light(), + home: Scaffold( + body: sessionState.when( + data: (AuthSession? session) => session == null + ? const SignInView() + : const IncomingShareListener( + child: ReminderNotificationListener( + child: AiDigestNotificationListener( + child: LlmDigestBackgroundRunner( + child: SyncBackgroundRunner(child: AppShell()), + ), + ), + ), + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: (Object error, StackTrace stackTrace) => const SignInView(), + ), + ), + ); + } +} diff --git a/lib/app/state/README.md b/lib/app/state/README.md new file mode 100644 index 0000000..dbe0cab --- /dev/null +++ b/lib/app/state/README.md @@ -0,0 +1,7 @@ +# App State + +`local_data_state.dart` is the aggregate snapshot that the prototype persists. + +Treat it as composition, not as a domain home. New models should usually be +added in a feature slice first, then referenced from the aggregate state if the +prototype store needs them persisted. diff --git a/lib/app/state/local_data_state.dart b/lib/app/state/local_data_state.dart new file mode 100644 index 0000000..2ef1c6d --- /dev/null +++ b/lib/app/state/local_data_state.dart @@ -0,0 +1,380 @@ +// ignore_for_file: sort_constructors_first + +import 'package:flutter/foundation.dart'; +import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart'; +import 'package:relationship_saver/features/dashboard/domain/dashboard_models.dart'; +import 'package:relationship_saver/features/ideas/domain/idea_models.dart'; +import 'package:relationship_saver/features/moments/domain/moment_models.dart'; +import 'package:relationship_saver/features/people/domain/person_models.dart'; +import 'package:relationship_saver/features/reminders/domain/reminder_models.dart'; +import 'package:relationship_saver/features/share_intake/domain/share_models.dart'; + +/// Aggregate local snapshot used by the prototype app store. +/// +/// During this prototyping phase the app still uses one persisted snapshot for +/// most product state. The types inside it are now feature-owned, so each slice +/// can evolve without hiding its models in a horizontal "local" module. +@immutable +class LocalDataState { + const LocalDataState({ + required this.people, + required this.moments, + required this.ideas, + required this.reminders, + required this.tasks, + this.dismissedSignalIds = const [], + this.sourceLinks = const [], + this.sharedMessages = const [], + this.sharedInbox = const [], + this.personFacts = const [], + this.importantDates = const [], + this.preferenceSignals = const [], + this.aiSuggestionDrafts = const [], + }); + + final List people; + final List moments; + final List ideas; + final List reminders; + final List tasks; + final List dismissedSignalIds; + final List sourceLinks; + final List sharedMessages; + final List sharedInbox; + final List personFacts; + final List importantDates; + final List preferenceSignals; + final List aiSuggestionDrafts; + + LocalDataState copyWith({ + List? people, + List? moments, + List? ideas, + List? reminders, + List? tasks, + List? dismissedSignalIds, + List? sourceLinks, + List? sharedMessages, + List? sharedInbox, + List? personFacts, + List? importantDates, + List? preferenceSignals, + List? aiSuggestionDrafts, + }) { + return LocalDataState( + people: people ?? this.people, + moments: moments ?? this.moments, + ideas: ideas ?? this.ideas, + reminders: reminders ?? this.reminders, + tasks: tasks ?? this.tasks, + dismissedSignalIds: dismissedSignalIds ?? this.dismissedSignalIds, + sourceLinks: sourceLinks ?? this.sourceLinks, + sharedMessages: sharedMessages ?? this.sharedMessages, + sharedInbox: sharedInbox ?? this.sharedInbox, + personFacts: personFacts ?? this.personFacts, + importantDates: importantDates ?? this.importantDates, + preferenceSignals: preferenceSignals ?? this.preferenceSignals, + aiSuggestionDrafts: aiSuggestionDrafts ?? this.aiSuggestionDrafts, + ); + } + + DashboardSummary summary({DateTime? now}) { + final DateTime baseline = now ?? DateTime.now(); + return DashboardSummary( + activePeople: people.length, + upcomingPlans: people + .where((PersonProfile p) => p.nextMoment.isAfter(baseline)) + .length, + pendingIdeas: ideas + .where((RelationshipIdea idea) => !idea.isArchived) + .length, + weeklyConsistency: moments.isEmpty + ? 0 + : (70 + moments.length * 4).clamp(0, 100), + ); + } + + Map toJson() { + return { + 'people': people + .map((PersonProfile person) => person.toJson()) + .toList(growable: false), + 'moments': moments + .map((RelationshipMoment moment) => moment.toJson()) + .toList(growable: false), + 'ideas': ideas + .map((RelationshipIdea idea) => idea.toJson()) + .toList(growable: false), + 'reminders': reminders + .map((ReminderRule reminder) => reminder.toJson()) + .toList(growable: false), + 'tasks': tasks + .map((DashboardTask task) => task.toJson()) + .toList(growable: false), + 'dismissedSignalIds': dismissedSignalIds, + 'sourceLinks': sourceLinks + .map((SourceProfileLink link) => link.toJson()) + .toList(growable: false), + 'sharedMessages': sharedMessages + .map((SharedMessageEntry entry) => entry.toJson()) + .toList(growable: false), + 'sharedInbox': sharedInbox + .map((SharedInboxEntry entry) => entry.toJson()) + .toList(growable: false), + 'personFacts': personFacts + .map((PersonFact fact) => fact.toJson()) + .toList(growable: false), + 'importantDates': importantDates + .map((PersonImportantDate value) => value.toJson()) + .toList(growable: false), + 'preferenceSignals': preferenceSignals + .map((PersonPreferenceSignal signal) => signal.toJson()) + .toList(growable: false), + 'aiSuggestionDrafts': aiSuggestionDrafts + .map((AiSuggestionDraft draft) => draft.toJson()) + .toList(growable: false), + }; + } + + factory LocalDataState.fromJson(Map json) { + return LocalDataState( + people: (json['people'] as List? ?? []) + .map( + (dynamic person) => + PersonProfile.fromJson(person as Map), + ) + .toList(growable: false), + moments: (json['moments'] as List? ?? []) + .map( + (dynamic moment) => + RelationshipMoment.fromJson(moment as Map), + ) + .toList(growable: false), + ideas: (json['ideas'] as List? ?? []) + .map( + (dynamic idea) => + RelationshipIdea.fromJson(idea as Map), + ) + .toList(growable: false), + reminders: (json['reminders'] as List? ?? []) + .map( + (dynamic reminder) => + ReminderRule.fromJson(reminder as Map), + ) + .toList(growable: false), + tasks: (json['tasks'] as List? ?? []) + .map( + (dynamic task) => + DashboardTask.fromJson(task as Map), + ) + .toList(growable: false), + dismissedSignalIds: + (json['dismissedSignalIds'] as List? ?? []) + .map((dynamic value) => '$value') + .toList(growable: false), + sourceLinks: (json['sourceLinks'] as List? ?? []) + .map( + (dynamic link) => + SourceProfileLink.fromJson(link as Map), + ) + .toList(growable: false), + sharedMessages: (json['sharedMessages'] as List? ?? []) + .map( + (dynamic entry) => + SharedMessageEntry.fromJson(entry as Map), + ) + .toList(growable: false), + sharedInbox: (json['sharedInbox'] as List? ?? []) + .map( + (dynamic entry) => + SharedInboxEntry.fromJson(entry as Map), + ) + .toList(growable: false), + personFacts: (json['personFacts'] as List? ?? []) + .map( + (dynamic fact) => PersonFact.fromJson(fact as Map), + ) + .toList(growable: false), + importantDates: (json['importantDates'] as List? ?? []) + .map( + (dynamic value) => + PersonImportantDate.fromJson(value as Map), + ) + .toList(growable: false), + preferenceSignals: + (json['preferenceSignals'] as List? ?? []) + .map( + (dynamic signal) => PersonPreferenceSignal.fromJson( + signal as Map, + ), + ) + .toList(growable: false), + aiSuggestionDrafts: + (json['aiSuggestionDrafts'] as List? ?? []) + .map( + (dynamic draft) => + AiSuggestionDraft.fromJson(draft as Map), + ) + .toList(growable: false), + ); + } + + static LocalDataState seed() { + return LocalDataState( + people: [ + PersonProfile( + id: 'p-ava', + name: 'Ava Hart', + relationship: 'Partner', + affinityScore: 92, + nextMoment: DateTime(2026, 2, 16, 19, 30), + tags: ['coffee', 'books', 'slow mornings'], + notes: 'Prefers thoughtful plans over expensive plans.', + aliases: ['Aves'], + location: 'Austin, TX', + lastUpdatedAt: DateTime(2026, 2, 14, 9, 0), + lastInteractedAt: DateTime(2026, 2, 12, 18, 30), + ), + PersonProfile( + id: 'p-jordan', + name: 'Jordan Lee', + relationship: 'Close Friend', + affinityScore: 78, + nextMoment: DateTime(2026, 2, 18, 18, 0), + tags: ['running', 'street food'], + notes: 'Has a race on Sunday; ask how training is going.', + location: 'Chicago, IL', + lastUpdatedAt: DateTime(2026, 2, 13, 8, 30), + lastInteractedAt: DateTime(2026, 2, 11, 19, 0), + ), + PersonProfile( + id: 'p-mila', + name: 'Mila Stone', + relationship: 'Sister', + affinityScore: 84, + nextMoment: DateTime(2026, 2, 20, 12, 0), + tags: ['plants', 'retro music'], + notes: 'Birthday prep should start this week.', + aliases: ['Millie'], + location: 'Seattle, WA', + lastUpdatedAt: DateTime(2026, 2, 13, 14, 20), + lastInteractedAt: DateTime(2026, 2, 12, 14, 20), + ), + ], + moments: [ + RelationshipMoment( + id: 'm-1', + personId: 'p-ava', + title: 'Sunday Walk Tradition', + summary: 'Short walk + no-phone hour worked really well.', + at: DateTime(2026, 2, 9, 9, 30), + type: 'ritual', + ), + RelationshipMoment( + id: 'm-2', + personId: 'p-jordan', + title: 'Post-workout Check-in', + summary: 'Shared training playlist and grabbed smoothies.', + at: DateTime(2026, 2, 11, 19, 0), + type: 'support', + ), + RelationshipMoment( + id: 'm-3', + personId: 'p-mila', + title: 'Gift Idea Captured', + summary: 'Found a vintage lamp from her saved style board.', + at: DateTime(2026, 2, 12, 14, 20), + type: 'gift', + ), + ], + ideas: [ + RelationshipIdea( + id: 'i-1', + personId: 'p-ava', + type: IdeaType.gift, + title: 'Handwritten recipe journal', + details: 'Collect 10 memories and recipes from this year.', + createdAt: DateTime(2026, 2, 12, 9), + ), + RelationshipIdea( + id: 'i-2', + personId: 'p-mila', + type: IdeaType.event, + title: 'Plant market + brunch date', + details: 'Saturday morning slot; book nearby cafe in advance.', + createdAt: DateTime(2026, 2, 13, 11), + ), + ], + reminders: [ + ReminderRule( + id: 'r-1', + personId: 'p-jordan', + title: 'Weekly check-in message', + cadence: ReminderCadence.weekly, + nextAt: DateTime(2026, 2, 18, 18), + ), + ReminderRule( + id: 'r-2', + personId: 'p-ava', + title: 'Sunday ritual planning', + cadence: ReminderCadence.weekly, + nextAt: DateTime(2026, 2, 16, 10), + ), + ], + tasks: [ + DashboardTask( + id: 't-1', + title: 'Plan Friday dinner with Ava', + description: 'Keep it low-key: ramen + bookstore stop.', + when: DateTime(2026, 2, 16, 17, 0), + ), + DashboardTask( + id: 't-2', + title: 'Send race-day encouragement to Jordan', + description: 'Voice note before 8:00 AM.', + when: DateTime(2026, 2, 17, 7, 30), + ), + DashboardTask( + id: 't-3', + title: 'Finalize Mila birthday shortlist', + description: 'Pick 1 meaningful gift and 1 backup.', + when: DateTime(2026, 2, 18, 20, 0), + ), + ], + personFacts: [ + PersonFact( + id: 'pf-1', + personId: 'p-ava', + type: CapturedFactType.like, + text: 'Quiet brunch spots and recipe-book stores', + label: 'Weekend preference', + sourceKind: CaptureSourceKind.manual, + createdAt: DateTime(2026, 2, 10, 8, 0), + updatedAt: DateTime(2026, 2, 10, 8, 0), + ), + PersonFact( + id: 'pf-2', + personId: 'p-mila', + type: CapturedFactType.giftIdea, + text: 'Vintage ceramic planter in muted green', + label: 'Gift lead', + sourceKind: CaptureSourceKind.manual, + createdAt: DateTime(2026, 2, 12, 14, 25), + updatedAt: DateTime(2026, 2, 12, 14, 25), + ), + ], + importantDates: [ + PersonImportantDate( + id: 'pd-1', + personId: 'p-mila', + label: 'Birthday', + date: DateTime(2026, 3, 3), + classification: 'birthday', + sourceKind: CaptureSourceKind.manual, + createdAt: DateTime(2026, 2, 12, 14, 30), + updatedAt: DateTime(2026, 2, 12, 14, 30), + ), + ], + ); + } +} diff --git a/lib/core/README.md b/lib/core/README.md new file mode 100644 index 0000000..75f8a3b --- /dev/null +++ b/lib/core/README.md @@ -0,0 +1,11 @@ +# Core + +`lib/core/` is for truly cross-cutting code: + +- config and theme +- auth primitives +- network clients and interceptors +- small shared utilities +- shared presentation primitives + +If a file mainly exists for one slice, keep it in that slice instead. diff --git a/lib/core/auth/README.md b/lib/core/auth/README.md new file mode 100644 index 0000000..81c34cb --- /dev/null +++ b/lib/core/auth/README.md @@ -0,0 +1,4 @@ +# Core Auth + +Cross-cutting auth primitives live here. Keep this folder limited to reusable +infrastructure rather than slice-specific sign-in UI or session flows. diff --git a/lib/core/config/README.md b/lib/core/config/README.md new file mode 100644 index 0000000..1f7e14c --- /dev/null +++ b/lib/core/config/README.md @@ -0,0 +1,4 @@ +# Core Config + +App-wide configuration, feature flags, and theme-level constants live here. +Use this folder for global configuration only, not feature-owned defaults. diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index 2675bff..f81223d 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -9,6 +9,11 @@ class AppConfig { static String? _baseUrlOverride; + static const String _defaultSentryDsn = String.fromEnvironment( + 'SENTRY_DSN', + defaultValue: '', + ); + /// Returns the configured backend base URL. static String get backendBaseUrl { final String? override = _baseUrlOverride; @@ -48,6 +53,9 @@ class AppConfig { defaultValue: 180, ); + /// Optional Sentry DSN for release crash/error reporting. + static String get sentryDsn => _defaultSentryDsn.trim(); + /// Runtime override for backend URL (e.g. local settings screen). static void overrideBackendBaseUrl(String? baseUrl) { _baseUrlOverride = baseUrl; diff --git a/lib/core/llm/captured_fact_draft_parser.dart b/lib/core/llm/captured_fact_draft_parser.dart new file mode 100644 index 0000000..8497d9d --- /dev/null +++ b/lib/core/llm/captured_fact_draft_parser.dart @@ -0,0 +1,73 @@ +import 'dart:convert'; + +import 'package:relationship_saver/features/share_intake/domain/share_models.dart'; + +CapturedFactDraft? parseCapturedFactDraftSuggestion( + String response, { + required SharedPayload fallbackPayload, +}) { + try { + final int jsonStart = response.indexOf('{'); + final int jsonEnd = response.lastIndexOf('}'); + if (jsonStart == -1 || jsonEnd == -1 || jsonEnd <= jsonStart) { + return null; + } + + final Map json = + jsonDecode(response.substring(jsonStart, jsonEnd + 1)) + as Map; + final String typeName = '${json['type'] ?? CapturedFactType.note.name}'; + final DateTime? parsedDate = _parseDate(json['dateValue']); + final double? confidence = _parseConfidence(json['confidence']); + + return CapturedFactDraft( + type: CapturedFactType.values.firstWhere( + (CapturedFactType type) => type.name == typeName, + orElse: () => CapturedFactType.note, + ), + text: '${json['text'] ?? fallbackPayload.rawText}'.trim(), + label: _trimToNull(json['label'] as String?), + dateValue: parsedDate, + confidence: confidence, + isSensitive: json['isSensitive'] as bool? ?? false, + needsReview: true, + ); + } catch (_) { + return null; + } +} + +String? _trimToNull(String? value) { + if (value == null) { + return null; + } + final String trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; +} + +DateTime? _parseDate(Object? raw) { + if (raw == null) { + return null; + } + final String value = '$raw'.trim(); + if (value.isEmpty) { + return null; + } + return DateTime.tryParse(value)?.toLocal(); +} + +double? _parseConfidence(Object? raw) { + if (raw == null) { + return null; + } + final double? value = switch (raw) { + final num number => number.toDouble(), + final String text => double.tryParse(text), + _ => null, + }; + if (value == null) { + return null; + } + final num clamped = value.clamp(0, 1); + return clamped.toDouble(); +} diff --git a/lib/core/llm/llm_config.dart b/lib/core/llm/llm_config.dart new file mode 100644 index 0000000..ce4f6f9 --- /dev/null +++ b/lib/core/llm/llm_config.dart @@ -0,0 +1,107 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +const String _llmApiKeyStorageKey = 'llm_api_key'; +const String _llmProviderStorageKey = 'llm_provider'; + +enum LlmProvider { openai, anthropic, google } + +class LlmConfigState { + const LlmConfigState({ + this.apiKeyConfigured = false, + this.provider = LlmProvider.openai, + this.isConfigured = false, + }); + + final bool apiKeyConfigured; + final LlmProvider provider; + final bool isConfigured; + + LlmConfigState copyWith({ + bool? apiKeyConfigured, + LlmProvider? provider, + bool? isConfigured, + }) { + return LlmConfigState( + apiKeyConfigured: apiKeyConfigured ?? this.apiKeyConfigured, + provider: provider ?? this.provider, + isConfigured: isConfigured ?? this.isConfigured, + ); + } +} + +class LlmConfigNotifier extends Notifier { + Future? _initializeFuture; + + @override + LlmConfigState build() { + return const LlmConfigState(); + } + + Future initialize() async { + final Future? inFlight = _initializeFuture; + if (inFlight != null) { + return inFlight; + } + _initializeFuture = _initializeFromStorage(); + return _initializeFuture; + } + + Future _initializeFromStorage() async { + const FlutterSecureStorage secureStorage = FlutterSecureStorage(); + final String? storedKey = await secureStorage.read( + key: _llmApiKeyStorageKey, + ); + final String? storedProvider = await secureStorage.read( + key: _llmProviderStorageKey, + ); + + state = LlmConfigState( + apiKeyConfigured: storedKey != null && storedKey.isNotEmpty, + provider: storedProvider != null + ? LlmProvider.values.firstWhere( + (LlmProvider p) => p.name == storedProvider, + orElse: () => LlmProvider.openai, + ) + : LlmProvider.openai, + isConfigured: storedKey != null && storedKey.isNotEmpty, + ); + } + + Future setApiKey(String apiKey) async { + const FlutterSecureStorage secureStorage = FlutterSecureStorage(); + if (apiKey.isEmpty) { + await secureStorage.delete(key: _llmApiKeyStorageKey); + } else { + await secureStorage.write(key: _llmApiKeyStorageKey, value: apiKey); + } + state = state.copyWith( + apiKeyConfigured: apiKey.isNotEmpty, + isConfigured: apiKey.isNotEmpty, + ); + } + + Future setProvider(LlmProvider provider) async { + const FlutterSecureStorage secureStorage = FlutterSecureStorage(); + await secureStorage.write( + key: _llmProviderStorageKey, + value: provider.name, + ); + state = state.copyWith(provider: provider); + } + + Future getApiKey() async { + const FlutterSecureStorage secureStorage = FlutterSecureStorage(); + return secureStorage.read(key: _llmApiKeyStorageKey); + } + + Future clearApiKey() async { + const FlutterSecureStorage secureStorage = FlutterSecureStorage(); + await secureStorage.delete(key: _llmApiKeyStorageKey); + state = state.copyWith(apiKeyConfigured: false, isConfigured: false); + } +} + +final llmConfigProvider = NotifierProvider( + LlmConfigNotifier.new, +); diff --git a/lib/core/llm/llm_service.dart b/lib/core/llm/llm_service.dart new file mode 100644 index 0000000..6ab1b3f --- /dev/null +++ b/lib/core/llm/llm_service.dart @@ -0,0 +1,328 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/llm/captured_fact_draft_parser.dart'; +import 'package:relationship_saver/core/llm/llm_config.dart'; +import 'package:relationship_saver/features/people/domain/person_models.dart'; +import 'package:relationship_saver/features/share_intake/domain/share_models.dart'; + +class LlmService { + LlmService(this._ref); + + final Ref _ref; + + Future get _config async { + final state = _ref.read(llmConfigProvider); + if (!state.isConfigured) { + throw Exception('LLM not configured'); + } + return state; + } + + Future> generateSignals( + List people, + ) async { + final config = await _config; + final apiKey = await _ref.read(llmConfigProvider.notifier).getApiKey(); + + if (apiKey == null || apiKey.isEmpty) { + throw Exception('No API key available'); + } + + final systemPrompt = + '''You are a relationship assistant that helps users maintain meaningful connections with their friends and family. +Based on the person's profile data, generate helpful signals such as: +- Check-in reminders +- Gift ideas +- Follow-up suggestions +- Relationship maintenance tips + +Return your response as a JSON array of signals, each with: +- title: short actionable title +- description: brief explanation +- type: "recommendation", "gift", "followup", or "reminder" +- personId: the person's ID (use a valid ID from the input if applicable) + +Only return the JSON array, no other text.'''; + + final peopleContext = people + .map( + (p) => + '- ${p.name} (${p.relationship}): tags=${p.tags.join(", ")}, notes=${p.notes.isEmpty ? "none" : p.notes}, affinity=${p.affinityScore}', + ) + .join('\n'); + + final userPrompt = + '''Here are the people in my life: +$peopleContext + +Generate 3-5 helpful relationship signals for these people.'''; + + try { + final response = await _callLlm( + provider: config.provider, + apiKey: apiKey, + systemPrompt: systemPrompt, + userPrompt: userPrompt, + ); + + return _parseSignalsResponse(response, people); + } catch (e) { + throw Exception('Failed to generate signals: $e'); + } + } + + Future completeText({ + required String systemPrompt, + required String userPrompt, + }) async { + final config = await _config; + final String? apiKey = await _ref + .read(llmConfigProvider.notifier) + .getApiKey(); + + if (apiKey == null || apiKey.isEmpty) { + throw Exception('No API key available'); + } + + return _callLlm( + provider: config.provider, + apiKey: apiKey, + systemPrompt: systemPrompt, + userPrompt: userPrompt, + ); + } + + Future suggestCaptureDraft(SharedPayload payload) async { + final config = await _config; + final String? apiKey = await _ref + .read(llmConfigProvider.notifier) + .getApiKey(); + + if (apiKey == null || apiKey.isEmpty) { + throw Exception('No API key available'); + } + + final String systemPrompt = + '''You analyze shared messages and links for a relationship-tracking app. +Return exactly one JSON object with: +- type: one of "note", "like", "dislike", "importantDate", "giftIdea", "placeIdea", "activityIdea", "misc" +- text: cleaned user-facing summary +- label: short optional label +- dateValue: ISO-8601 date if the content clearly describes an important date, otherwise null +- confidence: number from 0 to 1 +- isSensitive: true only if the content looks private or sensitive + +Be conservative. If the content is ambiguous, prefer "note". Return JSON only.'''; + + final String userPrompt = + '''Source app: ${payload.sourceApp} +Sender: ${payload.sourceDisplayName ?? "unknown"} +Platform: ${payload.platform} +URL: ${payload.url ?? "none"} +Raw text: +${payload.rawText}'''; + + try { + final String response = await _callLlm( + provider: config.provider, + apiKey: apiKey, + systemPrompt: systemPrompt, + userPrompt: userPrompt, + ); + return parseCapturedFactDraftSuggestion( + response, + fallbackPayload: payload, + ); + } catch (e) { + throw Exception('Failed to suggest share capture draft: $e'); + } + } + + Future _callLlm({ + required LlmProvider provider, + required String apiKey, + required String systemPrompt, + required String userPrompt, + }) async { + final dio = Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 20), + receiveTimeout: const Duration(seconds: 20), + sendTimeout: const Duration(seconds: 20), + ), + ); + + switch (provider) { + case LlmProvider.openai: + return _callOpenAI(dio, apiKey, systemPrompt, userPrompt); + case LlmProvider.anthropic: + return _callAnthropic(dio, apiKey, systemPrompt, userPrompt); + case LlmProvider.google: + return _callGoogleAI(dio, apiKey, systemPrompt, userPrompt); + } + } + + Future _callOpenAI( + Dio dio, + String apiKey, + String systemPrompt, + String userPrompt, + ) async { + final Response> response = await dio + .post>( + 'https://api.openai.com/v1/chat/completions', + options: Options( + headers: { + 'Authorization': 'Bearer $apiKey', + 'Content-Type': 'application/json', + }, + ), + data: jsonEncode({ + 'model': 'gpt-4o-mini', + 'messages': [ + {'role': 'system', 'content': systemPrompt}, + {'role': 'user', 'content': userPrompt}, + ], + 'max_tokens': 1000, + }), + ); + + final Map responseData = response.data!; + final List choices = responseData['choices'] as List; + final Map firstChoice = choices[0] as Map; + final Map message = + firstChoice['message'] as Map; + final String content = message['content'] as String; + return content; + } + + Future _callAnthropic( + Dio dio, + String apiKey, + String systemPrompt, + String userPrompt, + ) async { + final Response> response = await dio + .post>( + 'https://api.anthropic.com/v1/messages', + options: Options( + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + 'Content-Type': 'application/json', + }, + ), + data: jsonEncode({ + 'model': 'claude-3-haiku-20240307', + 'system': systemPrompt, + 'messages': [ + {'role': 'user', 'content': userPrompt}, + ], + 'max_tokens': 1000, + }), + ); + + final Map responseData = response.data!; + final List contentList = responseData['content'] as List; + final Map firstContent = + contentList[0] as Map; + final String content = firstContent['text'] as String; + return content; + } + + Future _callGoogleAI( + Dio dio, + String apiKey, + String systemPrompt, + String userPrompt, + ) async { + final Response> + response = await dio.post>( + 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent', + options: Options(headers: {'Content-Type': 'application/json'}), + queryParameters: {'key': apiKey}, + data: jsonEncode({ + 'systemInstruction': { + 'parts': [ + {'text': systemPrompt}, + ], + }, + 'contents': [ + { + 'parts': [ + {'text': userPrompt}, + ], + }, + ], + 'generationConfig': {'temperature': 0.7, 'maxOutputTokens': 1000}, + }), + ); + + final Map responseData = response.data!; + final List candidates = + responseData['candidates'] as List; + final Map firstCandidate = + candidates[0] as Map; + final Map content = + firstCandidate['content'] as Map; + final List parts = content['parts'] as List; + final Map firstPart = parts[0] as Map; + final String contentText = firstPart['text'] as String; + return contentText; + } + + List _parseSignalsResponse( + String response, + List people, + ) { + try { + final jsonStart = response.indexOf('['); + final jsonEnd = response.lastIndexOf(']'); + + if (jsonStart == -1 || jsonEnd == -1) { + return []; + } + + final jsonStr = response.substring(jsonStart, jsonEnd + 1); + final List items = jsonDecode(jsonStr) as List; + + return items.map((item) { + final Map itemMap = item as Map; + final String? personId = itemMap['personId'] as String?; + final validPersonId = + personId != null && people.any((p) => p.id == personId) + ? personId + : (people.isNotEmpty ? people.first.id : null); + + return GeneratedSignal( + title: itemMap['title'] as String? ?? 'Check in', + description: itemMap['description'] as String? ?? '', + type: itemMap['type'] as String? ?? 'recommendation', + personId: validPersonId, + ); + }).toList(); + } catch (e) { + return []; + } + } +} + +class GeneratedSignal { + const GeneratedSignal({ + required this.title, + required this.description, + required this.type, + this.personId, + }); + + final String title; + final String description; + final String type; + final String? personId; +} + +final llmServiceProvider = Provider((Ref ref) { + return LlmService(ref); +}); diff --git a/lib/core/network/README.md b/lib/core/network/README.md new file mode 100644 index 0000000..cbac158 --- /dev/null +++ b/lib/core/network/README.md @@ -0,0 +1,4 @@ +# Core Network + +This folder owns shared networking infrastructure. It is the right place for +transport concerns like reachability and interceptors, not feature business logic. diff --git a/lib/core/network/interceptors/README.md b/lib/core/network/interceptors/README.md new file mode 100644 index 0000000..08e7b34 --- /dev/null +++ b/lib/core/network/interceptors/README.md @@ -0,0 +1,4 @@ +# Network Interceptors + +Request and response interception belongs here. Keep these classes stateless and +transport-focused so features do not need to know about client plumbing. diff --git a/lib/core/network/reachability/README.md b/lib/core/network/reachability/README.md new file mode 100644 index 0000000..a3408a5 --- /dev/null +++ b/lib/core/network/reachability/README.md @@ -0,0 +1,4 @@ +# Reachability + +Network availability checks and platform-specific reachability code live here. +This folder should answer "can we talk to the network?" and nothing more. diff --git a/lib/core/network/reachability/network_reachability_io.dart b/lib/core/network/reachability/network_reachability_io.dart index 063a2ed..43fbf2c 100644 --- a/lib/core/network/reachability/network_reachability_io.dart +++ b/lib/core/network/reachability/network_reachability_io.dart @@ -30,19 +30,17 @@ class IoNetworkReachability implements NetworkReachability { Stream watch({Duration timeout = const Duration(seconds: 2)}) { final Stream changes = _connectivity.onConnectivityChanged as Stream; - return changes - .asyncMap((dynamic event) async { - final Iterable results = - _normalizeConnectivityResults(event); - final bool hasTransport = results.any( - (ConnectivityResult result) => result != ConnectivityResult.none, - ); - if (!hasTransport) { - return false; - } - return isReachable(timeout: timeout); - }) - .distinct(); + return changes.asyncMap((dynamic event) async { + final Iterable results = + _normalizeConnectivityResults(event); + final bool hasTransport = results.any( + (ConnectivityResult result) => result != ConnectivityResult.none, + ); + if (!hasTransport) { + return false; + } + return isReachable(timeout: timeout); + }).distinct(); } Iterable _normalizeConnectivityResults(dynamic event) { diff --git a/lib/core/observability/sentry_init.dart b/lib/core/observability/sentry_init.dart new file mode 100644 index 0000000..1a1aeea --- /dev/null +++ b/lib/core/observability/sentry_init.dart @@ -0,0 +1,63 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/config/app_config.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +class SentryInit { + SentryInit(); + + Future? _initializeFuture; + bool _initialized = false; + + Future initialize({FutureOr Function()? appRunner}) async { + final Future? inFlight = _initializeFuture; + if (inFlight != null) { + await inFlight; + if (appRunner != null && !_initialized) { + await Future.sync(appRunner); + } + return; + } + + _initializeFuture = _initializeInternal(appRunner: appRunner); + return _initializeFuture; + } + + Future _initializeInternal({ + FutureOr Function()? appRunner, + }) async { + if (!kReleaseMode || AppConfig.sentryDsn.isEmpty) { + if (appRunner != null) { + await Future.sync(appRunner); + } + return; + } + + await SentryFlutter.init((options) { + options.dsn = AppConfig.sentryDsn; + options.environment = AppConfig.useFakeBackend + ? 'development' + : 'production'; + options.tracesSampleRate = 0.1; + }, appRunner: appRunner); + _initialized = true; + } + + void captureException(Object error, {StackTrace? stackTrace}) { + if (_initialized) { + Sentry.captureException(error, stackTrace: stackTrace); + } + } + + void captureMessage(String message, {SentryLevel level = SentryLevel.info}) { + if (_initialized) { + Sentry.captureMessage(message, level: level); + } + } +} + +final sentryInitProvider = Provider((Ref ref) { + return SentryInit(); +}); diff --git a/lib/core/presentation/README.md b/lib/core/presentation/README.md new file mode 100644 index 0000000..e7ff271 --- /dev/null +++ b/lib/core/presentation/README.md @@ -0,0 +1,7 @@ +# Core Presentation + +This folder holds shared UI primitives that are not owned by one product +feature. Keep widgets here small and reusable. + +`frosted_card.dart` is the canonical shared card surface used across several +screens. diff --git a/lib/core/presentation/frosted_card.dart b/lib/core/presentation/frosted_card.dart new file mode 100644 index 0000000..21b3bdd --- /dev/null +++ b/lib/core/presentation/frosted_card.dart @@ -0,0 +1,40 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +class FrostedCard extends StatelessWidget { + const FrostedCard({ + required this.child, + super.key, + this.padding = const EdgeInsets.all(20), + }); + + final Widget child; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(24), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), + child: Container( + padding: padding, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Colors.white.withValues(alpha: 0.6)), + boxShadow: const [ + BoxShadow( + color: Color(0x14000000), + blurRadius: 24, + offset: Offset(0, 12), + ), + ], + ), + child: child, + ), + ), + ); + } +} diff --git a/lib/core/utils/README.md b/lib/core/utils/README.md new file mode 100644 index 0000000..d7485d2 --- /dev/null +++ b/lib/core/utils/README.md @@ -0,0 +1,4 @@ +# Core Utils + +Use this folder sparingly for tiny reusable helpers that are truly cross-cutting. +If a helper is owned by one slice, keep it in that slice instead. diff --git a/lib/features/README.md b/lib/features/README.md new file mode 100644 index 0000000..e415559 --- /dev/null +++ b/lib/features/README.md @@ -0,0 +1,13 @@ +# Feature Slices + +Each feature folder is the main place to work on product behavior. + +Typical subfolders: + +- `domain/`: models or invariants owned by the slice +- `application/`: orchestration, providers, controllers, side effects +- `presentation/`: screens, widgets, dialogs, UI state +- `data/`: persistence or repositories owned by the slice + +Some older files at the slice root still exist as compatibility exports so +tests and untouched code paths keep working while the structure settles. diff --git a/lib/features/ai_digest/application/ai_digest_notifier.dart b/lib/features/ai_digest/application/ai_digest_notifier.dart new file mode 100644 index 0000000..6016a6a --- /dev/null +++ b/lib/features/ai_digest/application/ai_digest_notifier.dart @@ -0,0 +1,105 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +const String aiDigestNotificationPayload = 'ai_digest_review_inbox'; + +final Provider +aiDigestNotificationIntentBusProvider = Provider( + (Ref ref) { + final AiDigestNotificationIntentBus bus = AiDigestNotificationIntentBus(); + ref.onDispose(() { + unawaited(bus.close()); + }); + return bus; + }, +); + +class AiDigestNotificationIntentBus { + final StreamController _controller = + StreamController.broadcast(); + + Stream get intents => _controller.stream; + + void emitTap(String payload) { + if (!_controller.isClosed) { + _controller.add(payload); + } + } + + Future close() => _controller.close(); +} + +abstract interface class AiDigestNotifier { + Future showDigestReady(int count); +} + +class LocalAiDigestNotifier implements AiDigestNotifier { + LocalAiDigestNotifier({ + FlutterLocalNotificationsPlugin? plugin, + void Function(String payload)? onTapPayload, + }) : _plugin = plugin ?? FlutterLocalNotificationsPlugin(), + _onTapPayload = onTapPayload; + + final FlutterLocalNotificationsPlugin _plugin; + final void Function(String payload)? _onTapPayload; + Future? _initializeFuture; + + @override + Future showDigestReady(int count) async { + if (!_supportsNotifications()) { + return; + } + await _ensureInitialized(); + await _plugin.show( + id: 8124, + title: 'Relationship digest ready', + body: + '$count private ${count == 1 ? 'suggestion is' : 'suggestions are'} ready to review.', + notificationDetails: const NotificationDetails( + android: AndroidNotificationDetails( + 'relationship_saver_ai_digest', + 'Relationship Digest', + channelDescription: 'Private AI digest suggestions ready for review', + importance: Importance.defaultImportance, + priority: Priority.defaultPriority, + ), + iOS: DarwinNotificationDetails(), + macOS: DarwinNotificationDetails(), + ), + payload: aiDigestNotificationPayload, + ); + } + + Future _ensureInitialized() { + return _initializeFuture ??= _plugin.initialize( + settings: const InitializationSettings( + android: AndroidInitializationSettings('@mipmap/ic_launcher'), + iOS: DarwinInitializationSettings(), + macOS: DarwinInitializationSettings(), + ), + onDidReceiveNotificationResponse: (NotificationResponse response) { + final String? payload = response.payload; + if (payload != null && payload.isNotEmpty) { + _onTapPayload?.call(payload); + } + }, + ); + } + + bool _supportsNotifications() { + if (kIsWeb) { + return false; + } + return defaultTargetPlatform != TargetPlatform.linux; + } +} + +final Provider aiDigestNotifierProvider = + Provider((Ref ref) { + return LocalAiDigestNotifier( + onTapPayload: ref.watch(aiDigestNotificationIntentBusProvider).emitTap, + ); + }); diff --git a/lib/features/ai_digest/application/ai_digest_response_parser.dart b/lib/features/ai_digest/application/ai_digest_response_parser.dart new file mode 100644 index 0000000..3a27da3 --- /dev/null +++ b/lib/features/ai_digest/application/ai_digest_response_parser.dart @@ -0,0 +1,175 @@ +import 'dart:convert'; + +import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart'; +import 'package:uuid/uuid.dart'; + +const Uuid _uuid = Uuid(); + +class AiDigestParseResult { + const AiDigestParseResult({required this.drafts, this.error}); + + final List drafts; + final String? error; + + bool get success => error == null; +} + +class AiDigestResponseParser { + const AiDigestResponseParser({ + this.maxItems = 10, + this.maxTitleChars = 90, + this.maxDetailsChars = 420, + }); + + final int maxItems; + final int maxTitleChars; + final int maxDetailsChars; + + AiDigestParseResult parse({ + required String response, + required Map tokenToPersonId, + required String sourceRunId, + required DateTime suggestedAt, + }) { + try { + final Object? decoded = jsonDecode(_extractJson(response)); + final List items = _extractItems(decoded); + final List drafts = []; + + for (final dynamic item in items.take(maxItems)) { + if (item is! Map) { + continue; + } + final String? token = _string(item['personToken']); + final String? personId = token == null ? null : tokenToPersonId[token]; + if (personId == null) { + continue; + } + + final AiSuggestionKind? kind = _kind(item['kind']); + final String title = _trimAndCap( + _string(item['title']) ?? '', + maxTitleChars, + ); + if (kind == null || title.isEmpty) { + continue; + } + + drafts.add( + AiSuggestionDraft( + id: 'ai-${_uuid.v4()}', + personId: personId, + kind: kind, + title: title, + details: _trimAndCap( + _string(item['details']) ?? '', + maxDetailsChars, + ), + suggestedAt: suggestedAt, + suggestedFor: + _dateOrNull(item['suggestedFor']) ?? + _dateOrNull(item['suggestedTiming']), + confidence: _confidence(item['confidence']), + status: AiSuggestionStatus.pending, + sourceRunId: sourceRunId, + reason: _trimAndCap(_string(item['reason']) ?? '', 240), + ), + ); + } + + if (drafts.isEmpty) { + return const AiDigestParseResult( + drafts: [], + error: 'No valid digest suggestions returned.', + ); + } + + return AiDigestParseResult(drafts: drafts); + } catch (error) { + return AiDigestParseResult( + drafts: const [], + error: 'Unable to parse digest response: $error', + ); + } + } + + String _extractJson(String response) { + final String trimmed = response.trim(); + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + return trimmed; + } + final int objectStart = trimmed.indexOf('{'); + final int arrayStart = trimmed.indexOf('['); + final int start = objectStart == -1 + ? arrayStart + : arrayStart == -1 + ? objectStart + : objectStart < arrayStart + ? objectStart + : arrayStart; + final int objectEnd = trimmed.lastIndexOf('}'); + final int arrayEnd = trimmed.lastIndexOf(']'); + final int end = objectEnd > arrayEnd ? objectEnd : arrayEnd; + if (start < 0 || end < start) { + throw const FormatException('No JSON object or array found.'); + } + return trimmed.substring(start, end + 1); + } + + List _extractItems(Object? decoded) { + if (decoded is List) { + return decoded; + } + if (decoded is Map) { + final Object? suggestions = decoded['suggestions'] ?? decoded['items']; + if (suggestions is List) { + return suggestions; + } + } + throw const FormatException('Digest response must be a JSON array.'); + } + + AiSuggestionKind? _kind(Object? raw) { + final String? value = _string(raw); + if (value == null) { + return null; + } + for (final AiSuggestionKind kind in AiSuggestionKind.values) { + if (kind.name == value) { + return kind; + } + } + return null; + } + + String? _string(Object? raw) { + if (raw == null) { + return null; + } + final String value = '$raw'.trim(); + return value.isEmpty ? null : value; + } + + DateTime? _dateOrNull(Object? raw) { + final String? value = _string(raw); + if (value == null) { + return null; + } + return DateTime.tryParse(value)?.toLocal(); + } + + double _confidence(Object? raw) { + if (raw is num) { + return raw.toDouble().clamp(0.0, 1.0).toDouble(); + } + return 0.5; + } + + String _trimAndCap(String value, int maxChars) { + final String trimmed = value.trim(); + if (trimmed.length <= maxChars) { + return trimmed; + } + return trimmed.substring(0, maxChars).trim(); + } +} diff --git a/lib/features/ai_digest/application/anonymized_llm_context_builder.dart b/lib/features/ai_digest/application/anonymized_llm_context_builder.dart new file mode 100644 index 0000000..c9eb8b1 --- /dev/null +++ b/lib/features/ai_digest/application/anonymized_llm_context_builder.dart @@ -0,0 +1,285 @@ +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:relationship_saver/app/state/local_data_state.dart'; +import 'package:relationship_saver/features/people/domain/person_models.dart'; + +class AnonymizedLlmDigestContext { + const AnonymizedLlmDigestContext({ + required this.payload, + required this.tokenToPersonId, + }); + + final Map payload; + final Map tokenToPersonId; + + String toPromptJson() { + return const JsonEncoder.withIndent(' ').convert(payload); + } +} + +class AnonymizedLlmContextBuilder { + const AnonymizedLlmContextBuilder({ + this.maxPeople = 20, + this.maxSignalsPerPerson = 8, + DateTime Function()? now, + }) : _now = now ?? DateTime.now; + + final int maxPeople; + final int maxSignalsPerPerson; + final DateTime Function() _now; + + AnonymizedLlmDigestContext build(LocalDataState state) { + final DateTime now = _now(); + final List selectedPeople = + state.people + .where( + (PersonProfile person) => _isDigestRelevant(state, person, now), + ) + .toList(growable: false) + ..sort( + (PersonProfile a, PersonProfile b) => _urgencyScore( + state, + b, + now, + ).compareTo(_urgencyScore(state, a, now)), + ); + + final List> peoplePayload = >[]; + final Map tokenToPersonId = {}; + + for (int i = 0; i < math.min(selectedPeople.length, maxPeople); i += 1) { + final PersonProfile person = selectedPeople[i]; + final String token = 'person_${(i + 1).toString().padLeft(3, '0')}'; + tokenToPersonId[token] = person.id; + peoplePayload.add(_personPayload(state, person, token, now)); + } + + return AnonymizedLlmDigestContext( + tokenToPersonId: tokenToPersonId, + payload: { + 'schema': 'relationship_saver_private_digest_v1', + 'task': + 'Create a balanced private weekly digest with gift ideas, event ideas, reminders, and check-ins.', + 'rules': [ + 'Use only personToken values from the input.', + 'Do not infer or ask for names.', + 'Return JSON only.', + 'Prefer practical suggestions that can be reviewed before saving.', + ], + 'limits': { + 'maxItems': 10, + 'allowedKinds': [ + 'giftIdea', + 'eventIdea', + 'checkIn', + 'reminder', + ], + }, + 'people': peoplePayload, + }, + ); + } + + bool _isDigestRelevant( + LocalDataState state, + PersonProfile person, + DateTime now, + ) { + if (_daysUntil(person.nextMoment, now).abs() <= 60) { + return true; + } + final DateTime? last = person.lastInteractedAt; + if (last == null || now.difference(last).inDays >= 14) { + return true; + } + return state.importantDates.any( + (PersonImportantDate value) => + value.personId == person.id && + !value.isSensitive && + _daysUntil(value.date, now).abs() <= 60, + ) || + state.preferenceSignals.any( + (PersonPreferenceSignal signal) => + signal.personId == person.id && + signal.status != PreferenceSignalStatus.dismissed, + ); + } + + int _urgencyScore(LocalDataState state, PersonProfile person, DateTime now) { + final int nextMomentDays = _daysUntil(person.nextMoment, now).abs(); + int score = math.max(0, 80 - nextMomentDays); + final DateTime? last = person.lastInteractedAt; + if (last == null) { + score += 30; + } else { + score += math.min(40, now.difference(last).inDays); + } + score += + state.importantDates + .where( + (PersonImportantDate value) => + value.personId == person.id && + !value.isSensitive && + _daysUntil(value.date, now).abs() <= 60, + ) + .length * + 10; + return score; + } + + Map _personPayload( + LocalDataState state, + PersonProfile person, + String token, + DateTime now, + ) { + final List signals = state.preferenceSignals + .where( + (PersonPreferenceSignal signal) => + signal.personId == person.id && + signal.status != PreferenceSignalStatus.dismissed, + ) + .take(maxSignalsPerPerson) + .toList(growable: false); + + final List dates = state.importantDates + .where( + (PersonImportantDate value) => + value.personId == person.id && !value.isSensitive, + ) + .take(maxSignalsPerPerson) + .toList(growable: false); + + return { + 'personToken': token, + 'relationshipCategory': _relationshipCategory(person.relationship), + 'affinityBand': _affinityBand(person.affinityScore), + 'upcoming': [ + _relativeWindow('next planned moment', person.nextMoment, now), + ...dates.map( + (PersonImportantDate value) => _relativeWindow( + _safeCategory(value.classification), + value.date, + now, + ), + ), + ], + 'recency': _recency(person.lastInteractedAt, now), + 'interests': _safeList([ + ...person.tags, + ...signals + .where( + (PersonPreferenceSignal signal) => + signal.polarity == PreferenceSignalPolarity.like, + ) + .map((PersonPreferenceSignal signal) => signal.label), + ]).take(maxSignalsPerPerson).toList(growable: false), + 'constraints': _safeList( + signals + .where( + (PersonPreferenceSignal signal) => + signal.polarity == PreferenceSignalPolarity.dislike, + ) + .map((PersonPreferenceSignal signal) => 'avoid ${signal.label}') + .toList(growable: false), + ).take(maxSignalsPerPerson).toList(growable: false), + }; + } + + String _relationshipCategory(String value) { + final String normalized = value.toLowerCase(); + if (_containsAny(normalized, [ + 'partner', + 'spouse', + 'wife', + 'husband', + ])) { + return 'partner'; + } + if (_containsAny(normalized, [ + 'family', + 'sister', + 'brother', + 'mother', + 'father', + 'parent', + 'cousin', + 'aunt', + 'uncle', + ])) { + return 'family'; + } + if (normalized.contains('friend')) { + return 'friend'; + } + if (_containsAny(normalized, ['work', 'colleague', 'coworker'])) { + return 'colleague'; + } + return 'relationship'; + } + + String _affinityBand(int score) { + if (score >= 85) { + return 'very close'; + } + if (score >= 65) { + return 'close'; + } + return 'light'; + } + + String _relativeWindow(String label, DateTime date, DateTime now) { + final int days = _daysUntil(date, now); + final String when = days == 0 + ? 'today' + : days > 0 + ? 'in about $days days' + : 'about ${days.abs()} days ago'; + return '${_safeCategory(label)} $when'; + } + + String _recency(DateTime? lastInteractedAt, DateTime now) { + if (lastInteractedAt == null) { + return 'no recent interaction recorded'; + } + final int days = now.difference(lastInteractedAt).inDays; + if (days <= 1) { + return 'contacted recently'; + } + return 'last contact about $days days ago'; + } + + List _safeList(Iterable values) { + final List out = []; + final Set seen = {}; + for (final String raw in values) { + final String value = _safeCategory(raw); + if (value.isEmpty) { + continue; + } + if (seen.add(value.toLowerCase())) { + out.add(value); + } + } + return out; + } + + String _safeCategory(String value) { + return value + .trim() + .replaceAll(RegExp(r'https?://\S+'), '') + .replaceAll(RegExp(r'[^a-zA-Z0-9 +&/-]+'), '') + .replaceAll(RegExp(r'\s+'), ' ') + .trim() + .toLowerCase(); + } + + int _daysUntil(DateTime date, DateTime now) { + return date.difference(DateTime(now.year, now.month, now.day)).inDays; + } + + bool _containsAny(String value, List probes) { + return probes.any(value.contains); + } +} diff --git a/lib/features/ai_digest/application/llm_digest_background_scheduler.dart b/lib/features/ai_digest/application/llm_digest_background_scheduler.dart new file mode 100644 index 0000000..c5f944d --- /dev/null +++ b/lib/features/ai_digest/application/llm_digest_background_scheduler.dart @@ -0,0 +1,125 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/features/ai_digest/data/llm_digest_config.dart'; +import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart'; +import 'package:workmanager/workmanager.dart'; + +const String llmDigestTaskUniqueName = 'relationship_saver_llm_digest_weekly'; +const String llmDigestTaskName = 'com.relationshipsaver.llm.digest'; + +abstract interface class LlmDigestBackgroundScheduler { + Future configure(LlmDigestConfigState config); + + Future cancel(); +} + +class WorkmanagerLlmDigestBackgroundScheduler + implements LlmDigestBackgroundScheduler { + const WorkmanagerLlmDigestBackgroundScheduler({Workmanager? workmanager}) + : _workmanager = workmanager; + + final Workmanager? _workmanager; + + Workmanager get _driver => _workmanager ?? Workmanager(); + + @override + Future configure(LlmDigestConfigState config) async { + if (!config.enabled) { + await cancel(); + return; + } + try { + await _driver.registerPeriodicTask( + llmDigestTaskUniqueName, + llmDigestTaskName, + frequency: const Duration(days: 7), + initialDelay: _initialDelay(config, DateTime.now()), + constraints: Constraints( + networkType: config.requireWifi + ? NetworkType.unmetered + : NetworkType.connected, + requiresCharging: config.requireCharging, + ), + existingWorkPolicy: ExistingPeriodicWorkPolicy.update, + ); + } on UnimplementedError { + // Flutter tests and unsupported platforms have no Workmanager backend. + } + } + + @override + Future cancel() async { + try { + await _driver.cancelByUniqueName(llmDigestTaskUniqueName); + } on UnimplementedError { + // Flutter tests and unsupported platforms have no Workmanager backend. + } + } + + Duration _initialDelay(LlmDigestConfigState config, DateTime now) { + DateTime target = DateTime( + now.year, + now.month, + now.day, + config.preferredHour, + ); + final int dayDelta = (config.preferredWeekday - now.weekday) % 7; + target = target.add(Duration(days: dayDelta)); + if (!target.isAfter(now)) { + target = target.add(const Duration(days: 7)); + } + return target.difference(now); + } +} + +final Provider +llmDigestBackgroundSchedulerProvider = Provider( + (Ref ref) => const WorkmanagerLlmDigestBackgroundScheduler(), +); + +class LlmDigestBackgroundRunner extends ConsumerStatefulWidget { + const LlmDigestBackgroundRunner({required this.child, super.key}); + + final Widget child; + + @override + ConsumerState createState() => + _LlmDigestBackgroundRunnerState(); +} + +class _LlmDigestBackgroundRunnerState + extends ConsumerState { + ProviderSubscription? _subscription; + + @override + void initState() { + super.initState(); + unawaited(_initializeAndConfigure()); + _subscription = ref.listenManual( + llmDigestConfigProvider, + (LlmDigestConfigState? previous, LlmDigestConfigState next) { + unawaited( + ref.read(llmDigestBackgroundSchedulerProvider).configure(next), + ); + }, + ); + } + + Future _initializeAndConfigure() async { + await ref.read(llmDigestConfigProvider.notifier).initialize(); + await ref + .read(llmDigestBackgroundSchedulerProvider) + .configure(ref.read(llmDigestConfigProvider)); + } + + @override + void dispose() { + _subscription?.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/lib/features/ai_digest/application/llm_digest_environment.dart b/lib/features/ai_digest/application/llm_digest_environment.dart new file mode 100644 index 0000000..857df33 --- /dev/null +++ b/lib/features/ai_digest/application/llm_digest_environment.dart @@ -0,0 +1,40 @@ +import 'package:battery_plus/battery_plus.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart'; + +abstract interface class LlmDigestEnvironment { + Future canRun(LlmDigestConfigState config); +} + +class DeviceLlmDigestEnvironment implements LlmDigestEnvironment { + DeviceLlmDigestEnvironment({Connectivity? connectivity, Battery? battery}) + : _connectivity = connectivity ?? Connectivity(), + _battery = battery ?? Battery(); + + final Connectivity _connectivity; + final Battery _battery; + + @override + Future canRun(LlmDigestConfigState config) async { + if (config.requireWifi) { + final List results = await _connectivity + .checkConnectivity(); + if (!results.contains(ConnectivityResult.wifi)) { + return false; + } + } + + if (config.requireCharging) { + final BatteryState state = await _battery.batteryState; + if (state != BatteryState.charging && state != BatteryState.full) { + return false; + } + } + + return true; + } +} + +final Provider llmDigestEnvironmentProvider = + Provider((Ref ref) => DeviceLlmDigestEnvironment()); diff --git a/lib/features/ai_digest/application/llm_digest_orchestrator.dart b/lib/features/ai_digest/application/llm_digest_orchestrator.dart new file mode 100644 index 0000000..0cff607 --- /dev/null +++ b/lib/features/ai_digest/application/llm_digest_orchestrator.dart @@ -0,0 +1,256 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/app/data/relationship_repository.dart'; +import 'package:relationship_saver/app/state/local_data_state.dart'; +import 'package:relationship_saver/core/llm/llm_config.dart'; +import 'package:relationship_saver/features/ai_digest/application/ai_digest_notifier.dart'; +import 'package:relationship_saver/features/ai_digest/application/ai_digest_response_parser.dart'; +import 'package:relationship_saver/features/ai_digest/application/anonymized_llm_context_builder.dart'; +import 'package:relationship_saver/features/ai_digest/application/llm_digest_environment.dart'; +import 'package:relationship_saver/features/ai_digest/application/llm_digest_text_client.dart'; +import 'package:relationship_saver/features/ai_digest/data/llm_digest_config.dart'; +import 'package:relationship_saver/features/ai_digest/data/llm_digest_run_store.dart'; +import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart'; +import 'package:uuid/uuid.dart'; + +const Duration _scheduledCooldown = Duration(days: 7); +const Duration _staleRunningAfter = Duration(minutes: 30); +const Uuid _uuid = Uuid(); + +final Provider llmDigestApiKeyConfiguredProvider = Provider( + (Ref ref) => ref.watch(llmConfigProvider).isConfigured, +); + +final Provider Function()> llmDigestInitializerProvider = + Provider Function()>((Ref ref) { + return () async { + await ref.read(llmConfigProvider.notifier).initialize(); + await ref.read(llmDigestConfigProvider.notifier).initialize(); + }; + }); + +class LlmDigestRunResult { + const LlmDigestRunResult({ + required this.started, + required this.completed, + this.createdDraftCount = 0, + this.reason, + }); + + final bool started; + final bool completed; + final int createdDraftCount; + final String? reason; +} + +class LlmDigestOrchestrator { + LlmDigestOrchestrator(this._ref, {DateTime Function()? now}) + : _now = now ?? DateTime.now; + + final Ref _ref; + final DateTime Function() _now; + + Future runScheduledDigest() { + return _run(bypassCooldown: false, notify: true); + } + + Future runManualDigest() { + return _run(bypassCooldown: true, notify: true); + } + + Future _run({ + required bool bypassCooldown, + required bool notify, + }) async { + await _ref.read(llmDigestInitializerProvider)(); + + final LlmDigestConfigState config = _ref.read(llmDigestConfigProvider); + if (!config.enabled && !bypassCooldown) { + return const LlmDigestRunResult( + started: false, + completed: false, + reason: 'Digest is disabled.', + ); + } + + if (!_ref.read(llmDigestApiKeyConfiguredProvider)) { + await _markFailure('No LLM API key configured.'); + return const LlmDigestRunResult( + started: false, + completed: false, + reason: 'No LLM API key configured.', + ); + } + + final LlmDigestRunStore store = _ref.read(llmDigestRunStoreProvider); + final LlmDigestRunState before = await store.read(); + final DateTime now = _now(); + if (_isAlreadyRunning(before, now)) { + return const LlmDigestRunResult( + started: false, + completed: false, + reason: 'Digest is already running.', + ); + } + if (!bypassCooldown && !_cooldownAllowsRun(before, now)) { + return const LlmDigestRunResult( + started: false, + completed: false, + reason: 'Weekly digest cooldown is still active.', + ); + } + if (!bypassCooldown && !_failureBackoffAllowsRun(before, now)) { + return const LlmDigestRunResult( + started: false, + completed: false, + reason: 'Failure backoff is still active.', + ); + } + + if (!await _ref.read(llmDigestEnvironmentProvider).canRun(config)) { + return const LlmDigestRunResult( + started: false, + completed: false, + reason: 'Network or charging policy is not satisfied.', + ); + } + + await store.write( + before.copyWith(lastStartedAt: now, running: true, lastFailure: null), + ); + + try { + final LocalDataState state = await _ref.read( + localRepositoryProvider.future, + ); + final AnonymizedLlmDigestContext context = + const AnonymizedLlmContextBuilder().build(state); + if (context.tokenToPersonId.isEmpty) { + await store.write( + before.copyWith( + lastStartedAt: now, + lastCompletedAt: now, + running: false, + lastFailure: null, + consecutiveFailures: 0, + ), + ); + return const LlmDigestRunResult( + started: true, + completed: true, + reason: 'No eligible people for digest.', + ); + } + + final String sourceRunId = 'digest-${_uuid.v4()}'; + final String response = await _ref + .read(llmDigestTextClientProvider) + .complete( + systemPrompt: _systemPrompt, + userPrompt: context.toPromptJson(), + ); + final AiDigestParseResult parsed = const AiDigestResponseParser().parse( + response: response, + tokenToPersonId: context.tokenToPersonId, + sourceRunId: sourceRunId, + suggestedAt: now, + ); + + if (!parsed.success) { + await _markFailure(parsed.error ?? 'Invalid digest response.'); + return LlmDigestRunResult( + started: true, + completed: false, + reason: parsed.error, + ); + } + + await _ref + .read(localRepositoryProvider.notifier) + .saveAiSuggestionDrafts(parsed.drafts); + await store.write( + LlmDigestRunState( + lastStartedAt: now, + lastCompletedAt: _now(), + consecutiveFailures: 0, + ), + ); + if (notify && parsed.drafts.isNotEmpty) { + await _ref + .read(aiDigestNotifierProvider) + .showDigestReady(parsed.drafts.length); + } + return LlmDigestRunResult( + started: true, + completed: true, + createdDraftCount: parsed.drafts.length, + ); + } catch (error) { + await _markFailure('$error'); + return LlmDigestRunResult( + started: true, + completed: false, + reason: '$error', + ); + } + } + + Future _markFailure(String reason) async { + final LlmDigestRunStore store = _ref.read(llmDigestRunStoreProvider); + final LlmDigestRunState current = await store.read(); + await store.write( + LlmDigestRunState( + lastStartedAt: current.lastStartedAt, + lastCompletedAt: current.lastCompletedAt, + lastFailure: reason, + consecutiveFailures: current.consecutiveFailures + 1, + ), + ); + } + + bool _isAlreadyRunning(LlmDigestRunState state, DateTime now) { + final DateTime? started = state.lastStartedAt; + return state.running && + started != null && + now.difference(started) < _staleRunningAfter; + } + + bool _cooldownAllowsRun(LlmDigestRunState state, DateTime now) { + final DateTime? completed = state.lastCompletedAt; + return completed == null || now.difference(completed) >= _scheduledCooldown; + } + + bool _failureBackoffAllowsRun(LlmDigestRunState state, DateTime now) { + if (state.consecutiveFailures <= 0) { + return true; + } + final DateTime? started = state.lastStartedAt; + if (started == null) { + return true; + } + final int multiplier = 1 << state.consecutiveFailures.clamp(0, 5).toInt(); + return now.difference(started) >= Duration(hours: multiplier); + } +} + +const String _systemPrompt = ''' +You create private relationship-maintenance digest suggestions. +The input contains pseudonymous people only. Never ask for or invent names. +Return exactly one JSON object: +{ + "suggestions": [ + { + "personToken": "person_001", + "kind": "giftIdea" | "eventIdea" | "checkIn" | "reminder", + "title": "short action title", + "details": "practical details for the user to review", + "suggestedTiming": "optional ISO-8601 date or null", + "confidence": 0.0, + "reason": "brief non-sensitive rationale" + } + ] +} +Return at most 10 suggestions. Use only personToken values present in input. +'''; + +final Provider llmDigestOrchestratorProvider = + Provider((Ref ref) => LlmDigestOrchestrator(ref)); diff --git a/lib/features/ai_digest/application/llm_digest_text_client.dart b/lib/features/ai_digest/application/llm_digest_text_client.dart new file mode 100644 index 0000000..800d0e3 --- /dev/null +++ b/lib/features/ai_digest/application/llm_digest_text_client.dart @@ -0,0 +1,31 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/llm/llm_service.dart'; + +abstract interface class LlmDigestTextClient { + Future complete({ + required String systemPrompt, + required String userPrompt, + }); +} + +class ConfiguredLlmDigestTextClient implements LlmDigestTextClient { + const ConfiguredLlmDigestTextClient(this._service); + + final LlmService _service; + + @override + Future complete({ + required String systemPrompt, + required String userPrompt, + }) { + return _service.completeText( + systemPrompt: systemPrompt, + userPrompt: userPrompt, + ); + } +} + +final Provider llmDigestTextClientProvider = + Provider((Ref ref) { + return ConfiguredLlmDigestTextClient(ref.watch(llmServiceProvider)); + }); diff --git a/lib/features/ai_digest/data/llm_digest_config.dart b/lib/features/ai_digest/data/llm_digest_config.dart new file mode 100644 index 0000000..95c3825 --- /dev/null +++ b/lib/features/ai_digest/data/llm_digest_config.dart @@ -0,0 +1,77 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const String _enabledKey = 'llm_digest_enabled'; +const String _preferredWeekdayKey = 'llm_digest_preferred_weekday'; +const String _preferredHourKey = 'llm_digest_preferred_hour'; +const String _requireWifiKey = 'llm_digest_require_wifi'; +const String _requireChargingKey = 'llm_digest_require_charging'; + +class LlmDigestConfigNotifier extends Notifier { + Future? _initializeFuture; + + @override + LlmDigestConfigState build() { + return const LlmDigestConfigState(); + } + + Future initialize() { + _initializeFuture ??= _initializeFromStorage(); + return _initializeFuture!; + } + + Future _initializeFromStorage() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + state = LlmDigestConfigState( + enabled: prefs.getBool(_enabledKey) ?? false, + preferredWeekday: prefs.getInt(_preferredWeekdayKey) ?? DateTime.sunday, + preferredHour: prefs.getInt(_preferredHourKey) ?? 21, + requireWifi: prefs.getBool(_requireWifiKey) ?? true, + requireCharging: prefs.getBool(_requireChargingKey) ?? true, + ); + } + + Future setEnabled(bool enabled) async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_enabledKey, enabled); + state = state.copyWith(enabled: enabled); + } + + Future setPreferredRunWindow({ + required int weekday, + required int hour, + }) async { + final int normalizedWeekday = weekday.clamp( + DateTime.monday, + DateTime.sunday, + ); + final int normalizedHour = hour.clamp(0, 23); + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_preferredWeekdayKey, normalizedWeekday); + await prefs.setInt(_preferredHourKey, normalizedHour); + state = state.copyWith( + preferredWeekday: normalizedWeekday, + preferredHour: normalizedHour, + ); + } + + Future setNetworkPolicy({ + required bool requireWifi, + required bool requireCharging, + }) async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_requireWifiKey, requireWifi); + await prefs.setBool(_requireChargingKey, requireCharging); + state = state.copyWith( + requireWifi: requireWifi, + requireCharging: requireCharging, + ); + } +} + +final NotifierProvider +llmDigestConfigProvider = + NotifierProvider( + LlmDigestConfigNotifier.new, + ); diff --git a/lib/features/ai_digest/data/llm_digest_run_store.dart b/lib/features/ai_digest/data/llm_digest_run_store.dart new file mode 100644 index 0000000..b8fd1c0 --- /dev/null +++ b/lib/features/ai_digest/data/llm_digest_run_store.dart @@ -0,0 +1,34 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const String _runStateKey = 'llm_digest_run_state'; + +class LlmDigestRunStore { + const LlmDigestRunStore(); + + Future read() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + final String? raw = prefs.getString(_runStateKey); + if (raw == null || raw.isEmpty) { + return const LlmDigestRunState(); + } + try { + return LlmDigestRunState.fromJson( + jsonDecode(raw) as Map, + ); + } on FormatException { + return const LlmDigestRunState(); + } + } + + Future write(LlmDigestRunState state) async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setString(_runStateKey, jsonEncode(state.toJson())); + } +} + +final Provider llmDigestRunStoreProvider = + Provider((Ref ref) => const LlmDigestRunStore()); diff --git a/lib/features/ai_digest/domain/ai_digest_models.dart b/lib/features/ai_digest/domain/ai_digest_models.dart new file mode 100644 index 0000000..b3deaa5 --- /dev/null +++ b/lib/features/ai_digest/domain/ai_digest_models.dart @@ -0,0 +1,207 @@ +// ignore_for_file: sort_constructors_first + +import 'package:flutter/foundation.dart'; + +enum AiSuggestionKind { giftIdea, eventIdea, checkIn, reminder } + +enum AiSuggestionStatus { pending, accepted, dismissed } + +enum LlmDigestCadence { weekly } + +@immutable +class AiSuggestionDraft { + const AiSuggestionDraft({ + required this.id, + required this.personId, + required this.kind, + required this.title, + required this.details, + required this.suggestedAt, + required this.confidence, + required this.status, + required this.sourceRunId, + this.suggestedFor, + this.reason, + }); + + final String id; + final String personId; + final AiSuggestionKind kind; + final String title; + final String details; + final DateTime suggestedAt; + final DateTime? suggestedFor; + final double confidence; + final AiSuggestionStatus status; + final String sourceRunId; + final String? reason; + + AiSuggestionDraft copyWith({ + String? id, + String? personId, + AiSuggestionKind? kind, + String? title, + String? details, + DateTime? suggestedAt, + DateTime? suggestedFor, + double? confidence, + AiSuggestionStatus? status, + String? sourceRunId, + String? reason, + }) { + return AiSuggestionDraft( + id: id ?? this.id, + personId: personId ?? this.personId, + kind: kind ?? this.kind, + title: title ?? this.title, + details: details ?? this.details, + suggestedAt: suggestedAt ?? this.suggestedAt, + suggestedFor: suggestedFor ?? this.suggestedFor, + confidence: confidence ?? this.confidence, + status: status ?? this.status, + sourceRunId: sourceRunId ?? this.sourceRunId, + reason: reason ?? this.reason, + ); + } + + Map toJson() { + return { + 'id': id, + 'personId': personId, + 'kind': kind.name, + 'title': title, + 'details': details, + 'suggestedAt': suggestedAt.toUtc().toIso8601String(), + 'suggestedFor': suggestedFor?.toUtc().toIso8601String(), + 'confidence': confidence, + 'status': status.name, + 'sourceRunId': sourceRunId, + 'reason': reason, + }; + } + + factory AiSuggestionDraft.fromJson(Map json) { + final String kindName = + json['kind'] as String? ?? AiSuggestionKind.checkIn.name; + final String statusName = + json['status'] as String? ?? AiSuggestionStatus.pending.name; + return AiSuggestionDraft( + id: json['id'] as String, + personId: json['personId'] as String, + kind: AiSuggestionKind.values.firstWhere( + (AiSuggestionKind value) => value.name == kindName, + orElse: () => AiSuggestionKind.checkIn, + ), + title: json['title'] as String? ?? 'Suggestion', + details: json['details'] as String? ?? '', + suggestedAt: DateTime.parse( + json['suggestedAt'] as String? ?? + DateTime.now().toUtc().toIso8601String(), + ).toLocal(), + suggestedFor: json['suggestedFor'] == null + ? null + : DateTime.parse(json['suggestedFor'] as String).toLocal(), + confidence: (json['confidence'] as num?)?.toDouble() ?? 0, + status: AiSuggestionStatus.values.firstWhere( + (AiSuggestionStatus value) => value.name == statusName, + orElse: () => AiSuggestionStatus.pending, + ), + sourceRunId: json['sourceRunId'] as String? ?? 'unknown-run', + reason: json['reason'] as String?, + ); + } +} + +@immutable +class LlmDigestRunState { + const LlmDigestRunState({ + this.lastStartedAt, + this.lastCompletedAt, + this.lastFailure, + this.consecutiveFailures = 0, + this.running = false, + }); + + final DateTime? lastStartedAt; + final DateTime? lastCompletedAt; + final String? lastFailure; + final int consecutiveFailures; + final bool running; + + LlmDigestRunState copyWith({ + DateTime? lastStartedAt, + DateTime? lastCompletedAt, + String? lastFailure, + int? consecutiveFailures, + bool? running, + }) { + return LlmDigestRunState( + lastStartedAt: lastStartedAt ?? this.lastStartedAt, + lastCompletedAt: lastCompletedAt ?? this.lastCompletedAt, + lastFailure: lastFailure, + consecutiveFailures: consecutiveFailures ?? this.consecutiveFailures, + running: running ?? this.running, + ); + } + + Map toJson() { + return { + 'lastStartedAt': lastStartedAt?.toUtc().toIso8601String(), + 'lastCompletedAt': lastCompletedAt?.toUtc().toIso8601String(), + 'lastFailure': lastFailure, + 'consecutiveFailures': consecutiveFailures, + 'running': running, + }; + } + + factory LlmDigestRunState.fromJson(Map json) { + return LlmDigestRunState( + lastStartedAt: json['lastStartedAt'] == null + ? null + : DateTime.parse(json['lastStartedAt'] as String).toLocal(), + lastCompletedAt: json['lastCompletedAt'] == null + ? null + : DateTime.parse(json['lastCompletedAt'] as String).toLocal(), + lastFailure: json['lastFailure'] as String?, + consecutiveFailures: (json['consecutiveFailures'] as num?)?.toInt() ?? 0, + running: json['running'] as bool? ?? false, + ); + } +} + +@immutable +class LlmDigestConfigState { + const LlmDigestConfigState({ + this.enabled = false, + this.cadence = LlmDigestCadence.weekly, + this.preferredWeekday = DateTime.sunday, + this.preferredHour = 21, + this.requireWifi = true, + this.requireCharging = true, + }); + + final bool enabled; + final LlmDigestCadence cadence; + final int preferredWeekday; + final int preferredHour; + final bool requireWifi; + final bool requireCharging; + + LlmDigestConfigState copyWith({ + bool? enabled, + LlmDigestCadence? cadence, + int? preferredWeekday, + int? preferredHour, + bool? requireWifi, + bool? requireCharging, + }) { + return LlmDigestConfigState( + enabled: enabled ?? this.enabled, + cadence: cadence ?? this.cadence, + preferredWeekday: preferredWeekday ?? this.preferredWeekday, + preferredHour: preferredHour ?? this.preferredHour, + requireWifi: requireWifi ?? this.requireWifi, + requireCharging: requireCharging ?? this.requireCharging, + ); + } +} diff --git a/lib/features/ai_digest/presentation/ai_digest_notification_listener.dart b/lib/features/ai_digest/presentation/ai_digest_notification_listener.dart new file mode 100644 index 0000000..8ed18fd --- /dev/null +++ b/lib/features/ai_digest/presentation/ai_digest_notification_listener.dart @@ -0,0 +1,50 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/features/ai_digest/application/ai_digest_notifier.dart'; +import 'package:relationship_saver/features/ai_digest/presentation/ai_digest_review_view.dart'; + +class AiDigestNotificationListener extends ConsumerStatefulWidget { + const AiDigestNotificationListener({required this.child, super.key}); + + final Widget child; + + @override + ConsumerState createState() => + _AiDigestNotificationListenerState(); +} + +class _AiDigestNotificationListenerState + extends ConsumerState { + StreamSubscription? _subscription; + + @override + void initState() { + super.initState(); + _subscription = ref + .read(aiDigestNotificationIntentBusProvider) + .intents + .listen(_handleIntent); + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; + + Future _handleIntent(String payload) async { + if (!mounted || payload != aiDigestNotificationPayload) { + return; + } + await Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) => const AiDigestReviewView(), + ), + ); + } +} diff --git a/lib/features/ai_digest/presentation/ai_digest_review_view.dart b/lib/features/ai_digest/presentation/ai_digest_review_view.dart new file mode 100644 index 0000000..88c8c08 --- /dev/null +++ b/lib/features/ai_digest/presentation/ai_digest_review_view.dart @@ -0,0 +1,197 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/app/data/relationship_repository.dart'; +import 'package:relationship_saver/app/state/local_data_state.dart'; +import 'package:relationship_saver/core/config/app_theme.dart'; +import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart'; +import 'package:relationship_saver/features/people/domain/person_models.dart'; +import 'package:relationship_saver/features/shared/frosted_card.dart'; + +class AiDigestReviewView extends ConsumerWidget { + const AiDigestReviewView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final AsyncValue localData = ref.watch( + localRepositoryProvider, + ); + + return Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar(title: const Text('AI Review Inbox')), + body: localData.when( + data: (LocalDataState data) => _AiDigestReviewContent(data: data), + loading: () => const Center(child: CircularProgressIndicator()), + error: (Object error, StackTrace stackTrace) => + Center(child: Text('Unable to load AI suggestions. $error')), + ), + ); + } +} + +class _AiDigestReviewContent extends ConsumerWidget { + const _AiDigestReviewContent({required this.data}); + + final LocalDataState data; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final List pending = data.aiSuggestionDrafts + .where( + (AiSuggestionDraft draft) => + draft.status == AiSuggestionStatus.pending, + ) + .toList(growable: false); + final Map peopleById = { + for (final PersonProfile person in data.people) person.id: person, + }; + + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final bool compact = constraints.maxWidth < 760; + return SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + compact ? 16 : 28, + compact ? 16 : 24, + compact ? 16 : 28, + 28, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Private suggestions', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 6), + Text( + 'Review each suggestion before it changes local data.', + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 16), + if (pending.isEmpty) + const FrostedCard(child: Text('No pending AI suggestions.')) + else + ...pending.map( + (AiSuggestionDraft draft) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _AiSuggestionCard( + draft: draft, + person: peopleById[draft.personId], + onAccept: () => ref + .read(localRepositoryProvider.notifier) + .acceptAiSuggestionDraft(draft.id), + onDismiss: () => ref + .read(localRepositoryProvider.notifier) + .dismissAiSuggestionDraft(draft.id), + ), + ), + ), + ], + ), + ); + }, + ); + } +} + +class _AiSuggestionCard extends StatelessWidget { + const _AiSuggestionCard({ + required this.draft, + required this.person, + required this.onAccept, + required this.onDismiss, + }); + + final AiSuggestionDraft draft; + final PersonProfile? person; + final VoidCallback onAccept; + final VoidCallback onDismiss; + + @override + Widget build(BuildContext context) { + return FrostedCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(_iconForKind(draft.kind), color: AppTheme.primary), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + draft.title, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 4), + Text( + '${_kindLabel(draft.kind)} · ${person?.name ?? 'Unknown person'}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + ], + ), + if (draft.details.isNotEmpty) ...[ + const SizedBox(height: 12), + Text(draft.details), + ], + if (draft.reason != null && + draft.reason!.trim().isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + 'Why: ${draft.reason}', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), + ), + ], + const SizedBox(height: 14), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton.icon( + onPressed: person == null ? null : onAccept, + icon: const Icon(Icons.check_rounded), + label: const Text('Accept'), + ), + OutlinedButton.icon( + onPressed: onDismiss, + icon: const Icon(Icons.close_rounded), + label: const Text('Dismiss'), + ), + ], + ), + ], + ), + ); + } + + IconData _iconForKind(AiSuggestionKind kind) { + return switch (kind) { + AiSuggestionKind.giftIdea => Icons.card_giftcard_rounded, + AiSuggestionKind.eventIdea => Icons.event_available_rounded, + AiSuggestionKind.checkIn => Icons.chat_bubble_outline_rounded, + AiSuggestionKind.reminder => Icons.notifications_active_outlined, + }; + } + + String _kindLabel(AiSuggestionKind kind) { + return switch (kind) { + AiSuggestionKind.giftIdea => 'Gift idea', + AiSuggestionKind.eventIdea => 'Event idea', + AiSuggestionKind.checkIn => 'Check-in', + AiSuggestionKind.reminder => 'Reminder', + }; + } +} diff --git a/lib/features/auth/README.md b/lib/features/auth/README.md new file mode 100644 index 0000000..2c9d4a9 --- /dev/null +++ b/lib/features/auth/README.md @@ -0,0 +1,9 @@ +# Auth Slice + +This slice owns sign-in UI and session lifecycle. + +- `application/session_controller.dart`: async auth session state. +- `presentation/sign_in_view.dart`: sign-in form and environment status UI. + +Touch this slice when you change auth UX, local session behavior, or backend +sign-in integration. diff --git a/lib/features/auth/application/README.md b/lib/features/auth/application/README.md new file mode 100644 index 0000000..d9427a6 --- /dev/null +++ b/lib/features/auth/application/README.md @@ -0,0 +1,4 @@ +# Auth Application + +This layer coordinates auth state and session lifecycle. Start here when you +need to change how the app decides whether a user is signed in. diff --git a/lib/features/auth/application/session_controller.dart b/lib/features/auth/application/session_controller.dart new file mode 100644 index 0000000..28d22c2 --- /dev/null +++ b/lib/features/auth/application/session_controller.dart @@ -0,0 +1,76 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/auth/token_store.dart'; +import 'package:relationship_saver/integrations/backend/backend_gateway.dart'; +import 'package:relationship_saver/integrations/backend/backend_gateway_provider.dart'; +import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; + +/// Manages authenticated session lifecycle for app-level navigation. +class SessionController extends AsyncNotifier { + @override + Future build() async { + return ref.read(tokenStoreProvider).read(); + } + + Future signIn({ + required SignInMethod method, + String? email, + String? password, + String? idToken, + }) async { + state = const AsyncLoading(); + + try { + final BackendGateway gateway = ref.read(backendGatewayProvider); + final AuthSession session = await gateway.signIn( + SignInRequest( + method: method, + email: email, + password: password, + idToken: idToken, + ), + ); + + // Ensure fake gateway sessions are also persisted. + await ref.read(tokenStoreProvider).write(session); + state = AsyncData(session); + } catch (error, stackTrace) { + state = AsyncError(error, stackTrace); + } + } + + Future signOut() async { + final BackendGateway gateway = ref.read(backendGatewayProvider); + final TokenStore tokenStore = ref.read(tokenStoreProvider); + + try { + await gateway.signOut(); + } catch (_) { + // Offline-first sign-out should always clear local session. + } + + await tokenStore.clear(); + state = const AsyncData(null); + } + + Future refreshProfile() async { + final AuthSession? current = state.asData?.value; + if (current == null) { + return; + } + + try { + final UserProfile profile = await ref.read(backendGatewayProvider).me(); + final AuthSession updated = current.copyWith(user: profile); + await ref.read(tokenStoreProvider).write(updated); + state = AsyncData(updated); + } catch (_) { + // Keep current state if profile refresh fails. + } + } +} + +final AsyncNotifierProvider +sessionControllerProvider = + AsyncNotifierProvider( + SessionController.new, + ); diff --git a/lib/features/auth/presentation/README.md b/lib/features/auth/presentation/README.md new file mode 100644 index 0000000..ce5c620 --- /dev/null +++ b/lib/features/auth/presentation/README.md @@ -0,0 +1,4 @@ +# Auth Presentation + +Auth UI belongs here. Keep sign-in screens, auth-specific widgets, and small +view helpers in this folder rather than mixing them into app bootstrap code. diff --git a/lib/features/auth/presentation/sign_in_view.dart b/lib/features/auth/presentation/sign_in_view.dart new file mode 100644 index 0000000..4ef716b --- /dev/null +++ b/lib/features/auth/presentation/sign_in_view.dart @@ -0,0 +1,228 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/config/app_config.dart'; +import 'package:relationship_saver/core/config/app_theme.dart'; +import 'package:relationship_saver/features/auth/session_controller.dart'; +import 'package:relationship_saver/features/shared/frosted_card.dart'; +import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; + +class SignInView extends ConsumerStatefulWidget { + const SignInView({super.key}); + + @override + ConsumerState createState() => _SignInViewState(); +} + +class _SignInViewState extends ConsumerState { + SignInMethod _method = SignInMethod.password; + final TextEditingController _emailController = TextEditingController( + text: 'demo@example.com', + ); + final TextEditingController _passwordController = TextEditingController( + text: 'password123', + ); + final TextEditingController _idTokenController = TextEditingController(); + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + _idTokenController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final AsyncValue session = ref.watch( + sessionControllerProvider, + ); + final bool busy = session.isLoading; + final Object? error = session.asError?.error; + + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints viewport) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: viewport.maxHeight - 32), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 620), + child: FrostedCard( + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final bool compact = constraints.maxWidth < 480; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sign in to continue', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 8), + Text( + 'Your local data remains available offline. Backend unlocks sync and signals.', + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 18), + if (compact) + DropdownButtonFormField( + initialValue: _method, + items: SignInMethod.values + .map( + (SignInMethod method) => + DropdownMenuItem( + value: method, + child: Text(_labelForMethod(method)), + ), + ) + .toList(growable: false), + onChanged: busy + ? null + : (SignInMethod? value) { + if (value == null) { + return; + } + setState(() { + _method = value; + }); + }, + decoration: const InputDecoration( + labelText: 'Method', + ), + ) + else + SegmentedButton( + segments: const >[ + ButtonSegment( + value: SignInMethod.password, + label: Text('Password'), + ), + ButtonSegment( + value: SignInMethod.emailMagicLink, + label: Text('Magic Link'), + ), + ButtonSegment( + value: SignInMethod.oidc, + label: Text('OIDC'), + ), + ], + selected: {_method}, + onSelectionChanged: busy + ? null + : (Set value) { + setState(() { + _method = value.first; + }); + }, + ), + const SizedBox(height: 14), + if (_method == SignInMethod.password || + _method == SignInMethod.emailMagicLink) + TextField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + enabled: !busy, + decoration: const InputDecoration( + labelText: 'Email', + ), + ), + if (_method == SignInMethod.password) + TextField( + controller: _passwordController, + obscureText: true, + enabled: !busy, + decoration: const InputDecoration( + labelText: 'Password', + ), + ), + if (_method == SignInMethod.oidc) + TextField( + controller: _idTokenController, + enabled: !busy, + decoration: const InputDecoration( + labelText: 'ID Token', + ), + ), + if (error != null) + Padding( + padding: const EdgeInsets.only(top: 10), + child: Text( + 'Sign-in failed: $error', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: const Color(0xFFC83030)), + ), + ), + const SizedBox(height: 16), + Wrap( + spacing: 12, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + FilledButton.icon( + onPressed: busy ? null : _submit, + icon: busy + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.login_rounded), + label: const Text('Sign In'), + ), + Text( + AppConfig.useFakeBackend + ? 'Fake mode enabled' + : 'REST mode (${AppConfig.backendBaseUrl})', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: AppTheme.textSecondary), + ), + ], + ), + ], + ); + }, + ), + ), + ), + ), + ), + ); + }, + ); + } + + Future _submit() async { + final String? email = _emailController.text.trim().isEmpty + ? null + : _emailController.text.trim(); + final String? password = _passwordController.text.trim().isEmpty + ? null + : _passwordController.text.trim(); + final String? idToken = _idTokenController.text.trim().isEmpty + ? null + : _idTokenController.text.trim(); + + await ref + .read(sessionControllerProvider.notifier) + .signIn( + method: _method, + email: email, + password: password, + idToken: idToken, + ); + } + + String _labelForMethod(SignInMethod method) { + return switch (method) { + SignInMethod.password => 'Password', + SignInMethod.emailMagicLink => 'Magic Link', + SignInMethod.oidc => 'OIDC', + }; + } +} diff --git a/lib/features/auth/session_controller.dart b/lib/features/auth/session_controller.dart index 28d22c2..57ad31d 100644 --- a/lib/features/auth/session_controller.dart +++ b/lib/features/auth/session_controller.dart @@ -1,76 +1,2 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:relationship_saver/core/auth/token_store.dart'; -import 'package:relationship_saver/integrations/backend/backend_gateway.dart'; -import 'package:relationship_saver/integrations/backend/backend_gateway_provider.dart'; -import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; - -/// Manages authenticated session lifecycle for app-level navigation. -class SessionController extends AsyncNotifier { - @override - Future build() async { - return ref.read(tokenStoreProvider).read(); - } - - Future signIn({ - required SignInMethod method, - String? email, - String? password, - String? idToken, - }) async { - state = const AsyncLoading(); - - try { - final BackendGateway gateway = ref.read(backendGatewayProvider); - final AuthSession session = await gateway.signIn( - SignInRequest( - method: method, - email: email, - password: password, - idToken: idToken, - ), - ); - - // Ensure fake gateway sessions are also persisted. - await ref.read(tokenStoreProvider).write(session); - state = AsyncData(session); - } catch (error, stackTrace) { - state = AsyncError(error, stackTrace); - } - } - - Future signOut() async { - final BackendGateway gateway = ref.read(backendGatewayProvider); - final TokenStore tokenStore = ref.read(tokenStoreProvider); - - try { - await gateway.signOut(); - } catch (_) { - // Offline-first sign-out should always clear local session. - } - - await tokenStore.clear(); - state = const AsyncData(null); - } - - Future refreshProfile() async { - final AuthSession? current = state.asData?.value; - if (current == null) { - return; - } - - try { - final UserProfile profile = await ref.read(backendGatewayProvider).me(); - final AuthSession updated = current.copyWith(user: profile); - await ref.read(tokenStoreProvider).write(updated); - state = AsyncData(updated); - } catch (_) { - // Keep current state if profile refresh fails. - } - } -} - -final AsyncNotifierProvider -sessionControllerProvider = - AsyncNotifierProvider( - SessionController.new, - ); +// Legacy compatibility export. Prefer the `application` file in this slice. +export 'package:relationship_saver/features/auth/application/session_controller.dart'; diff --git a/lib/features/auth/sign_in_view.dart b/lib/features/auth/sign_in_view.dart index 4ef716b..bca4108 100644 --- a/lib/features/auth/sign_in_view.dart +++ b/lib/features/auth/sign_in_view.dart @@ -1,228 +1,2 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:relationship_saver/core/config/app_config.dart'; -import 'package:relationship_saver/core/config/app_theme.dart'; -import 'package:relationship_saver/features/auth/session_controller.dart'; -import 'package:relationship_saver/features/shared/frosted_card.dart'; -import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; - -class SignInView extends ConsumerStatefulWidget { - const SignInView({super.key}); - - @override - ConsumerState createState() => _SignInViewState(); -} - -class _SignInViewState extends ConsumerState { - SignInMethod _method = SignInMethod.password; - final TextEditingController _emailController = TextEditingController( - text: 'demo@example.com', - ); - final TextEditingController _passwordController = TextEditingController( - text: 'password123', - ); - final TextEditingController _idTokenController = TextEditingController(); - - @override - void dispose() { - _emailController.dispose(); - _passwordController.dispose(); - _idTokenController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final AsyncValue session = ref.watch( - sessionControllerProvider, - ); - final bool busy = session.isLoading; - final Object? error = session.asError?.error; - - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints viewport) { - return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: viewport.maxHeight - 32), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 620), - child: FrostedCard( - child: LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - final bool compact = constraints.maxWidth < 480; - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Sign in to continue', - style: Theme.of(context).textTheme.headlineMedium, - ), - const SizedBox(height: 8), - Text( - 'Your local data remains available offline. Backend unlocks sync and signals.', - style: Theme.of(context).textTheme.bodyLarge - ?.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: 18), - if (compact) - DropdownButtonFormField( - initialValue: _method, - items: SignInMethod.values - .map( - (SignInMethod method) => - DropdownMenuItem( - value: method, - child: Text(_labelForMethod(method)), - ), - ) - .toList(growable: false), - onChanged: busy - ? null - : (SignInMethod? value) { - if (value == null) { - return; - } - setState(() { - _method = value; - }); - }, - decoration: const InputDecoration( - labelText: 'Method', - ), - ) - else - SegmentedButton( - segments: const >[ - ButtonSegment( - value: SignInMethod.password, - label: Text('Password'), - ), - ButtonSegment( - value: SignInMethod.emailMagicLink, - label: Text('Magic Link'), - ), - ButtonSegment( - value: SignInMethod.oidc, - label: Text('OIDC'), - ), - ], - selected: {_method}, - onSelectionChanged: busy - ? null - : (Set value) { - setState(() { - _method = value.first; - }); - }, - ), - const SizedBox(height: 14), - if (_method == SignInMethod.password || - _method == SignInMethod.emailMagicLink) - TextField( - controller: _emailController, - keyboardType: TextInputType.emailAddress, - enabled: !busy, - decoration: const InputDecoration( - labelText: 'Email', - ), - ), - if (_method == SignInMethod.password) - TextField( - controller: _passwordController, - obscureText: true, - enabled: !busy, - decoration: const InputDecoration( - labelText: 'Password', - ), - ), - if (_method == SignInMethod.oidc) - TextField( - controller: _idTokenController, - enabled: !busy, - decoration: const InputDecoration( - labelText: 'ID Token', - ), - ), - if (error != null) - Padding( - padding: const EdgeInsets.only(top: 10), - child: Text( - 'Sign-in failed: $error', - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(color: const Color(0xFFC83030)), - ), - ), - const SizedBox(height: 16), - Wrap( - spacing: 12, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - FilledButton.icon( - onPressed: busy ? null : _submit, - icon: busy - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Icon(Icons.login_rounded), - label: const Text('Sign In'), - ), - Text( - AppConfig.useFakeBackend - ? 'Fake mode enabled' - : 'REST mode (${AppConfig.backendBaseUrl})', - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(color: AppTheme.textSecondary), - ), - ], - ), - ], - ); - }, - ), - ), - ), - ), - ), - ); - }, - ); - } - - Future _submit() async { - final String? email = _emailController.text.trim().isEmpty - ? null - : _emailController.text.trim(); - final String? password = _passwordController.text.trim().isEmpty - ? null - : _passwordController.text.trim(); - final String? idToken = _idTokenController.text.trim().isEmpty - ? null - : _idTokenController.text.trim(); - - await ref - .read(sessionControllerProvider.notifier) - .signIn( - method: _method, - email: email, - password: password, - idToken: idToken, - ); - } - - String _labelForMethod(SignInMethod method) { - return switch (method) { - SignInMethod.password => 'Password', - SignInMethod.emailMagicLink => 'Magic Link', - SignInMethod.oidc => 'OIDC', - }; - } -} +// Legacy compatibility export. Prefer the `presentation` file in this slice. +export 'package:relationship_saver/features/auth/presentation/sign_in_view.dart'; diff --git a/lib/features/dashboard/README.md b/lib/features/dashboard/README.md new file mode 100644 index 0000000..864e2f1 --- /dev/null +++ b/lib/features/dashboard/README.md @@ -0,0 +1,9 @@ +# Dashboard Slice + +This slice owns the home overview experience. + +- `domain/dashboard_models.dart`: lightweight dashboard task and summary types. +- `presentation/dashboard_view.dart`: dashboard screen and interactions. + +If you want to change the founder daily workflow, this is usually the first +screen to inspect after the app shell. diff --git a/lib/features/dashboard/dashboard_view.dart b/lib/features/dashboard/dashboard_view.dart index 40e4ed3..0afd0e0 100644 --- a/lib/features/dashboard/dashboard_view.dart +++ b/lib/features/dashboard/dashboard_view.dart @@ -1,2582 +1,2 @@ -import 'dart:math' as math; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:relationship_saver/core/config/app_theme.dart'; -import 'package:relationship_saver/features/local/local_models.dart'; -import 'package:relationship_saver/features/local/local_repository.dart'; -import 'package:relationship_saver/features/shared/frosted_card.dart'; -import 'package:relationship_saver/features/signals/signals_controller.dart'; -import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; - -class DashboardView extends ConsumerWidget { - const DashboardView({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final AsyncValue localData = ref.watch( - localRepositoryProvider, - ); - - return localData.when( - data: (LocalDataState data) { - final DashboardSummary summary = data.summary(); - final List tasks = data.tasks; - - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - final bool compact = constraints.maxWidth < 720; - return SingleChildScrollView( - padding: EdgeInsets.fromLTRB( - compact ? 16 : 28, - compact ? 16 : 24, - compact ? 16 : 28, - compact ? 24 : 36, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Today', - style: Theme.of(context).textTheme.headlineMedium, - ), - const SizedBox(height: 8), - Text( - 'See the relationship graph first, then execute the highest-leverage next moves.', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: AppTheme.textSecondary, - ), - ), - const SizedBox(height: 18), - _RelationshipGraphCard(people: data.people, compact: compact), - const SizedBox(height: 20), - Wrap( - spacing: 12, - runSpacing: 12, - children: [ - _StatCard( - label: 'Active People', - value: '${summary.activePeople}', - accent: const Color(0xFF16B8CA), - compact: compact, - ), - _StatCard( - label: 'Upcoming Plans', - value: '${summary.upcomingPlans}', - accent: const Color(0xFF2B7FFF), - compact: compact, - ), - _StatCard( - label: 'Pending Ideas', - value: '${summary.pendingIdeas}', - accent: const Color(0xFFFFA447), - compact: compact, - ), - _StatCard( - label: 'Weekly Rhythm', - value: '${summary.weeklyConsistency}%', - accent: const Color(0xFF30B66A), - compact: compact, - ), - ], - ), - const SizedBox(height: 24), - FrostedCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Next Moves', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 14), - if (tasks.isEmpty) - Text( - 'No tasks yet. Add moments, ideas, or reminders to generate follow-through actions.', - style: Theme.of(context).textTheme.bodyLarge - ?.copyWith(color: AppTheme.textSecondary), - ) - else - ...tasks.map( - (DashboardTask task) => _TaskRow( - task: task, - compact: compact, - onToggleDone: () { - ref - .read(localRepositoryProvider.notifier) - .toggleTaskDone(task.id); - }, - ), - ), - ], - ), - ), - ], - ), - ); - }, - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (Object error, StackTrace stackTrace) { - return Center( - child: Text( - 'Could not load local data.', - style: Theme.of(context).textTheme.bodyLarge, - ), - ); - }, - ); - } -} - -class _RelationshipGraphCard extends StatelessWidget { - const _RelationshipGraphCard({required this.people, required this.compact}); - - final List people; - final bool compact; - - @override - Widget build(BuildContext context) { - final List<_RelationshipVisual> legend = _collectGraphLegend(people); - return FrostedCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text( - 'Relationship Graph', - style: Theme.of(context).textTheme.titleLarge, - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: BoxDecoration( - color: const Color(0xFFEAF7FB), - borderRadius: BorderRadius.circular(99), - ), - child: Text( - '${people.length} nodes', - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: AppTheme.primary, - ), - ), - ), - ], - ), - ), - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: people.isEmpty - ? null - : () => _openGraphExplorer(context, people), - icon: const Icon(Icons.open_in_full_rounded, size: 18), - label: Text(compact ? 'Explore' : 'Open Explorer'), - ), - ], - ), - const SizedBox(height: 12), - Container( - height: compact ? 330 : 410, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - const Color(0xFFEAF3F8), - const Color(0xFFF7FAFC), - Colors.white.withValues(alpha: 0.9), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(24), - border: Border.all(color: Colors.white.withValues(alpha: 0.8)), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(24), - child: LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - return _RelationshipGraphCanvas( - people: people, - compact: compact, - mode: _GraphCanvasMode.preview, - onOpenPersonInsights: (PersonProfile person) => - _openPersonInsights(context, person), - ); - }, - ), - ), - ), - const SizedBox(height: 8), - Text( - 'Dashboard preview is scroll-first. Long-press a person for insights, or open the explorer to pan and zoom.', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: 10), - Wrap( - spacing: 8, - runSpacing: 8, - children: legend - .map( - (_RelationshipVisual visual) => - _RelationshipLegendPill(visual: visual), - ) - .toList(growable: false), - ), - ], - ), - ); - } -} - -enum _GraphCanvasMode { preview, explorer } - -class _RelationshipGraphExplorerPage extends StatefulWidget { - const _RelationshipGraphExplorerPage({required this.people}); - - final List people; - - @override - State<_RelationshipGraphExplorerPage> createState() => - _RelationshipGraphExplorerPageState(); -} - -class _RelationshipGraphExplorerPageState - extends State<_RelationshipGraphExplorerPage> { - late final TransformationController _controller; - - @override - void initState() { - super.initState(); - _controller = TransformationController(); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - void _resetView() { - _controller.value = Matrix4.identity(); - } - - @override - Widget build(BuildContext context) { - final bool compact = MediaQuery.sizeOf(context).width < 760; - final List<_RelationshipVisual> legend = _collectGraphLegend(widget.people); - - return Scaffold( - appBar: AppBar( - title: const Text('Relationship Graph'), - actions: [ - IconButton( - tooltip: 'Reset view', - onPressed: _resetView, - icon: const Icon(Icons.center_focus_strong_rounded), - ), - ], - ), - body: Padding( - padding: EdgeInsets.fromLTRB( - compact ? 12 : 18, - compact ? 12 : 14, - compact ? 12 : 18, - compact ? 14 : 20, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.76), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Colors.white.withValues(alpha: 0.85)), - ), - child: Wrap( - spacing: 10, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text( - 'Explorer mode: pan + pinch to zoom. Long-press a person to open insights.', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppTheme.textSecondary, - ), - ), - FilledButton.tonalIcon( - onPressed: _resetView, - icon: const Icon(Icons.refresh_rounded, size: 18), - label: const Text('Reset View'), - ), - ], - ), - ), - const SizedBox(height: 12), - Expanded( - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - const Color(0xFFEAF3F8), - const Color(0xFFF7FAFC), - Colors.white.withValues(alpha: 0.92), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(24), - border: Border.all( - color: Colors.white.withValues(alpha: 0.82), - ), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(24), - child: _RelationshipGraphCanvas( - people: widget.people, - compact: compact, - mode: _GraphCanvasMode.explorer, - transformationController: _controller, - onOpenPersonInsights: (PersonProfile person) => - _openPersonInsights(context, person), - ), - ), - ), - ), - const SizedBox(height: 10), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: legend - .map( - (_RelationshipVisual visual) => Padding( - padding: const EdgeInsets.only(right: 8), - child: _RelationshipLegendPill(visual: visual), - ), - ) - .toList(growable: false), - ), - ), - ], - ), - ), - ); - } -} - -class _RelationshipGraphCanvas extends StatelessWidget { - const _RelationshipGraphCanvas({ - required this.people, - required this.compact, - required this.mode, - required this.onOpenPersonInsights, - this.transformationController, - }); - - final List people; - final bool compact; - final _GraphCanvasMode mode; - final ValueChanged onOpenPersonInsights; - final TransformationController? transformationController; - - @override - Widget build(BuildContext context) { - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - if (people.isEmpty) { - return Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Text( - 'No people yet. Add your first relationship to start the graph.', - textAlign: TextAlign.center, - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), - ), - ), - ); - } - - final _GraphLayout layout = _GraphLayout.build( - people: people, - size: constraints.biggest, - compact: compact, - ); - - final bool explorer = mode == _GraphCanvasMode.explorer; - return InteractiveViewer( - transformationController: transformationController, - minScale: explorer ? 0.55 : 1.0, - maxScale: explorer ? 3.2 : 1.0, - boundaryMargin: explorer - ? const EdgeInsets.all(260) - : EdgeInsets.zero, - panEnabled: explorer, - scaleEnabled: explorer, - child: SizedBox( - width: constraints.maxWidth, - height: constraints.maxHeight, - child: Stack( - children: [ - Positioned.fill( - child: CustomPaint( - painter: _RelationshipEdgePainter( - center: layout.center, - edges: layout.edges, - ), - ), - ), - ...layout.nodes.map( - (_GraphNode node) => _GraphBubble( - node: node, - compact: compact, - onLongPress: () => onOpenPersonInsights(node.person), - ), - ), - _CenterNode(center: layout.center, compact: compact), - if (layout.hiddenCount > 0) - Positioned( - right: 12, - top: 12, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.85), - borderRadius: BorderRadius.circular(99), - ), - child: Text( - '+${layout.hiddenCount} more', - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ), - ), - if (!explorer) - Positioned( - right: 12, - bottom: 12, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.88), - borderRadius: BorderRadius.circular(99), - border: Border.all(color: Colors.white), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.swipe_up_alt_rounded, - size: 14, - color: AppTheme.textSecondary, - ), - const SizedBox(width: 6), - Text( - 'Scroll page', - style: Theme.of(context).textTheme.labelLarge - ?.copyWith(color: AppTheme.textSecondary), - ), - ], - ), - ), - ), - ], - ), - ), - ); - }, - ); - } -} - -List<_RelationshipVisual> _collectGraphLegend(List source) { - final Set seen = {}; - final List<_RelationshipVisual> result = <_RelationshipVisual>[]; - - for (final PersonProfile person in source) { - final _RelationshipVisual visual = _relationshipVisualFor( - person.relationship, - ); - if (seen.add(visual.label)) { - result.add(visual); - } - if (result.length >= 7) { - break; - } - } - - if (result.isEmpty) { - result.add(_otherVisual); - } - return result; -} - -void _openGraphExplorer(BuildContext context, List people) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (BuildContext context) => - _RelationshipGraphExplorerPage(people: people), - ), - ); -} - -void _openPersonInsights(BuildContext context, PersonProfile person) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (BuildContext context) { - return _PersonInsightsPage(personId: person.id); - }, - ), - ); -} - -class _GraphLayout { - const _GraphLayout({ - required this.center, - required this.nodes, - required this.edges, - required this.hiddenCount, - }); - - final Offset center; - final List<_GraphNode> nodes; - final List<_GraphEdge> edges; - final int hiddenCount; - - static _GraphLayout build({ - required List people, - required Size size, - required bool compact, - }) { - final int maxNodes = compact ? 10 : 16; - final List visiblePeople = people - .take(maxNodes) - .toList(growable: false); - final int hiddenCount = people.length - visiblePeople.length; - - final Offset center = Offset(size.width / 2, size.height / 2); - if (visiblePeople.isEmpty) { - return _GraphLayout( - center: center, - nodes: const <_GraphNode>[], - edges: const <_GraphEdge>[], - hiddenCount: hiddenCount, - ); - } - - final double minSide = math.min(size.width, size.height); - final int total = visiblePeople.length; - final int innerCount = total <= 8 ? total : 8; - final int outerCount = total - innerCount; - final double innerRadius = minSide * (compact ? 0.28 : 0.31); - final double outerRadius = minSide * (compact ? 0.39 : 0.43); - - final List<_GraphNode> nodes = <_GraphNode>[]; - for (int i = 0; i < total; i += 1) { - final bool inOuterRing = i >= innerCount; - final int ringCount = inOuterRing ? outerCount : innerCount; - final int ringIndex = inOuterRing ? i - innerCount : i; - final double radius = inOuterRing ? outerRadius : innerRadius; - final double phaseOffset = inOuterRing ? math.pi / ringCount : 0; - final double angle = - (-math.pi / 2) + - phaseOffset + - ((2 * math.pi * ringIndex) / ringCount); - - final Offset position = Offset( - center.dx + radius * math.cos(angle), - center.dy + radius * math.sin(angle), - ); - final PersonProfile person = visiblePeople[i]; - final _RelationshipVisual visual = _relationshipVisualFor( - person.relationship, - ); - nodes.add(_GraphNode(person: person, visual: visual, position: position)); - } - - final List<_GraphEdge> edges = <_GraphEdge>[]; - for (final _GraphNode node in nodes) { - edges.add( - _GraphEdge( - start: center, - end: node.position, - color: node.visual.color.withValues(alpha: 0.46), - width: compact ? 1.7 : 2.0, - ), - ); - } - - if (nodes.length > 2) { - for (int i = 0; i < nodes.length; i += 1) { - final _GraphNode current = nodes[i]; - final _GraphNode next = nodes[(i + 1) % nodes.length]; - final Color color = - Color.lerp(current.visual.color, next.visual.color, 0.5) ?? - AppTheme.accent; - - edges.add( - _GraphEdge( - start: current.position, - end: next.position, - color: color.withValues(alpha: 0.2), - width: 1.1, - ), - ); - - if (nodes.length >= 7 && i.isEven) { - final _GraphNode jump = nodes[(i + 3) % nodes.length]; - edges.add( - _GraphEdge( - start: current.position, - end: jump.position, - color: current.visual.color.withValues(alpha: 0.1), - width: 1.0, - ), - ); - } - } - } - - return _GraphLayout( - center: center, - nodes: nodes, - edges: edges, - hiddenCount: hiddenCount, - ); - } -} - -class _GraphNode { - const _GraphNode({ - required this.person, - required this.visual, - required this.position, - }); - - final PersonProfile person; - final _RelationshipVisual visual; - final Offset position; -} - -class _GraphEdge { - const _GraphEdge({ - required this.start, - required this.end, - required this.color, - required this.width, - }); - - final Offset start; - final Offset end; - final Color color; - final double width; -} - -class _RelationshipEdgePainter extends CustomPainter { - const _RelationshipEdgePainter({required this.center, required this.edges}); - - final Offset center; - final List<_GraphEdge> edges; - - @override - void paint(Canvas canvas, Size size) { - final Paint centerGlow = Paint() - ..shader = - RadialGradient( - colors: [ - AppTheme.accent.withValues(alpha: 0.15), - Colors.transparent, - ], - ).createShader( - Rect.fromCircle( - center: center, - radius: math.min(size.width, size.height) * 0.36, - ), - ); - - canvas.drawCircle( - center, - math.min(size.width, size.height) * 0.36, - centerGlow, - ); - - for (final _GraphEdge edge in edges) { - final Paint paint = Paint() - ..color = edge.color - ..strokeWidth = edge.width - ..strokeCap = StrokeCap.round - ..style = PaintingStyle.stroke; - canvas.drawLine(edge.start, edge.end, paint); - } - } - - @override - bool shouldRepaint(covariant _RelationshipEdgePainter oldDelegate) { - return oldDelegate.edges != edges || oldDelegate.center != center; - } -} - -class _GraphBubble extends StatelessWidget { - const _GraphBubble({ - required this.node, - required this.compact, - required this.onLongPress, - }); - - final _GraphNode node; - final bool compact; - final VoidCallback onLongPress; - - @override - Widget build(BuildContext context) { - final double diameter = compact ? 52 : 62; - final double labelWidth = compact ? 66 : 86; - - return Positioned( - left: node.position.dx - (diameter / 2), - top: node.position.dy - (diameter / 2), - child: Tooltip( - message: '${node.person.name} • ${node.visual.label}', - child: GestureDetector( - key: ValueKey('graph-node-${node.person.id}'), - behavior: HitTestBehavior.translucent, - onLongPress: onLongPress, - child: SizedBox( - width: labelWidth, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Stack( - clipBehavior: Clip.none, - children: [ - Container( - width: diameter, - height: diameter, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - colors: [ - _shiftLightness(node.visual.color, 0.16), - _shiftLightness(node.visual.color, -0.12), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - boxShadow: [ - BoxShadow( - color: node.visual.color.withValues(alpha: 0.24), - blurRadius: 14, - offset: const Offset(0, 8), - ), - ], - ), - alignment: Alignment.center, - child: Text( - _initials(node.person.name), - style: Theme.of(context).textTheme.titleMedium - ?.copyWith( - color: Colors.white, - fontWeight: FontWeight.w700, - ), - ), - ), - Positioned( - right: -2, - top: -2, - child: Container( - width: compact ? 20 : 22, - height: compact ? 20 : 22, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Colors.white, - border: Border.all( - color: node.visual.color.withValues(alpha: 0.8), - ), - ), - child: Icon( - node.visual.icon, - size: compact ? 12 : 13, - color: node.visual.color, - ), - ), - ), - ], - ), - if (!compact) ...[ - const SizedBox(height: 5), - Text( - _shortName(node.person.name), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ], - ], - ), - ), - ), - ), - ); - } -} - -class _CenterNode extends StatelessWidget { - const _CenterNode({required this.center, required this.compact}); - - final Offset center; - final bool compact; - - @override - Widget build(BuildContext context) { - final double diameter = compact ? 74 : 86; - - return Positioned( - left: center.dx - (diameter / 2), - top: center.dy - (diameter / 2), - child: Container( - width: diameter, - height: diameter, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: const LinearGradient( - colors: [Color(0xFF1AB6C8), Color(0xFF286DE0)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - boxShadow: [ - BoxShadow( - color: const Color(0xFF1AB6C8).withValues(alpha: 0.26), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - ), - alignment: Alignment.center, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.favorite_rounded, color: Colors.white, size: 18), - const SizedBox(height: 2), - Text( - 'YOU', - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: Colors.white, - fontWeight: FontWeight.w700, - letterSpacing: 0.4, - ), - ), - ], - ), - ), - ); - } -} - -class _RelationshipLegendPill extends StatelessWidget { - const _RelationshipLegendPill({required this.visual}); - - final _RelationshipVisual visual; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: visual.color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(99), - border: Border.all(color: visual.color.withValues(alpha: 0.18)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(visual.icon, size: 14, color: visual.color), - const SizedBox(width: 6), - Text( - visual.label, - style: Theme.of( - context, - ).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary), - ), - ], - ), - ); - } -} - -enum _InsightQuickAction { capture, note, idea, reminder } - -class _PersonInsightsPage extends ConsumerWidget { - const _PersonInsightsPage({required this.personId}); - - final String personId; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final AsyncValue localData = ref.watch( - localRepositoryProvider, - ); - final AsyncValue signals = ref.watch( - signalsControllerProvider, - ); - final LocalDataState? localValue = localData.asData?.value; - final PersonProfile? quickAddPerson = localValue == null - ? null - : _findPersonById(localValue.people, personId); - - return Scaffold( - appBar: AppBar(title: const Text('Person Insights')), - floatingActionButton: quickAddPerson == null - ? null - : FloatingActionButton( - tooltip: 'Add to person', - onPressed: () => _openQuickAddSheet(context, ref, quickAddPerson), - child: const Icon(Icons.add_rounded), - ), - body: localData.when( - data: (LocalDataState data) { - final PersonProfile? person = _findPersonById(data.people, personId); - if (person == null) { - return Center( - child: Text( - 'Person was not found in local data.', - style: Theme.of(context).textTheme.bodyLarge, - ), - ); - } - - final List moments = - data.moments - .where( - (RelationshipMoment moment) => moment.personId == person.id, - ) - .toList(growable: false) - ..sort( - (RelationshipMoment a, RelationshipMoment b) => - b.at.compareTo(a.at), - ); - final List ideas = - data.ideas - .where((RelationshipIdea idea) => idea.personId == person.id) - .toList(growable: false) - ..sort( - (RelationshipIdea a, RelationshipIdea b) => - b.createdAt.compareTo(a.createdAt), - ); - final List reminders = - data.reminders - .where( - (ReminderRule reminder) => reminder.personId == person.id, - ) - .toList(growable: false) - ..sort( - (ReminderRule a, ReminderRule b) => - a.nextAt.compareTo(b.nextAt), - ); - final List preferenceSignals = - data.preferenceSignals - .where( - (PersonPreferenceSignal signal) => - signal.personId == person.id, - ) - .toList(growable: false) - ..sort(_comparePreferenceSignals); - final List relatedTasks = _findTasksForPerson( - data.tasks, - person, - ); - - final List personSignals = - signals.asData?.value.items - .where((SignalItem item) => item.personId == person.id) - .toList(growable: false) ?? - const []; - - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - final bool compact = constraints.maxWidth < 760; - return SingleChildScrollView( - padding: EdgeInsets.fromLTRB( - compact ? 16 : 24, - compact ? 16 : 20, - compact ? 16 : 24, - compact ? 24 : 30, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _personProfileCard( - context, - person, - compact, - onQuickAction: (_InsightQuickAction action) => - _handleQuickAction(context, ref, person, action), - ), - const SizedBox(height: 14), - _InsightSectionCard( - title: 'Chat-derived preferences', - icon: Icons.psychology_alt_outlined, - children: preferenceSignals.isEmpty - ? [ - const _SectionEmpty( - label: - 'No inferred preferences yet. Share chat messages to build context gradually.', - ), - ] - : preferenceSignals - .map( - ( - PersonPreferenceSignal signal, - ) => _InsightPreferenceSignalCard( - signal: signal, - compact: compact, - onConfirm: () async { - await ref - .read( - localRepositoryProvider.notifier, - ) - .confirmPreferenceSignal(signal.id); - }, - onDismiss: () async { - await ref - .read( - localRepositoryProvider.notifier, - ) - .dismissPreferenceSignal(signal.id); - }, - onWhy: () => _showPreferenceSignalEvidence( - context, - signal: signal, - personName: person.name, - ), - ), - ) - .toList(growable: false), - ), - const SizedBox(height: 12), - _InsightSectionCard( - title: 'Ideas', - icon: Icons.lightbulb_outline_rounded, - children: ideas.isEmpty - ? [ - const _SectionEmpty( - label: - 'No captured ideas yet for this relationship.', - ), - ] - : ideas - .map( - (RelationshipIdea idea) => _InsightRow( - title: idea.title, - subtitle: idea.details, - meta: - '${idea.type.name.toUpperCase()} • ${_shortDate(idea.createdAt)}', - ), - ) - .toList(growable: false), - ), - const SizedBox(height: 12), - _InsightSectionCard( - title: 'Promotions & Signals', - icon: Icons.local_offer_outlined, - children: _signalRows( - context: context, - signals: personSignals, - sourceState: signals, - ), - ), - const SizedBox(height: 12), - _InsightSectionCard( - title: 'Moments', - icon: Icons.auto_awesome_rounded, - children: moments.isEmpty - ? [ - const _SectionEmpty( - label: 'No logged moments for this person yet.', - ), - ] - : moments - .map( - (RelationshipMoment moment) => _InsightRow( - title: moment.title, - subtitle: moment.summary, - meta: - '${moment.type.toUpperCase()} • ${_shortDate(moment.at)}', - ), - ) - .toList(growable: false), - ), - const SizedBox(height: 12), - _InsightSectionCard( - title: 'Reminders', - icon: Icons.notifications_active_outlined, - children: reminders.isEmpty - ? [ - const _SectionEmpty( - label: - 'No reminders configured for this relationship.', - ), - ] - : reminders - .map( - (ReminderRule reminder) => _InsightRow( - title: reminder.title, - subtitle: reminder.enabled - ? 'Enabled' - : 'Disabled', - meta: - '${reminder.cadence.name.toUpperCase()} • Next ${_shortDate(reminder.nextAt)}', - ), - ) - .toList(growable: false), - ), - const SizedBox(height: 12), - _InsightSectionCard( - title: 'Tasks & Follow-Ups', - icon: Icons.checklist_rounded, - children: relatedTasks.isEmpty - ? [ - const _SectionEmpty( - label: - 'No direct follow-up tasks found for this person.', - ), - ] - : relatedTasks - .map( - (DashboardTask task) => _InsightRow( - title: task.title, - subtitle: task.description, - meta: - '${task.done ? 'DONE' : 'PENDING'} • ${_shortDate(task.when)}', - ), - ) - .toList(growable: false), - ), - ], - ), - ); - }, - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (Object error, StackTrace stackTrace) { - return Center( - child: Text( - 'Could not load person insights.', - style: Theme.of(context).textTheme.bodyLarge, - ), - ); - }, - ), - ); - } - - Future _showPreferenceSignalEvidence( - BuildContext context, { - required PersonPreferenceSignal signal, - required String personName, - }) { - return showModalBottomSheet( - context: context, - useSafeArea: true, - showDragHandle: true, - builder: (BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Why this signal?', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 6), - Text( - '${signal.label} • ${signal.category} • ${signal.polarity.name.toUpperCase()}', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: 4), - Text( - 'For $personName • confidence ${(signal.confidence * 100).round()}%', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: 12), - if (signal.evidenceSnippets.isEmpty) - const _SectionEmpty( - label: 'No evidence snippets stored yet for this signal.', - ) - else - ...signal.evidenceSnippets.map( - (String snippet) => _InsightRow( - title: 'Evidence', - subtitle: snippet, - meta: 'Source(s): ${signal.sourceApps.join(', ')}', - ), - ), - const SizedBox(height: 8), - Align( - alignment: Alignment.centerRight, - child: TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Close'), - ), - ), - ], - ), - ); - }, - ); - } - - Widget _personProfileCard( - BuildContext context, - PersonProfile person, - bool compact, { - required ValueChanged<_InsightQuickAction> onQuickAction, - }) { - final _RelationshipVisual visual = _relationshipVisualFor( - person.relationship, - ); - - return FrostedCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (compact) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - _insightAvatar(person: person, visual: visual), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - person.name, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 3), - Text( - person.relationship, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(color: AppTheme.textSecondary), - ), - ], - ), - ), - PopupMenuButton<_InsightQuickAction>( - tooltip: 'Quick add', - onSelected: onQuickAction, - itemBuilder: (BuildContext context) => - const >[ - PopupMenuItem<_InsightQuickAction>( - value: _InsightQuickAction.capture, - child: Text('Add Capture'), - ), - PopupMenuItem<_InsightQuickAction>( - value: _InsightQuickAction.note, - child: Text('Add Note'), - ), - PopupMenuItem<_InsightQuickAction>( - value: _InsightQuickAction.idea, - child: Text('Add Idea'), - ), - PopupMenuItem<_InsightQuickAction>( - value: _InsightQuickAction.reminder, - child: Text('Add Reminder'), - ), - ], - icon: const Icon(Icons.add_circle_outline_rounded), - ), - ], - ), - const SizedBox(height: 10), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _miniMetric( - label: 'Affinity', - value: '${person.affinityScore}%', - ), - _miniMetric( - label: 'Next Moment', - value: _shortDate(person.nextMoment), - ), - ], - ), - ], - ) - else - Row( - children: [ - _insightAvatar(person: person, visual: visual), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - person.name, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 3), - Text( - person.relationship, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ], - ), - ), - _miniMetric( - label: 'Affinity', - value: '${person.affinityScore}%', - ), - const SizedBox(width: 10), - _miniMetric( - label: 'Next Moment', - value: _shortDate(person.nextMoment), - ), - const SizedBox(width: 8), - PopupMenuButton<_InsightQuickAction>( - tooltip: 'Quick add', - onSelected: onQuickAction, - itemBuilder: (BuildContext context) => - const >[ - PopupMenuItem<_InsightQuickAction>( - value: _InsightQuickAction.capture, - child: Text('Add Capture'), - ), - PopupMenuItem<_InsightQuickAction>( - value: _InsightQuickAction.note, - child: Text('Add Note'), - ), - PopupMenuItem<_InsightQuickAction>( - value: _InsightQuickAction.idea, - child: Text('Add Idea'), - ), - PopupMenuItem<_InsightQuickAction>( - value: _InsightQuickAction.reminder, - child: Text('Add Reminder'), - ), - ], - icon: const Icon(Icons.add_circle_outline_rounded), - ), - ], - ), - if (person.tags.isNotEmpty) ...[ - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - children: person.tags - .map((String tag) => _TagPill(label: tag)) - .toList(growable: false), - ), - ], - if (person.notes.trim().isNotEmpty) ...[ - const SizedBox(height: 12), - Text( - person.notes, - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), - ), - ], - ], - ), - ); - } - - Future _handleQuickAction( - BuildContext context, - WidgetRef ref, - PersonProfile person, - _InsightQuickAction action, - ) async { - switch (action) { - case _InsightQuickAction.capture: - final String? summary = await _showQuickTextCaptureDialog( - context, - title: 'Add Capture', - hintText: 'What happened in this interaction?', - ); - if (summary == null) { - return; - } - await ref - .read(localRepositoryProvider.notifier) - .addMoment(personId: person.id, summary: summary, type: 'capture'); - return; - case _InsightQuickAction.note: - final String? note = await _showQuickTextCaptureDialog( - context, - title: 'Add Note', - hintText: 'Write a quick context note...', - ); - if (note == null) { - return; - } - await ref - .read(localRepositoryProvider.notifier) - .addMoment(personId: person.id, summary: note, type: 'note'); - return; - case _InsightQuickAction.idea: - final _InsightQuickIdeaDraft? idea = - await showDialog<_InsightQuickIdeaDraft>( - context: context, - builder: (BuildContext context) => - const _InsightQuickIdeaDialog(), - ); - if (idea == null) { - return; - } - await ref - .read(localRepositoryProvider.notifier) - .addIdea( - type: idea.type, - title: idea.title, - details: idea.details, - personId: person.id, - ); - return; - case _InsightQuickAction.reminder: - final _InsightQuickReminderDraft? reminder = - await showDialog<_InsightQuickReminderDraft>( - context: context, - builder: (BuildContext context) => - const _InsightQuickReminderDialog(), - ); - if (reminder == null) { - return; - } - await ref - .read(localRepositoryProvider.notifier) - .addReminder( - title: reminder.title, - cadence: reminder.cadence, - nextAt: reminder.nextAt, - personId: person.id, - ); - return; - } - } - - Future _openQuickAddSheet( - BuildContext context, - WidgetRef ref, - PersonProfile person, - ) async { - final _InsightQuickAction? selected = - await showModalBottomSheet<_InsightQuickAction>( - context: context, - showDragHandle: true, - builder: (BuildContext context) { - return SafeArea( - child: ListView( - shrinkWrap: true, - children: [ - ListTile( - leading: const Icon(Icons.auto_awesome_rounded), - title: const Text('Add Capture'), - subtitle: const Text('Log a new moment or interaction'), - onTap: () => - Navigator.of(context).pop(_InsightQuickAction.capture), - ), - ListTile( - leading: const Icon(Icons.note_add_outlined), - title: const Text('Add Note'), - subtitle: const Text('Save a context note for later'), - onTap: () => - Navigator.of(context).pop(_InsightQuickAction.note), - ), - ListTile( - leading: const Icon(Icons.lightbulb_outline_rounded), - title: const Text('Add Idea'), - subtitle: const Text('Gift or event idea'), - onTap: () => - Navigator.of(context).pop(_InsightQuickAction.idea), - ), - ListTile( - leading: const Icon(Icons.notifications_active_outlined), - title: const Text('Add Reminder'), - subtitle: const Text('Schedule a follow-up prompt'), - onTap: () => - Navigator.of(context).pop(_InsightQuickAction.reminder), - ), - ], - ), - ); - }, - ); - if (selected == null || !context.mounted) { - return; - } - await _handleQuickAction(context, ref, person, selected); - } - - Future _showQuickTextCaptureDialog( - BuildContext context, { - required String title, - required String hintText, - }) async { - return showDialog( - context: context, - builder: (BuildContext context) { - return _InsightQuickTextCaptureDialog(title: title, hintText: hintText); - }, - ); - } - - Widget _insightAvatar({ - required PersonProfile person, - required _RelationshipVisual visual, - }) { - return Container( - width: 56, - height: 56, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - colors: [ - _shiftLightness(visual.color, 0.16), - _shiftLightness(visual.color, -0.12), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - alignment: Alignment.center, - child: Text( - _initials(person.name), - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w700, - fontSize: 16, - ), - ), - ); - } - - Widget _miniMetric({required String label, required String value}) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - decoration: BoxDecoration( - color: const Color(0xFFF2F8FB), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: const TextStyle(fontSize: 11, color: AppTheme.textSecondary), - ), - const SizedBox(height: 2), - Text( - value, - style: const TextStyle( - fontWeight: FontWeight.w700, - color: AppTheme.textPrimary, - ), - ), - ], - ), - ); - } - - List _signalRows({ - required BuildContext context, - required List signals, - required AsyncValue sourceState, - }) { - if (sourceState.isLoading && sourceState.asData == null) { - return const [ - Padding( - padding: EdgeInsets.symmetric(vertical: 8), - child: LinearProgressIndicator(minHeight: 3), - ), - ]; - } - - if (signals.isEmpty) { - if (sourceState.hasError) { - return [ - const _SectionEmpty( - label: - 'Signals unavailable right now. Backend sync can repopulate.', - ), - ]; - } - return [ - const _SectionEmpty( - label: 'No promotions/signals targeted to this person yet.', - ), - ]; - } - - return signals - .map( - (SignalItem item) => _InsightRow( - title: item.title, - subtitle: item.description ?? 'No description', - meta: '${item.type.toUpperCase()} • ${_shortDate(item.createdAt)}', - ), - ) - .toList(growable: false); - } - - PersonProfile? _findPersonById(List people, String id) { - for (final PersonProfile person in people) { - if (person.id == id) { - return person; - } - } - return null; - } - - List _findTasksForPerson( - List tasks, - PersonProfile person, - ) { - final List tokens = person.name - .toLowerCase() - .split(' ') - .where((String token) => token.length > 2) - .toList(growable: false); - if (tokens.isEmpty) { - return const []; - } - return tasks - .where((DashboardTask task) { - final String fullText = '${task.title} ${task.description}' - .toLowerCase(); - for (final String token in tokens) { - if (fullText.contains(token)) { - return true; - } - } - return false; - }) - .toList(growable: false) - ..sort((DashboardTask a, DashboardTask b) => a.when.compareTo(b.when)); - } -} - -int _comparePreferenceSignals( - PersonPreferenceSignal a, - PersonPreferenceSignal b, -) { - int statusRank(PreferenceSignalStatus status) => switch (status) { - PreferenceSignalStatus.inferred => 0, - PreferenceSignalStatus.confirmed => 1, - PreferenceSignalStatus.dismissed => 2, - }; - - final int byStatus = statusRank(a.status).compareTo(statusRank(b.status)); - if (byStatus != 0) { - return byStatus; - } - final int byConfidence = b.confidence.compareTo(a.confidence); - if (byConfidence != 0) { - return byConfidence; - } - return a.label.toLowerCase().compareTo(b.label.toLowerCase()); -} - -class _InsightPreferenceSignalCard extends StatelessWidget { - const _InsightPreferenceSignalCard({ - required this.signal, - required this.compact, - required this.onConfirm, - required this.onDismiss, - required this.onWhy, - }); - - final PersonPreferenceSignal signal; - final bool compact; - final Future Function() onConfirm; - final Future Function() onDismiss; - final VoidCallback onWhy; - - @override - Widget build(BuildContext context) { - final Color tone = switch (signal.status) { - PreferenceSignalStatus.confirmed => const Color(0xFF1D9C66), - PreferenceSignalStatus.dismissed => const Color(0xFF9A6A00), - PreferenceSignalStatus.inferred => AppTheme.primary, - }; - final IconData polarityIcon = switch (signal.polarity) { - PreferenceSignalPolarity.like => Icons.thumb_up_alt_outlined, - PreferenceSignalPolarity.dislike => Icons.thumb_down_alt_outlined, - PreferenceSignalPolarity.neutral => Icons.tune_rounded, - }; - - return Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFFF5FAFB), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.white), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Icon(polarityIcon, size: 16, color: tone), - Text( - signal.label, - style: Theme.of(context).textTheme.titleSmall, - ), - _PreferenceBadge(label: signal.status.name, color: tone), - _PreferenceBadge( - label: '${(signal.confidence * 100).round()}%', - color: AppTheme.textSecondary, - filled: false, - ), - ], - ), - const SizedBox(height: 6), - Text( - '${signal.category.toUpperCase()} • seen ${signal.occurrenceCount}x', - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - OutlinedButton(onPressed: onWhy, child: const Text('Why?')), - if (signal.status != PreferenceSignalStatus.confirmed) - FilledButton.tonal( - onPressed: () async => onConfirm(), - child: const Text('Confirm'), - ), - if (signal.status != PreferenceSignalStatus.dismissed) - TextButton( - onPressed: () async => onDismiss(), - child: const Text('Dismiss'), - ), - ], - ), - if (!compact && signal.sourceApps.isNotEmpty) ...[ - const SizedBox(height: 6), - Text( - 'Sources: ${signal.sourceApps.join(', ')}', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), - ), - ], - ], - ), - ), - ); - } -} - -class _PreferenceBadge extends StatelessWidget { - const _PreferenceBadge({ - required this.label, - required this.color, - this.filled = true, - }); - - final String label; - final Color color; - final bool filled; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: filled ? color.withValues(alpha: 0.12) : Colors.transparent, - borderRadius: BorderRadius.circular(99), - border: Border.all(color: color.withValues(alpha: filled ? 0.2 : 0.35)), - ), - child: Text( - label.toUpperCase(), - style: Theme.of(context).textTheme.labelSmall?.copyWith(color: color), - ), - ); - } -} - -class _InsightQuickIdeaDraft { - const _InsightQuickIdeaDraft({ - required this.type, - required this.title, - required this.details, - }); - - final IdeaType type; - final String title; - final String details; -} - -class _InsightQuickIdeaDialog extends StatefulWidget { - const _InsightQuickIdeaDialog(); - - @override - State<_InsightQuickIdeaDialog> createState() => - _InsightQuickIdeaDialogState(); -} - -class _InsightQuickIdeaDialogState extends State<_InsightQuickIdeaDialog> { - IdeaType _type = IdeaType.gift; - final TextEditingController _titleController = TextEditingController(); - final TextEditingController _detailsController = TextEditingController(); - - @override - void dispose() { - _titleController.dispose(); - _detailsController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text('Add Idea'), - content: SingleChildScrollView( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SegmentedButton( - segments: const >[ - ButtonSegment( - value: IdeaType.gift, - label: Text('Gift'), - ), - ButtonSegment( - value: IdeaType.event, - label: Text('Event'), - ), - ], - selected: {_type}, - onSelectionChanged: (Set values) { - setState(() { - _type = values.first; - }); - }, - ), - const SizedBox(height: 10), - TextField( - controller: _titleController, - decoration: const InputDecoration(labelText: 'Title'), - ), - TextField( - controller: _detailsController, - maxLines: 3, - decoration: const InputDecoration(labelText: 'Details'), - ), - ], - ), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - final String title = _titleController.text.trim(); - if (title.isEmpty) { - return; - } - Navigator.of(context).pop( - _InsightQuickIdeaDraft( - type: _type, - title: title, - details: _detailsController.text.trim(), - ), - ); - }, - child: const Text('Add'), - ), - ], - ); - } -} - -class _InsightQuickReminderDraft { - const _InsightQuickReminderDraft({ - required this.title, - required this.cadence, - required this.nextAt, - }); - - final String title; - final ReminderCadence cadence; - final DateTime nextAt; -} - -class _InsightQuickReminderDialog extends StatefulWidget { - const _InsightQuickReminderDialog(); - - @override - State<_InsightQuickReminderDialog> createState() => - _InsightQuickReminderDialogState(); -} - -class _InsightQuickReminderDialogState - extends State<_InsightQuickReminderDialog> { - final TextEditingController _titleController = TextEditingController(); - ReminderCadence _cadence = ReminderCadence.weekly; - DateTime _nextAt = DateTime.now().add(const Duration(days: 1)); - - @override - void dispose() { - _titleController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text('Add Reminder'), - content: SingleChildScrollView( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: _titleController, - decoration: const InputDecoration(labelText: 'Title'), - ), - const SizedBox(height: 10), - DropdownButtonFormField( - initialValue: _cadence, - decoration: const InputDecoration(labelText: 'Cadence'), - items: ReminderCadence.values - .map( - (ReminderCadence cadence) => - DropdownMenuItem( - value: cadence, - child: Text(cadence.name), - ), - ) - .toList(growable: false), - onChanged: (ReminderCadence? value) { - if (value == null) { - return; - } - setState(() { - _cadence = value; - }); - }, - ), - const SizedBox(height: 10), - Row( - children: [ - Expanded( - child: Text( - 'Next: ${_nextAt.year}-${_nextAt.month.toString().padLeft(2, '0')}-${_nextAt.day.toString().padLeft(2, '0')} ${_nextAt.hour.toString().padLeft(2, '0')}:${_nextAt.minute.toString().padLeft(2, '0')}', - ), - ), - TextButton( - onPressed: () async { - final DateTime now = DateTime.now(); - final DateTime? date = await showDatePicker( - context: context, - firstDate: now, - lastDate: now.add(const Duration(days: 3650)), - initialDate: _nextAt, - ); - if (date == null || !context.mounted) { - return; - } - final TimeOfDay? time = await showTimePicker( - context: context, - initialTime: TimeOfDay.fromDateTime(_nextAt), - ); - if (time == null) { - return; - } - setState(() { - _nextAt = DateTime( - date.year, - date.month, - date.day, - time.hour, - time.minute, - ); - }); - }, - child: const Text('Change'), - ), - ], - ), - ], - ), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - final String title = _titleController.text.trim(); - if (title.isEmpty) { - return; - } - Navigator.of(context).pop( - _InsightQuickReminderDraft( - title: title, - cadence: _cadence, - nextAt: _nextAt, - ), - ); - }, - child: const Text('Add'), - ), - ], - ); - } -} - -class _InsightQuickTextCaptureDialog extends StatefulWidget { - const _InsightQuickTextCaptureDialog({ - required this.title, - required this.hintText, - }); - - final String title; - final String hintText; - - @override - State<_InsightQuickTextCaptureDialog> createState() => - _InsightQuickTextCaptureDialogState(); -} - -class _InsightQuickTextCaptureDialogState - extends State<_InsightQuickTextCaptureDialog> { - late final TextEditingController _controller; - bool _attemptedSubmit = false; - - @override - void initState() { - super.initState(); - _controller = TextEditingController(); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final String value = _controller.text.trim(); - return AlertDialog( - title: Text(widget.title), - content: TextField( - controller: _controller, - minLines: 2, - maxLines: 4, - autofocus: true, - onChanged: (_) { - if (_attemptedSubmit) { - setState(() {}); - } - }, - decoration: InputDecoration( - hintText: widget.hintText, - errorText: _attemptedSubmit && value.isEmpty ? 'Required' : null, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - final String submitted = _controller.text.trim(); - if (submitted.isEmpty) { - setState(() { - _attemptedSubmit = true; - }); - return; - } - Navigator.of(context).pop(submitted); - }, - child: const Text('Save'), - ), - ], - ); - } -} - -class _InsightSectionCard extends StatelessWidget { - const _InsightSectionCard({ - required this.title, - required this.icon, - required this.children, - }); - - final String title; - final IconData icon; - final List children; - - @override - Widget build(BuildContext context) { - return FrostedCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - spacing: 8, - runSpacing: 6, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Icon(icon, size: 18, color: AppTheme.primary), - Text(title, style: Theme.of(context).textTheme.titleMedium), - ], - ), - const SizedBox(height: 10), - ...children, - ], - ), - ); - } -} - -class _InsightRow extends StatelessWidget { - const _InsightRow({ - required this.title, - required this.subtitle, - required this.meta, - }); - - final String title; - final String subtitle; - final String meta; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFFF5FAFB), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: Theme.of(context).textTheme.titleSmall), - const SizedBox(height: 4), - Text( - subtitle, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: 6), - Text( - meta, - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: AppTheme.textSecondary), - ), - ], - ), - ), - ); - } -} - -class _SectionEmpty extends StatelessWidget { - const _SectionEmpty({required this.label}); - - final String label; - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFFF5FAFB), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - label, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), - ), - ); - } -} - -class _TagPill extends StatelessWidget { - const _TagPill({required this.label}); - - final String label; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: const Color(0xFFEFF7FA), - borderRadius: BorderRadius.circular(99), - ), - child: Text( - label, - style: Theme.of( - context, - ).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary), - ), - ); - } -} - -class _StatCard extends StatelessWidget { - const _StatCard({ - required this.label, - required this.value, - required this.accent, - required this.compact, - }); - - final String label; - final String value; - final Color accent; - final bool compact; - - @override - Widget build(BuildContext context) { - return FrostedCard( - padding: const EdgeInsets.all(16), - child: SizedBox( - width: compact ? double.infinity : 188, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - height: 8, - width: 40, - decoration: BoxDecoration( - color: accent, - borderRadius: BorderRadius.circular(99), - ), - ), - const SizedBox(height: 16), - Text( - value, - style: Theme.of( - context, - ).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w700), - ), - const SizedBox(height: 4), - Text( - label, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), - ), - ], - ), - ), - ); - } -} - -class _TaskRow extends StatelessWidget { - const _TaskRow({ - required this.task, - required this.onToggleDone, - required this.compact, - }); - - final DashboardTask task; - final VoidCallback onToggleDone; - final bool compact; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: const Color(0xFFF5FAFB), - borderRadius: BorderRadius.circular(16), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - InkWell( - onTap: onToggleDone, - borderRadius: BorderRadius.circular(20), - child: Icon( - task.done - ? Icons.check_circle_rounded - : Icons.radio_button_unchecked_rounded, - color: task.done - ? const Color(0xFF1D9C66) - : const Color(0xFF1AB6C8), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - task.title, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - decoration: task.done ? TextDecoration.lineThrough : null, - ), - ), - const SizedBox(height: 4), - Text( - task.description, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ], - ), - ), - if (!compact) ...[ - const SizedBox(width: 12), - Text( - _shortDate(task.when), - style: Theme.of( - context, - ).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary), - ), - ], - ], - ), - ), - ); - } -} - -class _RelationshipVisual { - const _RelationshipVisual({ - required this.label, - required this.icon, - required this.color, - }); - - final String label; - final IconData icon; - final Color color; -} - -const _RelationshipVisual _spouseVisual = _RelationshipVisual( - label: 'Spouse', - icon: Icons.favorite_rounded, - color: Color(0xFFE0557B), -); - -const _RelationshipVisual _fianceVisual = _RelationshipVisual( - label: 'Fiance', - icon: Icons.diamond_rounded, - color: Color(0xFFCB6CF2), -); - -const _RelationshipVisual _partnerVisual = _RelationshipVisual( - label: 'Partner', - icon: Icons.favorite_border_rounded, - color: Color(0xFFE67A5A), -); - -const _RelationshipVisual _bestieVisual = _RelationshipVisual( - label: 'Bestie', - icon: Icons.emoji_emotions_rounded, - color: Color(0xFF8E6BFF), -); - -const _RelationshipVisual _friendVisual = _RelationshipVisual( - label: 'Friend', - icon: Icons.groups_rounded, - color: Color(0xFF3F8CFF), -); - -const _RelationshipVisual _familyVisual = _RelationshipVisual( - label: 'Family', - icon: Icons.family_restroom_rounded, - color: Color(0xFF2FB67D), -); - -const _RelationshipVisual _sisterVisual = _RelationshipVisual( - label: 'Sister', - icon: Icons.female_rounded, - color: Color(0xFFFF6EA9), -); - -const _RelationshipVisual _brotherVisual = _RelationshipVisual( - label: 'Brother', - icon: Icons.male_rounded, - color: Color(0xFF4A7DFF), -); - -const _RelationshipVisual _otherVisual = _RelationshipVisual( - label: 'Connection', - icon: Icons.hub_rounded, - color: Color(0xFF5F7280), -); - -_RelationshipVisual _relationshipVisualFor(String relationship) { - final String value = relationship.toLowerCase(); - - if (_containsAny(value, ['spouse', 'wife', 'husband'])) { - return _spouseVisual; - } - if (_containsAny(value, ['fiance', 'engaged'])) { - return _fianceVisual; - } - if (_containsAny(value, [ - 'girlfriend', - 'boyfriend', - 'partner', - 'gf', - 'bf', - ])) { - return _partnerVisual; - } - if (_containsAny(value, ['bestie', 'best friend', 'bff'])) { - return _bestieVisual; - } - if (_containsAny(value, ['sister', 'sis'])) { - return _sisterVisual; - } - if (_containsAny(value, ['brother', 'bro'])) { - return _brotherVisual; - } - if (_containsAny(value, [ - 'family', - 'mother', - 'father', - 'mom', - 'dad', - 'parent', - 'aunt', - 'uncle', - 'cousin', - 'grand', - ])) { - return _familyVisual; - } - if (_containsAny(value, ['friend', 'pal', 'buddy', 'mate'])) { - return _friendVisual; - } - return _otherVisual; -} - -bool _containsAny(String value, List needles) { - for (final String needle in needles) { - if (value.contains(needle)) { - return true; - } - } - return false; -} - -Color _shiftLightness(Color color, double delta) { - final HSLColor hsl = HSLColor.fromColor(color); - final double next = (hsl.lightness + delta).clamp(0.0, 1.0); - return hsl.withLightness(next).toColor(); -} - -String _initials(String name) { - final List parts = name - .split(' ') - .where((String part) => part.isNotEmpty) - .toList(growable: false); - if (parts.isEmpty) { - return '?'; - } - - if (parts.length == 1) { - return parts.first - .substring(0, math.min(2, parts.first.length)) - .toUpperCase(); - } - - return '${parts.first[0]}${parts[1][0]}'.toUpperCase(); -} - -String _shortName(String name) { - final List parts = name - .split(' ') - .where((String part) => part.isNotEmpty) - .toList(growable: false); - if (parts.isEmpty) { - return 'Unknown'; - } - return parts.first; -} - -String _shortDate(DateTime value) { - const List weekdays = [ - 'Mon', - 'Tue', - 'Wed', - 'Thu', - 'Fri', - 'Sat', - 'Sun', - ]; - final String weekday = weekdays[value.weekday - 1]; - final String day = value.day.toString().padLeft(2, '0'); - return '$weekday $day'; -} +// Legacy compatibility export for the dashboard presentation entry. +export 'package:relationship_saver/features/dashboard/presentation/dashboard_view.dart'; diff --git a/lib/features/dashboard/domain/README.md b/lib/features/dashboard/domain/README.md new file mode 100644 index 0000000..65286ff --- /dev/null +++ b/lib/features/dashboard/domain/README.md @@ -0,0 +1,4 @@ +# Dashboard Domain + +Dashboard-owned value types live here. These models should stay dumb and stable +so the dashboard UI can evolve without changing persistence contracts everywhere. diff --git a/lib/features/dashboard/domain/dashboard_models.dart b/lib/features/dashboard/domain/dashboard_models.dart new file mode 100644 index 0000000..d796a6b --- /dev/null +++ b/lib/features/dashboard/domain/dashboard_models.dart @@ -0,0 +1,73 @@ +// ignore_for_file: sort_constructors_first + +import 'package:flutter/foundation.dart'; + +/// Lightweight task shown on the dashboard home surface. +@immutable +class DashboardTask { + const DashboardTask({ + required this.id, + required this.title, + required this.description, + required this.when, + this.done = false, + }); + + final String id; + final String title; + final String description; + final DateTime when; + final bool done; + + DashboardTask copyWith({ + String? id, + String? title, + String? description, + DateTime? when, + bool? done, + }) { + return DashboardTask( + id: id ?? this.id, + title: title ?? this.title, + description: description ?? this.description, + when: when ?? this.when, + done: done ?? this.done, + ); + } + + Map toJson() { + return { + 'id': id, + 'title': title, + 'description': description, + 'when': when.toUtc().toIso8601String(), + 'done': done, + }; + } + + factory DashboardTask.fromJson(Map json) { + return DashboardTask( + id: json['id'] as String, + title: json['title'] as String, + description: json['description'] as String, + when: DateTime.parse(json['when'] as String).toLocal(), + done: json['done'] as bool? ?? false, + ); + } +} + +/// Summary numbers consumed by the dashboard UI. +@immutable +class DashboardSummary { + const DashboardSummary({ + required this.activePeople, + required this.upcomingPlans, + required this.pendingIdeas, + required this.weeklyConsistency, + }); + + final int activePeople; + final int upcomingPlans; + final int pendingIdeas; + final int weeklyConsistency; +} diff --git a/lib/features/dashboard/presentation/README.md b/lib/features/dashboard/presentation/README.md new file mode 100644 index 0000000..13c9010 --- /dev/null +++ b/lib/features/dashboard/presentation/README.md @@ -0,0 +1,4 @@ +# Dashboard Presentation + +This folder contains the overview/home UI for the relationship workspace. +Use it for founder-facing summary screens, not for deeper people-specific flows. diff --git a/lib/features/dashboard/presentation/dashboard_view.dart b/lib/features/dashboard/presentation/dashboard_view.dart new file mode 100644 index 0000000..40e4ed3 --- /dev/null +++ b/lib/features/dashboard/presentation/dashboard_view.dart @@ -0,0 +1,2582 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/config/app_theme.dart'; +import 'package:relationship_saver/features/local/local_models.dart'; +import 'package:relationship_saver/features/local/local_repository.dart'; +import 'package:relationship_saver/features/shared/frosted_card.dart'; +import 'package:relationship_saver/features/signals/signals_controller.dart'; +import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; + +class DashboardView extends ConsumerWidget { + const DashboardView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final AsyncValue localData = ref.watch( + localRepositoryProvider, + ); + + return localData.when( + data: (LocalDataState data) { + final DashboardSummary summary = data.summary(); + final List tasks = data.tasks; + + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final bool compact = constraints.maxWidth < 720; + return SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + compact ? 16 : 28, + compact ? 16 : 24, + compact ? 16 : 28, + compact ? 24 : 36, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Today', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 8), + Text( + 'See the relationship graph first, then execute the highest-leverage next moves.', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 18), + _RelationshipGraphCard(people: data.people, compact: compact), + const SizedBox(height: 20), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _StatCard( + label: 'Active People', + value: '${summary.activePeople}', + accent: const Color(0xFF16B8CA), + compact: compact, + ), + _StatCard( + label: 'Upcoming Plans', + value: '${summary.upcomingPlans}', + accent: const Color(0xFF2B7FFF), + compact: compact, + ), + _StatCard( + label: 'Pending Ideas', + value: '${summary.pendingIdeas}', + accent: const Color(0xFFFFA447), + compact: compact, + ), + _StatCard( + label: 'Weekly Rhythm', + value: '${summary.weeklyConsistency}%', + accent: const Color(0xFF30B66A), + compact: compact, + ), + ], + ), + const SizedBox(height: 24), + FrostedCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Next Moves', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 14), + if (tasks.isEmpty) + Text( + 'No tasks yet. Add moments, ideas, or reminders to generate follow-through actions.', + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith(color: AppTheme.textSecondary), + ) + else + ...tasks.map( + (DashboardTask task) => _TaskRow( + task: task, + compact: compact, + onToggleDone: () { + ref + .read(localRepositoryProvider.notifier) + .toggleTaskDone(task.id); + }, + ), + ), + ], + ), + ), + ], + ), + ); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (Object error, StackTrace stackTrace) { + return Center( + child: Text( + 'Could not load local data.', + style: Theme.of(context).textTheme.bodyLarge, + ), + ); + }, + ); + } +} + +class _RelationshipGraphCard extends StatelessWidget { + const _RelationshipGraphCard({required this.people, required this.compact}); + + final List people; + final bool compact; + + @override + Widget build(BuildContext context) { + final List<_RelationshipVisual> legend = _collectGraphLegend(people); + return FrostedCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + 'Relationship Graph', + style: Theme.of(context).textTheme.titleLarge, + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: const Color(0xFFEAF7FB), + borderRadius: BorderRadius.circular(99), + ), + child: Text( + '${people.length} nodes', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: AppTheme.primary, + ), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: people.isEmpty + ? null + : () => _openGraphExplorer(context, people), + icon: const Icon(Icons.open_in_full_rounded, size: 18), + label: Text(compact ? 'Explore' : 'Open Explorer'), + ), + ], + ), + const SizedBox(height: 12), + Container( + height: compact ? 330 : 410, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + const Color(0xFFEAF3F8), + const Color(0xFFF7FAFC), + Colors.white.withValues(alpha: 0.9), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Colors.white.withValues(alpha: 0.8)), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return _RelationshipGraphCanvas( + people: people, + compact: compact, + mode: _GraphCanvasMode.preview, + onOpenPersonInsights: (PersonProfile person) => + _openPersonInsights(context, person), + ); + }, + ), + ), + ), + const SizedBox(height: 8), + Text( + 'Dashboard preview is scroll-first. Long-press a person for insights, or open the explorer to pan and zoom.', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: legend + .map( + (_RelationshipVisual visual) => + _RelationshipLegendPill(visual: visual), + ) + .toList(growable: false), + ), + ], + ), + ); + } +} + +enum _GraphCanvasMode { preview, explorer } + +class _RelationshipGraphExplorerPage extends StatefulWidget { + const _RelationshipGraphExplorerPage({required this.people}); + + final List people; + + @override + State<_RelationshipGraphExplorerPage> createState() => + _RelationshipGraphExplorerPageState(); +} + +class _RelationshipGraphExplorerPageState + extends State<_RelationshipGraphExplorerPage> { + late final TransformationController _controller; + + @override + void initState() { + super.initState(); + _controller = TransformationController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _resetView() { + _controller.value = Matrix4.identity(); + } + + @override + Widget build(BuildContext context) { + final bool compact = MediaQuery.sizeOf(context).width < 760; + final List<_RelationshipVisual> legend = _collectGraphLegend(widget.people); + + return Scaffold( + appBar: AppBar( + title: const Text('Relationship Graph'), + actions: [ + IconButton( + tooltip: 'Reset view', + onPressed: _resetView, + icon: const Icon(Icons.center_focus_strong_rounded), + ), + ], + ), + body: Padding( + padding: EdgeInsets.fromLTRB( + compact ? 12 : 18, + compact ? 12 : 14, + compact ? 12 : 18, + compact ? 14 : 20, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.76), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white.withValues(alpha: 0.85)), + ), + child: Wrap( + spacing: 10, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + 'Explorer mode: pan + pinch to zoom. Long-press a person to open insights.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.textSecondary, + ), + ), + FilledButton.tonalIcon( + onPressed: _resetView, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Reset View'), + ), + ], + ), + ), + const SizedBox(height: 12), + Expanded( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + const Color(0xFFEAF3F8), + const Color(0xFFF7FAFC), + Colors.white.withValues(alpha: 0.92), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: Colors.white.withValues(alpha: 0.82), + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: _RelationshipGraphCanvas( + people: widget.people, + compact: compact, + mode: _GraphCanvasMode.explorer, + transformationController: _controller, + onOpenPersonInsights: (PersonProfile person) => + _openPersonInsights(context, person), + ), + ), + ), + ), + const SizedBox(height: 10), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: legend + .map( + (_RelationshipVisual visual) => Padding( + padding: const EdgeInsets.only(right: 8), + child: _RelationshipLegendPill(visual: visual), + ), + ) + .toList(growable: false), + ), + ), + ], + ), + ), + ); + } +} + +class _RelationshipGraphCanvas extends StatelessWidget { + const _RelationshipGraphCanvas({ + required this.people, + required this.compact, + required this.mode, + required this.onOpenPersonInsights, + this.transformationController, + }); + + final List people; + final bool compact; + final _GraphCanvasMode mode; + final ValueChanged onOpenPersonInsights; + final TransformationController? transformationController; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + if (people.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + 'No people yet. Add your first relationship to start the graph.', + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), + ), + ), + ); + } + + final _GraphLayout layout = _GraphLayout.build( + people: people, + size: constraints.biggest, + compact: compact, + ); + + final bool explorer = mode == _GraphCanvasMode.explorer; + return InteractiveViewer( + transformationController: transformationController, + minScale: explorer ? 0.55 : 1.0, + maxScale: explorer ? 3.2 : 1.0, + boundaryMargin: explorer + ? const EdgeInsets.all(260) + : EdgeInsets.zero, + panEnabled: explorer, + scaleEnabled: explorer, + child: SizedBox( + width: constraints.maxWidth, + height: constraints.maxHeight, + child: Stack( + children: [ + Positioned.fill( + child: CustomPaint( + painter: _RelationshipEdgePainter( + center: layout.center, + edges: layout.edges, + ), + ), + ), + ...layout.nodes.map( + (_GraphNode node) => _GraphBubble( + node: node, + compact: compact, + onLongPress: () => onOpenPersonInsights(node.person), + ), + ), + _CenterNode(center: layout.center, compact: compact), + if (layout.hiddenCount > 0) + Positioned( + right: 12, + top: 12, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(99), + ), + child: Text( + '+${layout.hiddenCount} more', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ), + ), + if (!explorer) + Positioned( + right: 12, + bottom: 12, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.88), + borderRadius: BorderRadius.circular(99), + border: Border.all(color: Colors.white), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.swipe_up_alt_rounded, + size: 14, + color: AppTheme.textSecondary, + ), + const SizedBox(width: 6), + Text( + 'Scroll page', + style: Theme.of(context).textTheme.labelLarge + ?.copyWith(color: AppTheme.textSecondary), + ), + ], + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +List<_RelationshipVisual> _collectGraphLegend(List source) { + final Set seen = {}; + final List<_RelationshipVisual> result = <_RelationshipVisual>[]; + + for (final PersonProfile person in source) { + final _RelationshipVisual visual = _relationshipVisualFor( + person.relationship, + ); + if (seen.add(visual.label)) { + result.add(visual); + } + if (result.length >= 7) { + break; + } + } + + if (result.isEmpty) { + result.add(_otherVisual); + } + return result; +} + +void _openGraphExplorer(BuildContext context, List people) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) => + _RelationshipGraphExplorerPage(people: people), + ), + ); +} + +void _openPersonInsights(BuildContext context, PersonProfile person) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return _PersonInsightsPage(personId: person.id); + }, + ), + ); +} + +class _GraphLayout { + const _GraphLayout({ + required this.center, + required this.nodes, + required this.edges, + required this.hiddenCount, + }); + + final Offset center; + final List<_GraphNode> nodes; + final List<_GraphEdge> edges; + final int hiddenCount; + + static _GraphLayout build({ + required List people, + required Size size, + required bool compact, + }) { + final int maxNodes = compact ? 10 : 16; + final List visiblePeople = people + .take(maxNodes) + .toList(growable: false); + final int hiddenCount = people.length - visiblePeople.length; + + final Offset center = Offset(size.width / 2, size.height / 2); + if (visiblePeople.isEmpty) { + return _GraphLayout( + center: center, + nodes: const <_GraphNode>[], + edges: const <_GraphEdge>[], + hiddenCount: hiddenCount, + ); + } + + final double minSide = math.min(size.width, size.height); + final int total = visiblePeople.length; + final int innerCount = total <= 8 ? total : 8; + final int outerCount = total - innerCount; + final double innerRadius = minSide * (compact ? 0.28 : 0.31); + final double outerRadius = minSide * (compact ? 0.39 : 0.43); + + final List<_GraphNode> nodes = <_GraphNode>[]; + for (int i = 0; i < total; i += 1) { + final bool inOuterRing = i >= innerCount; + final int ringCount = inOuterRing ? outerCount : innerCount; + final int ringIndex = inOuterRing ? i - innerCount : i; + final double radius = inOuterRing ? outerRadius : innerRadius; + final double phaseOffset = inOuterRing ? math.pi / ringCount : 0; + final double angle = + (-math.pi / 2) + + phaseOffset + + ((2 * math.pi * ringIndex) / ringCount); + + final Offset position = Offset( + center.dx + radius * math.cos(angle), + center.dy + radius * math.sin(angle), + ); + final PersonProfile person = visiblePeople[i]; + final _RelationshipVisual visual = _relationshipVisualFor( + person.relationship, + ); + nodes.add(_GraphNode(person: person, visual: visual, position: position)); + } + + final List<_GraphEdge> edges = <_GraphEdge>[]; + for (final _GraphNode node in nodes) { + edges.add( + _GraphEdge( + start: center, + end: node.position, + color: node.visual.color.withValues(alpha: 0.46), + width: compact ? 1.7 : 2.0, + ), + ); + } + + if (nodes.length > 2) { + for (int i = 0; i < nodes.length; i += 1) { + final _GraphNode current = nodes[i]; + final _GraphNode next = nodes[(i + 1) % nodes.length]; + final Color color = + Color.lerp(current.visual.color, next.visual.color, 0.5) ?? + AppTheme.accent; + + edges.add( + _GraphEdge( + start: current.position, + end: next.position, + color: color.withValues(alpha: 0.2), + width: 1.1, + ), + ); + + if (nodes.length >= 7 && i.isEven) { + final _GraphNode jump = nodes[(i + 3) % nodes.length]; + edges.add( + _GraphEdge( + start: current.position, + end: jump.position, + color: current.visual.color.withValues(alpha: 0.1), + width: 1.0, + ), + ); + } + } + } + + return _GraphLayout( + center: center, + nodes: nodes, + edges: edges, + hiddenCount: hiddenCount, + ); + } +} + +class _GraphNode { + const _GraphNode({ + required this.person, + required this.visual, + required this.position, + }); + + final PersonProfile person; + final _RelationshipVisual visual; + final Offset position; +} + +class _GraphEdge { + const _GraphEdge({ + required this.start, + required this.end, + required this.color, + required this.width, + }); + + final Offset start; + final Offset end; + final Color color; + final double width; +} + +class _RelationshipEdgePainter extends CustomPainter { + const _RelationshipEdgePainter({required this.center, required this.edges}); + + final Offset center; + final List<_GraphEdge> edges; + + @override + void paint(Canvas canvas, Size size) { + final Paint centerGlow = Paint() + ..shader = + RadialGradient( + colors: [ + AppTheme.accent.withValues(alpha: 0.15), + Colors.transparent, + ], + ).createShader( + Rect.fromCircle( + center: center, + radius: math.min(size.width, size.height) * 0.36, + ), + ); + + canvas.drawCircle( + center, + math.min(size.width, size.height) * 0.36, + centerGlow, + ); + + for (final _GraphEdge edge in edges) { + final Paint paint = Paint() + ..color = edge.color + ..strokeWidth = edge.width + ..strokeCap = StrokeCap.round + ..style = PaintingStyle.stroke; + canvas.drawLine(edge.start, edge.end, paint); + } + } + + @override + bool shouldRepaint(covariant _RelationshipEdgePainter oldDelegate) { + return oldDelegate.edges != edges || oldDelegate.center != center; + } +} + +class _GraphBubble extends StatelessWidget { + const _GraphBubble({ + required this.node, + required this.compact, + required this.onLongPress, + }); + + final _GraphNode node; + final bool compact; + final VoidCallback onLongPress; + + @override + Widget build(BuildContext context) { + final double diameter = compact ? 52 : 62; + final double labelWidth = compact ? 66 : 86; + + return Positioned( + left: node.position.dx - (diameter / 2), + top: node.position.dy - (diameter / 2), + child: Tooltip( + message: '${node.person.name} • ${node.visual.label}', + child: GestureDetector( + key: ValueKey('graph-node-${node.person.id}'), + behavior: HitTestBehavior.translucent, + onLongPress: onLongPress, + child: SizedBox( + width: labelWidth, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: diameter, + height: diameter, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + _shiftLightness(node.visual.color, 0.16), + _shiftLightness(node.visual.color, -0.12), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: node.visual.color.withValues(alpha: 0.24), + blurRadius: 14, + offset: const Offset(0, 8), + ), + ], + ), + alignment: Alignment.center, + child: Text( + _initials(node.person.name), + style: Theme.of(context).textTheme.titleMedium + ?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + Positioned( + right: -2, + top: -2, + child: Container( + width: compact ? 20 : 22, + height: compact ? 20 : 22, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + border: Border.all( + color: node.visual.color.withValues(alpha: 0.8), + ), + ), + child: Icon( + node.visual.icon, + size: compact ? 12 : 13, + color: node.visual.color, + ), + ), + ), + ], + ), + if (!compact) ...[ + const SizedBox(height: 5), + Text( + _shortName(node.person.name), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ], + ), + ), + ), + ), + ); + } +} + +class _CenterNode extends StatelessWidget { + const _CenterNode({required this.center, required this.compact}); + + final Offset center; + final bool compact; + + @override + Widget build(BuildContext context) { + final double diameter = compact ? 74 : 86; + + return Positioned( + left: center.dx - (diameter / 2), + top: center.dy - (diameter / 2), + child: Container( + width: diameter, + height: diameter, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const LinearGradient( + colors: [Color(0xFF1AB6C8), Color(0xFF286DE0)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: const Color(0xFF1AB6C8).withValues(alpha: 0.26), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + alignment: Alignment.center, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.favorite_rounded, color: Colors.white, size: 18), + const SizedBox(height: 2), + Text( + 'YOU', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + ), + ), + ], + ), + ), + ); + } +} + +class _RelationshipLegendPill extends StatelessWidget { + const _RelationshipLegendPill({required this.visual}); + + final _RelationshipVisual visual; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: visual.color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(99), + border: Border.all(color: visual.color.withValues(alpha: 0.18)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(visual.icon, size: 14, color: visual.color), + const SizedBox(width: 6), + Text( + visual.label, + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary), + ), + ], + ), + ); + } +} + +enum _InsightQuickAction { capture, note, idea, reminder } + +class _PersonInsightsPage extends ConsumerWidget { + const _PersonInsightsPage({required this.personId}); + + final String personId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final AsyncValue localData = ref.watch( + localRepositoryProvider, + ); + final AsyncValue signals = ref.watch( + signalsControllerProvider, + ); + final LocalDataState? localValue = localData.asData?.value; + final PersonProfile? quickAddPerson = localValue == null + ? null + : _findPersonById(localValue.people, personId); + + return Scaffold( + appBar: AppBar(title: const Text('Person Insights')), + floatingActionButton: quickAddPerson == null + ? null + : FloatingActionButton( + tooltip: 'Add to person', + onPressed: () => _openQuickAddSheet(context, ref, quickAddPerson), + child: const Icon(Icons.add_rounded), + ), + body: localData.when( + data: (LocalDataState data) { + final PersonProfile? person = _findPersonById(data.people, personId); + if (person == null) { + return Center( + child: Text( + 'Person was not found in local data.', + style: Theme.of(context).textTheme.bodyLarge, + ), + ); + } + + final List moments = + data.moments + .where( + (RelationshipMoment moment) => moment.personId == person.id, + ) + .toList(growable: false) + ..sort( + (RelationshipMoment a, RelationshipMoment b) => + b.at.compareTo(a.at), + ); + final List ideas = + data.ideas + .where((RelationshipIdea idea) => idea.personId == person.id) + .toList(growable: false) + ..sort( + (RelationshipIdea a, RelationshipIdea b) => + b.createdAt.compareTo(a.createdAt), + ); + final List reminders = + data.reminders + .where( + (ReminderRule reminder) => reminder.personId == person.id, + ) + .toList(growable: false) + ..sort( + (ReminderRule a, ReminderRule b) => + a.nextAt.compareTo(b.nextAt), + ); + final List preferenceSignals = + data.preferenceSignals + .where( + (PersonPreferenceSignal signal) => + signal.personId == person.id, + ) + .toList(growable: false) + ..sort(_comparePreferenceSignals); + final List relatedTasks = _findTasksForPerson( + data.tasks, + person, + ); + + final List personSignals = + signals.asData?.value.items + .where((SignalItem item) => item.personId == person.id) + .toList(growable: false) ?? + const []; + + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final bool compact = constraints.maxWidth < 760; + return SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + compact ? 16 : 24, + compact ? 16 : 20, + compact ? 16 : 24, + compact ? 24 : 30, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _personProfileCard( + context, + person, + compact, + onQuickAction: (_InsightQuickAction action) => + _handleQuickAction(context, ref, person, action), + ), + const SizedBox(height: 14), + _InsightSectionCard( + title: 'Chat-derived preferences', + icon: Icons.psychology_alt_outlined, + children: preferenceSignals.isEmpty + ? [ + const _SectionEmpty( + label: + 'No inferred preferences yet. Share chat messages to build context gradually.', + ), + ] + : preferenceSignals + .map( + ( + PersonPreferenceSignal signal, + ) => _InsightPreferenceSignalCard( + signal: signal, + compact: compact, + onConfirm: () async { + await ref + .read( + localRepositoryProvider.notifier, + ) + .confirmPreferenceSignal(signal.id); + }, + onDismiss: () async { + await ref + .read( + localRepositoryProvider.notifier, + ) + .dismissPreferenceSignal(signal.id); + }, + onWhy: () => _showPreferenceSignalEvidence( + context, + signal: signal, + personName: person.name, + ), + ), + ) + .toList(growable: false), + ), + const SizedBox(height: 12), + _InsightSectionCard( + title: 'Ideas', + icon: Icons.lightbulb_outline_rounded, + children: ideas.isEmpty + ? [ + const _SectionEmpty( + label: + 'No captured ideas yet for this relationship.', + ), + ] + : ideas + .map( + (RelationshipIdea idea) => _InsightRow( + title: idea.title, + subtitle: idea.details, + meta: + '${idea.type.name.toUpperCase()} • ${_shortDate(idea.createdAt)}', + ), + ) + .toList(growable: false), + ), + const SizedBox(height: 12), + _InsightSectionCard( + title: 'Promotions & Signals', + icon: Icons.local_offer_outlined, + children: _signalRows( + context: context, + signals: personSignals, + sourceState: signals, + ), + ), + const SizedBox(height: 12), + _InsightSectionCard( + title: 'Moments', + icon: Icons.auto_awesome_rounded, + children: moments.isEmpty + ? [ + const _SectionEmpty( + label: 'No logged moments for this person yet.', + ), + ] + : moments + .map( + (RelationshipMoment moment) => _InsightRow( + title: moment.title, + subtitle: moment.summary, + meta: + '${moment.type.toUpperCase()} • ${_shortDate(moment.at)}', + ), + ) + .toList(growable: false), + ), + const SizedBox(height: 12), + _InsightSectionCard( + title: 'Reminders', + icon: Icons.notifications_active_outlined, + children: reminders.isEmpty + ? [ + const _SectionEmpty( + label: + 'No reminders configured for this relationship.', + ), + ] + : reminders + .map( + (ReminderRule reminder) => _InsightRow( + title: reminder.title, + subtitle: reminder.enabled + ? 'Enabled' + : 'Disabled', + meta: + '${reminder.cadence.name.toUpperCase()} • Next ${_shortDate(reminder.nextAt)}', + ), + ) + .toList(growable: false), + ), + const SizedBox(height: 12), + _InsightSectionCard( + title: 'Tasks & Follow-Ups', + icon: Icons.checklist_rounded, + children: relatedTasks.isEmpty + ? [ + const _SectionEmpty( + label: + 'No direct follow-up tasks found for this person.', + ), + ] + : relatedTasks + .map( + (DashboardTask task) => _InsightRow( + title: task.title, + subtitle: task.description, + meta: + '${task.done ? 'DONE' : 'PENDING'} • ${_shortDate(task.when)}', + ), + ) + .toList(growable: false), + ), + ], + ), + ); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (Object error, StackTrace stackTrace) { + return Center( + child: Text( + 'Could not load person insights.', + style: Theme.of(context).textTheme.bodyLarge, + ), + ); + }, + ), + ); + } + + Future _showPreferenceSignalEvidence( + BuildContext context, { + required PersonPreferenceSignal signal, + required String personName, + }) { + return showModalBottomSheet( + context: context, + useSafeArea: true, + showDragHandle: true, + builder: (BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Why this signal?', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 6), + Text( + '${signal.label} • ${signal.category} • ${signal.polarity.name.toUpperCase()}', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 4), + Text( + 'For $personName • confidence ${(signal.confidence * 100).round()}%', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 12), + if (signal.evidenceSnippets.isEmpty) + const _SectionEmpty( + label: 'No evidence snippets stored yet for this signal.', + ) + else + ...signal.evidenceSnippets.map( + (String snippet) => _InsightRow( + title: 'Evidence', + subtitle: snippet, + meta: 'Source(s): ${signal.sourceApps.join(', ')}', + ), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ), + ], + ), + ); + }, + ); + } + + Widget _personProfileCard( + BuildContext context, + PersonProfile person, + bool compact, { + required ValueChanged<_InsightQuickAction> onQuickAction, + }) { + final _RelationshipVisual visual = _relationshipVisualFor( + person.relationship, + ); + + return FrostedCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (compact) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _insightAvatar(person: person, visual: visual), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + person.name, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 3), + Text( + person.relationship, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: AppTheme.textSecondary), + ), + ], + ), + ), + PopupMenuButton<_InsightQuickAction>( + tooltip: 'Quick add', + onSelected: onQuickAction, + itemBuilder: (BuildContext context) => + const >[ + PopupMenuItem<_InsightQuickAction>( + value: _InsightQuickAction.capture, + child: Text('Add Capture'), + ), + PopupMenuItem<_InsightQuickAction>( + value: _InsightQuickAction.note, + child: Text('Add Note'), + ), + PopupMenuItem<_InsightQuickAction>( + value: _InsightQuickAction.idea, + child: Text('Add Idea'), + ), + PopupMenuItem<_InsightQuickAction>( + value: _InsightQuickAction.reminder, + child: Text('Add Reminder'), + ), + ], + icon: const Icon(Icons.add_circle_outline_rounded), + ), + ], + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _miniMetric( + label: 'Affinity', + value: '${person.affinityScore}%', + ), + _miniMetric( + label: 'Next Moment', + value: _shortDate(person.nextMoment), + ), + ], + ), + ], + ) + else + Row( + children: [ + _insightAvatar(person: person, visual: visual), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + person.name, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 3), + Text( + person.relationship, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + _miniMetric( + label: 'Affinity', + value: '${person.affinityScore}%', + ), + const SizedBox(width: 10), + _miniMetric( + label: 'Next Moment', + value: _shortDate(person.nextMoment), + ), + const SizedBox(width: 8), + PopupMenuButton<_InsightQuickAction>( + tooltip: 'Quick add', + onSelected: onQuickAction, + itemBuilder: (BuildContext context) => + const >[ + PopupMenuItem<_InsightQuickAction>( + value: _InsightQuickAction.capture, + child: Text('Add Capture'), + ), + PopupMenuItem<_InsightQuickAction>( + value: _InsightQuickAction.note, + child: Text('Add Note'), + ), + PopupMenuItem<_InsightQuickAction>( + value: _InsightQuickAction.idea, + child: Text('Add Idea'), + ), + PopupMenuItem<_InsightQuickAction>( + value: _InsightQuickAction.reminder, + child: Text('Add Reminder'), + ), + ], + icon: const Icon(Icons.add_circle_outline_rounded), + ), + ], + ), + if (person.tags.isNotEmpty) ...[ + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: person.tags + .map((String tag) => _TagPill(label: tag)) + .toList(growable: false), + ), + ], + if (person.notes.trim().isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + person.notes, + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), + ), + ], + ], + ), + ); + } + + Future _handleQuickAction( + BuildContext context, + WidgetRef ref, + PersonProfile person, + _InsightQuickAction action, + ) async { + switch (action) { + case _InsightQuickAction.capture: + final String? summary = await _showQuickTextCaptureDialog( + context, + title: 'Add Capture', + hintText: 'What happened in this interaction?', + ); + if (summary == null) { + return; + } + await ref + .read(localRepositoryProvider.notifier) + .addMoment(personId: person.id, summary: summary, type: 'capture'); + return; + case _InsightQuickAction.note: + final String? note = await _showQuickTextCaptureDialog( + context, + title: 'Add Note', + hintText: 'Write a quick context note...', + ); + if (note == null) { + return; + } + await ref + .read(localRepositoryProvider.notifier) + .addMoment(personId: person.id, summary: note, type: 'note'); + return; + case _InsightQuickAction.idea: + final _InsightQuickIdeaDraft? idea = + await showDialog<_InsightQuickIdeaDraft>( + context: context, + builder: (BuildContext context) => + const _InsightQuickIdeaDialog(), + ); + if (idea == null) { + return; + } + await ref + .read(localRepositoryProvider.notifier) + .addIdea( + type: idea.type, + title: idea.title, + details: idea.details, + personId: person.id, + ); + return; + case _InsightQuickAction.reminder: + final _InsightQuickReminderDraft? reminder = + await showDialog<_InsightQuickReminderDraft>( + context: context, + builder: (BuildContext context) => + const _InsightQuickReminderDialog(), + ); + if (reminder == null) { + return; + } + await ref + .read(localRepositoryProvider.notifier) + .addReminder( + title: reminder.title, + cadence: reminder.cadence, + nextAt: reminder.nextAt, + personId: person.id, + ); + return; + } + } + + Future _openQuickAddSheet( + BuildContext context, + WidgetRef ref, + PersonProfile person, + ) async { + final _InsightQuickAction? selected = + await showModalBottomSheet<_InsightQuickAction>( + context: context, + showDragHandle: true, + builder: (BuildContext context) { + return SafeArea( + child: ListView( + shrinkWrap: true, + children: [ + ListTile( + leading: const Icon(Icons.auto_awesome_rounded), + title: const Text('Add Capture'), + subtitle: const Text('Log a new moment or interaction'), + onTap: () => + Navigator.of(context).pop(_InsightQuickAction.capture), + ), + ListTile( + leading: const Icon(Icons.note_add_outlined), + title: const Text('Add Note'), + subtitle: const Text('Save a context note for later'), + onTap: () => + Navigator.of(context).pop(_InsightQuickAction.note), + ), + ListTile( + leading: const Icon(Icons.lightbulb_outline_rounded), + title: const Text('Add Idea'), + subtitle: const Text('Gift or event idea'), + onTap: () => + Navigator.of(context).pop(_InsightQuickAction.idea), + ), + ListTile( + leading: const Icon(Icons.notifications_active_outlined), + title: const Text('Add Reminder'), + subtitle: const Text('Schedule a follow-up prompt'), + onTap: () => + Navigator.of(context).pop(_InsightQuickAction.reminder), + ), + ], + ), + ); + }, + ); + if (selected == null || !context.mounted) { + return; + } + await _handleQuickAction(context, ref, person, selected); + } + + Future _showQuickTextCaptureDialog( + BuildContext context, { + required String title, + required String hintText, + }) async { + return showDialog( + context: context, + builder: (BuildContext context) { + return _InsightQuickTextCaptureDialog(title: title, hintText: hintText); + }, + ); + } + + Widget _insightAvatar({ + required PersonProfile person, + required _RelationshipVisual visual, + }) { + return Container( + width: 56, + height: 56, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + _shiftLightness(visual.color, 0.16), + _shiftLightness(visual.color, -0.12), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + alignment: Alignment.center, + child: Text( + _initials(person.name), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 16, + ), + ), + ); + } + + Widget _miniMetric({required String label, required String value}) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFFF2F8FB), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle(fontSize: 11, color: AppTheme.textSecondary), + ), + const SizedBox(height: 2), + Text( + value, + style: const TextStyle( + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary, + ), + ), + ], + ), + ); + } + + List _signalRows({ + required BuildContext context, + required List signals, + required AsyncValue sourceState, + }) { + if (sourceState.isLoading && sourceState.asData == null) { + return const [ + Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: LinearProgressIndicator(minHeight: 3), + ), + ]; + } + + if (signals.isEmpty) { + if (sourceState.hasError) { + return [ + const _SectionEmpty( + label: + 'Signals unavailable right now. Backend sync can repopulate.', + ), + ]; + } + return [ + const _SectionEmpty( + label: 'No promotions/signals targeted to this person yet.', + ), + ]; + } + + return signals + .map( + (SignalItem item) => _InsightRow( + title: item.title, + subtitle: item.description ?? 'No description', + meta: '${item.type.toUpperCase()} • ${_shortDate(item.createdAt)}', + ), + ) + .toList(growable: false); + } + + PersonProfile? _findPersonById(List people, String id) { + for (final PersonProfile person in people) { + if (person.id == id) { + return person; + } + } + return null; + } + + List _findTasksForPerson( + List tasks, + PersonProfile person, + ) { + final List tokens = person.name + .toLowerCase() + .split(' ') + .where((String token) => token.length > 2) + .toList(growable: false); + if (tokens.isEmpty) { + return const []; + } + return tasks + .where((DashboardTask task) { + final String fullText = '${task.title} ${task.description}' + .toLowerCase(); + for (final String token in tokens) { + if (fullText.contains(token)) { + return true; + } + } + return false; + }) + .toList(growable: false) + ..sort((DashboardTask a, DashboardTask b) => a.when.compareTo(b.when)); + } +} + +int _comparePreferenceSignals( + PersonPreferenceSignal a, + PersonPreferenceSignal b, +) { + int statusRank(PreferenceSignalStatus status) => switch (status) { + PreferenceSignalStatus.inferred => 0, + PreferenceSignalStatus.confirmed => 1, + PreferenceSignalStatus.dismissed => 2, + }; + + final int byStatus = statusRank(a.status).compareTo(statusRank(b.status)); + if (byStatus != 0) { + return byStatus; + } + final int byConfidence = b.confidence.compareTo(a.confidence); + if (byConfidence != 0) { + return byConfidence; + } + return a.label.toLowerCase().compareTo(b.label.toLowerCase()); +} + +class _InsightPreferenceSignalCard extends StatelessWidget { + const _InsightPreferenceSignalCard({ + required this.signal, + required this.compact, + required this.onConfirm, + required this.onDismiss, + required this.onWhy, + }); + + final PersonPreferenceSignal signal; + final bool compact; + final Future Function() onConfirm; + final Future Function() onDismiss; + final VoidCallback onWhy; + + @override + Widget build(BuildContext context) { + final Color tone = switch (signal.status) { + PreferenceSignalStatus.confirmed => const Color(0xFF1D9C66), + PreferenceSignalStatus.dismissed => const Color(0xFF9A6A00), + PreferenceSignalStatus.inferred => AppTheme.primary, + }; + final IconData polarityIcon = switch (signal.polarity) { + PreferenceSignalPolarity.like => Icons.thumb_up_alt_outlined, + PreferenceSignalPolarity.dislike => Icons.thumb_down_alt_outlined, + PreferenceSignalPolarity.neutral => Icons.tune_rounded, + }; + + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF5FAFB), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Icon(polarityIcon, size: 16, color: tone), + Text( + signal.label, + style: Theme.of(context).textTheme.titleSmall, + ), + _PreferenceBadge(label: signal.status.name, color: tone), + _PreferenceBadge( + label: '${(signal.confidence * 100).round()}%', + color: AppTheme.textSecondary, + filled: false, + ), + ], + ), + const SizedBox(height: 6), + Text( + '${signal.category.toUpperCase()} • seen ${signal.occurrenceCount}x', + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlinedButton(onPressed: onWhy, child: const Text('Why?')), + if (signal.status != PreferenceSignalStatus.confirmed) + FilledButton.tonal( + onPressed: () async => onConfirm(), + child: const Text('Confirm'), + ), + if (signal.status != PreferenceSignalStatus.dismissed) + TextButton( + onPressed: () async => onDismiss(), + child: const Text('Dismiss'), + ), + ], + ), + if (!compact && signal.sourceApps.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + 'Sources: ${signal.sourceApps.join(', ')}', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), + ), + ], + ], + ), + ), + ); + } +} + +class _PreferenceBadge extends StatelessWidget { + const _PreferenceBadge({ + required this.label, + required this.color, + this.filled = true, + }); + + final String label; + final Color color; + final bool filled; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: filled ? color.withValues(alpha: 0.12) : Colors.transparent, + borderRadius: BorderRadius.circular(99), + border: Border.all(color: color.withValues(alpha: filled ? 0.2 : 0.35)), + ), + child: Text( + label.toUpperCase(), + style: Theme.of(context).textTheme.labelSmall?.copyWith(color: color), + ), + ); + } +} + +class _InsightQuickIdeaDraft { + const _InsightQuickIdeaDraft({ + required this.type, + required this.title, + required this.details, + }); + + final IdeaType type; + final String title; + final String details; +} + +class _InsightQuickIdeaDialog extends StatefulWidget { + const _InsightQuickIdeaDialog(); + + @override + State<_InsightQuickIdeaDialog> createState() => + _InsightQuickIdeaDialogState(); +} + +class _InsightQuickIdeaDialogState extends State<_InsightQuickIdeaDialog> { + IdeaType _type = IdeaType.gift; + final TextEditingController _titleController = TextEditingController(); + final TextEditingController _detailsController = TextEditingController(); + + @override + void dispose() { + _titleController.dispose(); + _detailsController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Add Idea'), + content: SingleChildScrollView( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SegmentedButton( + segments: const >[ + ButtonSegment( + value: IdeaType.gift, + label: Text('Gift'), + ), + ButtonSegment( + value: IdeaType.event, + label: Text('Event'), + ), + ], + selected: {_type}, + onSelectionChanged: (Set values) { + setState(() { + _type = values.first; + }); + }, + ), + const SizedBox(height: 10), + TextField( + controller: _titleController, + decoration: const InputDecoration(labelText: 'Title'), + ), + TextField( + controller: _detailsController, + maxLines: 3, + decoration: const InputDecoration(labelText: 'Details'), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final String title = _titleController.text.trim(); + if (title.isEmpty) { + return; + } + Navigator.of(context).pop( + _InsightQuickIdeaDraft( + type: _type, + title: title, + details: _detailsController.text.trim(), + ), + ); + }, + child: const Text('Add'), + ), + ], + ); + } +} + +class _InsightQuickReminderDraft { + const _InsightQuickReminderDraft({ + required this.title, + required this.cadence, + required this.nextAt, + }); + + final String title; + final ReminderCadence cadence; + final DateTime nextAt; +} + +class _InsightQuickReminderDialog extends StatefulWidget { + const _InsightQuickReminderDialog(); + + @override + State<_InsightQuickReminderDialog> createState() => + _InsightQuickReminderDialogState(); +} + +class _InsightQuickReminderDialogState + extends State<_InsightQuickReminderDialog> { + final TextEditingController _titleController = TextEditingController(); + ReminderCadence _cadence = ReminderCadence.weekly; + DateTime _nextAt = DateTime.now().add(const Duration(days: 1)); + + @override + void dispose() { + _titleController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Add Reminder'), + content: SingleChildScrollView( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _titleController, + decoration: const InputDecoration(labelText: 'Title'), + ), + const SizedBox(height: 10), + DropdownButtonFormField( + initialValue: _cadence, + decoration: const InputDecoration(labelText: 'Cadence'), + items: ReminderCadence.values + .map( + (ReminderCadence cadence) => + DropdownMenuItem( + value: cadence, + child: Text(cadence.name), + ), + ) + .toList(growable: false), + onChanged: (ReminderCadence? value) { + if (value == null) { + return; + } + setState(() { + _cadence = value; + }); + }, + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: Text( + 'Next: ${_nextAt.year}-${_nextAt.month.toString().padLeft(2, '0')}-${_nextAt.day.toString().padLeft(2, '0')} ${_nextAt.hour.toString().padLeft(2, '0')}:${_nextAt.minute.toString().padLeft(2, '0')}', + ), + ), + TextButton( + onPressed: () async { + final DateTime now = DateTime.now(); + final DateTime? date = await showDatePicker( + context: context, + firstDate: now, + lastDate: now.add(const Duration(days: 3650)), + initialDate: _nextAt, + ); + if (date == null || !context.mounted) { + return; + } + final TimeOfDay? time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(_nextAt), + ); + if (time == null) { + return; + } + setState(() { + _nextAt = DateTime( + date.year, + date.month, + date.day, + time.hour, + time.minute, + ); + }); + }, + child: const Text('Change'), + ), + ], + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final String title = _titleController.text.trim(); + if (title.isEmpty) { + return; + } + Navigator.of(context).pop( + _InsightQuickReminderDraft( + title: title, + cadence: _cadence, + nextAt: _nextAt, + ), + ); + }, + child: const Text('Add'), + ), + ], + ); + } +} + +class _InsightQuickTextCaptureDialog extends StatefulWidget { + const _InsightQuickTextCaptureDialog({ + required this.title, + required this.hintText, + }); + + final String title; + final String hintText; + + @override + State<_InsightQuickTextCaptureDialog> createState() => + _InsightQuickTextCaptureDialogState(); +} + +class _InsightQuickTextCaptureDialogState + extends State<_InsightQuickTextCaptureDialog> { + late final TextEditingController _controller; + bool _attemptedSubmit = false; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final String value = _controller.text.trim(); + return AlertDialog( + title: Text(widget.title), + content: TextField( + controller: _controller, + minLines: 2, + maxLines: 4, + autofocus: true, + onChanged: (_) { + if (_attemptedSubmit) { + setState(() {}); + } + }, + decoration: InputDecoration( + hintText: widget.hintText, + errorText: _attemptedSubmit && value.isEmpty ? 'Required' : null, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final String submitted = _controller.text.trim(); + if (submitted.isEmpty) { + setState(() { + _attemptedSubmit = true; + }); + return; + } + Navigator.of(context).pop(submitted); + }, + child: const Text('Save'), + ), + ], + ); + } +} + +class _InsightSectionCard extends StatelessWidget { + const _InsightSectionCard({ + required this.title, + required this.icon, + required this.children, + }); + + final String title; + final IconData icon; + final List children; + + @override + Widget build(BuildContext context) { + return FrostedCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Icon(icon, size: 18, color: AppTheme.primary), + Text(title, style: Theme.of(context).textTheme.titleMedium), + ], + ), + const SizedBox(height: 10), + ...children, + ], + ), + ); + } +} + +class _InsightRow extends StatelessWidget { + const _InsightRow({ + required this.title, + required this.subtitle, + required this.meta, + }); + + final String title; + final String subtitle; + final String meta; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF5FAFB), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 4), + Text( + subtitle, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 6), + Text( + meta, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: AppTheme.textSecondary), + ), + ], + ), + ), + ); + } +} + +class _SectionEmpty extends StatelessWidget { + const _SectionEmpty({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF5FAFB), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + label, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), + ), + ); + } +} + +class _TagPill extends StatelessWidget { + const _TagPill({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFEFF7FA), + borderRadius: BorderRadius.circular(99), + ), + child: Text( + label, + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary), + ), + ); + } +} + +class _StatCard extends StatelessWidget { + const _StatCard({ + required this.label, + required this.value, + required this.accent, + required this.compact, + }); + + final String label; + final String value; + final Color accent; + final bool compact; + + @override + Widget build(BuildContext context) { + return FrostedCard( + padding: const EdgeInsets.all(16), + child: SizedBox( + width: compact ? double.infinity : 188, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 8, + width: 40, + decoration: BoxDecoration( + color: accent, + borderRadius: BorderRadius.circular(99), + ), + ), + const SizedBox(height: 16), + Text( + value, + style: Theme.of( + context, + ).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 4), + Text( + label, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), + ), + ], + ), + ), + ); + } +} + +class _TaskRow extends StatelessWidget { + const _TaskRow({ + required this.task, + required this.onToggleDone, + required this.compact, + }); + + final DashboardTask task; + final VoidCallback onToggleDone; + final bool compact; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFF5FAFB), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: onToggleDone, + borderRadius: BorderRadius.circular(20), + child: Icon( + task.done + ? Icons.check_circle_rounded + : Icons.radio_button_unchecked_rounded, + color: task.done + ? const Color(0xFF1D9C66) + : const Color(0xFF1AB6C8), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + task.title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + decoration: task.done ? TextDecoration.lineThrough : null, + ), + ), + const SizedBox(height: 4), + Text( + task.description, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + if (!compact) ...[ + const SizedBox(width: 12), + Text( + _shortDate(task.when), + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary), + ), + ], + ], + ), + ), + ); + } +} + +class _RelationshipVisual { + const _RelationshipVisual({ + required this.label, + required this.icon, + required this.color, + }); + + final String label; + final IconData icon; + final Color color; +} + +const _RelationshipVisual _spouseVisual = _RelationshipVisual( + label: 'Spouse', + icon: Icons.favorite_rounded, + color: Color(0xFFE0557B), +); + +const _RelationshipVisual _fianceVisual = _RelationshipVisual( + label: 'Fiance', + icon: Icons.diamond_rounded, + color: Color(0xFFCB6CF2), +); + +const _RelationshipVisual _partnerVisual = _RelationshipVisual( + label: 'Partner', + icon: Icons.favorite_border_rounded, + color: Color(0xFFE67A5A), +); + +const _RelationshipVisual _bestieVisual = _RelationshipVisual( + label: 'Bestie', + icon: Icons.emoji_emotions_rounded, + color: Color(0xFF8E6BFF), +); + +const _RelationshipVisual _friendVisual = _RelationshipVisual( + label: 'Friend', + icon: Icons.groups_rounded, + color: Color(0xFF3F8CFF), +); + +const _RelationshipVisual _familyVisual = _RelationshipVisual( + label: 'Family', + icon: Icons.family_restroom_rounded, + color: Color(0xFF2FB67D), +); + +const _RelationshipVisual _sisterVisual = _RelationshipVisual( + label: 'Sister', + icon: Icons.female_rounded, + color: Color(0xFFFF6EA9), +); + +const _RelationshipVisual _brotherVisual = _RelationshipVisual( + label: 'Brother', + icon: Icons.male_rounded, + color: Color(0xFF4A7DFF), +); + +const _RelationshipVisual _otherVisual = _RelationshipVisual( + label: 'Connection', + icon: Icons.hub_rounded, + color: Color(0xFF5F7280), +); + +_RelationshipVisual _relationshipVisualFor(String relationship) { + final String value = relationship.toLowerCase(); + + if (_containsAny(value, ['spouse', 'wife', 'husband'])) { + return _spouseVisual; + } + if (_containsAny(value, ['fiance', 'engaged'])) { + return _fianceVisual; + } + if (_containsAny(value, [ + 'girlfriend', + 'boyfriend', + 'partner', + 'gf', + 'bf', + ])) { + return _partnerVisual; + } + if (_containsAny(value, ['bestie', 'best friend', 'bff'])) { + return _bestieVisual; + } + if (_containsAny(value, ['sister', 'sis'])) { + return _sisterVisual; + } + if (_containsAny(value, ['brother', 'bro'])) { + return _brotherVisual; + } + if (_containsAny(value, [ + 'family', + 'mother', + 'father', + 'mom', + 'dad', + 'parent', + 'aunt', + 'uncle', + 'cousin', + 'grand', + ])) { + return _familyVisual; + } + if (_containsAny(value, ['friend', 'pal', 'buddy', 'mate'])) { + return _friendVisual; + } + return _otherVisual; +} + +bool _containsAny(String value, List needles) { + for (final String needle in needles) { + if (value.contains(needle)) { + return true; + } + } + return false; +} + +Color _shiftLightness(Color color, double delta) { + final HSLColor hsl = HSLColor.fromColor(color); + final double next = (hsl.lightness + delta).clamp(0.0, 1.0); + return hsl.withLightness(next).toColor(); +} + +String _initials(String name) { + final List parts = name + .split(' ') + .where((String part) => part.isNotEmpty) + .toList(growable: false); + if (parts.isEmpty) { + return '?'; + } + + if (parts.length == 1) { + return parts.first + .substring(0, math.min(2, parts.first.length)) + .toUpperCase(); + } + + return '${parts.first[0]}${parts[1][0]}'.toUpperCase(); +} + +String _shortName(String name) { + final List parts = name + .split(' ') + .where((String part) => part.isNotEmpty) + .toList(growable: false); + if (parts.isEmpty) { + return 'Unknown'; + } + return parts.first; +} + +String _shortDate(DateTime value) { + const List weekdays = [ + 'Mon', + 'Tue', + 'Wed', + 'Thu', + 'Fri', + 'Sat', + 'Sun', + ]; + final String weekday = weekdays[value.weekday - 1]; + final String day = value.day.toString().padLeft(2, '0'); + return '$weekday $day'; +} diff --git a/lib/features/home/README.md b/lib/features/home/README.md new file mode 100644 index 0000000..6527d12 --- /dev/null +++ b/lib/features/home/README.md @@ -0,0 +1,4 @@ +# Home Shell + +This folder is a compatibility surface around the app shell. The canonical shell +now lives in `lib/app/presentation/`, so only keep thin exports or temporary glue here. diff --git a/lib/features/home/app_shell.dart b/lib/features/home/app_shell.dart index ff4b7a8..c6ddb21 100644 --- a/lib/features/home/app_shell.dart +++ b/lib/features/home/app_shell.dart @@ -1,391 +1,2 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:relationship_saver/core/config/app_theme.dart'; -import 'package:relationship_saver/features/dashboard/dashboard_view.dart'; -import 'package:relationship_saver/features/ideas/ideas_view.dart'; -import 'package:relationship_saver/features/moments/moments_view.dart'; -import 'package:relationship_saver/features/people/people_view.dart'; -import 'package:relationship_saver/features/reminders/reminders_view.dart'; -import 'package:relationship_saver/features/settings/settings_view.dart'; -import 'package:relationship_saver/features/signals/signals_view.dart'; -import 'package:relationship_saver/features/sync/sync_view.dart'; - -class AppShell extends ConsumerStatefulWidget { - const AppShell({super.key}); - - @override - ConsumerState createState() => _AppShellState(); -} - -class _AppShellState extends ConsumerState { - int _index = 0; - - static const List<_ShellDestination> _destinations = <_ShellDestination>[ - _ShellDestination('Dashboard', Icons.dashboard_rounded, 0), - _ShellDestination('People', Icons.people_alt_rounded, 1), - _ShellDestination('Moments', Icons.auto_awesome_rounded, 2), - _ShellDestination('Signals', Icons.tips_and_updates_rounded, 3), - _ShellDestination('Ideas', Icons.lightbulb_outline_rounded, 4), - _ShellDestination('Reminders', Icons.notifications_active_outlined, 5), - _ShellDestination('Sync', Icons.sync_rounded, 6), - _ShellDestination('Settings', Icons.settings_rounded, 7), - ]; - - static const List<_ShellDestination> _compactPrimaryDestinations = - <_ShellDestination>[ - _ShellDestination('Dashboard', Icons.dashboard_rounded, 0), - _ShellDestination('People', Icons.people_alt_rounded, 1), - _ShellDestination('Moments', Icons.auto_awesome_rounded, 2), - _ShellDestination('Signals', Icons.tips_and_updates_rounded, 3), - ]; - - @override - Widget build(BuildContext context) { - return DecoratedBox( - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [ - Color(0xFFF3F8FB), - Color(0xFFEAF1F8), - Color(0xFFF8FAFC), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - child: Scaffold( - backgroundColor: Colors.transparent, - body: SafeArea( - child: LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - if (constraints.maxWidth < 1000) { - final int compactIndex = _compactSelectedIndex(_index); - final _ShellDestination active = - _compactPrimaryDestinations[compactIndex]; - return _CompactShell( - title: active.label, - index: compactIndex, - body: _screenFor(active.screenIndex), - destinations: _compactPrimaryDestinations, - onChange: (int value) { - setState(() { - _index = _compactPrimaryDestinations[value].screenIndex; - }); - }, - onOpenSecondary: (_SecondaryDestination destination) { - final int screenIndex = switch (destination) { - _SecondaryDestination.ideas => 4, - _SecondaryDestination.reminders => 5, - _SecondaryDestination.sync => 6, - _SecondaryDestination.settings => 7, - }; - - Navigator.of(context).push( - MaterialPageRoute( - builder: (BuildContext context) { - return _CompactSecondaryScreen( - title: _destinations[screenIndex].label, - child: _screenFor(screenIndex), - ); - }, - ), - ); - }, - ); - } - - return Row( - children: [ - _Sidebar( - index: _index, - destinations: _destinations, - onChange: (int value) { - setState(() { - _index = value; - }); - }, - ), - Expanded( - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 250), - child: KeyedSubtree( - key: ValueKey(_index), - child: _screenFor(_index), - ), - ), - ), - ], - ); - }, - ), - ), - ), - ); - } - - int _compactSelectedIndex(int fullIndex) { - for (int i = 0; i < _compactPrimaryDestinations.length; i += 1) { - if (_compactPrimaryDestinations[i].screenIndex == fullIndex) { - return i; - } - } - return 0; - } - - Widget _screenFor(int index) { - switch (index) { - case 0: - return const DashboardView(); - case 1: - return const PeopleView(); - case 2: - return const MomentsView(); - case 3: - return const SignalsView(); - case 4: - return const IdeasView(); - case 5: - return const RemindersView(); - case 6: - return const SyncView(); - case 7: - return const SettingsView(); - default: - return const DashboardView(); - } - } -} - -class _Sidebar extends StatelessWidget { - const _Sidebar({ - required this.index, - required this.destinations, - required this.onChange, - }); - - final int index; - final List<_ShellDestination> destinations; - final ValueChanged onChange; - - @override - Widget build(BuildContext context) { - return Container( - width: 290, - margin: const EdgeInsets.fromLTRB(20, 20, 8, 20), - padding: const EdgeInsets.fromLTRB(18, 20, 18, 18), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.78), - borderRadius: BorderRadius.circular(28), - border: Border.all(color: Colors.white.withValues(alpha: 0.7)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 34, - height: 34, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - gradient: const LinearGradient( - colors: [Color(0xFF1AB6C8), Color(0xFF286DE0)], - ), - ), - child: const Icon( - Icons.favorite_rounded, - color: Colors.white, - size: 20, - ), - ), - const SizedBox(width: 10), - Expanded( - child: Text( - 'Relationship Saver', - style: Theme.of(context).textTheme.titleMedium, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - const SizedBox(height: 22), - ...destinations.indexed.map(((int, _ShellDestination) tuple) { - final int destinationIndex = tuple.$1; - final _ShellDestination destination = tuple.$2; - final bool selected = destinationIndex == index; - return Padding( - padding: const EdgeInsets.only(bottom: 6), - child: InkWell( - onTap: () => onChange(destinationIndex), - borderRadius: BorderRadius.circular(14), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - decoration: BoxDecoration( - color: selected - ? const Color(0xFFEAF7FB) - : Colors.transparent, - borderRadius: BorderRadius.circular(14), - ), - child: Row( - children: [ - Icon( - destination.icon, - size: 20, - color: selected - ? AppTheme.primary - : AppTheme.textSecondary, - ), - const SizedBox(width: 10), - Text( - destination.label, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith( - color: selected - ? AppTheme.primary - : AppTheme.textSecondary, - ), - ), - ], - ), - ), - ), - ); - }), - const Spacer(), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFFF2F8FB), - borderRadius: BorderRadius.circular(16), - ), - child: Text( - 'Tip: Use Signals daily and convert at least one into a concrete plan.', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), - ), - ), - ], - ), - ); - } -} - -class _CompactShell extends StatelessWidget { - const _CompactShell({ - required this.title, - required this.index, - required this.body, - required this.destinations, - required this.onChange, - required this.onOpenSecondary, - }); - - final String title; - final int index; - final Widget body; - final List<_ShellDestination> destinations; - final ValueChanged onChange; - final ValueChanged<_SecondaryDestination> onOpenSecondary; - - @override - Widget build(BuildContext context) { - return Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), - child: Row( - children: [ - Expanded( - child: Text( - title, - style: Theme.of(context).textTheme.titleLarge, - ), - ), - PopupMenuButton<_SecondaryDestination>( - tooltip: 'More', - icon: const Icon(Icons.more_horiz_rounded), - onSelected: onOpenSecondary, - itemBuilder: (BuildContext context) => - >[ - const PopupMenuItem<_SecondaryDestination>( - value: _SecondaryDestination.ideas, - child: Text('Ideas'), - ), - const PopupMenuItem<_SecondaryDestination>( - value: _SecondaryDestination.reminders, - child: Text('Reminders'), - ), - const PopupMenuItem<_SecondaryDestination>( - value: _SecondaryDestination.sync, - child: Text('Sync'), - ), - const PopupMenuItem<_SecondaryDestination>( - value: _SecondaryDestination.settings, - child: Text('Settings'), - ), - ], - ), - ], - ), - ), - Expanded( - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 220), - child: KeyedSubtree(key: ValueKey(index), child: body), - ), - ), - NavigationBar( - selectedIndex: index, - onDestinationSelected: onChange, - destinations: destinations - .map( - (_ShellDestination destination) => NavigationDestination( - icon: Icon(destination.icon), - label: destination.label, - ), - ) - .toList(growable: false), - ), - ], - ); - } -} - -class _CompactSecondaryScreen extends StatelessWidget { - const _CompactSecondaryScreen({required this.title, required this.child}); - - final String title; - final Widget child; - - @override - Widget build(BuildContext context) { - return DecoratedBox( - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [Color(0xFFF3F8FB), Color(0xFFEAF1F8)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - child: Scaffold( - backgroundColor: Colors.transparent, - appBar: AppBar(title: Text(title)), - body: child, - ), - ); - } -} - -enum _SecondaryDestination { ideas, reminders, sync, settings } - -class _ShellDestination { - const _ShellDestination(this.label, this.icon, this.screenIndex); - - final String label; - final IconData icon; - final int screenIndex; -} +// Legacy compatibility export for the app shell. +export 'package:relationship_saver/app/presentation/app_shell.dart'; diff --git a/lib/features/ideas/README.md b/lib/features/ideas/README.md new file mode 100644 index 0000000..b0a0a28 --- /dev/null +++ b/lib/features/ideas/README.md @@ -0,0 +1,9 @@ +# Ideas Slice + +This slice owns saved gift and activity ideas. + +- `domain/idea_models.dart`: idea types and stored idea records. +- `presentation/ideas_view.dart`: ideas screen and CRUD UI. + +Use this slice when you want to improve recommendation surfaces or idea capture +without touching the share pipeline yet. diff --git a/lib/features/ideas/domain/README.md b/lib/features/ideas/domain/README.md new file mode 100644 index 0000000..dbde3ea --- /dev/null +++ b/lib/features/ideas/domain/README.md @@ -0,0 +1,4 @@ +# Ideas Domain + +Gift ideas, event ideas, and related idea models live here. Keep the models +simple so future recommendation logic can consume them without UI coupling. diff --git a/lib/features/ideas/domain/idea_models.dart b/lib/features/ideas/domain/idea_models.dart new file mode 100644 index 0000000..0fe8a25 --- /dev/null +++ b/lib/features/ideas/domain/idea_models.dart @@ -0,0 +1,76 @@ +// ignore_for_file: sort_constructors_first + +import 'package:flutter/foundation.dart'; + +/// Higher-level bucket for plan and gift suggestions. +enum IdeaType { gift, event } + +/// Saved idea for a person or for the broader relationship backlog. +@immutable +class RelationshipIdea { + const RelationshipIdea({ + required this.id, + required this.type, + required this.title, + required this.details, + required this.createdAt, + this.personId, + this.isArchived = false, + }); + + final String id; + final String? personId; + final IdeaType type; + final String title; + final String details; + final DateTime createdAt; + final bool isArchived; + + RelationshipIdea copyWith({ + String? id, + String? personId, + IdeaType? type, + String? title, + String? details, + DateTime? createdAt, + bool? isArchived, + }) { + return RelationshipIdea( + id: id ?? this.id, + personId: personId ?? this.personId, + type: type ?? this.type, + title: title ?? this.title, + details: details ?? this.details, + createdAt: createdAt ?? this.createdAt, + isArchived: isArchived ?? this.isArchived, + ); + } + + Map toJson() { + return { + 'id': id, + 'personId': personId, + 'type': type.name, + 'title': title, + 'details': details, + 'createdAt': createdAt.toUtc().toIso8601String(), + 'isArchived': isArchived, + }; + } + + factory RelationshipIdea.fromJson(Map json) { + final String typeName = json['type'] as String? ?? IdeaType.gift.name; + return RelationshipIdea( + id: json['id'] as String, + personId: json['personId'] as String?, + type: IdeaType.values.firstWhere( + (IdeaType type) => type.name == typeName, + orElse: () => IdeaType.gift, + ), + title: json['title'] as String, + details: json['details'] as String? ?? '', + createdAt: DateTime.parse(json['createdAt'] as String).toLocal(), + isArchived: json['isArchived'] as bool? ?? false, + ); + } +} diff --git a/lib/features/ideas/ideas_view.dart b/lib/features/ideas/ideas_view.dart index b256f84..f128366 100644 --- a/lib/features/ideas/ideas_view.dart +++ b/lib/features/ideas/ideas_view.dart @@ -1,559 +1,2 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:relationship_saver/core/config/app_theme.dart'; -import 'package:relationship_saver/features/local/local_models.dart'; -import 'package:relationship_saver/features/local/local_repository.dart'; -import 'package:relationship_saver/features/shared/frosted_card.dart'; - -class IdeasView extends ConsumerWidget { - const IdeasView({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final AsyncValue localData = ref.watch( - localRepositoryProvider, - ); - - return localData.when( - data: (LocalDataState data) { - final List ideas = data.ideas; - final Map peopleById = { - for (final PersonProfile person in data.people) person.id: person, - }; - - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - final bool compact = constraints.maxWidth < 760; - return Padding( - padding: EdgeInsets.fromLTRB( - compact ? 16 : 28, - compact ? 16 : 24, - compact ? 16 : 28, - compact ? 20 : 28, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (compact) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Ideas', - style: Theme.of(context).textTheme.headlineMedium, - ), - const SizedBox(height: 6), - Text( - 'Capture gift and event ideas before they slip away.', - style: Theme.of(context).textTheme.bodyLarge - ?.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: 12), - FilledButton.icon( - onPressed: () => _addIdea(context, ref, data.people), - icon: const Icon(Icons.lightbulb_outline_rounded), - label: const Text('Add Idea'), - ), - ], - ) - else - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Ideas', - style: Theme.of( - context, - ).textTheme.headlineMedium, - ), - const SizedBox(height: 6), - Text( - 'Capture gift and event ideas before they slip away.', - style: Theme.of(context).textTheme.bodyLarge - ?.copyWith(color: AppTheme.textSecondary), - ), - ], - ), - ), - FilledButton.icon( - onPressed: () => _addIdea(context, ref, data.people), - icon: const Icon(Icons.lightbulb_outline_rounded), - label: const Text('Add Idea'), - ), - ], - ), - const SizedBox(height: 16), - Expanded( - child: ListView.separated( - itemCount: ideas.length, - separatorBuilder: (_, _) => const SizedBox(height: 12), - itemBuilder: (BuildContext context, int index) { - final RelationshipIdea idea = ideas[index]; - final PersonProfile? person = idea.personId == null - ? null - : peopleById[idea.personId!]; - return FrostedCard( - child: compact - ? Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - _IdeaTypeTag(type: idea.type), - const SizedBox(width: 8), - Expanded( - child: Text( - idea.title, - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith( - decoration: idea.isArchived - ? TextDecoration - .lineThrough - : null, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - if (idea.details.trim().isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - idea.details, - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ), - const SizedBox(height: 8), - Text( - person == null - ? 'Unassigned' - : person.name, - style: Theme.of(context) - .textTheme - .labelLarge - ?.copyWith( - color: AppTheme.textSecondary, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 6, - runSpacing: 6, - children: [ - OutlinedButton.icon( - onPressed: () { - ref - .read( - localRepositoryProvider - .notifier, - ) - .toggleIdeaArchived(idea.id); - }, - icon: Icon( - idea.isArchived - ? Icons.unarchive_outlined - : Icons.archive_outlined, - ), - label: Text( - idea.isArchived - ? 'Unarchive' - : 'Archive', - ), - ), - OutlinedButton.icon( - onPressed: () => _editIdea( - context, - ref, - data.people, - idea, - ), - icon: const Icon(Icons.edit_rounded), - label: const Text('Edit'), - ), - TextButton.icon( - onPressed: () { - ref - .read( - localRepositoryProvider - .notifier, - ) - .deleteIdea(idea.id); - }, - icon: const Icon( - Icons.delete_outline_rounded, - ), - label: const Text('Delete'), - ), - ], - ), - ], - ) - : Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _IdeaTypeTag(type: idea.type), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - idea.title, - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith( - decoration: idea.isArchived - ? TextDecoration - .lineThrough - : null, - ), - ), - if (idea.details.trim().isNotEmpty) - Padding( - padding: const EdgeInsets.only( - top: 6, - ), - child: Text( - idea.details, - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith( - color: AppTheme - .textSecondary, - ), - ), - ), - const SizedBox(height: 8), - Text( - person == null - ? 'Unassigned' - : person.name, - style: Theme.of(context) - .textTheme - .labelLarge - ?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ], - ), - ), - const SizedBox(width: 8), - Column( - children: [ - IconButton( - onPressed: () { - ref - .read( - localRepositoryProvider - .notifier, - ) - .toggleIdeaArchived(idea.id); - }, - icon: Icon( - idea.isArchived - ? Icons.unarchive_outlined - : Icons.archive_outlined, - ), - tooltip: idea.isArchived - ? 'Unarchive' - : 'Archive', - ), - IconButton( - onPressed: () => _editIdea( - context, - ref, - data.people, - idea, - ), - icon: const Icon(Icons.edit_rounded), - tooltip: 'Edit', - ), - IconButton( - onPressed: () { - ref - .read( - localRepositoryProvider - .notifier, - ) - .deleteIdea(idea.id); - }, - icon: const Icon( - Icons.delete_outline_rounded, - ), - tooltip: 'Delete', - ), - ], - ), - ], - ), - ); - }, - ), - ), - ], - ), - ); - }, - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (Object error, StackTrace stackTrace) { - return const Center(child: Text('Unable to load ideas')); - }, - ); - } - - Future _addIdea( - BuildContext context, - WidgetRef ref, - List people, - ) async { - final _IdeaDraft? draft = await showDialog<_IdeaDraft>( - context: context, - builder: (BuildContext context) => - _IdeaEditorDialog(title: 'Add Idea', people: people), - ); - - if (draft == null) { - return; - } - - await ref - .read(localRepositoryProvider.notifier) - .addIdea( - type: draft.type, - title: draft.title, - details: draft.details, - personId: draft.personId, - ); - } - - Future _editIdea( - BuildContext context, - WidgetRef ref, - List people, - RelationshipIdea idea, - ) async { - final _IdeaDraft? draft = await showDialog<_IdeaDraft>( - context: context, - builder: (BuildContext context) => _IdeaEditorDialog( - title: 'Edit Idea', - people: people, - initial: _IdeaDraft( - personId: idea.personId, - type: idea.type, - title: idea.title, - details: idea.details, - ), - ), - ); - - if (draft == null) { - return; - } - - await ref - .read(localRepositoryProvider.notifier) - .updateIdea( - idea.copyWith( - personId: draft.personId, - type: draft.type, - title: draft.title, - details: draft.details, - ), - ); - } -} - -class _IdeaTypeTag extends StatelessWidget { - const _IdeaTypeTag({required this.type}); - - final IdeaType type; - - @override - Widget build(BuildContext context) { - final (Color bg, Color fg, String label) = switch (type) { - IdeaType.gift => (const Color(0xFFEAF7FB), AppTheme.primary, 'GIFT'), - IdeaType.event => ( - const Color(0xFFEEF7F3), - const Color(0xFF1D9C66), - 'EVENT', - ), - }; - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(99), - ), - child: Text( - label, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: fg, - fontWeight: FontWeight.w700, - ), - ), - ); - } -} - -class _IdeaEditorDialog extends StatefulWidget { - const _IdeaEditorDialog({ - required this.title, - required this.people, - this.initial, - }); - - final String title; - final List people; - final _IdeaDraft? initial; - - @override - State<_IdeaEditorDialog> createState() => _IdeaEditorDialogState(); -} - -class _IdeaEditorDialogState extends State<_IdeaEditorDialog> { - late final TextEditingController _titleController; - late final TextEditingController _detailsController; - late IdeaType _type; - String? _personId; - - @override - void initState() { - super.initState(); - _titleController = TextEditingController(text: widget.initial?.title ?? ''); - _detailsController = TextEditingController( - text: widget.initial?.details ?? '', - ); - _type = widget.initial?.type ?? IdeaType.gift; - _personId = widget.initial?.personId; - } - - @override - void dispose() { - _titleController.dispose(); - _detailsController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: Text(widget.title), - content: SingleChildScrollView( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 430), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - DropdownButtonFormField( - initialValue: _type, - items: IdeaType.values - .map( - (IdeaType type) => DropdownMenuItem( - value: type, - child: Text(type.name.toUpperCase()), - ), - ) - .toList(growable: false), - onChanged: (IdeaType? value) { - if (value == null) { - return; - } - setState(() { - _type = value; - }); - }, - decoration: const InputDecoration(labelText: 'Type'), - ), - DropdownButtonFormField( - initialValue: _personId, - items: >[ - const DropdownMenuItem( - value: null, - child: Text('Unassigned'), - ), - ...widget.people.map( - (PersonProfile person) => DropdownMenuItem( - value: person.id, - child: Text(person.name), - ), - ), - ], - onChanged: (String? value) { - setState(() { - _personId = value; - }); - }, - decoration: const InputDecoration(labelText: 'Person'), - ), - TextField( - controller: _titleController, - decoration: const InputDecoration(labelText: 'Title'), - ), - TextField( - controller: _detailsController, - maxLines: 3, - decoration: const InputDecoration(labelText: 'Details'), - ), - ], - ), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - final String title = _titleController.text.trim(); - if (title.isEmpty) { - return; - } - Navigator.of(context).pop( - _IdeaDraft( - personId: _personId, - type: _type, - title: title, - details: _detailsController.text.trim(), - ), - ); - }, - child: const Text('Save'), - ), - ], - ); - } -} - -class _IdeaDraft { - const _IdeaDraft({ - required this.personId, - required this.type, - required this.title, - required this.details, - }); - - final String? personId; - final IdeaType type; - final String title; - final String details; -} +// Legacy compatibility export for the ideas presentation entry. +export 'package:relationship_saver/features/ideas/presentation/ideas_view.dart'; diff --git a/lib/features/ideas/presentation/README.md b/lib/features/ideas/presentation/README.md new file mode 100644 index 0000000..4746186 --- /dev/null +++ b/lib/features/ideas/presentation/README.md @@ -0,0 +1,4 @@ +# Ideas Presentation + +This folder owns idea-specific screens and widgets. It should answer how ideas +are displayed and edited, not how they are stored or inferred. diff --git a/lib/features/ideas/presentation/ideas_view.dart b/lib/features/ideas/presentation/ideas_view.dart new file mode 100644 index 0000000..93a644d --- /dev/null +++ b/lib/features/ideas/presentation/ideas_view.dart @@ -0,0 +1,600 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/config/app_theme.dart'; +import 'package:relationship_saver/features/local/local_models.dart'; +import 'package:relationship_saver/features/local/local_repository.dart'; +import 'package:relationship_saver/features/shared/frosted_card.dart'; + +class IdeasView extends ConsumerStatefulWidget { + const IdeasView({super.key}); + + @override + ConsumerState createState() => _IdeasViewState(); +} + +class _IdeasViewState extends ConsumerState { + final TextEditingController _searchController = TextEditingController(); + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final AsyncValue localData = ref.watch( + localRepositoryProvider, + ); + + return localData.when( + data: (LocalDataState data) { + List ideas = data.ideas; + final Map peopleById = { + for (final PersonProfile person in data.people) person.id: person, + }; + + if (_searchController.text.isNotEmpty) { + final query = _searchController.text.toLowerCase(); + ideas = ideas.where((i) { + final person = i.personId != null ? peopleById[i.personId] : null; + return i.title.toLowerCase().contains(query) || + i.details.toLowerCase().contains(query) || + (person?.name.toLowerCase().contains(query) ?? false); + }).toList(); + } + + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final bool compact = constraints.maxWidth < 760; + return Padding( + padding: EdgeInsets.fromLTRB( + compact ? 16 : 28, + compact ? 16 : 24, + compact ? 16 : 28, + compact ? 20 : 28, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (compact) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ideas', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 6), + Text( + 'Capture gift and event ideas before they slip away.', + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: () => _addIdea(context, ref, data.people), + icon: const Icon(Icons.lightbulb_outline_rounded), + label: const Text('Add Idea'), + ), + ], + ) + else + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ideas', + style: Theme.of( + context, + ).textTheme.headlineMedium, + ), + const SizedBox(height: 6), + Text( + 'Capture gift and event ideas before they slip away.', + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith(color: AppTheme.textSecondary), + ), + ], + ), + ), + FilledButton.icon( + onPressed: () => _addIdea(context, ref, data.people), + icon: const Icon(Icons.lightbulb_outline_rounded), + label: const Text('Add Idea'), + ), + ], + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search ideas...', + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + onChanged: (_) => setState(() {}), + ), + ), + Expanded( + child: ListView.separated( + itemCount: ideas.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (BuildContext context, int index) { + final RelationshipIdea idea = ideas[index]; + final PersonProfile? person = idea.personId == null + ? null + : peopleById[idea.personId!]; + return FrostedCard( + child: compact + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _IdeaTypeTag(type: idea.type), + const SizedBox(width: 8), + Expanded( + child: Text( + idea.title, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + decoration: idea.isArchived + ? TextDecoration + .lineThrough + : null, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + if (idea.details.trim().isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + idea.details, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ), + const SizedBox(height: 8), + Text( + person == null + ? 'Unassigned' + : person.name, + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + OutlinedButton.icon( + onPressed: () { + ref + .read( + localRepositoryProvider + .notifier, + ) + .toggleIdeaArchived(idea.id); + }, + icon: Icon( + idea.isArchived + ? Icons.unarchive_outlined + : Icons.archive_outlined, + ), + label: Text( + idea.isArchived + ? 'Unarchive' + : 'Archive', + ), + ), + OutlinedButton.icon( + onPressed: () => _editIdea( + context, + ref, + data.people, + idea, + ), + icon: const Icon(Icons.edit_rounded), + label: const Text('Edit'), + ), + TextButton.icon( + onPressed: () { + ref + .read( + localRepositoryProvider + .notifier, + ) + .deleteIdea(idea.id); + }, + icon: const Icon( + Icons.delete_outline_rounded, + ), + label: const Text('Delete'), + ), + ], + ), + ], + ) + : Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _IdeaTypeTag(type: idea.type), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + idea.title, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + decoration: idea.isArchived + ? TextDecoration + .lineThrough + : null, + ), + ), + if (idea.details.trim().isNotEmpty) + Padding( + padding: const EdgeInsets.only( + top: 6, + ), + child: Text( + idea.details, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: AppTheme + .textSecondary, + ), + ), + ), + const SizedBox(height: 8), + Text( + person == null + ? 'Unassigned' + : person.name, + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Column( + children: [ + IconButton( + onPressed: () { + ref + .read( + localRepositoryProvider + .notifier, + ) + .toggleIdeaArchived(idea.id); + }, + icon: Icon( + idea.isArchived + ? Icons.unarchive_outlined + : Icons.archive_outlined, + ), + tooltip: idea.isArchived + ? 'Unarchive' + : 'Archive', + ), + IconButton( + onPressed: () => _editIdea( + context, + ref, + data.people, + idea, + ), + icon: const Icon(Icons.edit_rounded), + tooltip: 'Edit', + ), + IconButton( + onPressed: () { + ref + .read( + localRepositoryProvider + .notifier, + ) + .deleteIdea(idea.id); + }, + icon: const Icon( + Icons.delete_outline_rounded, + ), + tooltip: 'Delete', + ), + ], + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (Object error, StackTrace stackTrace) { + return const Center(child: Text('Unable to load ideas')); + }, + ); + } + + Future _addIdea( + BuildContext context, + WidgetRef ref, + List people, + ) async { + final _IdeaDraft? draft = await showDialog<_IdeaDraft>( + context: context, + builder: (BuildContext context) => + _IdeaEditorDialog(title: 'Add Idea', people: people), + ); + + if (draft == null) { + return; + } + + await ref + .read(localRepositoryProvider.notifier) + .addIdea( + type: draft.type, + title: draft.title, + details: draft.details, + personId: draft.personId, + ); + } + + Future _editIdea( + BuildContext context, + WidgetRef ref, + List people, + RelationshipIdea idea, + ) async { + final _IdeaDraft? draft = await showDialog<_IdeaDraft>( + context: context, + builder: (BuildContext context) => _IdeaEditorDialog( + title: 'Edit Idea', + people: people, + initial: _IdeaDraft( + personId: idea.personId, + type: idea.type, + title: idea.title, + details: idea.details, + ), + ), + ); + + if (draft == null) { + return; + } + + await ref + .read(localRepositoryProvider.notifier) + .updateIdea( + idea.copyWith( + personId: draft.personId, + type: draft.type, + title: draft.title, + details: draft.details, + ), + ); + } +} + +class _IdeaTypeTag extends StatelessWidget { + const _IdeaTypeTag({required this.type}); + + final IdeaType type; + + @override + Widget build(BuildContext context) { + final (Color bg, Color fg, String label) = switch (type) { + IdeaType.gift => (const Color(0xFFEAF7FB), AppTheme.primary, 'GIFT'), + IdeaType.event => ( + const Color(0xFFEEF7F3), + const Color(0xFF1D9C66), + 'EVENT', + ), + }; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(99), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: fg, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +class _IdeaEditorDialog extends StatefulWidget { + const _IdeaEditorDialog({ + required this.title, + required this.people, + this.initial, + }); + + final String title; + final List people; + final _IdeaDraft? initial; + + @override + State<_IdeaEditorDialog> createState() => _IdeaEditorDialogState(); +} + +class _IdeaEditorDialogState extends State<_IdeaEditorDialog> { + late final TextEditingController _titleController; + late final TextEditingController _detailsController; + late IdeaType _type; + String? _personId; + + @override + void initState() { + super.initState(); + _titleController = TextEditingController(text: widget.initial?.title ?? ''); + _detailsController = TextEditingController( + text: widget.initial?.details ?? '', + ); + _type = widget.initial?.type ?? IdeaType.gift; + _personId = widget.initial?.personId; + } + + @override + void dispose() { + _titleController.dispose(); + _detailsController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.title), + content: SingleChildScrollView( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 430), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButtonFormField( + initialValue: _type, + items: IdeaType.values + .map( + (IdeaType type) => DropdownMenuItem( + value: type, + child: Text(type.name.toUpperCase()), + ), + ) + .toList(growable: false), + onChanged: (IdeaType? value) { + if (value == null) { + return; + } + setState(() { + _type = value; + }); + }, + decoration: const InputDecoration(labelText: 'Type'), + ), + DropdownButtonFormField( + initialValue: _personId, + items: >[ + const DropdownMenuItem( + value: null, + child: Text('Unassigned'), + ), + ...widget.people.map( + (PersonProfile person) => DropdownMenuItem( + value: person.id, + child: Text(person.name), + ), + ), + ], + onChanged: (String? value) { + setState(() { + _personId = value; + }); + }, + decoration: const InputDecoration(labelText: 'Person'), + ), + TextField( + controller: _titleController, + decoration: const InputDecoration(labelText: 'Title'), + ), + TextField( + controller: _detailsController, + maxLines: 3, + decoration: const InputDecoration(labelText: 'Details'), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final String title = _titleController.text.trim(); + if (title.isEmpty) { + return; + } + Navigator.of(context).pop( + _IdeaDraft( + personId: _personId, + type: _type, + title: title, + details: _detailsController.text.trim(), + ), + ); + }, + child: const Text('Save'), + ), + ], + ); + } +} + +class _IdeaDraft { + const _IdeaDraft({ + required this.personId, + required this.type, + required this.title, + required this.details, + }); + + final String? personId; + final IdeaType type; + final String title; + final String details; +} diff --git a/lib/features/local/README.md b/lib/features/local/README.md new file mode 100644 index 0000000..f0a778c --- /dev/null +++ b/lib/features/local/README.md @@ -0,0 +1,8 @@ +# Legacy Local Layer + +This folder is now a compatibility layer. + +The old horizontal `local` module was the main architectural weak spot. Its +root files now re-export canonical code from `lib/app/` and feature-owned +`domain/` files so older imports keep working while the real implementation +lives in clearer locations. diff --git a/lib/features/local/local_models.dart b/lib/features/local/local_models.dart index 8a8009b..878edff 100644 --- a/lib/features/local/local_models.dart +++ b/lib/features/local/local_models.dart @@ -1,1022 +1,11 @@ -// ignore_for_file: sort_constructors_first - -import 'package:flutter/foundation.dart'; - -enum IdeaType { gift, event } - -enum ReminderCadence { daily, weekly, monthly } - -enum PreferenceSignalPolarity { like, dislike, neutral } - -enum PreferenceSignalStatus { inferred, confirmed, dismissed } - -@immutable -class PersonProfile { - const PersonProfile({ - required this.id, - required this.name, - required this.relationship, - required this.affinityScore, - required this.nextMoment, - required this.tags, - required this.notes, - this.location, - }); - - final String id; - final String name; - final String relationship; - final int affinityScore; - final DateTime nextMoment; - final List tags; - final String notes; - final String? location; - - PersonProfile copyWith({ - String? id, - String? name, - String? relationship, - int? affinityScore, - DateTime? nextMoment, - List? tags, - String? notes, - String? location, - }) { - return PersonProfile( - id: id ?? this.id, - name: name ?? this.name, - relationship: relationship ?? this.relationship, - affinityScore: affinityScore ?? this.affinityScore, - nextMoment: nextMoment ?? this.nextMoment, - tags: tags ?? this.tags, - notes: notes ?? this.notes, - location: location ?? this.location, - ); - } - - Map toJson() { - return { - 'id': id, - 'name': name, - 'relationship': relationship, - 'affinityScore': affinityScore, - 'nextMoment': nextMoment.toUtc().toIso8601String(), - 'tags': tags, - 'notes': notes, - 'location': location, - }; - } - - factory PersonProfile.fromJson(Map json) { - return PersonProfile( - id: json['id'] as String, - name: json['name'] as String, - relationship: json['relationship'] as String, - affinityScore: (json['affinityScore'] as num).toInt(), - nextMoment: DateTime.parse(json['nextMoment'] as String).toLocal(), - tags: (json['tags'] as List? ?? []) - .map((dynamic tag) => '$tag') - .toList(growable: false), - notes: json['notes'] as String? ?? '', - location: json['location'] as String?, - ); - } -} - -@immutable -class RelationshipMoment { - const RelationshipMoment({ - required this.id, - required this.personId, - required this.title, - required this.summary, - required this.at, - required this.type, - }); - - final String id; - final String personId; - final String title; - final String summary; - final DateTime at; - final String type; - - RelationshipMoment copyWith({ - String? id, - String? personId, - String? title, - String? summary, - DateTime? at, - String? type, - }) { - return RelationshipMoment( - id: id ?? this.id, - personId: personId ?? this.personId, - title: title ?? this.title, - summary: summary ?? this.summary, - at: at ?? this.at, - type: type ?? this.type, - ); - } - - Map toJson() { - return { - 'id': id, - 'personId': personId, - 'title': title, - 'summary': summary, - 'at': at.toUtc().toIso8601String(), - 'type': type, - }; - } - - factory RelationshipMoment.fromJson(Map json) { - return RelationshipMoment( - id: json['id'] as String, - personId: json['personId'] as String, - title: json['title'] as String, - summary: json['summary'] as String, - at: DateTime.parse(json['at'] as String).toLocal(), - type: json['type'] as String, - ); - } -} - -@immutable -class RelationshipIdea { - const RelationshipIdea({ - required this.id, - required this.type, - required this.title, - required this.details, - required this.createdAt, - this.personId, - this.isArchived = false, - }); - - final String id; - final String? personId; - final IdeaType type; - final String title; - final String details; - final DateTime createdAt; - final bool isArchived; - - RelationshipIdea copyWith({ - String? id, - String? personId, - IdeaType? type, - String? title, - String? details, - DateTime? createdAt, - bool? isArchived, - }) { - return RelationshipIdea( - id: id ?? this.id, - personId: personId ?? this.personId, - type: type ?? this.type, - title: title ?? this.title, - details: details ?? this.details, - createdAt: createdAt ?? this.createdAt, - isArchived: isArchived ?? this.isArchived, - ); - } - - Map toJson() { - return { - 'id': id, - 'personId': personId, - 'type': type.name, - 'title': title, - 'details': details, - 'createdAt': createdAt.toUtc().toIso8601String(), - 'isArchived': isArchived, - }; - } - - factory RelationshipIdea.fromJson(Map json) { - final String typeName = json['type'] as String? ?? IdeaType.gift.name; - return RelationshipIdea( - id: json['id'] as String, - personId: json['personId'] as String?, - type: IdeaType.values.firstWhere( - (IdeaType type) => type.name == typeName, - orElse: () => IdeaType.gift, - ), - title: json['title'] as String, - details: json['details'] as String? ?? '', - createdAt: DateTime.parse(json['createdAt'] as String).toLocal(), - isArchived: json['isArchived'] as bool? ?? false, - ); - } -} - -@immutable -class ReminderRule { - const ReminderRule({ - required this.id, - required this.title, - required this.cadence, - required this.nextAt, - this.personId, - this.enabled = true, - }); - - final String id; - final String? personId; - final String title; - final ReminderCadence cadence; - final DateTime nextAt; - final bool enabled; - - ReminderRule copyWith({ - String? id, - String? personId, - String? title, - ReminderCadence? cadence, - DateTime? nextAt, - bool? enabled, - }) { - return ReminderRule( - id: id ?? this.id, - personId: personId ?? this.personId, - title: title ?? this.title, - cadence: cadence ?? this.cadence, - nextAt: nextAt ?? this.nextAt, - enabled: enabled ?? this.enabled, - ); - } - - Map toJson() { - return { - 'id': id, - 'personId': personId, - 'title': title, - 'cadence': cadence.name, - 'nextAt': nextAt.toUtc().toIso8601String(), - 'enabled': enabled, - }; - } - - factory ReminderRule.fromJson(Map json) { - final String cadenceName = - json['cadence'] as String? ?? ReminderCadence.weekly.name; - return ReminderRule( - id: json['id'] as String, - personId: json['personId'] as String?, - title: json['title'] as String, - cadence: ReminderCadence.values.firstWhere( - (ReminderCadence cadence) => cadence.name == cadenceName, - orElse: () => ReminderCadence.weekly, - ), - nextAt: DateTime.parse(json['nextAt'] as String).toLocal(), - enabled: json['enabled'] as bool? ?? true, - ); - } -} - -@immutable -class DashboardTask { - const DashboardTask({ - required this.id, - required this.title, - required this.description, - required this.when, - this.done = false, - }); - - final String id; - final String title; - final String description; - final DateTime when; - final bool done; - - DashboardTask copyWith({ - String? id, - String? title, - String? description, - DateTime? when, - bool? done, - }) { - return DashboardTask( - id: id ?? this.id, - title: title ?? this.title, - description: description ?? this.description, - when: when ?? this.when, - done: done ?? this.done, - ); - } - - Map toJson() { - return { - 'id': id, - 'title': title, - 'description': description, - 'when': when.toUtc().toIso8601String(), - 'done': done, - }; - } - - factory DashboardTask.fromJson(Map json) { - return DashboardTask( - id: json['id'] as String, - title: json['title'] as String, - description: json['description'] as String, - when: DateTime.parse(json['when'] as String).toLocal(), - done: json['done'] as bool? ?? false, - ); - } -} - -@immutable -class SourceProfileLink { - const SourceProfileLink({ - required this.id, - required this.sourceApp, - required this.normalizedDisplayName, - required this.profileId, - required this.firstSeenAt, - required this.lastSeenAt, - this.sourceUserId, - this.sourceThreadId, - this.sourceFingerprint, - }); - - final String id; - final String sourceApp; - final String? sourceUserId; - final String? sourceThreadId; - final String? sourceFingerprint; - final String normalizedDisplayName; - final String profileId; - final DateTime firstSeenAt; - final DateTime lastSeenAt; - - SourceProfileLink copyWith({ - String? id, - String? sourceApp, - String? sourceUserId, - String? sourceThreadId, - String? sourceFingerprint, - String? normalizedDisplayName, - String? profileId, - DateTime? firstSeenAt, - DateTime? lastSeenAt, - }) { - return SourceProfileLink( - id: id ?? this.id, - sourceApp: sourceApp ?? this.sourceApp, - sourceUserId: sourceUserId ?? this.sourceUserId, - sourceThreadId: sourceThreadId ?? this.sourceThreadId, - sourceFingerprint: sourceFingerprint ?? this.sourceFingerprint, - normalizedDisplayName: - normalizedDisplayName ?? this.normalizedDisplayName, - profileId: profileId ?? this.profileId, - firstSeenAt: firstSeenAt ?? this.firstSeenAt, - lastSeenAt: lastSeenAt ?? this.lastSeenAt, - ); - } - - Map toJson() { - return { - 'id': id, - 'sourceApp': sourceApp, - 'sourceUserId': sourceUserId, - 'sourceThreadId': sourceThreadId, - 'sourceFingerprint': sourceFingerprint, - 'normalizedDisplayName': normalizedDisplayName, - 'profileId': profileId, - 'firstSeenAt': firstSeenAt.toUtc().toIso8601String(), - 'lastSeenAt': lastSeenAt.toUtc().toIso8601String(), - }; - } - - factory SourceProfileLink.fromJson(Map json) { - return SourceProfileLink( - id: json['id'] as String, - sourceApp: json['sourceApp'] as String, - sourceUserId: json['sourceUserId'] as String?, - sourceThreadId: json['sourceThreadId'] as String?, - sourceFingerprint: json['sourceFingerprint'] as String?, - normalizedDisplayName: json['normalizedDisplayName'] as String? ?? '', - profileId: json['profileId'] as String, - firstSeenAt: DateTime.parse(json['firstSeenAt'] as String).toLocal(), - lastSeenAt: DateTime.parse(json['lastSeenAt'] as String).toLocal(), - ); - } -} - -@immutable -class SharedMessageEntry { - const SharedMessageEntry({ - required this.id, - required this.sourceApp, - required this.profileId, - required this.messageText, - required this.sharedAt, - required this.importedAt, - required this.resolvedAutomatically, - this.sourceDisplayName, - this.sourceUserId, - this.sourceThreadId, - }); - - final String id; - final String sourceApp; - final String profileId; - final String messageText; - final DateTime sharedAt; - final DateTime importedAt; - final bool resolvedAutomatically; - final String? sourceDisplayName; - final String? sourceUserId; - final String? sourceThreadId; - - SharedMessageEntry copyWith({ - String? id, - String? sourceApp, - String? profileId, - String? messageText, - DateTime? sharedAt, - DateTime? importedAt, - bool? resolvedAutomatically, - String? sourceDisplayName, - String? sourceUserId, - String? sourceThreadId, - }) { - return SharedMessageEntry( - id: id ?? this.id, - sourceApp: sourceApp ?? this.sourceApp, - profileId: profileId ?? this.profileId, - messageText: messageText ?? this.messageText, - sharedAt: sharedAt ?? this.sharedAt, - importedAt: importedAt ?? this.importedAt, - resolvedAutomatically: - resolvedAutomatically ?? this.resolvedAutomatically, - sourceDisplayName: sourceDisplayName ?? this.sourceDisplayName, - sourceUserId: sourceUserId ?? this.sourceUserId, - sourceThreadId: sourceThreadId ?? this.sourceThreadId, - ); - } - - Map toJson() { - return { - 'id': id, - 'sourceApp': sourceApp, - 'profileId': profileId, - 'messageText': messageText, - 'sharedAt': sharedAt.toUtc().toIso8601String(), - 'importedAt': importedAt.toUtc().toIso8601String(), - 'resolvedAutomatically': resolvedAutomatically, - 'sourceDisplayName': sourceDisplayName, - 'sourceUserId': sourceUserId, - 'sourceThreadId': sourceThreadId, - }; - } - - factory SharedMessageEntry.fromJson(Map json) { - return SharedMessageEntry( - id: json['id'] as String, - sourceApp: json['sourceApp'] as String, - profileId: json['profileId'] as String, - messageText: json['messageText'] as String? ?? '', - sharedAt: DateTime.parse(json['sharedAt'] as String).toLocal(), - importedAt: DateTime.parse(json['importedAt'] as String).toLocal(), - resolvedAutomatically: json['resolvedAutomatically'] as bool? ?? true, - sourceDisplayName: json['sourceDisplayName'] as String?, - sourceUserId: json['sourceUserId'] as String?, - sourceThreadId: json['sourceThreadId'] as String?, - ); - } -} - -enum SharedInboxReason { - ambiguousProfileMatch, - nearProfileConflict, - missingIdentity, -} - -@immutable -class SharedInboxEntry { - const SharedInboxEntry({ - required this.id, - required this.sourceApp, - required this.messageText, - required this.sharedAt, - required this.receivedAt, - required this.reason, - required this.candidateProfileIds, - required this.normalizedDisplayName, - this.sourceFingerprint, - this.sourceDisplayName, - this.sourceUserId, - this.sourceThreadId, - }); - - final String id; - final String sourceApp; - final String messageText; - final DateTime sharedAt; - final DateTime receivedAt; - final SharedInboxReason reason; - final List candidateProfileIds; - final String normalizedDisplayName; - final String? sourceFingerprint; - final String? sourceDisplayName; - final String? sourceUserId; - final String? sourceThreadId; - - SharedInboxEntry copyWith({ - String? id, - String? sourceApp, - String? messageText, - DateTime? sharedAt, - DateTime? receivedAt, - SharedInboxReason? reason, - List? candidateProfileIds, - String? normalizedDisplayName, - String? sourceFingerprint, - String? sourceDisplayName, - String? sourceUserId, - String? sourceThreadId, - }) { - return SharedInboxEntry( - id: id ?? this.id, - sourceApp: sourceApp ?? this.sourceApp, - messageText: messageText ?? this.messageText, - sharedAt: sharedAt ?? this.sharedAt, - receivedAt: receivedAt ?? this.receivedAt, - reason: reason ?? this.reason, - candidateProfileIds: candidateProfileIds ?? this.candidateProfileIds, - normalizedDisplayName: - normalizedDisplayName ?? this.normalizedDisplayName, - sourceFingerprint: sourceFingerprint ?? this.sourceFingerprint, - sourceDisplayName: sourceDisplayName ?? this.sourceDisplayName, - sourceUserId: sourceUserId ?? this.sourceUserId, - sourceThreadId: sourceThreadId ?? this.sourceThreadId, - ); - } - - Map toJson() { - return { - 'id': id, - 'sourceApp': sourceApp, - 'messageText': messageText, - 'sharedAt': sharedAt.toUtc().toIso8601String(), - 'receivedAt': receivedAt.toUtc().toIso8601String(), - 'reason': reason.name, - 'candidateProfileIds': candidateProfileIds, - 'normalizedDisplayName': normalizedDisplayName, - 'sourceFingerprint': sourceFingerprint, - 'sourceDisplayName': sourceDisplayName, - 'sourceUserId': sourceUserId, - 'sourceThreadId': sourceThreadId, - }; - } - - factory SharedInboxEntry.fromJson(Map json) { - final String reasonName = - json['reason'] as String? ?? SharedInboxReason.missingIdentity.name; - return SharedInboxEntry( - id: json['id'] as String, - sourceApp: json['sourceApp'] as String, - messageText: json['messageText'] as String? ?? '', - sharedAt: DateTime.parse(json['sharedAt'] as String).toLocal(), - receivedAt: DateTime.parse(json['receivedAt'] as String).toLocal(), - reason: SharedInboxReason.values.firstWhere( - (SharedInboxReason item) => item.name == reasonName, - orElse: () => SharedInboxReason.missingIdentity, - ), - candidateProfileIds: - (json['candidateProfileIds'] as List? ?? []) - .map((dynamic id) => '$id') - .toList(growable: false), - normalizedDisplayName: json['normalizedDisplayName'] as String? ?? '', - sourceFingerprint: json['sourceFingerprint'] as String?, - sourceDisplayName: json['sourceDisplayName'] as String?, - sourceUserId: json['sourceUserId'] as String?, - sourceThreadId: json['sourceThreadId'] as String?, - ); - } -} - -@immutable -class PersonPreferenceSignal { - const PersonPreferenceSignal({ - required this.id, - required this.personId, - required this.key, - required this.label, - required this.category, - required this.polarity, - required this.confidence, - required this.status, - required this.firstSeenAt, - required this.lastSeenAt, - required this.occurrenceCount, - this.sourceApps = const [], - this.evidenceMessageIds = const [], - this.evidenceSnippets = const [], - }); - - final String id; - final String personId; - final String key; - final String label; - final String category; - final PreferenceSignalPolarity polarity; - final double confidence; - final PreferenceSignalStatus status; - final DateTime firstSeenAt; - final DateTime lastSeenAt; - final int occurrenceCount; - final List sourceApps; - final List evidenceMessageIds; - final List evidenceSnippets; - - PersonPreferenceSignal copyWith({ - String? id, - String? personId, - String? key, - String? label, - String? category, - PreferenceSignalPolarity? polarity, - double? confidence, - PreferenceSignalStatus? status, - DateTime? firstSeenAt, - DateTime? lastSeenAt, - int? occurrenceCount, - List? sourceApps, - List? evidenceMessageIds, - List? evidenceSnippets, - }) { - return PersonPreferenceSignal( - id: id ?? this.id, - personId: personId ?? this.personId, - key: key ?? this.key, - label: label ?? this.label, - category: category ?? this.category, - polarity: polarity ?? this.polarity, - confidence: confidence ?? this.confidence, - status: status ?? this.status, - firstSeenAt: firstSeenAt ?? this.firstSeenAt, - lastSeenAt: lastSeenAt ?? this.lastSeenAt, - occurrenceCount: occurrenceCount ?? this.occurrenceCount, - sourceApps: sourceApps ?? this.sourceApps, - evidenceMessageIds: evidenceMessageIds ?? this.evidenceMessageIds, - evidenceSnippets: evidenceSnippets ?? this.evidenceSnippets, - ); - } - - Map toJson() { - return { - 'id': id, - 'personId': personId, - 'key': key, - 'label': label, - 'category': category, - 'polarity': polarity.name, - 'confidence': confidence, - 'status': status.name, - 'firstSeenAt': firstSeenAt.toUtc().toIso8601String(), - 'lastSeenAt': lastSeenAt.toUtc().toIso8601String(), - 'occurrenceCount': occurrenceCount, - 'sourceApps': sourceApps, - 'evidenceMessageIds': evidenceMessageIds, - 'evidenceSnippets': evidenceSnippets, - }; - } - - factory PersonPreferenceSignal.fromJson(Map json) { - final String polarityName = - json['polarity'] as String? ?? PreferenceSignalPolarity.neutral.name; - final String statusName = - json['status'] as String? ?? PreferenceSignalStatus.inferred.name; - return PersonPreferenceSignal( - id: json['id'] as String, - personId: json['personId'] as String, - key: json['key'] as String, - label: json['label'] as String? ?? '', - category: json['category'] as String? ?? 'general', - polarity: PreferenceSignalPolarity.values.firstWhere( - (PreferenceSignalPolarity item) => item.name == polarityName, - orElse: () => PreferenceSignalPolarity.neutral, - ), - confidence: (json['confidence'] as num?)?.toDouble() ?? 0, - status: PreferenceSignalStatus.values.firstWhere( - (PreferenceSignalStatus item) => item.name == statusName, - orElse: () => PreferenceSignalStatus.inferred, - ), - firstSeenAt: DateTime.parse( - json['firstSeenAt'] as String? ?? - DateTime.now().toUtc().toIso8601String(), - ).toLocal(), - lastSeenAt: DateTime.parse( - json['lastSeenAt'] as String? ?? - DateTime.now().toUtc().toIso8601String(), - ).toLocal(), - occurrenceCount: (json['occurrenceCount'] as num?)?.toInt() ?? 1, - sourceApps: (json['sourceApps'] as List? ?? []) - .map((dynamic item) => '$item') - .toList(growable: false), - evidenceMessageIds: - (json['evidenceMessageIds'] as List? ?? []) - .map((dynamic item) => '$item') - .toList(growable: false), - evidenceSnippets: - (json['evidenceSnippets'] as List? ?? []) - .map((dynamic item) => '$item') - .toList(growable: false), - ); - } -} - -@immutable -class DashboardSummary { - const DashboardSummary({ - required this.activePeople, - required this.upcomingPlans, - required this.pendingIdeas, - required this.weeklyConsistency, - }); - - final int activePeople; - final int upcomingPlans; - final int pendingIdeas; - final int weeklyConsistency; -} - -@immutable -class LocalDataState { - const LocalDataState({ - required this.people, - required this.moments, - required this.ideas, - required this.reminders, - required this.tasks, - this.sourceLinks = const [], - this.sharedMessages = const [], - this.sharedInbox = const [], - this.preferenceSignals = const [], - }); - - final List people; - final List moments; - final List ideas; - final List reminders; - final List tasks; - final List sourceLinks; - final List sharedMessages; - final List sharedInbox; - final List preferenceSignals; - - LocalDataState copyWith({ - List? people, - List? moments, - List? ideas, - List? reminders, - List? tasks, - List? sourceLinks, - List? sharedMessages, - List? sharedInbox, - List? preferenceSignals, - }) { - return LocalDataState( - people: people ?? this.people, - moments: moments ?? this.moments, - ideas: ideas ?? this.ideas, - reminders: reminders ?? this.reminders, - tasks: tasks ?? this.tasks, - sourceLinks: sourceLinks ?? this.sourceLinks, - sharedMessages: sharedMessages ?? this.sharedMessages, - sharedInbox: sharedInbox ?? this.sharedInbox, - preferenceSignals: preferenceSignals ?? this.preferenceSignals, - ); - } - - DashboardSummary summary({DateTime? now}) { - final DateTime baseline = now ?? DateTime.now(); - return DashboardSummary( - activePeople: people.length, - upcomingPlans: people - .where((PersonProfile p) => p.nextMoment.isAfter(baseline)) - .length, - pendingIdeas: ideas - .where((RelationshipIdea idea) => !idea.isArchived) - .length, - weeklyConsistency: moments.isEmpty - ? 0 - : (70 + moments.length * 4).clamp(0, 100), - ); - } - - Map toJson() { - return { - 'people': people - .map((PersonProfile person) => person.toJson()) - .toList(growable: false), - 'moments': moments - .map((RelationshipMoment moment) => moment.toJson()) - .toList(growable: false), - 'ideas': ideas - .map((RelationshipIdea idea) => idea.toJson()) - .toList(growable: false), - 'reminders': reminders - .map((ReminderRule reminder) => reminder.toJson()) - .toList(growable: false), - 'tasks': tasks - .map((DashboardTask task) => task.toJson()) - .toList(growable: false), - 'sourceLinks': sourceLinks - .map((SourceProfileLink link) => link.toJson()) - .toList(growable: false), - 'sharedMessages': sharedMessages - .map((SharedMessageEntry entry) => entry.toJson()) - .toList(growable: false), - 'sharedInbox': sharedInbox - .map((SharedInboxEntry entry) => entry.toJson()) - .toList(growable: false), - 'preferenceSignals': preferenceSignals - .map((PersonPreferenceSignal signal) => signal.toJson()) - .toList(growable: false), - }; - } - - factory LocalDataState.fromJson(Map json) { - return LocalDataState( - people: (json['people'] as List? ?? []) - .map( - (dynamic person) => - PersonProfile.fromJson(person as Map), - ) - .toList(growable: false), - moments: (json['moments'] as List? ?? []) - .map( - (dynamic moment) => - RelationshipMoment.fromJson(moment as Map), - ) - .toList(growable: false), - ideas: (json['ideas'] as List? ?? []) - .map( - (dynamic idea) => - RelationshipIdea.fromJson(idea as Map), - ) - .toList(growable: false), - reminders: (json['reminders'] as List? ?? []) - .map( - (dynamic reminder) => - ReminderRule.fromJson(reminder as Map), - ) - .toList(growable: false), - tasks: (json['tasks'] as List? ?? []) - .map( - (dynamic task) => - DashboardTask.fromJson(task as Map), - ) - .toList(growable: false), - sourceLinks: (json['sourceLinks'] as List? ?? []) - .map( - (dynamic link) => - SourceProfileLink.fromJson(link as Map), - ) - .toList(growable: false), - sharedMessages: (json['sharedMessages'] as List? ?? []) - .map( - (dynamic entry) => - SharedMessageEntry.fromJson(entry as Map), - ) - .toList(growable: false), - sharedInbox: (json['sharedInbox'] as List? ?? []) - .map( - (dynamic entry) => - SharedInboxEntry.fromJson(entry as Map), - ) - .toList(growable: false), - preferenceSignals: - (json['preferenceSignals'] as List? ?? []) - .map( - (dynamic signal) => PersonPreferenceSignal.fromJson( - signal as Map, - ), - ) - .toList(growable: false), - ); - } - - static LocalDataState seed() { - return LocalDataState( - people: [ - PersonProfile( - id: 'p-ava', - name: 'Ava Hart', - relationship: 'Partner', - affinityScore: 92, - nextMoment: DateTime(2026, 2, 16, 19, 30), - tags: ['coffee', 'books', 'slow mornings'], - notes: 'Prefers thoughtful plans over expensive plans.', - location: 'Austin, TX', - ), - PersonProfile( - id: 'p-jordan', - name: 'Jordan Lee', - relationship: 'Close Friend', - affinityScore: 78, - nextMoment: DateTime(2026, 2, 18, 18, 0), - tags: ['running', 'street food'], - notes: 'Has a race on Sunday; ask how training is going.', - location: 'Chicago, IL', - ), - PersonProfile( - id: 'p-mila', - name: 'Mila Stone', - relationship: 'Sister', - affinityScore: 84, - nextMoment: DateTime(2026, 2, 20, 12, 0), - tags: ['plants', 'retro music'], - notes: 'Birthday prep should start this week.', - location: 'Seattle, WA', - ), - ], - moments: [ - RelationshipMoment( - id: 'm-1', - personId: 'p-ava', - title: 'Sunday Walk Tradition', - summary: 'Short walk + no-phone hour worked really well.', - at: DateTime(2026, 2, 9, 9, 30), - type: 'ritual', - ), - RelationshipMoment( - id: 'm-2', - personId: 'p-jordan', - title: 'Post-workout Check-in', - summary: 'Shared training playlist and grabbed smoothies.', - at: DateTime(2026, 2, 11, 19, 0), - type: 'support', - ), - RelationshipMoment( - id: 'm-3', - personId: 'p-mila', - title: 'Gift Idea Captured', - summary: 'Found a vintage lamp from her saved style board.', - at: DateTime(2026, 2, 12, 14, 20), - type: 'gift', - ), - ], - ideas: [ - RelationshipIdea( - id: 'i-1', - personId: 'p-ava', - type: IdeaType.gift, - title: 'Handwritten recipe journal', - details: 'Collect 10 memories and recipes from this year.', - createdAt: DateTime(2026, 2, 12, 9), - ), - RelationshipIdea( - id: 'i-2', - personId: 'p-mila', - type: IdeaType.event, - title: 'Plant market + brunch date', - details: 'Saturday morning slot; book nearby cafe in advance.', - createdAt: DateTime(2026, 2, 13, 11), - ), - ], - reminders: [ - ReminderRule( - id: 'r-1', - personId: 'p-jordan', - title: 'Weekly check-in message', - cadence: ReminderCadence.weekly, - nextAt: DateTime(2026, 2, 18, 18), - ), - ReminderRule( - id: 'r-2', - personId: 'p-ava', - title: 'Sunday ritual planning', - cadence: ReminderCadence.weekly, - nextAt: DateTime(2026, 2, 16, 10), - ), - ], - tasks: [ - DashboardTask( - id: 't-1', - title: 'Plan Friday dinner with Ava', - description: 'Keep it low-key: ramen + bookstore stop.', - when: DateTime(2026, 2, 16, 17, 0), - ), - DashboardTask( - id: 't-2', - title: 'Send race-day encouragement to Jordan', - description: 'Voice note before 8:00 AM.', - when: DateTime(2026, 2, 17, 7, 30), - ), - DashboardTask( - id: 't-3', - title: 'Finalize Mila birthday shortlist', - description: 'Pick 1 meaningful gift and 1 backup.', - when: DateTime(2026, 2, 18, 20, 0), - ), - ], - ); - } -} +// Legacy compatibility barrel. +// New work should import feature-owned models directly from their slice +// folders or the shared app-state aggregate from `lib/app/state`. +export 'package:relationship_saver/app/state/local_data_state.dart'; +export 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart'; +export 'package:relationship_saver/features/dashboard/domain/dashboard_models.dart'; +export 'package:relationship_saver/features/ideas/domain/idea_models.dart'; +export 'package:relationship_saver/features/moments/domain/moment_models.dart'; +export 'package:relationship_saver/features/people/domain/person_models.dart'; +export 'package:relationship_saver/features/reminders/domain/reminder_models.dart'; +export 'package:relationship_saver/features/share_intake/domain/share_models.dart'; diff --git a/lib/features/local/local_repository.dart b/lib/features/local/local_repository.dart index bf95d91..6db43a7 100644 --- a/lib/features/local/local_repository.dart +++ b/lib/features/local/local_repository.dart @@ -1,2175 +1,2 @@ -import 'dart:convert'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:relationship_saver/core/config/app_config.dart'; -import 'package:relationship_saver/features/local/chat_preference_extractor.dart'; -import 'package:relationship_saver/features/local/local_models.dart'; -import 'package:relationship_saver/features/local/storage/local_data_store.dart'; -import 'package:relationship_saver/features/local/storage/local_data_store_hive.dart'; -import 'package:relationship_saver/features/local/storage/local_data_store_provider.dart'; -import 'package:relationship_saver/features/local/storage/local_data_store_shared_prefs.dart'; -import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_provider.dart'; -import 'package:relationship_saver/features/sync/sync_queue_repository.dart'; -import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; -import 'package:uuid/uuid.dart'; - -/// Input payload for shared-message ingest flow (e.g. WhatsApp share intent). -class SharedMessageIngestInput { - const SharedMessageIngestInput({ - required this.sourceApp, - required this.messageText, - this.sourceDisplayName, - this.sourceUserId, - this.sourceThreadId, - this.sharedAt, - }); - - final String sourceApp; - final String messageText; - final String? sourceDisplayName; - final String? sourceUserId; - final String? sourceThreadId; - final DateTime? sharedAt; -} - -/// Result of shared-message ingest with profile resolution metadata. -enum SharedMessageIngestStatus { imported, queuedForResolution } - -class SharedMessageIngestResult { - const SharedMessageIngestResult({ - required this.status, - required this.createdProfile, - required this.createdSourceLink, - this.profileId, - this.profileName, - this.momentId, - this.inboxEntryId, - }); - - const SharedMessageIngestResult.imported({ - required this.profileId, - required this.profileName, - required this.createdProfile, - required this.createdSourceLink, - required this.momentId, - }) : status = SharedMessageIngestStatus.imported, - inboxEntryId = null; - - const SharedMessageIngestResult.queuedForResolution({ - required this.inboxEntryId, - }) : status = SharedMessageIngestStatus.queuedForResolution, - createdProfile = false, - createdSourceLink = false, - profileId = null, - profileName = null, - momentId = null; - - final SharedMessageIngestStatus status; - final String? profileId; - final String? profileName; - final bool createdProfile; - final bool createdSourceLink; - final String? momentId; - final String? inboxEntryId; - - bool get isQueuedForResolution => - status == SharedMessageIngestStatus.queuedForResolution; -} - -/// Persisted local data source for offline-first product state. -class LocalRepository extends AsyncNotifier { - static const int _schemaVersion = 3; - static const int _syncSchemaVersion = 1; - - static const Uuid _uuid = Uuid(); - static const ChatPreferenceExtractor _chatPreferenceExtractor = - ChatPreferenceExtractor(); - - @override - Future build() async { - final LocalDataStore store = ref.read(localDataStoreProvider); - LocalDataRecord? record = await store.read(); - - if ((record == null || record.rawState.isEmpty) && - AppConfig.useHiveLocalDb && - store is HiveLocalDataStore) { - final SharedPrefsLocalDataStore legacyStore = SharedPrefsLocalDataStore(); - final LocalDataRecord? legacy = await legacyStore.read(); - if (legacy != null && legacy.rawState.isNotEmpty) { - await store.write( - rawState: legacy.rawState, - schemaVersion: legacy.schemaVersion, - ); - await legacyStore.clear(); - record = await store.read(); - } - } - - if (record == null || record.rawState.isEmpty) { - final LocalDataState seeded = LocalDataState.seed(); - await _persist(seeded); - await _reconcileReminderSchedule(seeded); - return seeded; - } - - final LocalDataRecord migrated = await _migrateIfNeeded(record); - try { - final Map json = - jsonDecode(migrated.rawState) as Map; - final LocalDataState loaded = LocalDataState.fromJson(json); - await _reconcileReminderSchedule(loaded); - return loaded; - } on FormatException { - final LocalDataState fallback = LocalDataState.seed(); - await _persist(fallback); - await _reconcileReminderSchedule(fallback); - return fallback; - } - } - - Future addPerson({ - required String name, - required String relationship, - required String notes, - required List tags, - DateTime? nextMoment, - String? location, - }) async { - final String normalizedName = name.trim(); - final String normalizedRelationship = relationship.trim(); - if (normalizedName.isEmpty || normalizedRelationship.isEmpty) { - throw ArgumentError('Name and relationship are required'); - } - - final LocalDataState current = _requireState(); - final PersonProfile person = PersonProfile( - id: 'p-${_uuid.v4()}', - name: normalizedName, - relationship: normalizedRelationship, - affinityScore: 75, - nextMoment: nextMoment ?? DateTime.now().add(const Duration(days: 3)), - tags: tags, - notes: notes.trim(), - location: location?.trim().isEmpty == true ? null : location?.trim(), - ); - - final List people = [ - person, - ...current.people, - ]; - await _setState(current.copyWith(people: people)); - await _enqueueChange( - _buildEnvelope( - entityType: 'person', - entityId: person.id, - op: ChangeOperation.upsert, - payload: _personPayload(person), - ), - ); - } - - Future ingestSharedMessage( - SharedMessageIngestInput input, - ) async { - final String trimmedMessage = input.messageText.trim(); - if (trimmedMessage.isEmpty) { - throw ArgumentError('Shared message text cannot be empty'); - } - - final LocalDataState current = _requireState(); - final DateTime now = DateTime.now(); - final DateTime sharedAt = input.sharedAt ?? now; - final String sourceApp = input.sourceApp.trim().toLowerCase(); - final String? sourceDisplayName = _trimToNull(input.sourceDisplayName); - final String normalizedDisplayName = _normalizeDisplayName( - sourceDisplayName, - ); - final String? sourceUserId = _trimToNull(input.sourceUserId); - final String? sourceThreadId = _trimToNull(input.sourceThreadId); - final String? sourceFingerprint = _buildStableSourceFingerprint( - sourceApp: sourceApp, - sourceUserId: sourceUserId, - sourceThreadId: sourceThreadId, - ); - - final SourceProfileLink? matchedLink = _findExistingSourceLink( - links: current.sourceLinks, - sourceApp: sourceApp, - sourceFingerprint: sourceFingerprint, - sourceUserId: sourceUserId, - sourceThreadId: sourceThreadId, - normalizedDisplayName: normalizedDisplayName, - ); - - PersonProfile? resolvedPerson; - if (matchedLink != null) { - resolvedPerson = _findPersonById(current.people, matchedLink.profileId); - } - - final List matchingPeople = normalizedDisplayName.isEmpty - ? const [] - : _findPeopleByNormalizedName(current.people, normalizedDisplayName); - - if (resolvedPerson == null && matchingPeople.length == 1) { - resolvedPerson = matchingPeople.first; - } - - if (resolvedPerson == null && matchingPeople.length > 1) { - return _queueSharedInbox( - current: current, - sourceApp: sourceApp, - messageText: trimmedMessage, - sharedAt: sharedAt, - receivedAt: now, - sourceDisplayName: sourceDisplayName, - sourceUserId: sourceUserId, - sourceThreadId: sourceThreadId, - sourceFingerprint: sourceFingerprint, - normalizedDisplayName: normalizedDisplayName, - reason: SharedInboxReason.ambiguousProfileMatch, - candidateProfileIds: matchingPeople - .map((PersonProfile person) => person.id) - .toList(growable: false), - ); - } - - final List nearMatchPeople = resolvedPerson == null - ? _findNearMatchPeople( - current.people, - normalizedDisplayName: normalizedDisplayName, - ) - : const []; - if (resolvedPerson == null && nearMatchPeople.isNotEmpty) { - return _queueSharedInbox( - current: current, - sourceApp: sourceApp, - messageText: trimmedMessage, - sharedAt: sharedAt, - receivedAt: now, - sourceDisplayName: sourceDisplayName, - sourceUserId: sourceUserId, - sourceThreadId: sourceThreadId, - sourceFingerprint: sourceFingerprint, - normalizedDisplayName: normalizedDisplayName, - reason: SharedInboxReason.nearProfileConflict, - candidateProfileIds: nearMatchPeople - .map((PersonProfile person) => person.id) - .toList(growable: false), - ); - } - - bool createdProfile = false; - final List nextPeople = current.people.toList( - growable: true, - ); - if (resolvedPerson == null) { - final String? inferredName = _deriveAutoProfileName( - sourceDisplayName: sourceDisplayName, - sourceUserId: sourceUserId, - sourceThreadId: sourceThreadId, - ); - if (inferredName == null) { - return _queueSharedInbox( - current: current, - sourceApp: sourceApp, - messageText: trimmedMessage, - sharedAt: sharedAt, - receivedAt: now, - sourceDisplayName: sourceDisplayName, - sourceUserId: sourceUserId, - sourceThreadId: sourceThreadId, - sourceFingerprint: sourceFingerprint, - normalizedDisplayName: normalizedDisplayName, - reason: SharedInboxReason.missingIdentity, - candidateProfileIds: const [], - ); - } - createdProfile = true; - resolvedPerson = PersonProfile( - id: 'p-${_uuid.v4()}', - name: inferredName, - relationship: 'WhatsApp Contact', - affinityScore: 70, - nextMoment: DateTime.now().add(const Duration(days: 2)), - tags: const ['whatsapp'], - notes: 'Auto-created from shared message.', - ); - nextPeople.insert(0, resolvedPerson); - } - - return _ingestIntoResolvedProfile( - current: current, - sourceApp: sourceApp, - messageText: trimmedMessage, - sharedAt: sharedAt, - importedAt: now, - sourceDisplayName: sourceDisplayName, - sourceUserId: sourceUserId, - sourceThreadId: sourceThreadId, - sourceFingerprint: sourceFingerprint, - normalizedDisplayName: normalizedDisplayName, - resolvedPerson: resolvedPerson, - matchedLink: matchedLink, - createdProfile: createdProfile, - people: nextPeople, - resolvedAutomatically: true, - ); - } - - Future resolveSharedInboxToExistingProfile({ - required String inboxEntryId, - required String profileId, - }) async { - final LocalDataState current = _requireState(); - final SharedInboxEntry entry = _requireSharedInboxEntry( - current, - inboxEntryId, - ); - final PersonProfile? resolvedPerson = _findPersonById( - current.people, - profileId, - ); - if (resolvedPerson == null) { - throw ArgumentError('Profile not found for inbox entry'); - } - - final SourceProfileLink? matchedLink = _findExistingSourceLink( - links: current.sourceLinks, - sourceApp: entry.sourceApp, - sourceFingerprint: entry.sourceFingerprint, - sourceUserId: entry.sourceUserId, - sourceThreadId: entry.sourceThreadId, - normalizedDisplayName: entry.normalizedDisplayName, - ); - - return _ingestIntoResolvedProfile( - current: current, - sourceApp: entry.sourceApp, - messageText: entry.messageText, - sharedAt: entry.sharedAt, - importedAt: DateTime.now(), - sourceDisplayName: entry.sourceDisplayName, - sourceUserId: entry.sourceUserId, - sourceThreadId: entry.sourceThreadId, - sourceFingerprint: entry.sourceFingerprint, - normalizedDisplayName: entry.normalizedDisplayName, - resolvedPerson: resolvedPerson, - matchedLink: matchedLink, - createdProfile: false, - people: current.people, - resolvedAutomatically: false, - consumedInboxEntryId: entry.id, - ); - } - - Future resolveSharedInboxByCreatingProfile({ - required String inboxEntryId, - String? name, - String relationship = 'WhatsApp Contact', - String? location, - }) async { - final LocalDataState current = _requireState(); - final SharedInboxEntry entry = _requireSharedInboxEntry( - current, - inboxEntryId, - ); - final String profileName = - _trimToNull(name) ?? - _deriveAutoProfileName( - sourceDisplayName: entry.sourceDisplayName, - sourceUserId: entry.sourceUserId, - sourceThreadId: entry.sourceThreadId, - ) ?? - 'WhatsApp Contact'; - final String normalizedRelationship = relationship.trim().isEmpty - ? 'WhatsApp Contact' - : relationship.trim(); - final PersonProfile createdPerson = PersonProfile( - id: 'p-${_uuid.v4()}', - name: profileName, - relationship: normalizedRelationship, - affinityScore: 70, - nextMoment: DateTime.now().add(const Duration(days: 2)), - tags: const ['whatsapp'], - notes: 'Created from Share Inbox resolution.', - location: _trimToNull(location), - ); - - final SourceProfileLink? matchedLink = _findExistingSourceLink( - links: current.sourceLinks, - sourceApp: entry.sourceApp, - sourceFingerprint: entry.sourceFingerprint, - sourceUserId: entry.sourceUserId, - sourceThreadId: entry.sourceThreadId, - normalizedDisplayName: entry.normalizedDisplayName, - ); - - final List nextPeople = [ - createdPerson, - ...current.people, - ]; - - return _ingestIntoResolvedProfile( - current: current, - sourceApp: entry.sourceApp, - messageText: entry.messageText, - sharedAt: entry.sharedAt, - importedAt: DateTime.now(), - sourceDisplayName: entry.sourceDisplayName, - sourceUserId: entry.sourceUserId, - sourceThreadId: entry.sourceThreadId, - sourceFingerprint: entry.sourceFingerprint, - normalizedDisplayName: entry.normalizedDisplayName, - resolvedPerson: createdPerson, - matchedLink: matchedLink, - createdProfile: true, - people: nextPeople, - resolvedAutomatically: false, - consumedInboxEntryId: entry.id, - ); - } - - Future dismissSharedInboxEntry(String inboxEntryId) async { - final LocalDataState current = _requireState(); - final List nextInbox = current.sharedInbox - .where((SharedInboxEntry entry) => entry.id != inboxEntryId) - .toList(growable: false); - if (nextInbox.length == current.sharedInbox.length) { - return; - } - await _setState(current.copyWith(sharedInbox: nextInbox)); - } - - Future updatePerson(PersonProfile person) async { - if (person.name.trim().isEmpty || person.relationship.trim().isEmpty) { - throw ArgumentError('Name and relationship are required'); - } - - final LocalDataState current = _requireState(); - final List updated = current.people - .map((PersonProfile item) => item.id == person.id ? person : item) - .toList(growable: false); - await _setState(current.copyWith(people: updated)); - await _enqueueChange( - _buildEnvelope( - entityType: 'person', - entityId: person.id, - op: ChangeOperation.upsert, - payload: _personPayload(person), - ), - ); - } - - Future deletePerson(String personId) async { - final LocalDataState current = _requireState(); - final List removedMoments = current.moments - .where((RelationshipMoment moment) => moment.personId == personId) - .toList(growable: false); - final List removedIdeas = current.ideas - .where((RelationshipIdea idea) => idea.personId == personId) - .toList(growable: false); - final List removedReminders = current.reminders - .where((ReminderRule reminder) => reminder.personId == personId) - .toList(growable: false); - - final List people = current.people - .where((PersonProfile person) => person.id != personId) - .toList(growable: false); - final List moments = current.moments - .where((RelationshipMoment moment) => moment.personId != personId) - .toList(growable: false); - final List ideas = current.ideas - .where((RelationshipIdea idea) => idea.personId != personId) - .toList(growable: false); - final List reminders = current.reminders - .where((ReminderRule reminder) => reminder.personId != personId) - .toList(growable: false); - final List preferenceSignals = current - .preferenceSignals - .where((PersonPreferenceSignal signal) => signal.personId != personId) - .toList(growable: false); - - await _setState( - current.copyWith( - people: people, - moments: moments, - ideas: ideas, - reminders: reminders, - preferenceSignals: preferenceSignals, - ), - ); - - await _enqueueChanges([ - _buildEnvelope( - entityType: 'person', - entityId: personId, - op: ChangeOperation.delete, - ), - ...removedMoments.map( - (RelationshipMoment moment) => _buildEnvelope( - entityType: 'capture', - entityId: moment.id, - op: ChangeOperation.delete, - ), - ), - ...removedIdeas.map( - (RelationshipIdea idea) => _buildEnvelope( - entityType: _ideaEntityType(idea.type), - entityId: idea.id, - op: ChangeOperation.delete, - ), - ), - ...removedReminders.map( - (ReminderRule reminder) => _buildEnvelope( - entityType: 'reminderRule', - entityId: reminder.id, - op: ChangeOperation.delete, - ), - ), - ]); - } - - /// Merge one person profile into another and rebind related records. - Future mergePersonProfiles({ - required String sourcePersonId, - required String targetPersonId, - }) async { - if (sourcePersonId == targetPersonId) { - throw ArgumentError('Source and target profile must be different'); - } - - final LocalDataState current = _requireState(); - final PersonProfile? source = _findPersonById( - current.people, - sourcePersonId, - ); - final PersonProfile? target = _findPersonById( - current.people, - targetPersonId, - ); - if (source == null || target == null) { - throw ArgumentError('Both source and target profiles must exist'); - } - - final PersonProfile mergedTarget = target.copyWith( - affinityScore: target.affinityScore > source.affinityScore - ? target.affinityScore - : source.affinityScore, - nextMoment: source.nextMoment.isBefore(target.nextMoment) - ? source.nextMoment - : target.nextMoment, - tags: _mergeTags(target.tags, source.tags), - notes: _mergeNotes( - targetNotes: target.notes, - sourceName: source.name, - sourceNotes: source.notes, - ), - location: _effectiveLocation(target.location, source.location), - ); - - final List nextPeople = current.people - .where((PersonProfile person) => person.id != sourcePersonId) - .map( - (PersonProfile person) => - person.id == targetPersonId ? mergedTarget : person, - ) - .toList(growable: false); - - final List updatedMoments = []; - final List nextMoments = current.moments - .map((RelationshipMoment moment) { - if (moment.personId != sourcePersonId) { - return moment; - } - final RelationshipMoment merged = moment.copyWith( - personId: targetPersonId, - ); - updatedMoments.add(merged); - return merged; - }) - .toList(growable: false); - - final List updatedIdeas = []; - final List nextIdeas = current.ideas - .map((RelationshipIdea idea) { - if (idea.personId != sourcePersonId) { - return idea; - } - final RelationshipIdea merged = idea.copyWith( - personId: targetPersonId, - ); - updatedIdeas.add(merged); - return merged; - }) - .toList(growable: false); - - final List updatedReminders = []; - final List nextReminders = current.reminders - .map((ReminderRule reminder) { - if (reminder.personId != sourcePersonId) { - return reminder; - } - final ReminderRule merged = reminder.copyWith( - personId: targetPersonId, - ); - updatedReminders.add(merged); - return merged; - }) - .toList(growable: false); - - final List nextLinks = current.sourceLinks - .map((SourceProfileLink link) { - if (link.profileId != sourcePersonId) { - return link; - } - return link.copyWith(profileId: targetPersonId); - }) - .toList(growable: false); - - final List nextMessages = current.sharedMessages - .map((SharedMessageEntry entry) { - if (entry.profileId != sourcePersonId) { - return entry; - } - return entry.copyWith(profileId: targetPersonId); - }) - .toList(growable: false); - - final List nextInbox = current.sharedInbox - .map((SharedInboxEntry entry) { - if (!entry.candidateProfileIds.contains(sourcePersonId)) { - return entry; - } - final List candidates = entry.candidateProfileIds - .map( - (String profileId) => - profileId == sourcePersonId ? targetPersonId : profileId, - ) - .toSet() - .toList(growable: false); - return entry.copyWith(candidateProfileIds: candidates); - }) - .toList(growable: false); - final List nextPreferenceSignals = current - .preferenceSignals - .map((PersonPreferenceSignal signal) { - if (signal.personId != sourcePersonId) { - return signal; - } - return signal.copyWith(personId: targetPersonId); - }) - .toList(growable: false); - - await _setState( - current.copyWith( - people: nextPeople, - moments: nextMoments, - ideas: nextIdeas, - reminders: nextReminders, - sourceLinks: nextLinks, - sharedMessages: nextMessages, - sharedInbox: nextInbox, - preferenceSignals: nextPreferenceSignals, - ), - ); - - await _enqueueChanges([ - _buildEnvelope( - entityType: 'person', - entityId: mergedTarget.id, - op: ChangeOperation.upsert, - payload: _personPayload(mergedTarget), - ), - _buildEnvelope( - entityType: 'person', - entityId: sourcePersonId, - op: ChangeOperation.delete, - ), - ...updatedMoments.map( - (RelationshipMoment moment) => _buildEnvelope( - entityType: 'capture', - entityId: moment.id, - op: ChangeOperation.upsert, - payload: _momentPayload(moment), - ), - ), - ...updatedIdeas.map( - (RelationshipIdea idea) => _buildEnvelope( - entityType: _ideaEntityType(idea.type), - entityId: idea.id, - op: ChangeOperation.upsert, - payload: _ideaPayload(idea), - ), - ), - ...updatedReminders.map( - (ReminderRule reminder) => _buildEnvelope( - entityType: 'reminderRule', - entityId: reminder.id, - op: ChangeOperation.upsert, - payload: _reminderPayload(reminder), - ), - ), - ]); - } - - Future addMoment({ - required String personId, - required String summary, - String type = 'capture', - }) async { - final LocalDataState current = _requireState(); - final String normalized = summary.trim(); - if (normalized.isEmpty) { - throw ArgumentError('Capture summary cannot be empty'); - } - - final String payload = normalized.length > 280 - ? normalized.substring(0, 280) - : normalized; - final String title = _titleFromSummary(payload); - final RelationshipMoment moment = RelationshipMoment( - id: 'm-${_uuid.v4()}', - personId: personId, - title: title, - summary: payload, - at: DateTime.now(), - type: type, - ); - - final List moments = [ - moment, - ...current.moments, - ]; - await _setState(current.copyWith(moments: moments)); - await _enqueueChange( - _buildEnvelope( - entityType: 'capture', - entityId: moment.id, - op: ChangeOperation.upsert, - payload: _momentPayload(moment), - ), - ); - } - - Future updateMoment(RelationshipMoment moment) async { - if (moment.summary.trim().isEmpty) { - throw ArgumentError('Capture summary cannot be empty'); - } - - final LocalDataState current = _requireState(); - final List updated = current.moments - .map((RelationshipMoment item) => item.id == moment.id ? moment : item) - .toList(growable: false); - await _setState(current.copyWith(moments: updated)); - await _enqueueChange( - _buildEnvelope( - entityType: 'capture', - entityId: moment.id, - op: ChangeOperation.upsert, - payload: _momentPayload(moment), - ), - ); - } - - Future deleteMoment(String momentId) async { - final LocalDataState current = _requireState(); - final List moments = current.moments - .where((RelationshipMoment moment) => moment.id != momentId) - .toList(growable: false); - await _setState(current.copyWith(moments: moments)); - await _enqueueChange( - _buildEnvelope( - entityType: 'capture', - entityId: momentId, - op: ChangeOperation.delete, - ), - ); - } - - Future addIdea({ - required IdeaType type, - required String title, - required String details, - String? personId, - }) async { - final String normalizedTitle = title.trim(); - if (normalizedTitle.isEmpty) { - throw ArgumentError('Idea title is required'); - } - - final LocalDataState current = _requireState(); - final RelationshipIdea idea = RelationshipIdea( - id: 'i-${_uuid.v4()}', - personId: personId, - type: type, - title: normalizedTitle, - details: details.trim(), - createdAt: DateTime.now(), - ); - - final List ideas = [ - idea, - ...current.ideas, - ]; - await _setState(current.copyWith(ideas: ideas)); - await _enqueueChange( - _buildEnvelope( - entityType: _ideaEntityType(idea.type), - entityId: idea.id, - op: ChangeOperation.upsert, - payload: _ideaPayload(idea), - ), - ); - } - - Future updateIdea(RelationshipIdea idea) async { - if (idea.title.trim().isEmpty) { - throw ArgumentError('Idea title is required'); - } - - final LocalDataState current = _requireState(); - final List ideas = current.ideas - .map((RelationshipIdea item) => item.id == idea.id ? idea : item) - .toList(growable: false); - await _setState(current.copyWith(ideas: ideas)); - await _enqueueChange( - _buildEnvelope( - entityType: _ideaEntityType(idea.type), - entityId: idea.id, - op: ChangeOperation.upsert, - payload: _ideaPayload(idea), - ), - ); - } - - Future toggleIdeaArchived(String ideaId) async { - final LocalDataState current = _requireState(); - RelationshipIdea? updatedIdea; - final List ideas = current.ideas - .map((RelationshipIdea item) { - if (item.id != ideaId) { - return item; - } - final RelationshipIdea toggled = item.copyWith( - isArchived: !item.isArchived, - ); - updatedIdea = toggled; - return toggled; - }) - .toList(growable: false); - await _setState(current.copyWith(ideas: ideas)); - if (updatedIdea != null) { - await _enqueueChange( - _buildEnvelope( - entityType: _ideaEntityType(updatedIdea!.type), - entityId: updatedIdea!.id, - op: ChangeOperation.upsert, - payload: _ideaPayload(updatedIdea!), - ), - ); - } - } - - Future deleteIdea(String ideaId) async { - final LocalDataState current = _requireState(); - final List ideas = current.ideas - .where((RelationshipIdea idea) => idea.id != ideaId) - .toList(growable: false); - await _setState(current.copyWith(ideas: ideas)); - await _enqueueChange( - _buildEnvelope( - entityType: _ideaEntityTypeFromId(current.ideas, ideaId), - entityId: ideaId, - op: ChangeOperation.delete, - ), - ); - } - - Future addReminder({ - required String title, - required ReminderCadence cadence, - required DateTime nextAt, - String? personId, - }) async { - final String normalizedTitle = title.trim(); - if (normalizedTitle.isEmpty) { - throw ArgumentError('Reminder title is required'); - } - - final LocalDataState current = _requireState(); - final ReminderRule reminder = ReminderRule( - id: 'r-${_uuid.v4()}', - personId: personId, - title: normalizedTitle, - cadence: cadence, - nextAt: nextAt, - enabled: true, - ); - - final List reminders = [ - reminder, - ...current.reminders, - ]; - await _setState(current.copyWith(reminders: reminders)); - await _enqueueChange( - _buildEnvelope( - entityType: 'reminderRule', - entityId: reminder.id, - op: ChangeOperation.upsert, - payload: _reminderPayload(reminder), - ), - ); - } - - Future updateReminder(ReminderRule reminder) async { - if (reminder.title.trim().isEmpty) { - throw ArgumentError('Reminder title is required'); - } - - final LocalDataState current = _requireState(); - final List reminders = current.reminders - .map((ReminderRule item) => item.id == reminder.id ? reminder : item) - .toList(growable: false); - await _setState(current.copyWith(reminders: reminders)); - await _enqueueChange( - _buildEnvelope( - entityType: 'reminderRule', - entityId: reminder.id, - op: ChangeOperation.upsert, - payload: _reminderPayload(reminder), - ), - ); - } - - Future toggleReminderEnabled(String reminderId) async { - final LocalDataState current = _requireState(); - ReminderRule? updatedReminder; - final List reminders = current.reminders - .map((ReminderRule item) { - if (item.id != reminderId) { - return item; - } - final ReminderRule toggled = item.copyWith(enabled: !item.enabled); - updatedReminder = toggled; - return toggled; - }) - .toList(growable: false); - await _setState(current.copyWith(reminders: reminders)); - if (updatedReminder != null) { - await _enqueueChange( - _buildEnvelope( - entityType: 'reminderRule', - entityId: updatedReminder!.id, - op: ChangeOperation.upsert, - payload: _reminderPayload(updatedReminder!), - ), - ); - } - } - - Future deleteReminder(String reminderId) async { - final LocalDataState current = _requireState(); - final List reminders = current.reminders - .where((ReminderRule reminder) => reminder.id != reminderId) - .toList(growable: false); - await _setState(current.copyWith(reminders: reminders)); - await _enqueueChange( - _buildEnvelope( - entityType: 'reminderRule', - entityId: reminderId, - op: ChangeOperation.delete, - ), - ); - } - - /// Upserts a profile preference signal observation inferred from chat context. - /// - /// This is intentionally local-only for now. We keep inferred signals separate - /// from manual tags until the user confirms them. - Future upsertInferredPreferenceSignalObservation({ - required String personId, - required String key, - required String label, - required String category, - required PreferenceSignalPolarity polarity, - required double confidence, - String? sourceApp, - String? evidenceMessageId, - String? evidenceSnippet, - DateTime? observedAt, - }) async { - final LocalDataState current = _requireState(); - final DateTime observed = observedAt ?? DateTime.now(); - final String normalizedKey = _normalizePreferenceKey(key); - if (normalizedKey.isEmpty) { - throw ArgumentError('Preference signal key cannot be empty'); - } - if (!_personExists(current.people, personId)) { - throw ArgumentError('Profile does not exist for preference signal'); - } - - final int existingIndex = current.preferenceSignals.indexWhere( - (PersonPreferenceSignal signal) => - signal.personId == personId && - signal.key == normalizedKey && - signal.polarity == polarity, - ); - - final double normalizedConfidence = _clampUnit(confidence); - final String normalizedLabel = label.trim().isEmpty - ? normalizedKey - : label.trim(); - final String normalizedCategory = category.trim().isEmpty - ? 'general' - : category.trim().toLowerCase(); - final String? snippet = _trimToNull(evidenceSnippet); - final String? messageId = _trimToNull(evidenceMessageId); - final String? source = _trimToNull(sourceApp)?.toLowerCase(); - - late final PersonPreferenceSignal nextSignal; - final List nextSignals = current.preferenceSignals - .toList(growable: true); - - if (existingIndex < 0) { - nextSignal = PersonPreferenceSignal( - id: 'ps-${_uuid.v4()}', - personId: personId, - key: normalizedKey, - label: normalizedLabel, - category: normalizedCategory, - polarity: polarity, - confidence: normalizedConfidence, - status: PreferenceSignalStatus.inferred, - firstSeenAt: observed, - lastSeenAt: observed, - occurrenceCount: 1, - sourceApps: source == null ? const [] : [source], - evidenceMessageIds: messageId == null - ? const [] - : [messageId], - evidenceSnippets: snippet == null - ? const [] - : [snippet], - ); - nextSignals.insert(0, nextSignal); - } else { - final PersonPreferenceSignal existing = nextSignals[existingIndex]; - final int currentCount = existing.occurrenceCount < 1 - ? 1 - : existing.occurrenceCount; - final int nextCount = currentCount + 1; - final double mergedConfidence = - ((existing.confidence * currentCount) + normalizedConfidence) / - nextCount; - nextSignal = existing.copyWith( - label: normalizedLabel, - category: normalizedCategory, - confidence: _clampUnit(mergedConfidence), - lastSeenAt: observed, - occurrenceCount: nextCount, - sourceApps: _mergeUniqueStrings(existing.sourceApps, source), - evidenceMessageIds: _mergeUniqueStrings( - existing.evidenceMessageIds, - messageId, - maxItems: 8, - ), - evidenceSnippets: _mergeUniqueStrings( - existing.evidenceSnippets, - snippet == null ? null : _truncate(snippet, 220), - maxItems: 6, - ), - ); - nextSignals[existingIndex] = nextSignal; - } - - await _setState(current.copyWith(preferenceSignals: nextSignals)); - return nextSignal; - } - - Future upsertPreferenceSignal(PersonPreferenceSignal signal) async { - final LocalDataState current = _requireState(); - if (!_personExists(current.people, signal.personId)) { - throw ArgumentError('Profile does not exist for preference signal'); - } - final List nextSignals = _upsertItem( - items: current.preferenceSignals, - item: signal, - idOf: (PersonPreferenceSignal value) => value.id, - ); - await _setState(current.copyWith(preferenceSignals: nextSignals)); - } - - Future setPreferenceSignalStatus({ - required String signalId, - required PreferenceSignalStatus status, - }) async { - final LocalDataState current = _requireState(); - final List nextSignals = current.preferenceSignals - .map((PersonPreferenceSignal signal) { - if (signal.id != signalId) { - return signal; - } - return signal.copyWith(status: status); - }) - .toList(growable: false); - await _setState(current.copyWith(preferenceSignals: nextSignals)); - } - - Future confirmPreferenceSignal(String signalId) { - return setPreferenceSignalStatus( - signalId: signalId, - status: PreferenceSignalStatus.confirmed, - ); - } - - Future dismissPreferenceSignal(String signalId) { - return setPreferenceSignalStatus( - signalId: signalId, - status: PreferenceSignalStatus.dismissed, - ); - } - - Future toggleTaskDone(String taskId) async { - final LocalDataState current = _requireState(); - final List tasks = current.tasks - .map( - (DashboardTask task) => - task.id == taskId ? task.copyWith(done: !task.done) : task, - ) - .toList(growable: false); - - await _setState(current.copyWith(tasks: tasks)); - } - - Future applyRemoteChanges(List changes) async { - if (changes.isEmpty) { - return; - } - - LocalDataState next = _requireState(); - for (final ChangeEnvelope change in changes) { - next = _applyRemoteChange(next, change); - } - await _setState(next); - } - - LocalDataState _applyRemoteChange( - LocalDataState current, - ChangeEnvelope change, - ) { - switch (change.entityType) { - case 'person': - return _applyPersonChange(current, change); - case 'capture': - return _applyMomentChange(current, change); - case 'giftIdea': - case 'eventIdea': - return _applyIdeaChange(current, change); - case 'reminderRule': - return _applyReminderChange(current, change); - default: - return current; - } - } - - LocalDataState _applyPersonChange( - LocalDataState current, - ChangeEnvelope change, - ) { - if (change.op == ChangeOperation.delete) { - final String personId = change.entityId; - return current.copyWith( - people: current.people - .where((PersonProfile person) => person.id != personId) - .toList(growable: false), - moments: current.moments - .where((RelationshipMoment moment) => moment.personId != personId) - .toList(growable: false), - ideas: current.ideas - .where((RelationshipIdea idea) => idea.personId != personId) - .toList(growable: false), - reminders: current.reminders - .where((ReminderRule reminder) => reminder.personId != personId) - .toList(growable: false), - preferenceSignals: current.preferenceSignals - .where( - (PersonPreferenceSignal signal) => signal.personId != personId, - ) - .toList(growable: false), - ); - } - - final int existingIndex = current.people.indexWhere( - (PersonProfile person) => person.id == change.entityId, - ); - final PersonProfile? existing = existingIndex >= 0 - ? current.people[existingIndex] - : null; - final Map payload = change.payload ?? {}; - final PersonProfile next = PersonProfile( - id: change.entityId, - name: _stringField(payload, 'name', existing?.name ?? 'Unknown'), - relationship: _stringField( - payload, - 'relationship', - existing?.relationship ?? 'Relationship', - ), - affinityScore: _intField( - payload, - 'affinityScore', - existing?.affinityScore ?? 70, - ), - nextMoment: _dateField( - payload, - 'nextMoment', - existing?.nextMoment ?? DateTime.now().add(const Duration(days: 3)), - ), - tags: _stringListField(payload, 'tags', existing?.tags ?? []), - notes: _stringField(payload, 'notes', existing?.notes ?? ''), - location: _stringOrNullField(payload, 'location', existing?.location), - ); - final List people = _upsertItem( - items: current.people, - item: next, - idOf: (PersonProfile person) => person.id, - ); - return current.copyWith(people: people); - } - - LocalDataState _applyMomentChange( - LocalDataState current, - ChangeEnvelope change, - ) { - if (change.op == ChangeOperation.delete) { - return current.copyWith( - moments: current.moments - .where((RelationshipMoment moment) => moment.id != change.entityId) - .toList(growable: false), - ); - } - - final int existingIndex = current.moments.indexWhere( - (RelationshipMoment moment) => moment.id == change.entityId, - ); - final RelationshipMoment? existing = existingIndex >= 0 - ? current.moments[existingIndex] - : null; - final Map payload = change.payload ?? {}; - final String summary = _stringField( - payload, - 'summary', - existing?.summary ?? '', - ); - final RelationshipMoment next = RelationshipMoment( - id: change.entityId, - personId: _stringField( - payload, - 'personId', - existing?.personId ?? - (current.people.isEmpty - ? 'person-unknown' - : current.people.first.id), - ), - title: _stringField( - payload, - 'title', - existing?.title ?? _titleFromSummary(summary), - ), - summary: summary, - at: _dateField(payload, 'at', existing?.at ?? DateTime.now()), - type: _stringField(payload, 'type', existing?.type ?? 'capture'), - ); - final List moments = _upsertItem( - items: current.moments, - item: next, - idOf: (RelationshipMoment moment) => moment.id, - ); - return current.copyWith(moments: moments); - } - - LocalDataState _applyIdeaChange( - LocalDataState current, - ChangeEnvelope change, - ) { - if (change.op == ChangeOperation.delete) { - return current.copyWith( - ideas: current.ideas - .where((RelationshipIdea idea) => idea.id != change.entityId) - .toList(growable: false), - ); - } - - final int existingIndex = current.ideas.indexWhere( - (RelationshipIdea idea) => idea.id == change.entityId, - ); - final RelationshipIdea? existing = existingIndex >= 0 - ? current.ideas[existingIndex] - : null; - final Map payload = change.payload ?? {}; - final IdeaType fallbackType = _ideaTypeFromEntityType( - change.entityType, - fallback: existing?.type ?? IdeaType.gift, - ); - final IdeaType type = _ideaTypeFromRaw( - payload['type'], - fallback: fallbackType, - ); - final RelationshipIdea next = RelationshipIdea( - id: change.entityId, - personId: payload['personId'] as String? ?? existing?.personId, - type: type, - title: _stringField(payload, 'title', existing?.title ?? 'Untitled idea'), - details: _stringField(payload, 'details', existing?.details ?? ''), - createdAt: _dateField( - payload, - 'createdAt', - existing?.createdAt ?? DateTime.now(), - ), - isArchived: _boolField( - payload, - 'isArchived', - existing?.isArchived ?? false, - ), - ); - final List ideas = _upsertItem( - items: current.ideas, - item: next, - idOf: (RelationshipIdea idea) => idea.id, - ); - return current.copyWith(ideas: ideas); - } - - LocalDataState _applyReminderChange( - LocalDataState current, - ChangeEnvelope change, - ) { - if (change.op == ChangeOperation.delete) { - return current.copyWith( - reminders: current.reminders - .where((ReminderRule reminder) => reminder.id != change.entityId) - .toList(growable: false), - ); - } - - final int existingIndex = current.reminders.indexWhere( - (ReminderRule reminder) => reminder.id == change.entityId, - ); - final ReminderRule? existing = existingIndex >= 0 - ? current.reminders[existingIndex] - : null; - final Map payload = change.payload ?? {}; - final ReminderRule next = ReminderRule( - id: change.entityId, - personId: payload['personId'] as String? ?? existing?.personId, - title: _stringField(payload, 'title', existing?.title ?? 'Reminder'), - cadence: _cadenceFromRaw( - payload['cadence'], - fallback: existing?.cadence ?? ReminderCadence.weekly, - ), - nextAt: _dateField(payload, 'nextAt', existing?.nextAt ?? DateTime.now()), - enabled: _boolField(payload, 'enabled', existing?.enabled ?? true), - ); - final List reminders = _upsertItem( - items: current.reminders, - item: next, - idOf: (ReminderRule reminder) => reminder.id, - ); - return current.copyWith(reminders: reminders); - } - - Future _enqueueChange(ChangeEnvelope change) async { - await ref.read(syncQueueRepositoryProvider.notifier).enqueue(change); - } - - Future _enqueueChanges(List changes) async { - await ref.read(syncQueueRepositoryProvider.notifier).enqueueAll(changes); - } - - ChangeEnvelope _buildEnvelope({ - required String entityType, - required String entityId, - required ChangeOperation op, - Map? payload, - }) { - return ChangeEnvelope( - schemaVersion: _syncSchemaVersion, - entityType: entityType, - entityId: entityId, - op: op, - modifiedAt: DateTime.now().toUtc(), - clientMutationId: _uuid.v4(), - payload: payload, - ); - } - - Map _personPayload(PersonProfile person) { - return { - 'name': person.name, - 'relationship': person.relationship, - 'affinityScore': person.affinityScore, - 'nextMoment': person.nextMoment.toUtc().toIso8601String(), - 'tags': person.tags, - 'notes': person.notes, - 'location': person.location, - }; - } - - Map _momentPayload(RelationshipMoment moment) { - return { - 'personId': moment.personId, - 'title': moment.title, - 'summary': moment.summary, - 'at': moment.at.toUtc().toIso8601String(), - 'type': moment.type, - }; - } - - Map _ideaPayload(RelationshipIdea idea) { - return { - 'personId': idea.personId, - 'type': idea.type.name, - 'title': idea.title, - 'details': idea.details, - 'createdAt': idea.createdAt.toUtc().toIso8601String(), - 'isArchived': idea.isArchived, - }; - } - - Map _reminderPayload(ReminderRule reminder) { - return { - 'personId': reminder.personId, - 'title': reminder.title, - 'cadence': reminder.cadence.name, - 'nextAt': reminder.nextAt.toUtc().toIso8601String(), - 'enabled': reminder.enabled, - }; - } - - String _ideaEntityType(IdeaType type) { - return type == IdeaType.event ? 'eventIdea' : 'giftIdea'; - } - - String _ideaEntityTypeFromId(List ideas, String ideaId) { - final int index = ideas.indexWhere( - (RelationshipIdea idea) => idea.id == ideaId, - ); - final RelationshipIdea? existing = index >= 0 ? ideas[index] : null; - if (existing == null) { - return 'giftIdea'; - } - return _ideaEntityType(existing.type); - } - - IdeaType _ideaTypeFromEntityType( - String entityType, { - required IdeaType fallback, - }) { - switch (entityType) { - case 'eventIdea': - return IdeaType.event; - case 'giftIdea': - return IdeaType.gift; - default: - return fallback; - } - } - - IdeaType _ideaTypeFromRaw(dynamic value, {required IdeaType fallback}) { - if (value is! String || value.isEmpty) { - return fallback; - } - - return IdeaType.values.firstWhere( - (IdeaType type) => type.name == value, - orElse: () => fallback, - ); - } - - ReminderCadence _cadenceFromRaw( - dynamic value, { - required ReminderCadence fallback, - }) { - if (value is! String || value.isEmpty) { - return fallback; - } - - return ReminderCadence.values.firstWhere( - (ReminderCadence cadence) => cadence.name == value, - orElse: () => fallback, - ); - } - - List _upsertItem({ - required List items, - required T item, - required String Function(T value) idOf, - }) { - final int index = items.indexWhere((T value) => idOf(value) == idOf(item)); - if (index < 0) { - return [item, ...items]; - } - - final List copy = items.toList(); - copy[index] = item; - return copy; - } - - String _stringField( - Map payload, - String key, - String fallback, - ) { - final dynamic value = payload[key]; - if (value is String && value.trim().isNotEmpty) { - return value.trim(); - } - return fallback; - } - - String? _stringOrNullField( - Map payload, - String key, - String? fallback, - ) { - final dynamic value = payload[key]; - if (value is String) { - final String trimmed = value.trim(); - if (trimmed.isNotEmpty) { - return trimmed; - } - return null; - } - return fallback; - } - - int _intField(Map payload, String key, int fallback) { - final dynamic value = payload[key]; - if (value is int) { - return value; - } - if (value is num) { - return value.toInt(); - } - return fallback; - } - - bool _boolField(Map payload, String key, bool fallback) { - final dynamic value = payload[key]; - if (value is bool) { - return value; - } - return fallback; - } - - List _stringListField( - Map payload, - String key, - List fallback, - ) { - final dynamic value = payload[key]; - if (value is! List) { - return fallback; - } - - return value.map((dynamic item) => '$item').toList(growable: false); - } - - DateTime _dateField( - Map payload, - String key, - DateTime fallback, - ) { - final dynamic value = payload[key]; - if (value is String) { - final DateTime? parsed = DateTime.tryParse(value); - if (parsed != null) { - return parsed.toLocal(); - } - } - return fallback; - } - - bool _personExists(List people, String personId) { - return people.any((PersonProfile person) => person.id == personId); - } - - String _normalizePreferenceKey(String value) { - return value - .trim() - .toLowerCase() - .replaceAll(RegExp(r'[^a-z0-9:_ -]+'), '') - .replaceAll(RegExp(r'\s+'), ' '); - } - - double _clampUnit(double value) { - if (value.isNaN) { - return 0; - } - return value.clamp(0.0, 1.0).toDouble(); - } - - List _mergeUniqueStrings( - List existing, - String? incoming, { - int maxItems = 12, - }) { - final List values = []; - final Set seen = {}; - - for (final String raw in existing) { - final String value = raw.trim(); - if (value.isEmpty) { - continue; - } - final String key = value.toLowerCase(); - if (seen.add(key)) { - values.add(value); - } - } - - final String? newValue = _trimToNull(incoming); - if (newValue != null) { - final String key = newValue.toLowerCase(); - if (!seen.contains(key)) { - values.insert(0, newValue); - } - } - - if (values.length <= maxItems) { - return values; - } - return values.take(maxItems).toList(growable: false); - } - - String _truncate(String value, int maxChars) { - if (value.length <= maxChars) { - return value; - } - return value.substring(0, maxChars); - } - - Future _setState(LocalDataState next) async { - state = AsyncData(next); - await _persist(next); - await _reconcileReminderSchedule(next); - } - - LocalDataState _requireState() { - final LocalDataState? value = state.asData?.value; - if (value == null) { - throw StateError('Local data state is not ready yet'); - } - return value; - } - - Future _persist(LocalDataState state) async { - await ref - .read(localDataStoreProvider) - .write( - rawState: jsonEncode(state.toJson()), - schemaVersion: _schemaVersion, - ); - } - - Future _reconcileReminderSchedule(LocalDataState state) async { - try { - await ref.read(reminderSchedulerProvider).reconcile(state.reminders); - } catch (_) { - // Scheduling failures must not block local-first data updates. - } - } - - Future _migrateIfNeeded(LocalDataRecord record) async { - final int currentVersion = record.schemaVersion; - if (currentVersion == _schemaVersion) { - return record; - } - - final LocalDataStore store = ref.read(localDataStoreProvider); - if (currentVersion <= 0) { - final LocalDataState seeded = LocalDataState.seed(); - await store.clear(); - await _persist(seeded); - return LocalDataRecord( - rawState: jsonEncode(seeded.toJson()), - schemaVersion: _schemaVersion, - ); - } - - // Migration placeholder for future schema versions. - await store.write(rawState: record.rawState, schemaVersion: _schemaVersion); - return LocalDataRecord( - rawState: record.rawState, - schemaVersion: _schemaVersion, - ); - } - - List _mergeTags(List primary, List secondary) { - final List merged = []; - final Set seen = {}; - for (final String raw in [...primary, ...secondary]) { - final String tag = raw.trim(); - if (tag.isEmpty) { - continue; - } - final String normalized = tag.toLowerCase(); - if (seen.add(normalized)) { - merged.add(tag); - } - } - return merged; - } - - String _mergeNotes({ - required String targetNotes, - required String sourceName, - required String sourceNotes, - }) { - final String target = targetNotes.trim(); - final String source = sourceNotes.trim(); - if (target.isEmpty) { - return source; - } - if (source.isEmpty) { - return target; - } - if (target == source) { - return target; - } - return '$target\n\nMerged from $sourceName:\n$source'; - } - - String? _effectiveLocation(String? target, String? source) { - final String? targetValue = _trimToNull(target); - if (targetValue != null) { - return targetValue; - } - return _trimToNull(source); - } - - Future _queueSharedInbox({ - required LocalDataState current, - required String sourceApp, - required String messageText, - required DateTime sharedAt, - required DateTime receivedAt, - required SharedInboxReason reason, - required List candidateProfileIds, - required String normalizedDisplayName, - String? sourceFingerprint, - String? sourceDisplayName, - String? sourceUserId, - String? sourceThreadId, - }) async { - final String summary = messageText.length > 1800 - ? messageText.substring(0, 1800) - : messageText; - final SharedInboxEntry entry = SharedInboxEntry( - id: 'si-${_uuid.v4()}', - sourceApp: sourceApp, - messageText: summary, - sharedAt: sharedAt, - receivedAt: receivedAt, - reason: reason, - candidateProfileIds: candidateProfileIds, - normalizedDisplayName: normalizedDisplayName, - sourceFingerprint: sourceFingerprint, - sourceDisplayName: sourceDisplayName, - sourceUserId: sourceUserId, - sourceThreadId: sourceThreadId, - ); - final List nextInbox = [ - entry, - ...current.sharedInbox, - ]; - await _setState(current.copyWith(sharedInbox: nextInbox)); - return SharedMessageIngestResult.queuedForResolution( - inboxEntryId: entry.id, - ); - } - - Future _ingestIntoResolvedProfile({ - required LocalDataState current, - required String sourceApp, - required String messageText, - required DateTime sharedAt, - required DateTime importedAt, - required String normalizedDisplayName, - required String? sourceFingerprint, - required PersonProfile resolvedPerson, - required bool createdProfile, - required List people, - required bool resolvedAutomatically, - SourceProfileLink? matchedLink, - String? sourceDisplayName, - String? sourceUserId, - String? sourceThreadId, - String? consumedInboxEntryId, - }) async { - final bool createdSourceLink = matchedLink == null; - final List nextLinks = current.sourceLinks.toList( - growable: true, - ); - if (matchedLink == null) { - nextLinks.insert( - 0, - SourceProfileLink( - id: 'sl-${_uuid.v4()}', - sourceApp: sourceApp, - sourceUserId: sourceUserId, - sourceThreadId: sourceThreadId, - sourceFingerprint: sourceFingerprint, - normalizedDisplayName: normalizedDisplayName, - profileId: resolvedPerson.id, - firstSeenAt: sharedAt, - lastSeenAt: sharedAt, - ), - ); - } else { - final int index = nextLinks.indexWhere( - (SourceProfileLink link) => link.id == matchedLink.id, - ); - if (index >= 0) { - nextLinks[index] = matchedLink.copyWith( - sourceUserId: sourceUserId ?? matchedLink.sourceUserId, - sourceThreadId: sourceThreadId ?? matchedLink.sourceThreadId, - sourceFingerprint: sourceFingerprint ?? matchedLink.sourceFingerprint, - normalizedDisplayName: normalizedDisplayName.isEmpty - ? matchedLink.normalizedDisplayName - : normalizedDisplayName, - profileId: resolvedPerson.id, - lastSeenAt: sharedAt, - ); - } - } - - final String summary = messageText.length > 1800 - ? messageText.substring(0, 1800) - : messageText; - final RelationshipMoment moment = RelationshipMoment( - id: 'm-${_uuid.v4()}', - personId: resolvedPerson.id, - title: _titleFromSummary(summary), - summary: summary, - at: sharedAt, - type: sourceApp == 'whatsapp' ? 'whatsapp' : 'shared', - ); - - final List nextMoments = [ - moment, - ...current.moments, - ]; - final SharedMessageEntry sharedEntry = SharedMessageEntry( - id: 'sm-${_uuid.v4()}', - sourceApp: sourceApp, - profileId: resolvedPerson.id, - messageText: summary, - sharedAt: sharedAt, - importedAt: importedAt, - resolvedAutomatically: resolvedAutomatically, - sourceDisplayName: sourceDisplayName, - sourceUserId: sourceUserId, - sourceThreadId: sourceThreadId, - ); - final List nextMessages = [ - sharedEntry, - ...current.sharedMessages, - ]; - final List nextInbox = consumedInboxEntryId == null - ? current.sharedInbox - : current.sharedInbox - .where( - (SharedInboxEntry entry) => entry.id != consumedInboxEntryId, - ) - .toList(growable: false); - - await _setState( - current.copyWith( - people: people, - moments: nextMoments, - sourceLinks: nextLinks, - sharedMessages: nextMessages, - sharedInbox: nextInbox, - ), - ); - - final List outbound = [ - if (createdProfile) - _buildEnvelope( - entityType: 'person', - entityId: resolvedPerson.id, - op: ChangeOperation.upsert, - payload: _personPayload(resolvedPerson), - ), - _buildEnvelope( - entityType: 'capture', - entityId: moment.id, - op: ChangeOperation.upsert, - payload: _momentPayload(moment), - ), - ]; - await _enqueueChanges(outbound); - - final List extractedSignals = - _chatPreferenceExtractor.extract(summary); - for (final ExtractedPreferenceSignalCandidate candidate - in extractedSignals) { - await upsertInferredPreferenceSignalObservation( - personId: resolvedPerson.id, - key: candidate.key, - label: candidate.label, - category: candidate.category, - polarity: candidate.polarity, - confidence: candidate.confidence, - sourceApp: sourceApp, - evidenceMessageId: sharedEntry.id, - evidenceSnippet: candidate.evidenceSnippet, - observedAt: sharedAt, - ); - } - - return SharedMessageIngestResult.imported( - profileId: resolvedPerson.id, - profileName: resolvedPerson.name, - createdProfile: createdProfile, - createdSourceLink: createdSourceLink, - momentId: moment.id, - ); - } - - String _titleFromSummary(String summary) { - final List words = summary.split(RegExp(r'\s+')); - final int take = words.length < 5 ? words.length : 5; - return words.take(take).join(' '); - } - - SourceProfileLink? _findExistingSourceLink({ - required List links, - required String sourceApp, - required String? sourceFingerprint, - required String? sourceUserId, - required String? sourceThreadId, - required String normalizedDisplayName, - }) { - if (sourceFingerprint != null && sourceFingerprint.isNotEmpty) { - for (final SourceProfileLink link in links) { - final String? linkFingerprint = - link.sourceFingerprint ?? - _buildStableSourceFingerprint( - sourceApp: link.sourceApp, - sourceUserId: link.sourceUserId, - sourceThreadId: link.sourceThreadId, - ); - if (link.sourceApp == sourceApp && - linkFingerprint == sourceFingerprint) { - return link; - } - } - } - - if (sourceUserId != null && sourceUserId.isNotEmpty) { - for (final SourceProfileLink link in links) { - if (link.sourceApp == sourceApp && link.sourceUserId == sourceUserId) { - return link; - } - } - } - - if (sourceThreadId != null && sourceThreadId.isNotEmpty) { - for (final SourceProfileLink link in links) { - if (link.sourceApp == sourceApp && - link.sourceThreadId == sourceThreadId) { - return link; - } - } - } - - if (normalizedDisplayName.isNotEmpty) { - for (final SourceProfileLink link in links) { - if (link.sourceApp == sourceApp && - link.normalizedDisplayName == normalizedDisplayName) { - return link; - } - } - } - - return null; - } - - PersonProfile? _findPersonById(List people, String id) { - for (final PersonProfile person in people) { - if (person.id == id) { - return person; - } - } - return null; - } - - List _findPeopleByNormalizedName( - List people, - String normalizedDisplayName, - ) { - return people - .where( - (PersonProfile person) => - _normalizeDisplayName(person.name) == normalizedDisplayName, - ) - .toList(growable: false); - } - - List _findNearMatchPeople( - List people, { - required String normalizedDisplayName, - }) { - if (normalizedDisplayName.isEmpty) { - return const []; - } - - final Map distanceById = {}; - final List candidates = []; - for (final PersonProfile person in people) { - final String personName = _normalizeDisplayName(person.name); - if (personName.isEmpty || personName == normalizedDisplayName) { - continue; - } - final int distance = _levenshteinDistance( - normalizedDisplayName, - personName, - ); - if (_isNearDisplayName( - normalizedDisplayName, - personName, - distance: distance, - )) { - distanceById[person.id] = distance; - candidates.add(person); - } - } - - candidates.sort((PersonProfile a, PersonProfile b) { - final int left = distanceById[a.id] ?? 999; - final int right = distanceById[b.id] ?? 999; - if (left != right) { - return left.compareTo(right); - } - return a.name.length.compareTo(b.name.length); - }); - if (candidates.length <= 5) { - return candidates; - } - return candidates.take(5).toList(growable: false); - } - - bool _isNearDisplayName(String left, String right, {required int distance}) { - final int maxLength = left.length > right.length - ? left.length - : right.length; - final int minLength = left.length < right.length - ? left.length - : right.length; - if (minLength >= 4 && (left.contains(right) || right.contains(left))) { - return true; - } - final int threshold = maxLength >= 14 ? 3 : 2; - if (distance <= threshold) { - return true; - } - return maxLength >= 8 && (distance / maxLength) <= 0.22; - } - - int _levenshteinDistance(String left, String right) { - if (left == right) { - return 0; - } - if (left.isEmpty) { - return right.length; - } - if (right.isEmpty) { - return left.length; - } - - List previous = List.generate( - right.length + 1, - (int index) => index, - growable: false, - ); - for (int i = 1; i <= left.length; i += 1) { - final List current = List.filled(right.length + 1, 0); - current[0] = i; - for (int j = 1; j <= right.length; j += 1) { - final int substitutionCost = left[i - 1] == right[j - 1] ? 0 : 1; - final int deletion = previous[j] + 1; - final int insertion = current[j - 1] + 1; - final int substitution = previous[j - 1] + substitutionCost; - final int value = deletion < insertion ? deletion : insertion; - current[j] = value < substitution ? value : substitution; - } - previous = current; - } - return previous[right.length]; - } - - SharedInboxEntry _requireSharedInboxEntry( - LocalDataState current, - String inboxEntryId, - ) { - for (final SharedInboxEntry entry in current.sharedInbox) { - if (entry.id == inboxEntryId) { - return entry; - } - } - throw ArgumentError('Shared inbox entry not found'); - } - - String? _deriveAutoProfileName({ - required String? sourceDisplayName, - required String? sourceUserId, - required String? sourceThreadId, - }) { - return _trimToNull(sourceDisplayName) ?? - _trimToNull(sourceUserId) ?? - _trimToNull(sourceThreadId); - } - - String? _buildStableSourceFingerprint({ - required String sourceApp, - required String? sourceUserId, - required String? sourceThreadId, - }) { - final String? userId = _trimToNull(sourceUserId); - final String? threadId = _trimToNull(sourceThreadId); - if (userId == null && threadId == null) { - return null; - } - - final List parts = [ - sourceApp.trim().toLowerCase(), - if (userId != null) 'u:${userId.toLowerCase()}', - if (threadId != null) 't:${threadId.toLowerCase()}', - ]; - return parts.join('|'); - } - - String _normalizeDisplayName(String? raw) { - if (raw == null) { - return ''; - } - final String compact = raw - .toLowerCase() - .replaceAll(RegExp(r'[^a-z0-9]+'), ' ') - .trim(); - return compact.replaceAll(RegExp(r'\s+'), ' '); - } - - String? _trimToNull(String? value) { - if (value == null) { - return null; - } - final String trimmed = value.trim(); - return trimmed.isEmpty ? null : trimmed; - } -} - -final AsyncNotifierProvider -localRepositoryProvider = - AsyncNotifierProvider(LocalRepository.new); +// Legacy compatibility export for the prototype app repository. +export 'package:relationship_saver/app/data/relationship_repository.dart'; diff --git a/lib/features/local/storage/README.md b/lib/features/local/storage/README.md new file mode 100644 index 0000000..9459ef6 --- /dev/null +++ b/lib/features/local/storage/README.md @@ -0,0 +1,4 @@ +# Legacy Local Storage Exports + +These files exist as compatibility exports while the canonical storage layer has +moved to `lib/app/data/storage/`. Prefer editing the app-layer storage files. diff --git a/lib/features/local/storage/local_data_store.dart b/lib/features/local/storage/local_data_store.dart index 3835104..1dd6472 100644 --- a/lib/features/local/storage/local_data_store.dart +++ b/lib/features/local/storage/local_data_store.dart @@ -1,19 +1,2 @@ -/// Raw local state payload and associated schema version. -class LocalDataRecord { - const LocalDataRecord({required this.rawState, required this.schemaVersion}); - - final String rawState; - final int schemaVersion; -} - -/// Persistence boundary for local app state. -abstract interface class LocalDataStore { - /// Reads latest persisted state payload. - Future read(); - - /// Writes state payload and schema version. - Future write({required String rawState, required int schemaVersion}); - - /// Clears persisted state payload. - Future clear(); -} +// Legacy compatibility export for the app storage contract. +export 'package:relationship_saver/app/data/storage/local_data_store.dart'; diff --git a/lib/features/local/storage/local_data_store_hive.dart b/lib/features/local/storage/local_data_store_hive.dart index dc35ef5..667e74b 100644 --- a/lib/features/local/storage/local_data_store_hive.dart +++ b/lib/features/local/storage/local_data_store_hive.dart @@ -1,54 +1,2 @@ -import 'package:hive_flutter/hive_flutter.dart'; -import 'package:relationship_saver/features/local/storage/local_data_store.dart'; - -/// Hive-backed local store for future DB migration path. -class HiveLocalDataStore implements LocalDataStore { - static const String _boxName = 'relationship_saver_local'; - static const String _stateKey = 'state_json'; - static const String _schemaVersionKey = 'schema_version'; - - static bool _initialized = false; - - @override - Future clear() async { - final Box box = await _openBox(); - await box.delete(_stateKey); - await box.delete(_schemaVersionKey); - } - - @override - Future read() async { - final Box box = await _openBox(); - final String? rawState = box.get(_stateKey) as String?; - if (rawState == null || rawState.isEmpty) { - return null; - } - - final int schemaVersion = - (box.get(_schemaVersionKey) as num?)?.toInt() ?? 0; - return LocalDataRecord(rawState: rawState, schemaVersion: schemaVersion); - } - - @override - Future write({ - required String rawState, - required int schemaVersion, - }) async { - final Box box = await _openBox(); - await box.put(_stateKey, rawState); - await box.put(_schemaVersionKey, schemaVersion); - } - - Future> _openBox() async { - if (!_initialized) { - await Hive.initFlutter(); - _initialized = true; - } - - if (!Hive.isBoxOpen(_boxName)) { - return Hive.openBox(_boxName); - } - - return Hive.box(_boxName); - } -} +// Legacy compatibility export for the Hive local data store. +export 'package:relationship_saver/app/data/storage/local_data_store_hive.dart'; diff --git a/lib/features/local/storage/local_data_store_in_memory.dart b/lib/features/local/storage/local_data_store_in_memory.dart index ab7e1cd..51b770a 100644 --- a/lib/features/local/storage/local_data_store_in_memory.dart +++ b/lib/features/local/storage/local_data_store_in_memory.dart @@ -1,22 +1,2 @@ -import 'package:relationship_saver/features/local/storage/local_data_store.dart'; - -/// In-memory local store for deterministic tests. -class InMemoryLocalDataStore implements LocalDataStore { - LocalDataRecord? _record; - - @override - Future clear() async { - _record = null; - } - - @override - Future read() async => _record; - - @override - Future write({ - required String rawState, - required int schemaVersion, - }) async { - _record = LocalDataRecord(rawState: rawState, schemaVersion: schemaVersion); - } -} +// Legacy compatibility export for the in-memory local data store. +export 'package:relationship_saver/app/data/storage/local_data_store_in_memory.dart'; diff --git a/lib/features/local/storage/local_data_store_provider.dart b/lib/features/local/storage/local_data_store_provider.dart index f7966c4..93a01d2 100644 --- a/lib/features/local/storage/local_data_store_provider.dart +++ b/lib/features/local/storage/local_data_store_provider.dart @@ -1,14 +1,2 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:relationship_saver/core/config/app_config.dart'; -import 'package:relationship_saver/features/local/storage/local_data_store.dart'; -import 'package:relationship_saver/features/local/storage/local_data_store_hive.dart'; -import 'package:relationship_saver/features/local/storage/local_data_store_shared_prefs.dart'; - -/// Chooses local persistence backend for app state. -final Provider localDataStoreProvider = - Provider((Ref ref) { - if (AppConfig.useHiveLocalDb) { - return HiveLocalDataStore(); - } - return SharedPrefsLocalDataStore(); - }); +// Legacy compatibility export for the local data store provider. +export 'package:relationship_saver/app/data/storage/local_data_store_provider.dart'; diff --git a/lib/features/local/storage/local_data_store_shared_prefs.dart b/lib/features/local/storage/local_data_store_shared_prefs.dart index 6cd4a54..22ae7ba 100644 --- a/lib/features/local/storage/local_data_store_shared_prefs.dart +++ b/lib/features/local/storage/local_data_store_shared_prefs.dart @@ -1,44 +1,2 @@ -import 'package:relationship_saver/features/local/storage/local_data_store.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -/// shared_preferences-backed local store used as stable default. -class SharedPrefsLocalDataStore implements LocalDataStore { - SharedPrefsLocalDataStore({ - this.stateKey = 'local_data_state_v1', - this.schemaVersionKey = 'local_data_schema_version', - }); - - final String stateKey; - final String schemaVersionKey; - - @override - Future clear() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.remove(stateKey); - await prefs.remove(schemaVersionKey); - } - - @override - Future read() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - final String? rawState = prefs.getString(stateKey); - if (rawState == null || rawState.isEmpty) { - return null; - } - - return LocalDataRecord( - rawState: rawState, - schemaVersion: prefs.getInt(schemaVersionKey) ?? 0, - ); - } - - @override - Future write({ - required String rawState, - required int schemaVersion, - }) async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.setString(stateKey, rawState); - await prefs.setInt(schemaVersionKey, schemaVersion); - } -} +// Legacy compatibility export for the shared preferences local store. +export 'package:relationship_saver/app/data/storage/local_data_store_shared_prefs.dart'; diff --git a/lib/features/moments/README.md b/lib/features/moments/README.md new file mode 100644 index 0000000..1bb7cbf --- /dev/null +++ b/lib/features/moments/README.md @@ -0,0 +1,9 @@ +# Moments Slice + +This slice owns the lightweight relationship timeline. + +- `domain/moment_models.dart`: stored moment model. +- `presentation/moments_view.dart`: moments UI and quick capture paths. + +Moments are still simpler than structured facts. They are good for fast manual +logging and timeline history. diff --git a/lib/features/moments/domain/README.md b/lib/features/moments/domain/README.md new file mode 100644 index 0000000..465b2a3 --- /dev/null +++ b/lib/features/moments/domain/README.md @@ -0,0 +1,4 @@ +# Moments Domain + +Relationship capture and timeline models live here. Keep this folder focused on +recorded events and notes about interactions. diff --git a/lib/features/moments/domain/moment_models.dart b/lib/features/moments/domain/moment_models.dart new file mode 100644 index 0000000..2e81254 --- /dev/null +++ b/lib/features/moments/domain/moment_models.dart @@ -0,0 +1,63 @@ +// ignore_for_file: sort_constructors_first + +import 'package:flutter/foundation.dart'; + +/// Timeline event stored for a specific relationship. +@immutable +class RelationshipMoment { + const RelationshipMoment({ + required this.id, + required this.personId, + required this.title, + required this.summary, + required this.at, + required this.type, + }); + + final String id; + final String personId; + final String title; + final String summary; + final DateTime at; + final String type; + + RelationshipMoment copyWith({ + String? id, + String? personId, + String? title, + String? summary, + DateTime? at, + String? type, + }) { + return RelationshipMoment( + id: id ?? this.id, + personId: personId ?? this.personId, + title: title ?? this.title, + summary: summary ?? this.summary, + at: at ?? this.at, + type: type ?? this.type, + ); + } + + Map toJson() { + return { + 'id': id, + 'personId': personId, + 'title': title, + 'summary': summary, + 'at': at.toUtc().toIso8601String(), + 'type': type, + }; + } + + factory RelationshipMoment.fromJson(Map json) { + return RelationshipMoment( + id: json['id'] as String, + personId: json['personId'] as String, + title: json['title'] as String, + summary: json['summary'] as String, + at: DateTime.parse(json['at'] as String).toLocal(), + type: json['type'] as String, + ); + } +} diff --git a/lib/features/moments/moments_view.dart b/lib/features/moments/moments_view.dart index 37de72e..a2f0888 100644 --- a/lib/features/moments/moments_view.dart +++ b/lib/features/moments/moments_view.dart @@ -1,344 +1,2 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:relationship_saver/core/config/app_theme.dart'; -import 'package:relationship_saver/features/local/local_models.dart'; -import 'package:relationship_saver/features/local/local_repository.dart'; -import 'package:relationship_saver/features/shared/frosted_card.dart'; - -class MomentsView extends ConsumerStatefulWidget { - const MomentsView({super.key}); - - @override - ConsumerState createState() => _MomentsViewState(); -} - -class _MomentsViewState extends ConsumerState { - final TextEditingController _captureController = TextEditingController(); - String? _selectedPersonId; - - @override - void dispose() { - _captureController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final AsyncValue localData = ref.watch( - localRepositoryProvider, - ); - - return localData.when( - data: (LocalDataState data) { - final List people = data.people; - final List moments = data.moments; - final Map peopleById = { - for (final PersonProfile person in people) person.id: person, - }; - - final String? fallbackPersonId = people.isEmpty - ? null - : people.first.id; - final String? activePerson = - people.any((PersonProfile p) => p.id == _selectedPersonId) - ? _selectedPersonId - : fallbackPersonId; - - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - final bool compact = constraints.maxWidth < 760; - return Padding( - padding: EdgeInsets.fromLTRB( - compact ? 16 : 28, - compact ? 16 : 24, - compact ? 16 : 28, - compact ? 20 : 28, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Moments', - style: Theme.of(context).textTheme.headlineMedium, - ), - const SizedBox(height: 6), - Text( - 'Capture wins and signals from everyday interactions.', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: AppTheme.textSecondary, - ), - ), - const SizedBox(height: 16), - FrostedCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextField( - controller: _captureController, - minLines: 1, - maxLines: compact ? 4 : 2, - decoration: InputDecoration( - hintText: - 'Quick capture: what happened, how it felt, what to repeat...', - hintStyle: Theme.of(context).textTheme.bodyMedium - ?.copyWith(color: AppTheme.textSecondary), - border: InputBorder.none, - ), - ), - const SizedBox(height: 8), - if (compact) - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (people.isNotEmpty) - DropdownButtonFormField( - initialValue: activePerson, - items: people - .map( - (PersonProfile person) => - DropdownMenuItem( - value: person.id, - child: Text(person.name), - ), - ) - .toList(growable: false), - onChanged: (String? value) { - setState(() { - _selectedPersonId = value; - }); - }, - decoration: const InputDecoration( - labelText: 'Person', - ), - ), - const SizedBox(height: 10), - FilledButton.icon( - onPressed: - people.isEmpty || activePerson == null - ? null - : () => _addCapture(activePerson), - icon: const Icon(Icons.add_rounded), - label: const Text('Add Capture'), - ), - ], - ) - else - Row( - children: [ - if (people.isNotEmpty) - DropdownButton( - value: activePerson, - hint: const Text('Person'), - items: people - .map( - (PersonProfile person) => - DropdownMenuItem( - value: person.id, - child: Text(person.name), - ), - ) - .toList(growable: false), - onChanged: (String? value) { - setState(() { - _selectedPersonId = value; - }); - }, - ), - const SizedBox(width: 8), - FilledButton.icon( - onPressed: - people.isEmpty || activePerson == null - ? null - : () => _addCapture(activePerson), - icon: const Icon(Icons.add_rounded), - label: const Text('Add Capture'), - ), - ], - ), - if (people.isEmpty) - Padding( - padding: const EdgeInsets.only(top: 10), - child: Text( - 'Add people first before capturing moments.', - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(color: AppTheme.textSecondary), - ), - ), - ], - ), - ), - const SizedBox(height: 16), - Expanded( - child: ListView.separated( - itemCount: moments.length, - separatorBuilder: (_, _) => const SizedBox(height: 12), - itemBuilder: (BuildContext context, int index) { - final RelationshipMoment moment = moments[index]; - final PersonProfile? person = - peopleById[moment.personId]; - return FrostedCard( - child: compact - ? Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 10, - height: 10, - decoration: const BoxDecoration( - color: Color(0xFF1AB6C8), - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 10), - Expanded( - child: Text( - moment.title, - style: Theme.of( - context, - ).textTheme.titleMedium, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - IconButton( - onPressed: () { - ref - .read( - localRepositoryProvider - .notifier, - ) - .deleteMoment(moment.id); - }, - icon: const Icon( - Icons.delete_outline_rounded, - ), - tooltip: 'Delete capture', - ), - ], - ), - Text( - '${person?.name ?? 'Unknown'} • ${moment.type.toUpperCase()} • ${moment.at.month}/${moment.at.day}', - style: Theme.of(context) - .textTheme - .labelLarge - ?.copyWith( - color: AppTheme.textSecondary, - ), - ), - const SizedBox(height: 8), - Text( - moment.summary, - style: Theme.of( - context, - ).textTheme.bodyLarge, - ), - ], - ) - : Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 12, - height: 12, - margin: const EdgeInsets.only(top: 6), - decoration: const BoxDecoration( - color: Color(0xFF1AB6C8), - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - moment.title, - style: Theme.of( - context, - ).textTheme.titleMedium, - ), - const SizedBox(height: 4), - Text( - '${person?.name ?? 'Unknown'} • ${moment.type.toUpperCase()}', - style: Theme.of(context) - .textTheme - .labelLarge - ?.copyWith( - color: AppTheme.textSecondary, - ), - ), - const SizedBox(height: 8), - Text( - moment.summary, - style: Theme.of( - context, - ).textTheme.bodyLarge, - ), - ], - ), - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: - CrossAxisAlignment.end, - children: [ - Text( - '${moment.at.month}/${moment.at.day}', - style: Theme.of(context) - .textTheme - .labelLarge - ?.copyWith( - color: AppTheme.textSecondary, - ), - ), - const SizedBox(height: 8), - IconButton( - onPressed: () { - ref - .read( - localRepositoryProvider - .notifier, - ) - .deleteMoment(moment.id); - }, - icon: const Icon( - Icons.delete_outline_rounded, - ), - tooltip: 'Delete capture', - ), - ], - ), - ], - ), - ); - }, - ), - ), - ], - ), - ); - }, - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (Object error, StackTrace stackTrace) { - return const Center(child: Text('Unable to load moments')); - }, - ); - } - - Future _addCapture(String personId) async { - final String summary = _captureController.text.trim(); - if (summary.isEmpty) { - return; - } - - await ref - .read(localRepositoryProvider.notifier) - .addMoment(personId: personId, summary: summary); - _captureController.clear(); - } -} +// Legacy compatibility export for the moments presentation entry. +export 'package:relationship_saver/features/moments/presentation/moments_view.dart'; diff --git a/lib/features/moments/presentation/README.md b/lib/features/moments/presentation/README.md new file mode 100644 index 0000000..2cf50a6 --- /dev/null +++ b/lib/features/moments/presentation/README.md @@ -0,0 +1,4 @@ +# Moments Presentation + +Moment timeline UI belongs here. If you are improving how captures are browsed +or edited, this is the slice entry point. diff --git a/lib/features/moments/presentation/moments_view.dart b/lib/features/moments/presentation/moments_view.dart new file mode 100644 index 0000000..2685f8b --- /dev/null +++ b/lib/features/moments/presentation/moments_view.dart @@ -0,0 +1,372 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/config/app_theme.dart'; +import 'package:relationship_saver/features/local/local_models.dart'; +import 'package:relationship_saver/features/local/local_repository.dart'; +import 'package:relationship_saver/features/shared/frosted_card.dart'; + +class MomentsView extends ConsumerStatefulWidget { + const MomentsView({super.key}); + + @override + ConsumerState createState() => _MomentsViewState(); +} + +class _MomentsViewState extends ConsumerState { + final TextEditingController _captureController = TextEditingController(); + final TextEditingController _searchController = TextEditingController(); + String? _selectedPersonId; + + @override + void dispose() { + _captureController.dispose(); + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final AsyncValue localData = ref.watch( + localRepositoryProvider, + ); + + return localData.when( + data: (LocalDataState data) { + final List people = data.people; + List moments = data.moments; + final Map peopleById = { + for (final PersonProfile person in people) person.id: person, + }; + + if (_searchController.text.isNotEmpty) { + final query = _searchController.text.toLowerCase(); + moments = moments.where((m) { + final person = peopleById[m.personId]; + return m.title.toLowerCase().contains(query) || + m.summary.toLowerCase().contains(query) || + (person?.name.toLowerCase().contains(query) ?? false); + }).toList(); + } + + final String? fallbackPersonId = people.isEmpty + ? null + : people.first.id; + final String? activePerson = + people.any((PersonProfile p) => p.id == _selectedPersonId) + ? _selectedPersonId + : fallbackPersonId; + + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final bool compact = constraints.maxWidth < 760; + return Padding( + padding: EdgeInsets.fromLTRB( + compact ? 16 : 28, + compact ? 16 : 24, + compact ? 16 : 28, + compact ? 20 : 28, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Moments', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 6), + Text( + 'Capture wins and signals from everyday interactions.', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search moments...', + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 16), + FrostedCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _captureController, + minLines: 1, + maxLines: compact ? 4 : 2, + decoration: InputDecoration( + hintText: + 'Quick capture: what happened, how it felt, what to repeat...', + hintStyle: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: AppTheme.textSecondary), + border: InputBorder.none, + ), + ), + const SizedBox(height: 8), + if (compact) + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (people.isNotEmpty) + DropdownButtonFormField( + initialValue: activePerson, + items: people + .map( + (PersonProfile person) => + DropdownMenuItem( + value: person.id, + child: Text(person.name), + ), + ) + .toList(growable: false), + onChanged: (String? value) { + setState(() { + _selectedPersonId = value; + }); + }, + decoration: const InputDecoration( + labelText: 'Person', + ), + ), + const SizedBox(height: 10), + FilledButton.icon( + onPressed: + people.isEmpty || activePerson == null + ? null + : () => _addCapture(activePerson), + icon: const Icon(Icons.add_rounded), + label: const Text('Add Capture'), + ), + ], + ) + else + Row( + children: [ + if (people.isNotEmpty) + DropdownButton( + value: activePerson, + hint: const Text('Person'), + items: people + .map( + (PersonProfile person) => + DropdownMenuItem( + value: person.id, + child: Text(person.name), + ), + ) + .toList(growable: false), + onChanged: (String? value) { + setState(() { + _selectedPersonId = value; + }); + }, + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: + people.isEmpty || activePerson == null + ? null + : () => _addCapture(activePerson), + icon: const Icon(Icons.add_rounded), + label: const Text('Add Capture'), + ), + ], + ), + if (people.isEmpty) + Padding( + padding: const EdgeInsets.only(top: 10), + child: Text( + 'Add people first before capturing moments.', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: AppTheme.textSecondary), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Expanded( + child: ListView.separated( + itemCount: moments.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (BuildContext context, int index) { + final RelationshipMoment moment = moments[index]; + final PersonProfile? person = + peopleById[moment.personId]; + return FrostedCard( + child: compact + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 10, + height: 10, + decoration: const BoxDecoration( + color: Color(0xFF1AB6C8), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + moment.title, + style: Theme.of( + context, + ).textTheme.titleMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + onPressed: () { + ref + .read( + localRepositoryProvider + .notifier, + ) + .deleteMoment(moment.id); + }, + icon: const Icon( + Icons.delete_outline_rounded, + ), + tooltip: 'Delete capture', + ), + ], + ), + Text( + '${person?.name ?? 'Unknown'} • ${moment.type.toUpperCase()} • ${moment.at.month}/${moment.at.day}', + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 8), + Text( + moment.summary, + style: Theme.of( + context, + ).textTheme.bodyLarge, + ), + ], + ) + : Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 12, + height: 12, + margin: const EdgeInsets.only(top: 6), + decoration: const BoxDecoration( + color: Color(0xFF1AB6C8), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + moment.title, + style: Theme.of( + context, + ).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + '${person?.name ?? 'Unknown'} • ${moment.type.toUpperCase()}', + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 8), + Text( + moment.summary, + style: Theme.of( + context, + ).textTheme.bodyLarge, + ), + ], + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: + CrossAxisAlignment.end, + children: [ + Text( + '${moment.at.month}/${moment.at.day}', + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 8), + IconButton( + onPressed: () { + ref + .read( + localRepositoryProvider + .notifier, + ) + .deleteMoment(moment.id); + }, + icon: const Icon( + Icons.delete_outline_rounded, + ), + tooltip: 'Delete capture', + ), + ], + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (Object error, StackTrace stackTrace) { + return const Center(child: Text('Unable to load moments')); + }, + ); + } + + Future _addCapture(String personId) async { + final String summary = _captureController.text.trim(); + if (summary.isEmpty) { + return; + } + + await ref + .read(localRepositoryProvider.notifier) + .addMoment(personId: personId, summary: summary); + _captureController.clear(); + } +} diff --git a/lib/features/people/README.md b/lib/features/people/README.md new file mode 100644 index 0000000..692415e --- /dev/null +++ b/lib/features/people/README.md @@ -0,0 +1,12 @@ +# People Slice + +This is the richest slice in the app and the main relationship workspace. + +- `domain/person_models.dart`: person profile, facts, dates, preference signals, + and source-link models. +- `presentation/people_view.dart`: main people workspace screen. +- `presentation/providers/people_ui_state.dart`: list/search/filter selection + state for the people workspace. + +If you want to extend person intelligence, start in `domain/` for data shape +and then wire the UI through `presentation/`. diff --git a/lib/features/people/domain/README.md b/lib/features/people/domain/README.md new file mode 100644 index 0000000..9a61397 --- /dev/null +++ b/lib/features/people/domain/README.md @@ -0,0 +1,4 @@ +# People Domain + +Person profiles, structured facts, dates, source links, and preference signals +live here. These are core app entities, so keep them framework-free and explicit. diff --git a/lib/features/people/domain/person_models.dart b/lib/features/people/domain/person_models.dart new file mode 100644 index 0000000..572e369 --- /dev/null +++ b/lib/features/people/domain/person_models.dart @@ -0,0 +1,538 @@ +// ignore_for_file: sort_constructors_first + +import 'package:flutter/foundation.dart'; +import 'package:relationship_saver/features/share_intake/domain/share_models.dart'; + +/// Polarity for inferred or confirmed preference signals. +enum PreferenceSignalPolarity { like, dislike, neutral } + +/// Lifecycle state for machine-assisted preference signals. +enum PreferenceSignalStatus { inferred, confirmed, dismissed } + +/// Primary profile object used across the relationship app. +@immutable +class PersonProfile { + const PersonProfile({ + required this.id, + required this.name, + required this.relationship, + required this.affinityScore, + required this.nextMoment, + required this.tags, + required this.notes, + this.aliases = const [], + this.location, + this.lastUpdatedAt, + this.lastInteractedAt, + }); + + final String id; + final String name; + final String relationship; + final int affinityScore; + final DateTime nextMoment; + final List tags; + final String notes; + final List aliases; + final String? location; + final DateTime? lastUpdatedAt; + final DateTime? lastInteractedAt; + + PersonProfile copyWith({ + String? id, + String? name, + String? relationship, + int? affinityScore, + DateTime? nextMoment, + List? tags, + String? notes, + List? aliases, + String? location, + DateTime? lastUpdatedAt, + DateTime? lastInteractedAt, + }) { + return PersonProfile( + id: id ?? this.id, + name: name ?? this.name, + relationship: relationship ?? this.relationship, + affinityScore: affinityScore ?? this.affinityScore, + nextMoment: nextMoment ?? this.nextMoment, + tags: tags ?? this.tags, + notes: notes ?? this.notes, + aliases: aliases ?? this.aliases, + location: location ?? this.location, + lastUpdatedAt: lastUpdatedAt ?? this.lastUpdatedAt, + lastInteractedAt: lastInteractedAt ?? this.lastInteractedAt, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'relationship': relationship, + 'affinityScore': affinityScore, + 'nextMoment': nextMoment.toUtc().toIso8601String(), + 'tags': tags, + 'notes': notes, + 'aliases': aliases, + 'location': location, + 'lastUpdatedAt': lastUpdatedAt?.toUtc().toIso8601String(), + 'lastInteractedAt': lastInteractedAt?.toUtc().toIso8601String(), + }; + } + + factory PersonProfile.fromJson(Map json) { + return PersonProfile( + id: json['id'] as String, + name: json['name'] as String, + relationship: json['relationship'] as String, + affinityScore: (json['affinityScore'] as num).toInt(), + nextMoment: DateTime.parse(json['nextMoment'] as String).toLocal(), + tags: (json['tags'] as List? ?? []) + .map((dynamic tag) => '$tag') + .toList(growable: false), + notes: json['notes'] as String? ?? '', + aliases: (json['aliases'] as List? ?? []) + .map((dynamic value) => '$value') + .toList(growable: false), + location: json['location'] as String?, + lastUpdatedAt: json['lastUpdatedAt'] == null + ? null + : DateTime.parse(json['lastUpdatedAt'] as String).toLocal(), + lastInteractedAt: json['lastInteractedAt'] == null + ? null + : DateTime.parse(json['lastInteractedAt'] as String).toLocal(), + ); + } +} + +/// Stable mapping between an external sender/thread identity and a person. +@immutable +class SourceProfileLink { + const SourceProfileLink({ + required this.id, + required this.sourceApp, + required this.normalizedDisplayName, + required this.profileId, + required this.firstSeenAt, + required this.lastSeenAt, + this.sourceUserId, + this.sourceThreadId, + this.sourceFingerprint, + }); + + final String id; + final String sourceApp; + final String? sourceUserId; + final String? sourceThreadId; + final String? sourceFingerprint; + final String normalizedDisplayName; + final String profileId; + final DateTime firstSeenAt; + final DateTime lastSeenAt; + + SourceProfileLink copyWith({ + String? id, + String? sourceApp, + String? sourceUserId, + String? sourceThreadId, + String? sourceFingerprint, + String? normalizedDisplayName, + String? profileId, + DateTime? firstSeenAt, + DateTime? lastSeenAt, + }) { + return SourceProfileLink( + id: id ?? this.id, + sourceApp: sourceApp ?? this.sourceApp, + sourceUserId: sourceUserId ?? this.sourceUserId, + sourceThreadId: sourceThreadId ?? this.sourceThreadId, + sourceFingerprint: sourceFingerprint ?? this.sourceFingerprint, + normalizedDisplayName: + normalizedDisplayName ?? this.normalizedDisplayName, + profileId: profileId ?? this.profileId, + firstSeenAt: firstSeenAt ?? this.firstSeenAt, + lastSeenAt: lastSeenAt ?? this.lastSeenAt, + ); + } + + Map toJson() { + return { + 'id': id, + 'sourceApp': sourceApp, + 'sourceUserId': sourceUserId, + 'sourceThreadId': sourceThreadId, + 'sourceFingerprint': sourceFingerprint, + 'normalizedDisplayName': normalizedDisplayName, + 'profileId': profileId, + 'firstSeenAt': firstSeenAt.toUtc().toIso8601String(), + 'lastSeenAt': lastSeenAt.toUtc().toIso8601String(), + }; + } + + factory SourceProfileLink.fromJson(Map json) { + return SourceProfileLink( + id: json['id'] as String, + sourceApp: json['sourceApp'] as String, + sourceUserId: json['sourceUserId'] as String?, + sourceThreadId: json['sourceThreadId'] as String?, + sourceFingerprint: json['sourceFingerprint'] as String?, + normalizedDisplayName: json['normalizedDisplayName'] as String? ?? '', + profileId: json['profileId'] as String, + firstSeenAt: DateTime.parse(json['firstSeenAt'] as String).toLocal(), + lastSeenAt: DateTime.parse(json['lastSeenAt'] as String).toLocal(), + ); + } +} + +/// Structured fact captured about a person. +@immutable +class PersonFact { + const PersonFact({ + required this.id, + required this.personId, + required this.type, + required this.text, + required this.sourceKind, + required this.createdAt, + required this.updatedAt, + this.label, + this.sourceApp, + this.sourceUrl, + this.sharedMessageId, + this.inboxEntryId, + this.confidence, + this.needsReview = false, + this.isSensitive = false, + }); + + final String id; + final String personId; + final CapturedFactType type; + final String text; + final String? label; + final CaptureSourceKind sourceKind; + final String? sourceApp; + final String? sourceUrl; + final String? sharedMessageId; + final String? inboxEntryId; + final double? confidence; + final bool needsReview; + final bool isSensitive; + final DateTime createdAt; + final DateTime updatedAt; + + PersonFact copyWith({ + String? id, + String? personId, + CapturedFactType? type, + String? text, + String? label, + CaptureSourceKind? sourceKind, + String? sourceApp, + String? sourceUrl, + String? sharedMessageId, + String? inboxEntryId, + double? confidence, + bool? needsReview, + bool? isSensitive, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return PersonFact( + id: id ?? this.id, + personId: personId ?? this.personId, + type: type ?? this.type, + text: text ?? this.text, + label: label ?? this.label, + sourceKind: sourceKind ?? this.sourceKind, + sourceApp: sourceApp ?? this.sourceApp, + sourceUrl: sourceUrl ?? this.sourceUrl, + sharedMessageId: sharedMessageId ?? this.sharedMessageId, + inboxEntryId: inboxEntryId ?? this.inboxEntryId, + confidence: confidence ?? this.confidence, + needsReview: needsReview ?? this.needsReview, + isSensitive: isSensitive ?? this.isSensitive, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + Map toJson() { + return { + 'id': id, + 'personId': personId, + 'type': type.name, + 'text': text, + 'label': label, + 'sourceKind': sourceKind.name, + 'sourceApp': sourceApp, + 'sourceUrl': sourceUrl, + 'sharedMessageId': sharedMessageId, + 'inboxEntryId': inboxEntryId, + 'confidence': confidence, + 'needsReview': needsReview, + 'isSensitive': isSensitive, + 'createdAt': createdAt.toUtc().toIso8601String(), + 'updatedAt': updatedAt.toUtc().toIso8601String(), + }; + } + + factory PersonFact.fromJson(Map json) { + final String typeName = + json['type'] as String? ?? CapturedFactType.note.name; + final String sourceKindName = + json['sourceKind'] as String? ?? CaptureSourceKind.manual.name; + return PersonFact( + id: json['id'] as String, + personId: json['personId'] as String, + type: CapturedFactType.values.firstWhere( + (CapturedFactType value) => value.name == typeName, + orElse: () => CapturedFactType.note, + ), + text: json['text'] as String? ?? '', + label: json['label'] as String?, + sourceKind: CaptureSourceKind.values.firstWhere( + (CaptureSourceKind value) => value.name == sourceKindName, + orElse: () => CaptureSourceKind.manual, + ), + sourceApp: json['sourceApp'] as String?, + sourceUrl: json['sourceUrl'] as String?, + sharedMessageId: json['sharedMessageId'] as String?, + inboxEntryId: json['inboxEntryId'] as String?, + confidence: (json['confidence'] as num?)?.toDouble(), + needsReview: json['needsReview'] as bool? ?? false, + isSensitive: json['isSensitive'] as bool? ?? false, + createdAt: DateTime.parse(json['createdAt'] as String).toLocal(), + updatedAt: DateTime.parse(json['updatedAt'] as String).toLocal(), + ); + } +} + +/// Important date owned by a person profile. +@immutable +class PersonImportantDate { + const PersonImportantDate({ + required this.id, + required this.personId, + required this.label, + required this.date, + required this.classification, + required this.sourceKind, + required this.createdAt, + required this.updatedAt, + this.sourceApp, + this.inboxEntryId, + this.isSensitive = false, + }); + + final String id; + final String personId; + final String label; + final DateTime date; + final String classification; + final CaptureSourceKind sourceKind; + final String? sourceApp; + final String? inboxEntryId; + final bool isSensitive; + final DateTime createdAt; + final DateTime updatedAt; + + PersonImportantDate copyWith({ + String? id, + String? personId, + String? label, + DateTime? date, + String? classification, + CaptureSourceKind? sourceKind, + String? sourceApp, + String? inboxEntryId, + bool? isSensitive, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return PersonImportantDate( + id: id ?? this.id, + personId: personId ?? this.personId, + label: label ?? this.label, + date: date ?? this.date, + classification: classification ?? this.classification, + sourceKind: sourceKind ?? this.sourceKind, + sourceApp: sourceApp ?? this.sourceApp, + inboxEntryId: inboxEntryId ?? this.inboxEntryId, + isSensitive: isSensitive ?? this.isSensitive, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + Map toJson() { + return { + 'id': id, + 'personId': personId, + 'label': label, + 'date': date.toUtc().toIso8601String(), + 'classification': classification, + 'sourceKind': sourceKind.name, + 'sourceApp': sourceApp, + 'inboxEntryId': inboxEntryId, + 'isSensitive': isSensitive, + 'createdAt': createdAt.toUtc().toIso8601String(), + 'updatedAt': updatedAt.toUtc().toIso8601String(), + }; + } + + factory PersonImportantDate.fromJson(Map json) { + final String sourceKindName = + json['sourceKind'] as String? ?? CaptureSourceKind.manual.name; + return PersonImportantDate( + id: json['id'] as String, + personId: json['personId'] as String, + label: json['label'] as String? ?? '', + date: DateTime.parse(json['date'] as String).toLocal(), + classification: json['classification'] as String? ?? 'important', + sourceKind: CaptureSourceKind.values.firstWhere( + (CaptureSourceKind value) => value.name == sourceKindName, + orElse: () => CaptureSourceKind.manual, + ), + sourceApp: json['sourceApp'] as String?, + inboxEntryId: json['inboxEntryId'] as String?, + isSensitive: json['isSensitive'] as bool? ?? false, + createdAt: DateTime.parse(json['createdAt'] as String).toLocal(), + updatedAt: DateTime.parse(json['updatedAt'] as String).toLocal(), + ); + } +} + +/// Inferred preference signal that can later drive recommendations or prompts. +@immutable +class PersonPreferenceSignal { + const PersonPreferenceSignal({ + required this.id, + required this.personId, + required this.key, + required this.label, + required this.category, + required this.polarity, + required this.confidence, + required this.status, + required this.firstSeenAt, + required this.lastSeenAt, + required this.occurrenceCount, + this.sourceApps = const [], + this.evidenceMessageIds = const [], + this.evidenceSnippets = const [], + }); + + final String id; + final String personId; + final String key; + final String label; + final String category; + final PreferenceSignalPolarity polarity; + final double confidence; + final PreferenceSignalStatus status; + final DateTime firstSeenAt; + final DateTime lastSeenAt; + final int occurrenceCount; + final List sourceApps; + final List evidenceMessageIds; + final List evidenceSnippets; + + PersonPreferenceSignal copyWith({ + String? id, + String? personId, + String? key, + String? label, + String? category, + PreferenceSignalPolarity? polarity, + double? confidence, + PreferenceSignalStatus? status, + DateTime? firstSeenAt, + DateTime? lastSeenAt, + int? occurrenceCount, + List? sourceApps, + List? evidenceMessageIds, + List? evidenceSnippets, + }) { + return PersonPreferenceSignal( + id: id ?? this.id, + personId: personId ?? this.personId, + key: key ?? this.key, + label: label ?? this.label, + category: category ?? this.category, + polarity: polarity ?? this.polarity, + confidence: confidence ?? this.confidence, + status: status ?? this.status, + firstSeenAt: firstSeenAt ?? this.firstSeenAt, + lastSeenAt: lastSeenAt ?? this.lastSeenAt, + occurrenceCount: occurrenceCount ?? this.occurrenceCount, + sourceApps: sourceApps ?? this.sourceApps, + evidenceMessageIds: evidenceMessageIds ?? this.evidenceMessageIds, + evidenceSnippets: evidenceSnippets ?? this.evidenceSnippets, + ); + } + + Map toJson() { + return { + 'id': id, + 'personId': personId, + 'key': key, + 'label': label, + 'category': category, + 'polarity': polarity.name, + 'confidence': confidence, + 'status': status.name, + 'firstSeenAt': firstSeenAt.toUtc().toIso8601String(), + 'lastSeenAt': lastSeenAt.toUtc().toIso8601String(), + 'occurrenceCount': occurrenceCount, + 'sourceApps': sourceApps, + 'evidenceMessageIds': evidenceMessageIds, + 'evidenceSnippets': evidenceSnippets, + }; + } + + factory PersonPreferenceSignal.fromJson(Map json) { + final String polarityName = + json['polarity'] as String? ?? PreferenceSignalPolarity.neutral.name; + final String statusName = + json['status'] as String? ?? PreferenceSignalStatus.inferred.name; + return PersonPreferenceSignal( + id: json['id'] as String, + personId: json['personId'] as String, + key: json['key'] as String, + label: json['label'] as String? ?? '', + category: json['category'] as String? ?? 'general', + polarity: PreferenceSignalPolarity.values.firstWhere( + (PreferenceSignalPolarity item) => item.name == polarityName, + orElse: () => PreferenceSignalPolarity.neutral, + ), + confidence: (json['confidence'] as num?)?.toDouble() ?? 0, + status: PreferenceSignalStatus.values.firstWhere( + (PreferenceSignalStatus item) => item.name == statusName, + orElse: () => PreferenceSignalStatus.inferred, + ), + firstSeenAt: DateTime.parse( + json['firstSeenAt'] as String? ?? + DateTime.now().toUtc().toIso8601String(), + ).toLocal(), + lastSeenAt: DateTime.parse( + json['lastSeenAt'] as String? ?? + DateTime.now().toUtc().toIso8601String(), + ).toLocal(), + occurrenceCount: (json['occurrenceCount'] as num?)?.toInt() ?? 1, + sourceApps: (json['sourceApps'] as List? ?? []) + .map((dynamic item) => '$item') + .toList(growable: false), + evidenceMessageIds: + (json['evidenceMessageIds'] as List? ?? []) + .map((dynamic item) => '$item') + .toList(growable: false), + evidenceSnippets: + (json['evidenceSnippets'] as List? ?? []) + .map((dynamic item) => '$item') + .toList(growable: false), + ); + } +} diff --git a/lib/features/people/people_view.dart b/lib/features/people/people_view.dart index 4f0905c..d31a010 100644 --- a/lib/features/people/people_view.dart +++ b/lib/features/people/people_view.dart @@ -1,3203 +1,2 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:relationship_saver/core/config/app_theme.dart'; -import 'package:relationship_saver/features/local/local_models.dart'; -import 'package:relationship_saver/features/local/local_repository.dart'; -import 'package:relationship_saver/features/shared/frosted_card.dart'; - -class SelectedPersonIdNotifier extends Notifier { - @override - String? build() => null; - - void select(String id) { - state = id; - } - - void clear() { - state = null; - } -} - -final NotifierProvider -selectedPersonIdProvider = NotifierProvider( - SelectedPersonIdNotifier.new, -); - -enum PeopleSortOption { affinity, nextMoment, alphabetical } - -class PeopleSearchQueryNotifier extends Notifier { - @override - String build() => ''; - - void set(String value) { - state = value; - } -} - -final NotifierProvider -peopleSearchQueryProvider = NotifierProvider( - PeopleSearchQueryNotifier.new, -); - -class PeopleRelationshipFilterNotifier extends Notifier { - @override - String? build() => null; - - void set(String? value) { - state = value; - } -} - -final NotifierProvider -peopleRelationshipFilterProvider = - NotifierProvider( - PeopleRelationshipFilterNotifier.new, - ); - -class PeopleSortOptionNotifier extends Notifier { - @override - PeopleSortOption build() => PeopleSortOption.affinity; - - void set(PeopleSortOption value) { - state = value; - } -} - -final NotifierProvider -peopleSortOptionProvider = - NotifierProvider( - PeopleSortOptionNotifier.new, - ); - -enum _PersonQuickAction { capture, note, idea, reminder } - -class PeopleView extends ConsumerWidget { - const PeopleView({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final AsyncValue localData = ref.watch( - localRepositoryProvider, - ); - final String searchQuery = ref.watch(peopleSearchQueryProvider); - final String? relationshipFilter = ref.watch( - peopleRelationshipFilterProvider, - ); - final PeopleSortOption sortOption = ref.watch(peopleSortOptionProvider); - - return localData.when( - data: (LocalDataState data) { - final List allPeople = data.people; - if (allPeople.isEmpty) { - return _EmptyPeopleView(onAdd: () => _handleAddPerson(context, ref)); - } - - final List people = _applyPeopleFilters( - allPeople, - searchQuery: searchQuery, - relationshipFilter: relationshipFilter, - sortOption: sortOption, - ); - final List relationshipOptions = _relationshipOptions( - allPeople, - ); - - if (people.isEmpty) { - return _FilteredPeopleEmptyView( - onClear: () { - ref.read(peopleSearchQueryProvider.notifier).set(''); - ref.read(peopleRelationshipFilterProvider.notifier).set(null); - }, - ); - } - - final String selectedId = - ref.watch(selectedPersonIdProvider) ?? people.first.id; - final PersonProfile selected = people.firstWhere( - (PersonProfile person) => person.id == selectedId, - orElse: () => people.first, - ); - - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - final bool isCompact = constraints.maxWidth < 860; - if (isCompact) { - return _buildCompactLayout( - context, - ref, - data, - people, - selected, - relationshipOptions, - ); - } - return _buildWideLayout( - context, - ref, - data, - people, - selected, - relationshipOptions, - ); - }, - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (Object error, StackTrace stackTrace) { - return const Center(child: Text('Unable to load people')); - }, - ); - } - - List _applyPeopleFilters( - List people, { - required String searchQuery, - required String? relationshipFilter, - required PeopleSortOption sortOption, - }) { - final String query = searchQuery.trim().toLowerCase(); - final String? relationship = relationshipFilter?.trim().toLowerCase(); - - final List filtered = people - .where((PersonProfile person) { - if (relationship != null && - relationship.isNotEmpty && - person.relationship.trim().toLowerCase() != relationship) { - return false; - } - if (query.isEmpty) { - return true; - } - final String haystack = [ - person.name, - person.relationship, - person.location ?? '', - person.notes, - ...person.tags, - ].join(' ').toLowerCase(); - return haystack.contains(query); - }) - .toList(growable: true); - - filtered.sort((PersonProfile a, PersonProfile b) { - switch (sortOption) { - case PeopleSortOption.affinity: - final int score = b.affinityScore.compareTo(a.affinityScore); - if (score != 0) { - return score; - } - return a.name.toLowerCase().compareTo(b.name.toLowerCase()); - case PeopleSortOption.nextMoment: - final int next = a.nextMoment.compareTo(b.nextMoment); - if (next != 0) { - return next; - } - return a.name.toLowerCase().compareTo(b.name.toLowerCase()); - case PeopleSortOption.alphabetical: - return a.name.toLowerCase().compareTo(b.name.toLowerCase()); - } - }); - - return filtered; - } - - List _relationshipOptions(List people) { - final Set seen = {}; - final List values = []; - for (final PersonProfile person in people) { - final String relationship = person.relationship.trim(); - if (relationship.isEmpty) { - continue; - } - final String key = relationship.toLowerCase(); - if (seen.add(key)) { - values.add(relationship); - } - } - values.sort( - (String a, String b) => a.toLowerCase().compareTo(b.toLowerCase()), - ); - return values; - } - - Future _handleAddPerson(BuildContext context, WidgetRef ref) async { - final List existingPeople = - ref.read(localRepositoryProvider).asData?.value.people ?? - const []; - final _PersonDraft? draft = await _showPersonEditor( - context, - title: 'Add Person', - existingPeople: existingPeople, - ); - - if (draft == null) { - return; - } - - await ref - .read(localRepositoryProvider.notifier) - .addPerson( - name: draft.name, - relationship: draft.relationship, - notes: draft.notes, - tags: draft.tags, - location: draft.location, - ); - - final LocalDataState? latest = ref - .read(localRepositoryProvider) - .asData - ?.value; - if (latest == null) { - return; - } - for (final PersonProfile person in latest.people) { - final bool locationMatches = - (person.location ?? '').trim() == (draft.location ?? '').trim(); - if (person.name == draft.name && - person.relationship == draft.relationship && - locationMatches) { - ref.read(selectedPersonIdProvider.notifier).select(person.id); - break; - } - } - } - - Future _handleEditPerson( - BuildContext context, - WidgetRef ref, - PersonProfile person, - ) async { - final List existingPeople = - ref.read(localRepositoryProvider).asData?.value.people ?? - const []; - final _PersonDraft? draft = await _showPersonEditor( - context, - title: 'Edit Person', - initial: _PersonDraft( - name: person.name, - relationship: person.relationship, - notes: person.notes, - tags: person.tags, - location: person.location, - ), - existingPeople: existingPeople, - editingPersonId: person.id, - ); - - if (draft == null) { - return; - } - - await ref - .read(localRepositoryProvider.notifier) - .updatePerson( - person.copyWith( - name: draft.name, - relationship: draft.relationship, - notes: draft.notes, - tags: draft.tags, - location: draft.location, - ), - ); - } - - Future _handleDeletePerson( - BuildContext context, - WidgetRef ref, - String personId, - ) async { - final bool? confirmed = await showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: const Text('Delete person?'), - content: const Text( - 'This will remove the person and related moments from local storage.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Delete'), - ), - ], - ); - }, - ); - - if (confirmed != true) { - return; - } - - await ref.read(localRepositoryProvider.notifier).deletePerson(personId); - ref.read(selectedPersonIdProvider.notifier).clear(); - } - - Widget _buildWideLayout( - BuildContext context, - WidgetRef ref, - LocalDataState data, - List people, - PersonProfile selected, - List relationshipOptions, - ) { - return Padding( - padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), - child: Row( - children: [ - Expanded( - flex: 5, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _PeopleHeader( - onAdd: () => _handleAddPerson(context, ref), - onMerge: () => _handleMergePeople(context, ref, people), - compact: false, - ), - const SizedBox(height: 12), - _PeopleListControls( - compact: false, - relationshipOptions: relationshipOptions, - ), - const SizedBox(height: 20), - Expanded( - child: ListView.separated( - itemCount: people.length, - separatorBuilder: (_, _) => const SizedBox(height: 12), - itemBuilder: (BuildContext context, int index) { - final PersonProfile person = people[index]; - return _PersonListItem( - person: person, - selectedId: selected.id, - compact: false, - onSelect: () { - ref - .read(selectedPersonIdProvider.notifier) - .select(person.id); - }, - ); - }, - ), - ), - ], - ), - ), - const SizedBox(width: 18), - Expanded( - flex: 6, - child: FrostedCard( - child: SingleChildScrollView( - child: _PersonDetail( - person: selected, - data: data, - onEdit: () => _handleEditPerson(context, ref, selected), - onDelete: () => - _handleDeletePerson(context, ref, selected.id), - onQuickAction: (_PersonQuickAction action) => - _handleQuickAction(context, ref, selected, action), - ), - ), - ), - ), - ], - ), - ); - } - - Widget _buildCompactLayout( - BuildContext context, - WidgetRef ref, - LocalDataState data, - List people, - PersonProfile selected, - List relationshipOptions, - ) { - return Padding( - padding: const EdgeInsets.fromLTRB(18, 16, 18, 20), - child: ListView( - children: [ - _PeopleHeader( - onAdd: () => _handleAddPerson(context, ref), - onMerge: () => _handleMergePeople(context, ref, people), - compact: true, - ), - const SizedBox(height: 10), - _PeopleListControls( - compact: true, - relationshipOptions: relationshipOptions, - ), - const SizedBox(height: 14), - FrostedCard( - child: _PersonDetail( - person: selected, - data: data, - compact: true, - onEdit: () => _handleEditPerson(context, ref, selected), - onDelete: () => _handleDeletePerson(context, ref, selected.id), - onQuickAction: (_PersonQuickAction action) => - _handleQuickAction(context, ref, selected, action), - ), - ), - const SizedBox(height: 14), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: Row( - children: [ - Text( - 'All profiles', - style: Theme.of(context).textTheme.titleMedium, - ), - const Spacer(), - Text( - '${people.length}', - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ], - ), - ), - const SizedBox(height: 10), - ...people.map( - (PersonProfile person) => Padding( - padding: const EdgeInsets.only(bottom: 10), - child: _PersonListItem( - person: person, - selectedId: selected.id, - compact: true, - onSelect: () { - ref.read(selectedPersonIdProvider.notifier).select(person.id); - }, - ), - ), - ), - ], - ), - ); - } - - Future _handleQuickAction( - BuildContext context, - WidgetRef ref, - PersonProfile person, - _PersonQuickAction action, - ) async { - switch (action) { - case _PersonQuickAction.capture: - final String? summary = await _showQuickTextCaptureDialog( - context, - title: 'Add Capture', - hintText: 'What happened in this interaction?', - ); - if (summary == null) { - return; - } - await ref - .read(localRepositoryProvider.notifier) - .addMoment(personId: person.id, summary: summary, type: 'capture'); - return; - case _PersonQuickAction.note: - final String? note = await _showQuickTextCaptureDialog( - context, - title: 'Add Note', - hintText: 'Write a quick context note...', - ); - if (note == null) { - return; - } - await ref - .read(localRepositoryProvider.notifier) - .addMoment(personId: person.id, summary: note, type: 'note'); - return; - case _PersonQuickAction.idea: - final _QuickIdeaDraft? idea = await showDialog<_QuickIdeaDraft>( - context: context, - builder: (BuildContext context) => const _QuickIdeaDialog(), - ); - if (idea == null) { - return; - } - await ref - .read(localRepositoryProvider.notifier) - .addIdea( - type: idea.type, - title: idea.title, - details: idea.details, - personId: person.id, - ); - return; - case _PersonQuickAction.reminder: - final _QuickReminderDraft? reminder = - await showDialog<_QuickReminderDraft>( - context: context, - builder: (BuildContext context) => const _QuickReminderDialog(), - ); - if (reminder == null) { - return; - } - await ref - .read(localRepositoryProvider.notifier) - .addReminder( - title: reminder.title, - cadence: reminder.cadence, - nextAt: reminder.nextAt, - personId: person.id, - ); - return; - } - } - - Future _handleMergePeople( - BuildContext context, - WidgetRef ref, - List people, - ) async { - if (people.length < 2) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Need at least two profiles to merge.')), - ); - return; - } - - final List duplicateCandidates = _duplicateCandidates( - people, - ); - if (duplicateCandidates.length < 2) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'No duplicate-name profiles found. Edit names first if needed.', - ), - ), - ); - return; - } - - final _MergePeopleDraft? draft = await showDialog<_MergePeopleDraft>( - context: context, - builder: (BuildContext context) => - _MergePeopleDialog(people: duplicateCandidates), - ); - if (draft == null) { - return; - } - - await ref - .read(localRepositoryProvider.notifier) - .mergePersonProfiles( - sourcePersonId: draft.sourcePersonId, - targetPersonId: draft.targetPersonId, - ); - ref.read(selectedPersonIdProvider.notifier).select(draft.targetPersonId); - if (!context.mounted) { - return; - } - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Profiles merged successfully.')), - ); - } - - List _duplicateCandidates(List people) { - final Map> grouped = - >{}; - for (final PersonProfile person in people) { - final String key = _normalizeNameKey(person.name); - if (key.isEmpty) { - continue; - } - grouped.putIfAbsent(key, () => []).add(person); - } - final List result = []; - for (final List group in grouped.values) { - if (group.length > 1) { - result.addAll(group); - } - } - return result; - } - - String _normalizeNameKey(String value) { - return value - .trim() - .toLowerCase() - .replaceAll(RegExp(r'[^a-z0-9]+'), ' ') - .replaceAll(RegExp(r'\s+'), ' '); - } - - Future<_PersonDraft?> _showPersonEditor( - BuildContext context, { - required String title, - _PersonDraft? initial, - List existingPeople = const [], - String? editingPersonId, - }) { - final bool compact = MediaQuery.sizeOf(context).width < 720; - if (compact) { - return showModalBottomSheet<_PersonDraft>( - context: context, - isScrollControlled: true, - useSafeArea: true, - backgroundColor: Colors.transparent, - builder: (BuildContext context) { - return _PersonEditorBottomSheet( - title: title, - initial: initial, - existingPeople: existingPeople, - editingPersonId: editingPersonId, - ); - }, - ); - } - - return showDialog<_PersonDraft>( - context: context, - builder: (BuildContext context) { - return _PersonEditorDialog( - title: title, - initial: initial, - existingPeople: existingPeople, - editingPersonId: editingPersonId, - ); - }, - ); - } - - Future _showQuickTextCaptureDialog( - BuildContext context, { - required String title, - required String hintText, - }) async { - return showDialog( - context: context, - builder: (BuildContext context) { - return _QuickTextCaptureDialog(title: title, hintText: hintText); - }, - ); - } -} - -class _EmptyPeopleView extends StatelessWidget { - const _EmptyPeopleView({required this.onAdd}); - - final VoidCallback onAdd; - - @override - Widget build(BuildContext context) { - return Center( - child: FrostedCard( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'No people yet', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - Text( - 'Add the first relationship profile to start tracking moments.', - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), - textAlign: TextAlign.center, - ), - const SizedBox(height: 12), - FilledButton.icon( - onPressed: onAdd, - icon: const Icon(Icons.person_add_alt_1_rounded), - label: const Text('Add Person'), - ), - ], - ), - ), - ); - } -} - -class _FilteredPeopleEmptyView extends StatelessWidget { - const _FilteredPeopleEmptyView({required this.onClear}); - - final VoidCallback onClear; - - @override - Widget build(BuildContext context) { - return Center( - child: FrostedCard( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.search_off_rounded, - size: 34, - color: AppTheme.textSecondary, - ), - const SizedBox(height: 10), - Text( - 'No matching people', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 6), - Text( - 'Try another search, relationship filter, or clear the current filters.', - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), - textAlign: TextAlign.center, - ), - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: onClear, - icon: const Icon(Icons.restart_alt_rounded), - label: const Text('Clear Filters'), - ), - ], - ), - ), - ); - } -} - -class _PeopleListControls extends ConsumerWidget { - const _PeopleListControls({ - required this.compact, - required this.relationshipOptions, - }); - - final bool compact; - final List relationshipOptions; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final String query = ref.watch(peopleSearchQueryProvider); - final String? selectedRelationship = ref.watch( - peopleRelationshipFilterProvider, - ); - final PeopleSortOption sortOption = ref.watch(peopleSortOptionProvider); - - return FrostedCard( - padding: EdgeInsets.all(compact ? 12 : 14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: TextFormField( - key: ValueKey('people-search-$query'), - initialValue: query, - onChanged: (String value) { - ref.read(peopleSearchQueryProvider.notifier).set(value); - }, - decoration: const InputDecoration( - hintText: 'Search name, tags, notes, location', - prefixIcon: Icon(Icons.search_rounded), - isDense: true, - border: OutlineInputBorder(), - ), - ), - ), - const SizedBox(width: 8), - PopupMenuButton( - tooltip: 'Sort', - onSelected: (PeopleSortOption value) { - ref.read(peopleSortOptionProvider.notifier).set(value); - }, - itemBuilder: (BuildContext context) => - >[ - const PopupMenuItem( - value: PeopleSortOption.affinity, - child: Text('Sort by affinity'), - ), - const PopupMenuItem( - value: PeopleSortOption.nextMoment, - child: Text('Sort by next moment'), - ), - const PopupMenuItem( - value: PeopleSortOption.alphabetical, - child: Text('Sort A-Z'), - ), - ], - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 10, - ), - decoration: BoxDecoration( - color: const Color(0xFFF3F8FB), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.sort_rounded, size: 18), - if (!compact) ...[ - const SizedBox(width: 6), - Text(switch (sortOption) { - PeopleSortOption.affinity => 'Affinity', - PeopleSortOption.nextMoment => 'Next', - PeopleSortOption.alphabetical => 'A-Z', - }), - ], - ], - ), - ), - ), - ], - ), - const SizedBox(height: 10), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - _FilterChipButton( - label: 'All', - selected: selectedRelationship == null, - onTap: () { - ref - .read(peopleRelationshipFilterProvider.notifier) - .set(null); - }, - ), - ...relationshipOptions.map( - (String relationship) => Padding( - padding: const EdgeInsets.only(left: 8), - child: _FilterChipButton( - label: relationship, - selected: - selectedRelationship?.toLowerCase() == - relationship.toLowerCase(), - onTap: () { - final PeopleRelationshipFilterNotifier controller = ref - .read(peopleRelationshipFilterProvider.notifier); - final bool isSelected = - selectedRelationship?.toLowerCase() == - relationship.toLowerCase(); - controller.set(isSelected ? null : relationship); - }, - ), - ), - ), - ], - ), - ), - ], - ), - ); - } -} - -class _FilterChipButton extends StatelessWidget { - const _FilterChipButton({ - required this.label, - required this.selected, - required this.onTap, - }); - - final String label; - final bool selected; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(99), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: selected ? const Color(0xFFEAF7FB) : const Color(0xFFF4F8FB), - borderRadius: BorderRadius.circular(99), - border: Border.all( - color: selected - ? const Color(0xFFB8E4EE) - : Colors.white.withValues(alpha: 0.8), - ), - ), - child: Text( - label, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: selected ? AppTheme.primary : AppTheme.textSecondary, - fontWeight: selected ? FontWeight.w700 : FontWeight.w500, - ), - ), - ), - ); - } -} - -class _PersonDetail extends ConsumerWidget { - const _PersonDetail({ - required this.person, - required this.data, - required this.onEdit, - required this.onDelete, - required this.onQuickAction, - this.compact = false, - }); - - final PersonProfile person; - final LocalDataState data; - final VoidCallback onEdit; - final VoidCallback onDelete; - final ValueChanged<_PersonQuickAction> onQuickAction; - final bool compact; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final List moments = - data.moments - .where((RelationshipMoment moment) => moment.personId == person.id) - .toList(growable: false) - ..sort( - (RelationshipMoment a, RelationshipMoment b) => - b.at.compareTo(a.at), - ); - final List ideas = - data.ideas - .where((RelationshipIdea idea) => idea.personId == person.id) - .toList(growable: false) - ..sort( - (RelationshipIdea a, RelationshipIdea b) => - b.createdAt.compareTo(a.createdAt), - ); - final List reminders = - data.reminders - .where((ReminderRule reminder) => reminder.personId == person.id) - .toList(growable: false) - ..sort( - (ReminderRule a, ReminderRule b) => a.nextAt.compareTo(b.nextAt), - ); - final List sharedMessages = - data.sharedMessages - .where((SharedMessageEntry entry) => entry.profileId == person.id) - .toList(growable: false) - ..sort( - (SharedMessageEntry a, SharedMessageEntry b) => - b.sharedAt.compareTo(a.sharedAt), - ); - final List sourceLinks = - data.sourceLinks - .where((SourceProfileLink link) => link.profileId == person.id) - .toList(growable: false) - ..sort( - (SourceProfileLink a, SourceProfileLink b) => - b.lastSeenAt.compareTo(a.lastSeenAt), - ); - final List preferenceSignals = - data.preferenceSignals - .where( - (PersonPreferenceSignal signal) => signal.personId == person.id, - ) - .toList(growable: false) - ..sort(_peoplePreferenceSignalSort); - final bool hasNotes = person.notes.trim().isNotEmpty; - final String? location = person.location?.trim().isEmpty ?? true - ? null - : person.location!.trim(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _PersonWorkspaceHero( - person: person, - compact: compact, - sourceLinks: sourceLinks, - onEdit: onEdit, - onDelete: onDelete, - onQuickAction: onQuickAction, - ), - SizedBox(height: compact ? 12 : 14), - Wrap( - spacing: 10, - runSpacing: 10, - children: [ - _PersonStatTile( - label: 'Affinity', - value: '${person.affinityScore}%', - accent: const Color(0xFF1AB6C8), - compact: compact, - ), - _PersonStatTile( - label: 'Next moment', - value: _dateTimeLabel(person.nextMoment), - accent: const Color(0xFF2274E0), - compact: compact, - ), - _PersonStatTile( - label: 'Captures', - value: '${moments.length}', - accent: const Color(0xFF23A26D), - compact: compact, - ), - _PersonStatTile( - label: 'Ideas', - value: - '${ideas.where((RelationshipIdea e) => !e.isArchived).length}', - accent: const Color(0xFFF4A524), - compact: compact, - ), - ], - ), - const SizedBox(height: 12), - _PeopleInsightSectionCard( - title: 'Profile context', - icon: Icons.person_outline_rounded, - children: [ - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _InlineMetaPill( - icon: Icons.favorite_outline_rounded, - label: person.relationship, - ), - if (location != null) - _InlineMetaPill( - icon: Icons.location_on_outlined, - label: location, - ), - if (sourceLinks.isNotEmpty) - _InlineMetaPill( - icon: Icons.link_rounded, - label: - '${sourceLinks.length} linked source${sourceLinks.length == 1 ? '' : 's'}', - ), - ...person.tags.map( - (String tag) => - _InlineMetaPill(icon: Icons.sell_outlined, label: tag), - ), - ], - ), - const SizedBox(height: 10), - _PeopleInsightBodyBlock( - text: hasNotes - ? person.notes.trim() - : 'Add notes about preferences, boundaries, and what matters so future follow-ups stay relevant.', - muted: !hasNotes, - ), - ], - ), - const SizedBox(height: 12), - _PeopleInsightSectionCard( - title: 'Chat-derived preferences', - icon: Icons.psychology_alt_outlined, - children: preferenceSignals.isEmpty - ? const [ - _PeopleSectionEmpty( - label: - 'No inferred preferences yet. Share messages from chat apps to build context for this person.', - ), - ] - : preferenceSignals - .map( - (PersonPreferenceSignal signal) => - _PeoplePreferenceSignalCard( - signal: signal, - compact: compact, - onWhy: () => _showPeoplePreferenceEvidence( - context, - signal: signal, - personName: person.name, - ), - onConfirm: () async { - await ref - .read(localRepositoryProvider.notifier) - .confirmPreferenceSignal(signal.id); - }, - onDismiss: () async { - await ref - .read(localRepositoryProvider.notifier) - .dismissPreferenceSignal(signal.id); - }, - ), - ) - .toList(growable: false), - ), - const SizedBox(height: 12), - _PeopleInsightSectionCard( - title: 'Ideas', - icon: Icons.lightbulb_outline_rounded, - children: ideas.isEmpty - ? const [ - _PeopleSectionEmpty( - label: - 'No ideas yet. Use Quick add to capture gift or event ideas.', - ), - ] - : ideas - .take(compact ? 4 : 6) - .map( - (RelationshipIdea idea) => _PeopleInsightRow( - title: idea.title, - subtitle: idea.details.isEmpty - ? 'No details yet' - : idea.details, - meta: - '${idea.type.name.toUpperCase()} • ${_shortDateTimeLabel(idea.createdAt)}${idea.isArchived ? ' • ARCHIVED' : ''}', - ), - ) - .toList(growable: false), - ), - const SizedBox(height: 12), - _PeopleInsightSectionCard( - title: 'Moments', - icon: Icons.auto_awesome_rounded, - children: moments.isEmpty - ? const [ - _PeopleSectionEmpty( - label: 'No captures logged yet for this person.', - ), - ] - : moments - .take(compact ? 4 : 6) - .map( - (RelationshipMoment moment) => _PeopleInsightRow( - title: moment.title, - subtitle: moment.summary, - meta: - '${moment.type.toUpperCase()} • ${_shortDateTimeLabel(moment.at)}', - ), - ) - .toList(growable: false), - ), - const SizedBox(height: 12), - _PeopleInsightSectionCard( - title: 'Reminders', - icon: Icons.notifications_active_outlined, - children: reminders.isEmpty - ? const [ - _PeopleSectionEmpty( - label: - 'No reminders yet. Add one to keep the relationship active.', - ), - ] - : reminders - .take(compact ? 4 : 6) - .map( - (ReminderRule reminder) => _PeopleInsightRow( - title: reminder.title, - subtitle: reminder.enabled ? 'Enabled' : 'Disabled', - meta: - '${reminder.cadence.name.toUpperCase()} • Next ${_shortDateTimeLabel(reminder.nextAt)}', - ), - ) - .toList(growable: false), - ), - const SizedBox(height: 12), - _PeopleInsightSectionCard( - title: 'Shared message history', - icon: Icons.chat_bubble_outline_rounded, - children: sharedMessages.isEmpty - ? const [ - _PeopleSectionEmpty( - label: 'No imported shared messages for this profile yet.', - ), - ] - : sharedMessages - .take(compact ? 3 : 5) - .map( - (SharedMessageEntry entry) => _PeopleInsightRow( - title: - entry.sourceDisplayName?.trim().isNotEmpty == true - ? entry.sourceDisplayName!.trim() - : 'Shared from ${entry.sourceApp}', - subtitle: entry.messageText.trim().isEmpty - ? 'Imported message content is empty' - : entry.messageText.trim(), - meta: - '${entry.resolvedAutomatically ? 'AUTO-LINKED' : 'MANUAL-LINK'} • ${_shortDateTimeLabel(entry.sharedAt)}', - ), - ) - .toList(growable: false), - ), - ], - ); - } -} - -Future _showPeoplePreferenceEvidence( - BuildContext context, { - required PersonPreferenceSignal signal, - required String personName, -}) { - return showModalBottomSheet( - context: context, - useSafeArea: true, - showDragHandle: true, - builder: (BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Why this signal?', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 6), - Text( - '${signal.label} • ${signal.category} • ${signal.polarity.name.toUpperCase()}', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: 4), - Text( - 'For $personName • confidence ${(signal.confidence * 100).round()}%', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: 12), - if (signal.evidenceSnippets.isEmpty) - const _PeopleSectionEmpty( - label: 'No evidence snippets stored yet for this signal.', - ) - else - ...signal.evidenceSnippets.map( - (String snippet) => _PeopleInsightRow( - title: 'Evidence', - subtitle: snippet, - meta: 'Source(s): ${signal.sourceApps.join(', ')}', - ), - ), - const SizedBox(height: 8), - Align( - alignment: Alignment.centerRight, - child: TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Close'), - ), - ), - ], - ), - ); - }, - ); -} - -int _peoplePreferenceSignalSort( - PersonPreferenceSignal a, - PersonPreferenceSignal b, -) { - int rank(PreferenceSignalStatus status) => switch (status) { - PreferenceSignalStatus.inferred => 0, - PreferenceSignalStatus.confirmed => 1, - PreferenceSignalStatus.dismissed => 2, - }; - - final int byStatus = rank(a.status).compareTo(rank(b.status)); - if (byStatus != 0) { - return byStatus; - } - final int byConfidence = b.confidence.compareTo(a.confidence); - if (byConfidence != 0) { - return byConfidence; - } - return a.label.toLowerCase().compareTo(b.label.toLowerCase()); -} - -class _PeoplePreferenceSignalCard extends StatelessWidget { - const _PeoplePreferenceSignalCard({ - required this.signal, - required this.compact, - required this.onConfirm, - required this.onDismiss, - required this.onWhy, - }); - - final PersonPreferenceSignal signal; - final bool compact; - final Future Function() onConfirm; - final Future Function() onDismiss; - final VoidCallback onWhy; - - @override - Widget build(BuildContext context) { - final Color tone = switch (signal.status) { - PreferenceSignalStatus.confirmed => const Color(0xFF1D9C66), - PreferenceSignalStatus.dismissed => const Color(0xFFA56A00), - PreferenceSignalStatus.inferred => AppTheme.primary, - }; - final IconData polarityIcon = switch (signal.polarity) { - PreferenceSignalPolarity.like => Icons.thumb_up_alt_outlined, - PreferenceSignalPolarity.dislike => Icons.thumb_down_alt_outlined, - PreferenceSignalPolarity.neutral => Icons.tune_rounded, - }; - - return Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFEAF1F4)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Icon(polarityIcon, size: 16, color: tone), - Text( - signal.label, - style: Theme.of(context).textTheme.titleSmall, - ), - _PeoplePreferenceBadge(label: signal.status.name, color: tone), - _PeoplePreferenceBadge( - label: '${(signal.confidence * 100).round()}%', - color: AppTheme.textSecondary, - filled: false, - ), - ], - ), - const SizedBox(height: 5), - Text( - '${signal.category.toUpperCase()} • seen ${signal.occurrenceCount}x', - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - OutlinedButton(onPressed: onWhy, child: const Text('Why?')), - if (signal.status != PreferenceSignalStatus.confirmed) - FilledButton.tonal( - onPressed: () async => onConfirm(), - child: const Text('Confirm'), - ), - if (signal.status != PreferenceSignalStatus.dismissed) - TextButton( - onPressed: () async => onDismiss(), - child: const Text('Dismiss'), - ), - ], - ), - if (!compact && signal.sourceApps.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - 'Sources: ${signal.sourceApps.join(', ')}', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), - ), - ], - ], - ), - ), - ); - } -} - -class _PeoplePreferenceBadge extends StatelessWidget { - const _PeoplePreferenceBadge({ - required this.label, - required this.color, - this.filled = true, - }); - - final String label; - final Color color; - final bool filled; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: filled ? color.withValues(alpha: 0.12) : Colors.transparent, - borderRadius: BorderRadius.circular(99), - border: Border.all(color: color.withValues(alpha: filled ? 0.2 : 0.35)), - ), - child: Text( - label.toUpperCase(), - style: Theme.of(context).textTheme.labelSmall?.copyWith(color: color), - ), - ); - } -} - -class _PersonWorkspaceHero extends StatelessWidget { - const _PersonWorkspaceHero({ - required this.person, - required this.compact, - required this.sourceLinks, - required this.onEdit, - required this.onDelete, - required this.onQuickAction, - }); - - final PersonProfile person; - final bool compact; - final List sourceLinks; - final VoidCallback onEdit; - final VoidCallback onDelete; - final ValueChanged<_PersonQuickAction> onQuickAction; - - @override - Widget build(BuildContext context) { - final Widget actions = Wrap( - spacing: 8, - runSpacing: 8, - alignment: WrapAlignment.end, - children: [ - FilledButton.icon( - onPressed: () => onQuickAction(_PersonQuickAction.capture), - icon: const Icon(Icons.add_comment_outlined, size: 18), - label: const Text('Capture'), - ), - OutlinedButton.icon( - onPressed: () => onQuickAction(_PersonQuickAction.idea), - icon: const Icon(Icons.lightbulb_outline_rounded, size: 18), - label: const Text('Idea'), - ), - PopupMenuButton<_PersonQuickAction>( - tooltip: 'More quick add actions', - onSelected: onQuickAction, - itemBuilder: (BuildContext context) => - const >[ - PopupMenuItem<_PersonQuickAction>( - value: _PersonQuickAction.capture, - child: Text('Add Capture'), - ), - PopupMenuItem<_PersonQuickAction>( - value: _PersonQuickAction.note, - child: Text('Add Note'), - ), - PopupMenuItem<_PersonQuickAction>( - value: _PersonQuickAction.idea, - child: Text('Add Idea'), - ), - PopupMenuItem<_PersonQuickAction>( - value: _PersonQuickAction.reminder, - child: Text('Add Reminder'), - ), - ], - child: const _HeroIconButton( - icon: Icons.add_circle_outline_rounded, - tooltip: 'Quick add', - ), - ), - IconButton( - onPressed: onEdit, - icon: const Icon(Icons.edit_rounded), - tooltip: 'Edit person', - ), - IconButton( - onPressed: onDelete, - icon: const Icon(Icons.delete_outline_rounded), - tooltip: 'Delete person', - ), - ], - ); - - return Container( - width: double.infinity, - padding: EdgeInsets.all(compact ? 14 : 16), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(18), - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFFF7FBFD), Color(0xFFEAF4FA)], - ), - border: Border.all(color: Colors.white.withValues(alpha: 0.9)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (compact) ...[ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _AvatarSeed(name: person.name, size: 58), - const SizedBox(width: 12), - Expanded(child: _HeroIdentity(person: person)), - ], - ), - const SizedBox(height: 12), - actions, - ] else ...[ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _AvatarSeed(name: person.name, size: 64), - const SizedBox(width: 14), - Expanded(child: _HeroIdentity(person: person)), - const SizedBox(width: 10), - Flexible( - child: Align(alignment: Alignment.topRight, child: actions), - ), - ], - ), - ], - const SizedBox(height: 10), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _InlineMetaPill( - icon: Icons.schedule_rounded, - label: 'Next ${_dateTimeLabel(person.nextMoment)}', - ), - if (sourceLinks.isNotEmpty) - _InlineMetaPill( - icon: Icons.link_rounded, - label: - 'Last linked ${_shortDateTimeLabel(sourceLinks.first.lastSeenAt)}', - ), - ], - ), - ], - ), - ); - } -} - -class _HeroIdentity extends StatelessWidget { - const _HeroIdentity({required this.person}); - - final PersonProfile person; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - person.name, - style: Theme.of(context).textTheme.titleLarge, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Text( - person.relationship, - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ); - } -} - -class _HeroIconButton extends StatelessWidget { - const _HeroIconButton({required this.icon, required this.tooltip}); - - final IconData icon; - final String tooltip; - - @override - Widget build(BuildContext context) { - return Tooltip( - message: tooltip, - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: const Color(0xFFF2F7FA), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.white), - ), - child: Icon(icon, color: AppTheme.primary, size: 20), - ), - ); - } -} - -class _PersonStatTile extends StatelessWidget { - const _PersonStatTile({ - required this.label, - required this.value, - required this.accent, - required this.compact, - }); - - final String label; - final String value; - final Color accent; - final bool compact; - - @override - Widget build(BuildContext context) { - final double width = compact ? 154 : 172; - return Container( - width: width, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.66), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: accent.withValues(alpha: 0.12)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: Theme.of( - context, - ).textTheme.labelMedium?.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: 6), - Text( - value, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: accent, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - ); - } -} - -class _PeopleInsightSectionCard extends StatelessWidget { - const _PeopleInsightSectionCard({ - required this.title, - required this.icon, - required this.children, - }); - - final String title; - final IconData icon; - final List children; - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFFF8FBFD), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Colors.white.withValues(alpha: 0.9)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(icon, size: 18, color: AppTheme.primary), - const SizedBox(width: 8), - Expanded( - child: Text( - title, - style: Theme.of(context).textTheme.titleMedium, - ), - ), - ], - ), - const SizedBox(height: 10), - ...children, - ], - ), - ); - } -} - -class _PeopleInsightRow extends StatelessWidget { - const _PeopleInsightRow({ - required this.title, - required this.subtitle, - required this.meta, - }); - - final String title; - final String subtitle; - final String meta; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFEAF1F4)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: Theme.of(context).textTheme.titleSmall, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Text( - subtitle, - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: 6), - Text( - meta, - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: AppTheme.textSecondary), - ), - ], - ), - ), - ); - } -} - -class _PeopleInsightBodyBlock extends StatelessWidget { - const _PeopleInsightBodyBlock({required this.text, required this.muted}); - - final String text; - final bool muted; - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFEAF1F4)), - ), - child: Text( - text, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: muted ? AppTheme.textSecondary : null, - height: 1.35, - ), - ), - ); - } -} - -class _PeopleSectionEmpty extends StatelessWidget { - const _PeopleSectionEmpty({required this.label}); - - final String label; - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFEAF1F4)), - ), - child: Text( - label, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), - ), - ); - } -} - -class _PeopleHeader extends StatelessWidget { - const _PeopleHeader({ - required this.onAdd, - required this.onMerge, - required this.compact, - }); - - final VoidCallback onAdd; - final VoidCallback onMerge; - final bool compact; - - @override - Widget build(BuildContext context) { - final TextTheme textTheme = Theme.of(context).textTheme; - if (compact) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('People', style: textTheme.headlineMedium), - const SizedBox(height: 6), - Text( - 'Track what matters to each relationship and follow through.', - style: textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 12), - FilledButton.icon( - onPressed: onAdd, - icon: const Icon(Icons.person_add_alt_1_rounded), - label: const Text('Add person'), - ), - const SizedBox(height: 8), - OutlinedButton.icon( - onPressed: onMerge, - icon: const Icon(Icons.merge_type_rounded), - label: const Text('Merge duplicates'), - ), - ], - ); - } - - return Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('People', style: textTheme.headlineMedium), - const SizedBox(height: 6), - Text( - 'Track what matters to each relationship and follow through.', - style: textTheme.bodyLarge?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ], - ), - ), - FilledButton.icon( - onPressed: onAdd, - icon: const Icon(Icons.person_add_alt_1_rounded), - label: const Text('Add'), - ), - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: onMerge, - icon: const Icon(Icons.merge_type_rounded), - label: const Text('Merge'), - ), - ], - ); - } -} - -class _PersonListItem extends StatelessWidget { - const _PersonListItem({ - required this.person, - required this.selectedId, - required this.compact, - required this.onSelect, - }); - - final PersonProfile person; - final String selectedId; - final bool compact; - final VoidCallback onSelect; - - @override - Widget build(BuildContext context) { - final bool isSelected = person.id == selectedId; - return InkWell( - onTap: onSelect, - borderRadius: BorderRadius.circular(20), - child: FrostedCard( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - _AvatarSeed(name: person.name), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - person.name, - style: Theme.of(context).textTheme.titleMedium, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Text( - person.relationship, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppTheme.textSecondary, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - const SizedBox(width: 8), - _ScorePill(score: person.affinityScore), - if (isSelected) - const Padding( - padding: EdgeInsets.only(left: 10), - child: Icon(Icons.check_circle, color: Color(0xFF1AB6C8)), - ), - ], - ), - const SizedBox(height: 10), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _InlineMetaPill( - icon: Icons.schedule_rounded, - label: _dateTimeLabel(person.nextMoment), - ), - if ((person.location ?? '').trim().isNotEmpty) - _InlineMetaPill( - icon: Icons.location_on_outlined, - label: person.location!.trim(), - ), - ...person.tags - .take(compact ? 2 : 3) - .map( - (String tag) => _InlineMetaPill( - icon: Icons.sell_outlined, - label: tag, - ), - ), - ], - ), - ], - ), - ), - ); - } -} - -class _PersonEditorDialog extends StatelessWidget { - const _PersonEditorDialog({ - required this.title, - required this.existingPeople, - this.initial, - this.editingPersonId, - }); - - final String title; - final List existingPeople; - final _PersonDraft? initial; - final String? editingPersonId; - - @override - Widget build(BuildContext context) { - return Dialog( - backgroundColor: Colors.transparent, - insetPadding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 720), - child: _PersonEditorPanel( - title: title, - initial: initial, - existingPeople: existingPeople, - editingPersonId: editingPersonId, - compact: false, - sheetStyle: false, - ), - ), - ); - } -} - -class _QuickTextCaptureDialog extends StatefulWidget { - const _QuickTextCaptureDialog({required this.title, required this.hintText}); - - final String title; - final String hintText; - - @override - State<_QuickTextCaptureDialog> createState() => - _QuickTextCaptureDialogState(); -} - -class _QuickTextCaptureDialogState extends State<_QuickTextCaptureDialog> { - late final TextEditingController _controller; - bool _attemptedSubmit = false; - - @override - void initState() { - super.initState(); - _controller = TextEditingController(); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final String value = _controller.text.trim(); - return AlertDialog( - title: Text(widget.title), - content: TextField( - controller: _controller, - minLines: 2, - maxLines: 4, - autofocus: true, - onChanged: (_) { - if (_attemptedSubmit) { - setState(() {}); - } - }, - decoration: InputDecoration( - hintText: widget.hintText, - errorText: _attemptedSubmit && value.isEmpty ? 'Required' : null, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - final String submitted = _controller.text.trim(); - if (submitted.isEmpty) { - setState(() { - _attemptedSubmit = true; - }); - return; - } - Navigator.of(context).pop(submitted); - }, - child: const Text('Save'), - ), - ], - ); - } -} - -class _PersonEditorBottomSheet extends StatelessWidget { - const _PersonEditorBottomSheet({ - required this.title, - required this.existingPeople, - this.initial, - this.editingPersonId, - }); - - final String title; - final List existingPeople; - final _PersonDraft? initial; - final String? editingPersonId; - - @override - Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only( - left: 8, - right: 8, - bottom: MediaQuery.viewInsetsOf(context).bottom + 8, - ), - child: _PersonEditorPanel( - title: title, - initial: initial, - existingPeople: existingPeople, - editingPersonId: editingPersonId, - compact: true, - sheetStyle: true, - ), - ); - } -} - -class _PersonEditorPanel extends StatefulWidget { - const _PersonEditorPanel({ - required this.title, - required this.compact, - required this.sheetStyle, - required this.existingPeople, - this.initial, - this.editingPersonId, - }); - - final String title; - final bool compact; - final bool sheetStyle; - final List existingPeople; - final _PersonDraft? initial; - final String? editingPersonId; - - @override - State<_PersonEditorPanel> createState() => _PersonEditorPanelState(); -} - -class _PersonEditorPanelState extends State<_PersonEditorPanel> { - late final TextEditingController _nameController; - late final TextEditingController _relationshipController; - late final TextEditingController _locationController; - late final TextEditingController _tagsController; - late final TextEditingController _notesController; - bool _attemptedSubmit = false; - - @override - void initState() { - super.initState(); - _nameController = TextEditingController(text: widget.initial?.name ?? '') - ..addListener(_handlePreviewChanged); - _relationshipController = TextEditingController( - text: widget.initial?.relationship ?? '', - )..addListener(_handlePreviewChanged); - _locationController = TextEditingController( - text: widget.initial?.location ?? '', - )..addListener(_handlePreviewChanged); - _tagsController = TextEditingController( - text: widget.initial?.tags.join(', ') ?? '', - )..addListener(_handlePreviewChanged); - _notesController = TextEditingController(text: widget.initial?.notes ?? ''); - } - - @override - void dispose() { - _nameController - ..removeListener(_handlePreviewChanged) - ..dispose(); - _relationshipController - ..removeListener(_handlePreviewChanged) - ..dispose(); - _locationController - ..removeListener(_handlePreviewChanged) - ..dispose(); - _tagsController - ..removeListener(_handlePreviewChanged) - ..dispose(); - _notesController.dispose(); - super.dispose(); - } - - void _handlePreviewChanged() { - if (mounted) { - setState(() {}); - } - } - - @override - Widget build(BuildContext context) { - final String name = _nameController.text.trim(); - final String relationship = _relationshipController.text.trim(); - final bool nameMissing = _attemptedSubmit && name.isEmpty; - final bool relationshipMissing = _attemptedSubmit && relationship.isEmpty; - final String? location = _locationController.text.trim().isEmpty - ? null - : _locationController.text.trim(); - final List tags = _tagsController.text - .split(',') - .map((String tag) => tag.trim()) - .where((String tag) => tag.isNotEmpty) - .toList(growable: false); - final List duplicateMatches = _findDuplicateNameMatches( - name: name, - people: widget.existingPeople, - excludePersonId: widget.editingPersonId, - ); - final bool hasDuplicateWarning = duplicateMatches.isNotEmpty; - - final BorderRadius radius = BorderRadius.circular(widget.compact ? 24 : 22); - final double maxHeight = widget.compact ? 720 : 760; - - return Material( - color: Colors.transparent, - child: Container( - constraints: BoxConstraints( - maxHeight: maxHeight, - minHeight: widget.compact ? 360 : 420, - ), - decoration: BoxDecoration( - borderRadius: radius, - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFFF7FBFD), Color(0xFFEAF3F9)], - ), - border: Border.all(color: Colors.white.withValues(alpha: 0.95)), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.08), - blurRadius: 26, - offset: const Offset(0, 14), - ), - ], - ), - child: ClipRRect( - borderRadius: radius, - child: Column( - children: [ - if (widget.sheetStyle) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Container( - width: 42, - height: 4, - decoration: BoxDecoration( - color: const Color(0xFFCCD6DD), - borderRadius: BorderRadius.circular(99), - ), - ), - ), - Expanded( - child: SingleChildScrollView( - padding: EdgeInsets.fromLTRB( - widget.compact ? 14 : 18, - widget.compact ? 10 : 16, - widget.compact ? 14 : 18, - 12, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _PersonEditorHero( - title: widget.title, - compact: widget.compact, - previewName: name, - previewRelationship: relationship, - previewLocation: location, - previewTags: tags, - ), - if (hasDuplicateWarning) ...[ - const SizedBox(height: 10), - _PersonEditorWarningBanner(matches: duplicateMatches), - ], - const SizedBox(height: 12), - if (widget.compact) - Column( - children: [ - _PersonEditorSectionCard( - title: 'Identity', - icon: Icons.person_outline_rounded, - child: Column( - children: [ - TextField( - controller: _nameController, - autofocus: true, - decoration: InputDecoration( - labelText: 'Name', - errorText: nameMissing - ? 'Name is required' - : null, - ), - ), - const SizedBox(height: 10), - TextField( - controller: _relationshipController, - decoration: InputDecoration( - labelText: 'Relationship', - errorText: relationshipMissing - ? 'Relationship is required' - : null, - ), - ), - const SizedBox(height: 10), - TextField( - controller: _locationController, - decoration: const InputDecoration( - labelText: 'Location (optional)', - ), - ), - ], - ), - ), - const SizedBox(height: 10), - _PersonEditorSectionCard( - title: 'Context', - icon: Icons.auto_awesome_rounded, - child: Column( - children: [ - TextField( - controller: _tagsController, - decoration: const InputDecoration( - labelText: 'Tags (comma separated)', - ), - ), - const SizedBox(height: 10), - TextField( - controller: _notesController, - minLines: 3, - maxLines: 5, - decoration: const InputDecoration( - labelText: 'Notes', - hintText: - 'Preferences, boundaries, gift clues, recurring themes...', - ), - ), - ], - ), - ), - ], - ) - else - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: _PersonEditorSectionCard( - title: 'Identity', - icon: Icons.person_outline_rounded, - child: Column( - children: [ - TextField( - controller: _nameController, - autofocus: true, - decoration: InputDecoration( - labelText: 'Name', - errorText: nameMissing - ? 'Name is required' - : null, - ), - ), - const SizedBox(height: 10), - TextField( - controller: _relationshipController, - decoration: InputDecoration( - labelText: 'Relationship', - errorText: relationshipMissing - ? 'Relationship is required' - : null, - ), - ), - const SizedBox(height: 10), - TextField( - controller: _locationController, - decoration: const InputDecoration( - labelText: 'Location (optional)', - ), - ), - ], - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: _PersonEditorSectionCard( - title: 'Context', - icon: Icons.auto_awesome_rounded, - child: Column( - children: [ - TextField( - controller: _tagsController, - decoration: const InputDecoration( - labelText: 'Tags (comma separated)', - ), - ), - const SizedBox(height: 10), - TextField( - controller: _notesController, - minLines: 5, - maxLines: 8, - decoration: const InputDecoration( - labelText: 'Notes', - hintText: - 'Preferences, boundaries, gift clues, recurring themes...', - ), - ), - ], - ), - ), - ), - ], - ), - ], - ), - ), - ), - Container( - padding: EdgeInsets.fromLTRB( - widget.compact ? 14 : 18, - 10, - widget.compact ? 14 : 18, - widget.compact ? 14 : 16, - ), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.75), - border: Border( - top: BorderSide(color: Colors.white.withValues(alpha: 0.9)), - ), - ), - child: Row( - children: [ - Expanded( - child: Text( - 'Required: name and relationship', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ), - const SizedBox(width: 8), - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - const SizedBox(width: 8), - FilledButton( - onPressed: () { - final String submittedName = _nameController.text - .trim(); - final String submittedRelationship = - _relationshipController.text.trim(); - if (submittedName.isEmpty || - submittedRelationship.isEmpty) { - setState(() { - _attemptedSubmit = true; - }); - return; - } - - final _PersonDraft draft = _PersonDraft( - name: submittedName, - relationship: submittedRelationship, - location: _locationController.text.trim().isEmpty - ? null - : _locationController.text.trim(), - notes: _notesController.text.trim(), - tags: _tagsController.text - .split(',') - .map((String tag) => tag.trim()) - .where((String tag) => tag.isNotEmpty) - .toList(growable: false), - ); - Navigator.of(context).pop(draft); - }, - child: const Text('Save'), - ), - ], - ), - ), - ], - ), - ), - ), - ); - } -} - -class _PersonEditorHero extends StatelessWidget { - const _PersonEditorHero({ - required this.title, - required this.compact, - required this.previewName, - required this.previewRelationship, - required this.previewLocation, - required this.previewTags, - }); - - final String title; - final bool compact; - final String previewName; - final String previewRelationship; - final String? previewLocation; - final List previewTags; - - @override - Widget build(BuildContext context) { - final String displayName = previewName.isEmpty ? 'New person' : previewName; - final String displayRelationship = previewRelationship.isEmpty - ? 'Relationship type' - : previewRelationship; - - return Container( - width: double.infinity, - padding: EdgeInsets.all(compact ? 14 : 16), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: Colors.white.withValues(alpha: 0.72), - border: Border.all(color: Colors.white), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _AvatarSeed(name: displayName, size: compact ? 50 : 56), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 4), - Text( - 'Shape the profile first, then use quick-add from the People workspace.', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _InlineMetaPill( - icon: Icons.person_outline_rounded, - label: displayName, - ), - _InlineMetaPill( - icon: Icons.favorite_outline_rounded, - label: displayRelationship, - ), - if (previewLocation != null) - _InlineMetaPill( - icon: Icons.location_on_outlined, - label: previewLocation!, - ), - ...previewTags - .take(compact ? 2 : 4) - .map( - (String tag) => - _InlineMetaPill(icon: Icons.sell_outlined, label: tag), - ), - ], - ), - ], - ), - ); - } -} - -class _PersonEditorSectionCard extends StatelessWidget { - const _PersonEditorSectionCard({ - required this.title, - required this.icon, - required this.child, - }); - - final String title; - final IconData icon; - final Widget child; - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.78), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Colors.white), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(icon, size: 18, color: AppTheme.primary), - const SizedBox(width: 8), - Expanded( - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium, - ), - ), - ], - ), - const SizedBox(height: 10), - child, - ], - ), - ); - } -} - -class _PersonEditorWarningBanner extends StatelessWidget { - const _PersonEditorWarningBanner({required this.matches}); - - final List matches; - - @override - Widget build(BuildContext context) { - final String names = matches - .take(2) - .map( - (PersonProfile person) => '${person.name} (${person.relationship})', - ) - .join(', '); - final int extraCount = matches.length - 2; - final String suffix = extraCount > 0 ? ' +$extraCount more' : ''; - - return Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFFFFF7E8), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: const Color(0xFFFFE2A8)), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Padding( - padding: EdgeInsets.only(top: 1), - child: Icon( - Icons.warning_amber_rounded, - size: 18, - color: Color(0xFFA56A00), - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Possible duplicate profile name detected. Existing: $names$suffix. You can still save, then merge duplicates from People.', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: const Color(0xFF6F5200)), - ), - ), - ], - ), - ); - } -} - -class _PersonDraft { - const _PersonDraft({ - required this.name, - required this.relationship, - required this.notes, - required this.tags, - this.location, - }); - - final String name; - final String relationship; - final String notes; - final List tags; - final String? location; -} - -List _findDuplicateNameMatches({ - required String name, - required List people, - String? excludePersonId, -}) { - final String normalized = _normalizePersonNameKey(name); - if (normalized.isEmpty) { - return const []; - } - - return people - .where((PersonProfile person) { - if (excludePersonId != null && person.id == excludePersonId) { - return false; - } - return _normalizePersonNameKey(person.name) == normalized; - }) - .toList(growable: false); -} - -String _normalizePersonNameKey(String value) { - return value - .trim() - .toLowerCase() - .replaceAll(RegExp(r'[^a-z0-9]+'), ' ') - .replaceAll(RegExp(r'\s+'), ' '); -} - -class _MergePeopleDraft { - const _MergePeopleDraft({ - required this.sourcePersonId, - required this.targetPersonId, - }); - - final String sourcePersonId; - final String targetPersonId; -} - -class _MergePeopleDialog extends StatefulWidget { - const _MergePeopleDialog({required this.people}); - - final List people; - - @override - State<_MergePeopleDialog> createState() => _MergePeopleDialogState(); -} - -class _MergePeopleDialogState extends State<_MergePeopleDialog> { - late String _sourcePersonId; - late String _targetPersonId; - - @override - void initState() { - super.initState(); - _targetPersonId = widget.people.first.id; - _sourcePersonId = widget.people.length > 1 - ? widget.people[1].id - : widget.people.first.id; - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text('Merge Duplicate Profiles'), - content: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Move captures, ideas, reminders, and share links from source into target profile.', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: 12), - DropdownButtonFormField( - key: ValueKey('target-$_targetPersonId'), - initialValue: _targetPersonId, - decoration: const InputDecoration( - labelText: 'Keep target profile', - ), - items: widget.people - .map( - (PersonProfile person) => DropdownMenuItem( - value: person.id, - child: Text('${person.name} · ${person.relationship}'), - ), - ) - .toList(growable: false), - onChanged: (String? value) { - if (value == null) { - return; - } - setState(() { - _targetPersonId = value; - if (_targetPersonId == _sourcePersonId) { - _sourcePersonId = widget.people - .firstWhere( - (PersonProfile person) => - person.id != _targetPersonId, - orElse: () => widget.people.first, - ) - .id; - } - }); - }, - ), - const SizedBox(height: 10), - DropdownButtonFormField( - key: ValueKey('source-$_targetPersonId-$_sourcePersonId'), - initialValue: _sourcePersonId, - decoration: const InputDecoration( - labelText: 'Merge and remove source profile', - ), - items: widget.people - .where((PersonProfile person) => person.id != _targetPersonId) - .map( - (PersonProfile person) => DropdownMenuItem( - value: person.id, - child: Text('${person.name} · ${person.relationship}'), - ), - ) - .toList(growable: false), - onChanged: (String? value) { - if (value == null) { - return; - } - setState(() { - _sourcePersonId = value; - }); - }, - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - if (_sourcePersonId == _targetPersonId) { - return; - } - Navigator.of(context).pop( - _MergePeopleDraft( - sourcePersonId: _sourcePersonId, - targetPersonId: _targetPersonId, - ), - ); - }, - child: const Text('Merge'), - ), - ], - ); - } -} - -class _QuickIdeaDraft { - const _QuickIdeaDraft({ - required this.type, - required this.title, - required this.details, - }); - - final IdeaType type; - final String title; - final String details; -} - -class _QuickIdeaDialog extends StatefulWidget { - const _QuickIdeaDialog(); - - @override - State<_QuickIdeaDialog> createState() => _QuickIdeaDialogState(); -} - -class _QuickIdeaDialogState extends State<_QuickIdeaDialog> { - IdeaType _type = IdeaType.gift; - final TextEditingController _titleController = TextEditingController(); - final TextEditingController _detailsController = TextEditingController(); - - @override - void dispose() { - _titleController.dispose(); - _detailsController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text('Add Idea'), - content: SingleChildScrollView( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SegmentedButton( - segments: const >[ - ButtonSegment( - value: IdeaType.gift, - label: Text('Gift'), - ), - ButtonSegment( - value: IdeaType.event, - label: Text('Event'), - ), - ], - selected: {_type}, - onSelectionChanged: (Set values) { - setState(() { - _type = values.first; - }); - }, - ), - const SizedBox(height: 10), - TextField( - controller: _titleController, - decoration: const InputDecoration(labelText: 'Title'), - ), - TextField( - controller: _detailsController, - maxLines: 3, - decoration: const InputDecoration(labelText: 'Details'), - ), - ], - ), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - final String title = _titleController.text.trim(); - if (title.isEmpty) { - return; - } - Navigator.of(context).pop( - _QuickIdeaDraft( - type: _type, - title: title, - details: _detailsController.text.trim(), - ), - ); - }, - child: const Text('Add'), - ), - ], - ); - } -} - -class _QuickReminderDraft { - const _QuickReminderDraft({ - required this.title, - required this.cadence, - required this.nextAt, - }); - - final String title; - final ReminderCadence cadence; - final DateTime nextAt; -} - -class _QuickReminderDialog extends StatefulWidget { - const _QuickReminderDialog(); - - @override - State<_QuickReminderDialog> createState() => _QuickReminderDialogState(); -} - -class _QuickReminderDialogState extends State<_QuickReminderDialog> { - final TextEditingController _titleController = TextEditingController(); - ReminderCadence _cadence = ReminderCadence.weekly; - DateTime _nextAt = DateTime.now().add(const Duration(days: 1)); - - @override - void dispose() { - _titleController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text('Add Reminder'), - content: SingleChildScrollView( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: _titleController, - decoration: const InputDecoration(labelText: 'Title'), - ), - const SizedBox(height: 10), - DropdownButtonFormField( - initialValue: _cadence, - decoration: const InputDecoration(labelText: 'Cadence'), - items: ReminderCadence.values - .map( - (ReminderCadence cadence) => - DropdownMenuItem( - value: cadence, - child: Text(cadence.name), - ), - ) - .toList(growable: false), - onChanged: (ReminderCadence? value) { - if (value == null) { - return; - } - setState(() { - _cadence = value; - }); - }, - ), - const SizedBox(height: 10), - Row( - children: [ - Expanded( - child: Text( - 'Next: ${_nextAt.year}-${_nextAt.month.toString().padLeft(2, '0')}-${_nextAt.day.toString().padLeft(2, '0')} ${_nextAt.hour.toString().padLeft(2, '0')}:${_nextAt.minute.toString().padLeft(2, '0')}', - ), - ), - TextButton( - onPressed: () async { - final DateTime now = DateTime.now(); - final DateTime? date = await showDatePicker( - context: context, - firstDate: now, - lastDate: now.add(const Duration(days: 3650)), - initialDate: _nextAt, - ); - if (date == null || !context.mounted) { - return; - } - final TimeOfDay? time = await showTimePicker( - context: context, - initialTime: TimeOfDay.fromDateTime(_nextAt), - ); - if (time == null) { - return; - } - setState(() { - _nextAt = DateTime( - date.year, - date.month, - date.day, - time.hour, - time.minute, - ); - }); - }, - child: const Text('Change'), - ), - ], - ), - ], - ), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - final String title = _titleController.text.trim(); - if (title.isEmpty) { - return; - } - Navigator.of(context).pop( - _QuickReminderDraft( - title: title, - cadence: _cadence, - nextAt: _nextAt, - ), - ); - }, - child: const Text('Add'), - ), - ], - ); - } -} - -class _InlineMetaPill extends StatelessWidget { - const _InlineMetaPill({required this.icon, required this.label}); - - final IconData icon; - final String label; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), - decoration: BoxDecoration( - color: const Color(0xFFF1F7FA), - borderRadius: BorderRadius.circular(99), - border: Border.all(color: Colors.white), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 14, color: AppTheme.textSecondary), - const SizedBox(width: 6), - Flexible( - child: Text( - label, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary), - ), - ), - ], - ), - ); - } -} - -class _AvatarSeed extends StatelessWidget { - const _AvatarSeed({required this.name, this.size = 48}); - - final String name; - final double size; - - @override - Widget build(BuildContext context) { - final String initials = name - .split(' ') - .where((String part) => part.isNotEmpty) - .take(2) - .map((String part) => part[0].toUpperCase()) - .join(); - - return Container( - width: size, - height: size, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(size / 2), - gradient: const LinearGradient( - colors: [Color(0xFF1AB6C8), Color(0xFF2274E0)], - ), - ), - alignment: Alignment.center, - child: Text( - initials, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Colors.white, - fontWeight: FontWeight.w700, - ), - ), - ); - } -} - -class _ScorePill extends StatelessWidget { - const _ScorePill({required this.score}); - - final int score; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: const Color(0xFFEEF7F3), - borderRadius: BorderRadius.circular(99), - ), - child: Text( - '$score%', - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: const Color(0xFF1D9C66), - fontWeight: FontWeight.w700, - ), - ), - ); - } -} - -String _dateTimeLabel(DateTime value) { - final DateTime local = value.toLocal(); - final String month = local.month.toString().padLeft(2, '0'); - final String day = local.day.toString().padLeft(2, '0'); - final String hour = local.hour.toString().padLeft(2, '0'); - final String minute = local.minute.toString().padLeft(2, '0'); - return '$month/$day $hour:$minute'; -} - -String _shortDateTimeLabel(DateTime value) { - final DateTime local = value.toLocal(); - final String year = local.year.toString(); - final String month = local.month.toString().padLeft(2, '0'); - final String day = local.day.toString().padLeft(2, '0'); - final String hour = local.hour.toString().padLeft(2, '0'); - final String minute = local.minute.toString().padLeft(2, '0'); - return '$year-$month-$day $hour:$minute'; -} +// Legacy compatibility export for the people presentation entry. +export 'package:relationship_saver/features/people/presentation/people_view.dart'; diff --git a/lib/features/people/presentation/README.md b/lib/features/people/presentation/README.md new file mode 100644 index 0000000..028b1f4 --- /dev/null +++ b/lib/features/people/presentation/README.md @@ -0,0 +1,14 @@ +# People Presentation + +This folder holds the people workspace UI. + +`people_view.dart` is now the composition root for the slice, with the heavy UI +split into library parts: + +- `people_view_list.dart`: list, filters, empty states, and person cards. +- `people_view_detail.dart`: detail workspace, insights, and inline editors. +- `people_view_dialogs.dart`: modal editors and quick-capture flows. +- `providers/people_ui_state.dart`: search, selection, and sorting state. + +Add new people UI inside this folder. Do not move slice-specific widgets back +into shared horizontal folders unless they are genuinely cross-feature. diff --git a/lib/features/people/presentation/people_view.dart b/lib/features/people/presentation/people_view.dart new file mode 100644 index 0000000..0737d25 --- /dev/null +++ b/lib/features/people/presentation/people_view.dart @@ -0,0 +1,627 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/config/app_theme.dart'; +import 'package:relationship_saver/features/local/local_models.dart'; +import 'package:relationship_saver/features/local/local_repository.dart'; +import 'package:relationship_saver/features/people/presentation/providers/people_ui_state.dart'; +import 'package:relationship_saver/features/shared/frosted_card.dart'; + +part 'people_view_detail.dart'; +part 'people_view_dialogs.dart'; +part 'people_view_list.dart'; + +enum _PersonQuickAction { capture, note, idea, reminder } + +class PeopleView extends ConsumerWidget { + const PeopleView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final AsyncValue localData = ref.watch( + localRepositoryProvider, + ); + final String searchQuery = ref.watch(peopleSearchQueryProvider); + final String? relationshipFilter = ref.watch( + peopleRelationshipFilterProvider, + ); + final PeopleSortOption sortOption = ref.watch(peopleSortOptionProvider); + + return localData.when( + data: (LocalDataState data) { + final List allPeople = data.people; + if (allPeople.isEmpty) { + return _EmptyPeopleView(onAdd: () => _handleAddPerson(context, ref)); + } + + final List people = _applyPeopleFilters( + allPeople, + searchQuery: searchQuery, + relationshipFilter: relationshipFilter, + sortOption: sortOption, + ); + final List relationshipOptions = _relationshipOptions( + allPeople, + ); + + if (people.isEmpty) { + return _FilteredPeopleEmptyView( + onClear: () { + ref.read(peopleSearchQueryProvider.notifier).set(''); + ref.read(peopleRelationshipFilterProvider.notifier).set(null); + }, + ); + } + + final String selectedId = + ref.watch(selectedPersonIdProvider) ?? people.first.id; + final PersonProfile selected = people.firstWhere( + (PersonProfile person) => person.id == selectedId, + orElse: () => people.first, + ); + + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final bool isCompact = constraints.maxWidth < 860; + if (isCompact) { + return _buildCompactLayout( + context, + ref, + data, + people, + selected, + relationshipOptions, + ); + } + return _buildWideLayout( + context, + ref, + data, + people, + selected, + relationshipOptions, + ); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (Object error, StackTrace stackTrace) { + return const Center(child: Text('Unable to load people')); + }, + ); + } + + List _applyPeopleFilters( + List people, { + required String searchQuery, + required String? relationshipFilter, + required PeopleSortOption sortOption, + }) { + final String query = searchQuery.trim().toLowerCase(); + final String? relationship = relationshipFilter?.trim().toLowerCase(); + + final List filtered = people + .where((PersonProfile person) { + if (relationship != null && + relationship.isNotEmpty && + person.relationship.trim().toLowerCase() != relationship) { + return false; + } + if (query.isEmpty) { + return true; + } + final String haystack = [ + person.name, + ...person.aliases, + person.relationship, + person.location ?? '', + person.notes, + ...person.tags, + ].join(' ').toLowerCase(); + return haystack.contains(query); + }) + .toList(growable: true); + + filtered.sort((PersonProfile a, PersonProfile b) { + switch (sortOption) { + case PeopleSortOption.affinity: + final int score = b.affinityScore.compareTo(a.affinityScore); + if (score != 0) { + return score; + } + return a.name.toLowerCase().compareTo(b.name.toLowerCase()); + case PeopleSortOption.nextMoment: + final int next = a.nextMoment.compareTo(b.nextMoment); + if (next != 0) { + return next; + } + return a.name.toLowerCase().compareTo(b.name.toLowerCase()); + case PeopleSortOption.alphabetical: + return a.name.toLowerCase().compareTo(b.name.toLowerCase()); + } + }); + + return filtered; + } + + List _relationshipOptions(List people) { + final Set seen = {}; + final List values = []; + for (final PersonProfile person in people) { + final String relationship = person.relationship.trim(); + if (relationship.isEmpty) { + continue; + } + final String key = relationship.toLowerCase(); + if (seen.add(key)) { + values.add(relationship); + } + } + values.sort( + (String a, String b) => a.toLowerCase().compareTo(b.toLowerCase()), + ); + return values; + } + + Future _handleAddPerson(BuildContext context, WidgetRef ref) async { + final List existingPeople = + ref.read(localRepositoryProvider).asData?.value.people ?? + const []; + final _PersonDraft? draft = await _showPersonEditor( + context, + title: 'Add Person', + existingPeople: existingPeople, + ); + + if (draft == null) { + return; + } + + await ref + .read(localRepositoryProvider.notifier) + .addPerson( + name: draft.name, + relationship: draft.relationship, + notes: draft.notes, + tags: draft.tags, + location: draft.location, + aliases: draft.aliases, + ); + + final LocalDataState? latest = ref + .read(localRepositoryProvider) + .asData + ?.value; + if (latest == null) { + return; + } + for (final PersonProfile person in latest.people) { + final bool locationMatches = + (person.location ?? '').trim() == (draft.location ?? '').trim(); + if (person.name == draft.name && + person.relationship == draft.relationship && + locationMatches) { + ref.read(selectedPersonIdProvider.notifier).select(person.id); + break; + } + } + } + + Future _handleEditPerson( + BuildContext context, + WidgetRef ref, + PersonProfile person, + ) async { + final List existingPeople = + ref.read(localRepositoryProvider).asData?.value.people ?? + const []; + final _PersonDraft? draft = await _showPersonEditor( + context, + title: 'Edit Person', + initial: _PersonDraft( + name: person.name, + relationship: person.relationship, + notes: person.notes, + tags: person.tags, + aliases: person.aliases, + location: person.location, + ), + existingPeople: existingPeople, + editingPersonId: person.id, + ); + + if (draft == null) { + return; + } + + await ref + .read(localRepositoryProvider.notifier) + .updatePerson( + person.copyWith( + name: draft.name, + relationship: draft.relationship, + notes: draft.notes, + tags: draft.tags, + aliases: draft.aliases, + location: draft.location, + ), + ); + } + + Future _handleDeletePerson( + BuildContext context, + WidgetRef ref, + String personId, + ) async { + final bool? confirmed = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Delete person?'), + content: const Text( + 'This will remove the person and related moments from local storage.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Delete'), + ), + ], + ); + }, + ); + + if (confirmed != true) { + return; + } + + await ref.read(localRepositoryProvider.notifier).deletePerson(personId); + ref.read(selectedPersonIdProvider.notifier).clear(); + } + + Widget _buildWideLayout( + BuildContext context, + WidgetRef ref, + LocalDataState data, + List people, + PersonProfile selected, + List relationshipOptions, + ) { + return Padding( + padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), + child: Row( + children: [ + Expanded( + flex: 5, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _PeopleHeader( + onAdd: () => _handleAddPerson(context, ref), + onMerge: () => _handleMergePeople(context, ref, people), + compact: false, + ), + const SizedBox(height: 12), + _PeopleListControls( + compact: false, + relationshipOptions: relationshipOptions, + ), + const SizedBox(height: 20), + Expanded( + child: ListView.separated( + itemCount: people.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (BuildContext context, int index) { + final PersonProfile person = people[index]; + return _PersonListItem( + person: person, + selectedId: selected.id, + compact: false, + onSelect: () { + ref + .read(selectedPersonIdProvider.notifier) + .select(person.id); + }, + ); + }, + ), + ), + ], + ), + ), + const SizedBox(width: 18), + Expanded( + flex: 6, + child: FrostedCard( + child: SingleChildScrollView( + child: _PersonDetail( + person: selected, + data: data, + onEdit: () => _handleEditPerson(context, ref, selected), + onDelete: () => + _handleDeletePerson(context, ref, selected.id), + onQuickAction: (_PersonQuickAction action) => + _handleQuickAction(context, ref, selected, action), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildCompactLayout( + BuildContext context, + WidgetRef ref, + LocalDataState data, + List people, + PersonProfile selected, + List relationshipOptions, + ) { + return Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 18, 20), + child: ListView( + children: [ + _PeopleHeader( + onAdd: () => _handleAddPerson(context, ref), + onMerge: () => _handleMergePeople(context, ref, people), + compact: true, + ), + const SizedBox(height: 10), + _PeopleListControls( + compact: true, + relationshipOptions: relationshipOptions, + ), + const SizedBox(height: 14), + FrostedCard( + child: _PersonDetail( + person: selected, + data: data, + compact: true, + onEdit: () => _handleEditPerson(context, ref, selected), + onDelete: () => _handleDeletePerson(context, ref, selected.id), + onQuickAction: (_PersonQuickAction action) => + _handleQuickAction(context, ref, selected, action), + ), + ), + const SizedBox(height: 14), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Row( + children: [ + Text( + 'All profiles', + style: Theme.of(context).textTheme.titleMedium, + ), + const Spacer(), + Text( + '${people.length}', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + const SizedBox(height: 10), + ...people.map( + (PersonProfile person) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _PersonListItem( + person: person, + selectedId: selected.id, + compact: true, + onSelect: () { + ref.read(selectedPersonIdProvider.notifier).select(person.id); + }, + ), + ), + ), + ], + ), + ); + } + + Future _handleQuickAction( + BuildContext context, + WidgetRef ref, + PersonProfile person, + _PersonQuickAction action, + ) async { + switch (action) { + case _PersonQuickAction.capture: + final String? summary = await _showQuickTextCaptureDialog( + context, + title: 'Add Capture', + hintText: 'What happened in this interaction?', + ); + if (summary == null) { + return; + } + await ref + .read(localRepositoryProvider.notifier) + .addMoment(personId: person.id, summary: summary, type: 'capture'); + return; + case _PersonQuickAction.note: + final String? note = await _showQuickTextCaptureDialog( + context, + title: 'Add Note', + hintText: 'Write a quick context note...', + ); + if (note == null) { + return; + } + await ref + .read(localRepositoryProvider.notifier) + .addMoment(personId: person.id, summary: note, type: 'note'); + return; + case _PersonQuickAction.idea: + final _QuickIdeaDraft? idea = await showDialog<_QuickIdeaDraft>( + context: context, + builder: (BuildContext context) => const _QuickIdeaDialog(), + ); + if (idea == null) { + return; + } + await ref + .read(localRepositoryProvider.notifier) + .addIdea( + type: idea.type, + title: idea.title, + details: idea.details, + personId: person.id, + ); + return; + case _PersonQuickAction.reminder: + final _QuickReminderDraft? reminder = + await showDialog<_QuickReminderDraft>( + context: context, + builder: (BuildContext context) => const _QuickReminderDialog(), + ); + if (reminder == null) { + return; + } + await ref + .read(localRepositoryProvider.notifier) + .addReminder( + title: reminder.title, + cadence: reminder.cadence, + nextAt: reminder.nextAt, + personId: person.id, + ); + return; + } + } + + Future _handleMergePeople( + BuildContext context, + WidgetRef ref, + List people, + ) async { + if (people.length < 2) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Need at least two profiles to merge.')), + ); + return; + } + + final List duplicateCandidates = _duplicateCandidates( + people, + ); + if (duplicateCandidates.length < 2) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'No duplicate-name profiles found. Edit names first if needed.', + ), + ), + ); + return; + } + + final _MergePeopleDraft? draft = await showDialog<_MergePeopleDraft>( + context: context, + builder: (BuildContext context) => + _MergePeopleDialog(people: duplicateCandidates), + ); + if (draft == null) { + return; + } + + await ref + .read(localRepositoryProvider.notifier) + .mergePersonProfiles( + sourcePersonId: draft.sourcePersonId, + targetPersonId: draft.targetPersonId, + ); + ref.read(selectedPersonIdProvider.notifier).select(draft.targetPersonId); + if (!context.mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Profiles merged successfully.')), + ); + } + + List _duplicateCandidates(List people) { + final Map> grouped = + >{}; + for (final PersonProfile person in people) { + final String key = _normalizeNameKey(person.name); + if (key.isEmpty) { + continue; + } + grouped.putIfAbsent(key, () => []).add(person); + } + final List result = []; + for (final List group in grouped.values) { + if (group.length > 1) { + result.addAll(group); + } + } + return result; + } + + String _normalizeNameKey(String value) { + return value + .trim() + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9]+'), ' ') + .replaceAll(RegExp(r'\s+'), ' '); + } + + Future<_PersonDraft?> _showPersonEditor( + BuildContext context, { + required String title, + _PersonDraft? initial, + List existingPeople = const [], + String? editingPersonId, + }) { + final bool compact = MediaQuery.sizeOf(context).width < 720; + if (compact) { + return showModalBottomSheet<_PersonDraft>( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Colors.transparent, + builder: (BuildContext context) { + return _PersonEditorBottomSheet( + title: title, + initial: initial, + existingPeople: existingPeople, + editingPersonId: editingPersonId, + ); + }, + ); + } + + return showDialog<_PersonDraft>( + context: context, + builder: (BuildContext context) { + return _PersonEditorDialog( + title: title, + initial: initial, + existingPeople: existingPeople, + editingPersonId: editingPersonId, + ); + }, + ); + } + + Future _showQuickTextCaptureDialog( + BuildContext context, { + required String title, + required String hintText, + }) async { + return showDialog( + context: context, + builder: (BuildContext context) { + return _QuickTextCaptureDialog(title: title, hintText: hintText); + }, + ); + } +} diff --git a/lib/features/people/presentation/people_view_detail.dart b/lib/features/people/presentation/people_view_detail.dart new file mode 100644 index 0000000..602b852 --- /dev/null +++ b/lib/features/people/presentation/people_view_detail.dart @@ -0,0 +1,1033 @@ +part of 'people_view.dart'; + +class _PersonDetail extends ConsumerWidget { + const _PersonDetail({ + required this.person, + required this.data, + required this.onEdit, + required this.onDelete, + required this.onQuickAction, + this.compact = false, + }); + + final PersonProfile person; + final LocalDataState data; + final VoidCallback onEdit; + final VoidCallback onDelete; + final ValueChanged<_PersonQuickAction> onQuickAction; + final bool compact; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final List moments = + data.moments + .where((RelationshipMoment moment) => moment.personId == person.id) + .toList(growable: false) + ..sort( + (RelationshipMoment a, RelationshipMoment b) => + b.at.compareTo(a.at), + ); + final List ideas = + data.ideas + .where((RelationshipIdea idea) => idea.personId == person.id) + .toList(growable: false) + ..sort( + (RelationshipIdea a, RelationshipIdea b) => + b.createdAt.compareTo(a.createdAt), + ); + final List reminders = + data.reminders + .where((ReminderRule reminder) => reminder.personId == person.id) + .toList(growable: false) + ..sort( + (ReminderRule a, ReminderRule b) => a.nextAt.compareTo(b.nextAt), + ); + final List sharedMessages = + data.sharedMessages + .where((SharedMessageEntry entry) => entry.profileId == person.id) + .toList(growable: false) + ..sort( + (SharedMessageEntry a, SharedMessageEntry b) => + b.sharedAt.compareTo(a.sharedAt), + ); + final List sourceLinks = + data.sourceLinks + .where((SourceProfileLink link) => link.profileId == person.id) + .toList(growable: false) + ..sort( + (SourceProfileLink a, SourceProfileLink b) => + b.lastSeenAt.compareTo(a.lastSeenAt), + ); + final List preferenceSignals = + data.preferenceSignals + .where( + (PersonPreferenceSignal signal) => signal.personId == person.id, + ) + .toList(growable: false) + ..sort(_peoplePreferenceSignalSort); + final List personFacts = + data.personFacts + .where((PersonFact fact) => fact.personId == person.id) + .toList(growable: false) + ..sort( + (PersonFact a, PersonFact b) => b.updatedAt.compareTo(a.updatedAt), + ); + final List importantDates = + data.importantDates + .where((PersonImportantDate value) => value.personId == person.id) + .toList(growable: false) + ..sort( + (PersonImportantDate a, PersonImportantDate b) => + a.date.compareTo(b.date), + ); + final bool hasNotes = person.notes.trim().isNotEmpty; + final String? location = person.location?.trim().isEmpty ?? true + ? null + : person.location!.trim(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _PersonWorkspaceHero( + person: person, + compact: compact, + sourceLinks: sourceLinks, + onEdit: onEdit, + onDelete: onDelete, + onQuickAction: onQuickAction, + ), + SizedBox(height: compact ? 12 : 14), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + _PersonStatTile( + label: 'Affinity', + value: '${person.affinityScore}%', + accent: const Color(0xFF1AB6C8), + compact: compact, + ), + _PersonStatTile( + label: 'Next moment', + value: _dateTimeLabel(person.nextMoment), + accent: const Color(0xFF2274E0), + compact: compact, + ), + _PersonStatTile( + label: 'Captures', + value: '${moments.length}', + accent: const Color(0xFF23A26D), + compact: compact, + ), + _PersonStatTile( + label: 'Ideas', + value: + '${ideas.where((RelationshipIdea e) => !e.isArchived).length}', + accent: const Color(0xFFF4A524), + compact: compact, + ), + ], + ), + const SizedBox(height: 12), + _PeopleInsightSectionCard( + title: 'Profile context', + icon: Icons.person_outline_rounded, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _InlineMetaPill( + icon: Icons.favorite_outline_rounded, + label: person.relationship, + ), + if (location != null) + _InlineMetaPill( + icon: Icons.location_on_outlined, + label: location, + ), + if (sourceLinks.isNotEmpty) + _InlineMetaPill( + icon: Icons.link_rounded, + label: + '${sourceLinks.length} linked source${sourceLinks.length == 1 ? '' : 's'}', + ), + ...person.aliases.map( + (String alias) => _InlineMetaPill( + icon: Icons.alternate_email_rounded, + label: alias, + ), + ), + ...person.tags.map( + (String tag) => + _InlineMetaPill(icon: Icons.sell_outlined, label: tag), + ), + ], + ), + const SizedBox(height: 10), + _PeopleInsightBodyBlock( + text: hasNotes + ? person.notes.trim() + : 'Add notes about preferences, boundaries, and what matters so future follow-ups stay relevant.', + muted: !hasNotes, + ), + ], + ), + const SizedBox(height: 12), + _PeopleInsightSectionCard( + title: 'Captured facts', + icon: Icons.fact_check_outlined, + children: personFacts.isEmpty + ? const [ + _PeopleSectionEmpty( + label: + 'No structured facts yet. Shared captures can become notes, likes, dislikes, and ideas here.', + ), + ] + : personFacts + .take(compact ? 4 : 6) + .map( + (PersonFact fact) => _PeopleInsightRow( + title: fact.label?.trim().isNotEmpty == true + ? fact.label!.trim() + : _factTypeLabel(fact.type), + subtitle: fact.text, + meta: + '${_factTypeLabel(fact.type).toUpperCase()} • ${fact.sourceKind.name.toUpperCase()} • ${_shortDateTimeLabel(fact.updatedAt)}${fact.isSensitive ? ' • SENSITIVE' : ''}', + actions: [ + TextButton( + onPressed: () async { + final PersonFact? edited = + await _showPersonFactEditor( + context, + fact: fact, + ); + if (edited == null) { + return; + } + await ref + .read(localRepositoryProvider.notifier) + .updatePersonFact(edited); + }, + child: const Text('Edit'), + ), + TextButton( + onPressed: () async { + await ref + .read(localRepositoryProvider.notifier) + .deletePersonFact(fact.id); + }, + child: const Text('Delete'), + ), + ], + ), + ) + .toList(growable: false), + ), + const SizedBox(height: 12), + _PeopleInsightSectionCard( + title: 'Important dates', + icon: Icons.event_outlined, + children: importantDates.isEmpty + ? const [ + _PeopleSectionEmpty( + label: + 'No structured dates yet. Save birthdays, anniversaries, and milestones from shared captures or manual edits.', + ), + ] + : importantDates + .take(compact ? 4 : 6) + .map( + (PersonImportantDate value) => _PeopleInsightRow( + title: value.label, + subtitle: _dateTimeLabel(value.date), + meta: + '${value.classification.toUpperCase()} • ${value.sourceKind.name.toUpperCase()}${value.isSensitive ? ' • SENSITIVE' : ''}', + actions: [ + TextButton( + onPressed: () async { + final PersonImportantDate? edited = + await _showImportantDateEditor( + context, + value: value, + ); + if (edited == null) { + return; + } + await ref + .read(localRepositoryProvider.notifier) + .updateImportantDate(edited); + }, + child: const Text('Edit'), + ), + TextButton( + onPressed: () async { + await ref + .read(localRepositoryProvider.notifier) + .deleteImportantDate(value.id); + }, + child: const Text('Delete'), + ), + ], + ), + ) + .toList(growable: false), + ), + const SizedBox(height: 12), + _PeopleInsightSectionCard( + title: 'Chat-derived preferences', + icon: Icons.psychology_alt_outlined, + children: preferenceSignals.isEmpty + ? const [ + _PeopleSectionEmpty( + label: + 'No inferred preferences yet. Share messages from chat apps to build context for this person.', + ), + ] + : preferenceSignals + .map( + (PersonPreferenceSignal signal) => + _PeoplePreferenceSignalCard( + signal: signal, + compact: compact, + onWhy: () => _showPeoplePreferenceEvidence( + context, + signal: signal, + personName: person.name, + ), + onConfirm: () async { + await ref + .read(localRepositoryProvider.notifier) + .confirmPreferenceSignal(signal.id); + }, + onDismiss: () async { + await ref + .read(localRepositoryProvider.notifier) + .dismissPreferenceSignal(signal.id); + }, + ), + ) + .toList(growable: false), + ), + const SizedBox(height: 12), + _PeopleInsightSectionCard( + title: 'Ideas', + icon: Icons.lightbulb_outline_rounded, + children: ideas.isEmpty + ? const [ + _PeopleSectionEmpty( + label: + 'No ideas yet. Use Quick add to capture gift or event ideas.', + ), + ] + : ideas + .take(compact ? 4 : 6) + .map( + (RelationshipIdea idea) => _PeopleInsightRow( + title: idea.title, + subtitle: idea.details.isEmpty + ? 'No details yet' + : idea.details, + meta: + '${idea.type.name.toUpperCase()} • ${_shortDateTimeLabel(idea.createdAt)}${idea.isArchived ? ' • ARCHIVED' : ''}', + ), + ) + .toList(growable: false), + ), + const SizedBox(height: 12), + _PeopleInsightSectionCard( + title: 'Moments', + icon: Icons.auto_awesome_rounded, + children: moments.isEmpty + ? const [ + _PeopleSectionEmpty( + label: 'No captures logged yet for this person.', + ), + ] + : moments + .take(compact ? 4 : 6) + .map( + (RelationshipMoment moment) => _PeopleInsightRow( + title: moment.title, + subtitle: moment.summary, + meta: + '${moment.type.toUpperCase()} • ${_shortDateTimeLabel(moment.at)}', + ), + ) + .toList(growable: false), + ), + const SizedBox(height: 12), + _PeopleInsightSectionCard( + title: 'Reminders', + icon: Icons.notifications_active_outlined, + children: reminders.isEmpty + ? const [ + _PeopleSectionEmpty( + label: + 'No reminders yet. Add one to keep the relationship active.', + ), + ] + : reminders + .take(compact ? 4 : 6) + .map( + (ReminderRule reminder) => _PeopleInsightRow( + title: reminder.title, + subtitle: reminder.enabled ? 'Enabled' : 'Disabled', + meta: + '${reminder.cadence.name.toUpperCase()} • Next ${_shortDateTimeLabel(reminder.nextAt)}', + ), + ) + .toList(growable: false), + ), + const SizedBox(height: 12), + _PeopleInsightSectionCard( + title: 'Shared message history', + icon: Icons.chat_bubble_outline_rounded, + children: sharedMessages.isEmpty + ? const [ + _PeopleSectionEmpty( + label: 'No imported shared messages for this profile yet.', + ), + ] + : sharedMessages + .take(compact ? 3 : 5) + .map( + (SharedMessageEntry entry) => _PeopleInsightRow( + title: + entry.sourceDisplayName?.trim().isNotEmpty == true + ? entry.sourceDisplayName!.trim() + : 'Shared from ${entry.sourceApp}', + subtitle: entry.messageText.trim().isEmpty + ? 'Imported message content is empty' + : entry.messageText.trim(), + meta: + '${entry.resolvedAutomatically ? 'AUTO-LINKED' : 'MANUAL-LINK'} • ${_shortDateTimeLabel(entry.sharedAt)}', + ), + ) + .toList(growable: false), + ), + ], + ); + } +} + +Future _showPeoplePreferenceEvidence( + BuildContext context, { + required PersonPreferenceSignal signal, + required String personName, +}) { + return showModalBottomSheet( + context: context, + useSafeArea: true, + showDragHandle: true, + builder: (BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Why this signal?', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 6), + Text( + '${signal.label} • ${signal.category} • ${signal.polarity.name.toUpperCase()}', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 4), + Text( + 'For $personName • confidence ${(signal.confidence * 100).round()}%', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 12), + if (signal.evidenceSnippets.isEmpty) + const _PeopleSectionEmpty( + label: 'No evidence snippets stored yet for this signal.', + ) + else + ...signal.evidenceSnippets.map( + (String snippet) => _PeopleInsightRow( + title: 'Evidence', + subtitle: snippet, + meta: 'Source(s): ${signal.sourceApps.join(', ')}', + ), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ), + ], + ), + ); + }, + ); +} + +int _peoplePreferenceSignalSort( + PersonPreferenceSignal a, + PersonPreferenceSignal b, +) { + int rank(PreferenceSignalStatus status) => switch (status) { + PreferenceSignalStatus.inferred => 0, + PreferenceSignalStatus.confirmed => 1, + PreferenceSignalStatus.dismissed => 2, + }; + + final int byStatus = rank(a.status).compareTo(rank(b.status)); + if (byStatus != 0) { + return byStatus; + } + final int byConfidence = b.confidence.compareTo(a.confidence); + if (byConfidence != 0) { + return byConfidence; + } + return a.label.toLowerCase().compareTo(b.label.toLowerCase()); +} + +String _factTypeLabel(CapturedFactType type) { + return switch (type) { + CapturedFactType.note => 'Note', + CapturedFactType.like => 'Like', + CapturedFactType.dislike => 'Dislike', + CapturedFactType.importantDate => 'Date', + CapturedFactType.giftIdea => 'Gift Idea', + CapturedFactType.placeIdea => 'Place Idea', + CapturedFactType.activityIdea => 'Activity Idea', + CapturedFactType.misc => 'Misc', + }; +} + +class _PeoplePreferenceSignalCard extends StatelessWidget { + const _PeoplePreferenceSignalCard({ + required this.signal, + required this.compact, + required this.onConfirm, + required this.onDismiss, + required this.onWhy, + }); + + final PersonPreferenceSignal signal; + final bool compact; + final Future Function() onConfirm; + final Future Function() onDismiss; + final VoidCallback onWhy; + + @override + Widget build(BuildContext context) { + final Color tone = switch (signal.status) { + PreferenceSignalStatus.confirmed => const Color(0xFF1D9C66), + PreferenceSignalStatus.dismissed => const Color(0xFFA56A00), + PreferenceSignalStatus.inferred => AppTheme.primary, + }; + final IconData polarityIcon = switch (signal.polarity) { + PreferenceSignalPolarity.like => Icons.thumb_up_alt_outlined, + PreferenceSignalPolarity.dislike => Icons.thumb_down_alt_outlined, + PreferenceSignalPolarity.neutral => Icons.tune_rounded, + }; + + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFEAF1F4)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Icon(polarityIcon, size: 16, color: tone), + Text( + signal.label, + style: Theme.of(context).textTheme.titleSmall, + ), + _PeoplePreferenceBadge(label: signal.status.name, color: tone), + _PeoplePreferenceBadge( + label: '${(signal.confidence * 100).round()}%', + color: AppTheme.textSecondary, + filled: false, + ), + ], + ), + const SizedBox(height: 5), + Text( + '${signal.category.toUpperCase()} • seen ${signal.occurrenceCount}x', + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlinedButton(onPressed: onWhy, child: const Text('Why?')), + if (signal.status != PreferenceSignalStatus.confirmed) + FilledButton.tonal( + onPressed: () async => onConfirm(), + child: const Text('Confirm'), + ), + if (signal.status != PreferenceSignalStatus.dismissed) + TextButton( + onPressed: () async => onDismiss(), + child: const Text('Dismiss'), + ), + ], + ), + if (!compact && signal.sourceApps.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + 'Sources: ${signal.sourceApps.join(', ')}', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), + ), + ], + ], + ), + ), + ); + } +} + +class _PeoplePreferenceBadge extends StatelessWidget { + const _PeoplePreferenceBadge({ + required this.label, + required this.color, + this.filled = true, + }); + + final String label; + final Color color; + final bool filled; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: filled ? color.withValues(alpha: 0.12) : Colors.transparent, + borderRadius: BorderRadius.circular(99), + border: Border.all(color: color.withValues(alpha: filled ? 0.2 : 0.35)), + ), + child: Text( + label.toUpperCase(), + style: Theme.of(context).textTheme.labelSmall?.copyWith(color: color), + ), + ); + } +} + +class _PersonWorkspaceHero extends StatelessWidget { + const _PersonWorkspaceHero({ + required this.person, + required this.compact, + required this.sourceLinks, + required this.onEdit, + required this.onDelete, + required this.onQuickAction, + }); + + final PersonProfile person; + final bool compact; + final List sourceLinks; + final VoidCallback onEdit; + final VoidCallback onDelete; + final ValueChanged<_PersonQuickAction> onQuickAction; + + @override + Widget build(BuildContext context) { + final Widget actions = Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.end, + children: [ + FilledButton.icon( + onPressed: () => onQuickAction(_PersonQuickAction.capture), + icon: const Icon(Icons.add_comment_outlined, size: 18), + label: const Text('Capture'), + ), + OutlinedButton.icon( + onPressed: () => onQuickAction(_PersonQuickAction.idea), + icon: const Icon(Icons.lightbulb_outline_rounded, size: 18), + label: const Text('Idea'), + ), + PopupMenuButton<_PersonQuickAction>( + tooltip: 'More quick add actions', + onSelected: onQuickAction, + itemBuilder: (BuildContext context) => + const >[ + PopupMenuItem<_PersonQuickAction>( + value: _PersonQuickAction.capture, + child: Text('Add Capture'), + ), + PopupMenuItem<_PersonQuickAction>( + value: _PersonQuickAction.note, + child: Text('Add Note'), + ), + PopupMenuItem<_PersonQuickAction>( + value: _PersonQuickAction.idea, + child: Text('Add Idea'), + ), + PopupMenuItem<_PersonQuickAction>( + value: _PersonQuickAction.reminder, + child: Text('Add Reminder'), + ), + ], + child: const _HeroIconButton( + icon: Icons.add_circle_outline_rounded, + tooltip: 'Quick add', + ), + ), + IconButton( + onPressed: onEdit, + icon: const Icon(Icons.edit_rounded), + tooltip: 'Edit person', + ), + IconButton( + onPressed: onDelete, + icon: const Icon(Icons.delete_outline_rounded), + tooltip: 'Delete person', + ), + ], + ); + + return Container( + width: double.infinity, + padding: EdgeInsets.all(compact ? 14 : 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFF7FBFD), Color(0xFFEAF4FA)], + ), + border: Border.all(color: Colors.white.withValues(alpha: 0.9)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (compact) ...[ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _AvatarSeed(name: person.name, size: 58), + const SizedBox(width: 12), + Expanded(child: _HeroIdentity(person: person)), + ], + ), + const SizedBox(height: 12), + actions, + ] else ...[ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _AvatarSeed(name: person.name, size: 64), + const SizedBox(width: 14), + Expanded(child: _HeroIdentity(person: person)), + const SizedBox(width: 10), + Flexible( + child: Align(alignment: Alignment.topRight, child: actions), + ), + ], + ), + ], + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _InlineMetaPill( + icon: Icons.schedule_rounded, + label: 'Next ${_dateTimeLabel(person.nextMoment)}', + ), + if (sourceLinks.isNotEmpty) + _InlineMetaPill( + icon: Icons.link_rounded, + label: + 'Last linked ${_shortDateTimeLabel(sourceLinks.first.lastSeenAt)}', + ), + ], + ), + ], + ), + ); + } +} + +class _HeroIdentity extends StatelessWidget { + const _HeroIdentity({required this.person}); + + final PersonProfile person; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + person.name, + style: Theme.of(context).textTheme.titleLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + person.relationship, + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ); + } +} + +class _HeroIconButton extends StatelessWidget { + const _HeroIconButton({required this.icon, required this.tooltip}); + + final IconData icon; + final String tooltip; + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: const Color(0xFFF2F7FA), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white), + ), + child: Icon(icon, color: AppTheme.primary, size: 20), + ), + ); + } +} + +class _PersonStatTile extends StatelessWidget { + const _PersonStatTile({ + required this.label, + required this.value, + required this.accent, + required this.compact, + }); + + final String label; + final String value; + final Color accent; + final bool compact; + + @override + Widget build(BuildContext context) { + final double width = compact ? 154 : 172; + return Container( + width: width, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.66), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: accent.withValues(alpha: 0.12)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of( + context, + ).textTheme.labelMedium?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 6), + Text( + value, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: accent, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +class _PeopleInsightSectionCard extends StatelessWidget { + const _PeopleInsightSectionCard({ + required this.title, + required this.icon, + required this.children, + }); + + final String title; + final IconData icon; + final List children; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF8FBFD), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white.withValues(alpha: 0.9)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 18, color: AppTheme.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 10), + ...children, + ], + ), + ); + } +} + +class _PeopleInsightRow extends StatelessWidget { + const _PeopleInsightRow({ + required this.title, + required this.subtitle, + required this.meta, + this.actions = const [], + }); + + final String title; + final String subtitle; + final String meta; + final List actions; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFEAF1F4)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleSmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + subtitle, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 6), + Text( + meta, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: AppTheme.textSecondary), + ), + if (actions.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap(spacing: 8, runSpacing: 8, children: actions), + ], + ], + ), + ), + ); + } +} + +class _PeopleInsightBodyBlock extends StatelessWidget { + const _PeopleInsightBodyBlock({required this.text, required this.muted}); + + final String text; + final bool muted; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFEAF1F4)), + ), + child: Text( + text, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: muted ? AppTheme.textSecondary : null, + height: 1.35, + ), + ), + ); + } +} + +class _PeopleSectionEmpty extends StatelessWidget { + const _PeopleSectionEmpty({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFEAF1F4)), + ), + child: Text( + label, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), + ), + ); + } +} diff --git a/lib/features/people/presentation/people_view_dialogs.dart b/lib/features/people/presentation/people_view_dialogs.dart new file mode 100644 index 0000000..9973156 --- /dev/null +++ b/lib/features/people/presentation/people_view_dialogs.dart @@ -0,0 +1,1383 @@ +part of 'people_view.dart'; + +Future _showPersonFactEditor( + BuildContext context, { + required PersonFact fact, +}) { + return showDialog( + context: context, + builder: (BuildContext context) => _PersonFactEditorDialog(fact: fact), + ); +} + +Future _showImportantDateEditor( + BuildContext context, { + required PersonImportantDate value, +}) { + return showDialog( + context: context, + builder: (BuildContext context) => _ImportantDateEditorDialog(value: value), + ); +} + +class _PersonFactEditorDialog extends StatefulWidget { + const _PersonFactEditorDialog({required this.fact}); + + final PersonFact fact; + + @override + State<_PersonFactEditorDialog> createState() => + _PersonFactEditorDialogState(); +} + +class _PersonFactEditorDialogState extends State<_PersonFactEditorDialog> { + late CapturedFactType _type; + late final TextEditingController _labelController; + late final TextEditingController _textController; + bool _isSensitive = false; + + @override + void initState() { + super.initState(); + _type = widget.fact.type; + _labelController = TextEditingController(text: widget.fact.label ?? ''); + _textController = TextEditingController(text: widget.fact.text); + _isSensitive = widget.fact.isSensitive; + } + + @override + void dispose() { + _labelController.dispose(); + _textController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Edit Captured Fact'), + content: SingleChildScrollView( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButtonFormField( + initialValue: _type, + decoration: const InputDecoration(labelText: 'Type'), + items: CapturedFactType.values + .where( + (CapturedFactType type) => + type != CapturedFactType.importantDate, + ) + .map( + (CapturedFactType type) => + DropdownMenuItem( + value: type, + child: Text(_factTypeLabel(type)), + ), + ) + .toList(growable: false), + onChanged: (CapturedFactType? value) { + if (value == null) { + return; + } + setState(() { + _type = value; + }); + }, + ), + const SizedBox(height: 10), + TextField( + controller: _labelController, + decoration: const InputDecoration(labelText: 'Label'), + ), + const SizedBox(height: 10), + TextField( + controller: _textController, + minLines: 2, + maxLines: 4, + decoration: const InputDecoration(labelText: 'Text'), + ), + const SizedBox(height: 10), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('Sensitive'), + value: _isSensitive, + onChanged: (bool value) { + setState(() { + _isSensitive = value; + }); + }, + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + Navigator.of(context).pop( + widget.fact.copyWith( + type: _type, + label: _labelController.text.trim().isEmpty + ? null + : _labelController.text.trim(), + text: _textController.text.trim(), + isSensitive: _isSensitive, + ), + ); + }, + child: const Text('Save'), + ), + ], + ); + } +} + +class _ImportantDateEditorDialog extends StatefulWidget { + const _ImportantDateEditorDialog({required this.value}); + + final PersonImportantDate value; + + @override + State<_ImportantDateEditorDialog> createState() => + _ImportantDateEditorDialogState(); +} + +class _ImportantDateEditorDialogState + extends State<_ImportantDateEditorDialog> { + late final TextEditingController _labelController; + late final TextEditingController _classificationController; + late DateTime _date; + bool _isSensitive = false; + + @override + void initState() { + super.initState(); + _labelController = TextEditingController(text: widget.value.label); + _classificationController = TextEditingController( + text: widget.value.classification, + ); + _date = widget.value.date; + _isSensitive = widget.value.isSensitive; + } + + @override + void dispose() { + _labelController.dispose(); + _classificationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Edit Important Date'), + content: SingleChildScrollView( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _labelController, + decoration: const InputDecoration(labelText: 'Label'), + ), + const SizedBox(height: 10), + TextField( + controller: _classificationController, + decoration: const InputDecoration(labelText: 'Classification'), + ), + const SizedBox(height: 10), + OutlinedButton.icon( + onPressed: _pickDate, + icon: const Icon(Icons.event_outlined), + label: Text(_dateTimeLabel(_date)), + ), + const SizedBox(height: 10), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('Sensitive'), + value: _isSensitive, + onChanged: (bool value) { + setState(() { + _isSensitive = value; + }); + }, + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + Navigator.of(context).pop( + widget.value.copyWith( + label: _labelController.text.trim(), + classification: _classificationController.text.trim(), + date: _date, + isSensitive: _isSensitive, + ), + ); + }, + child: const Text('Save'), + ), + ], + ); + } + + Future _pickDate() async { + final DateTime? picked = await showDatePicker( + context: context, + firstDate: DateTime(_date.year - 5), + lastDate: DateTime(_date.year + 10), + initialDate: _date, + ); + if (picked == null) { + return; + } + setState(() { + _date = picked; + }); + } +} + +class _PersonEditorDialog extends StatelessWidget { + const _PersonEditorDialog({ + required this.title, + required this.existingPeople, + this.initial, + this.editingPersonId, + }); + + final String title; + final List existingPeople; + final _PersonDraft? initial; + final String? editingPersonId; + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: Colors.transparent, + insetPadding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: _PersonEditorPanel( + title: title, + initial: initial, + existingPeople: existingPeople, + editingPersonId: editingPersonId, + compact: false, + sheetStyle: false, + ), + ), + ); + } +} + +class _QuickTextCaptureDialog extends StatefulWidget { + const _QuickTextCaptureDialog({required this.title, required this.hintText}); + + final String title; + final String hintText; + + @override + State<_QuickTextCaptureDialog> createState() => + _QuickTextCaptureDialogState(); +} + +class _QuickTextCaptureDialogState extends State<_QuickTextCaptureDialog> { + late final TextEditingController _controller; + bool _attemptedSubmit = false; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final String value = _controller.text.trim(); + return AlertDialog( + title: Text(widget.title), + content: TextField( + controller: _controller, + minLines: 2, + maxLines: 4, + autofocus: true, + onChanged: (_) { + if (_attemptedSubmit) { + setState(() {}); + } + }, + decoration: InputDecoration( + hintText: widget.hintText, + errorText: _attemptedSubmit && value.isEmpty ? 'Required' : null, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final String submitted = _controller.text.trim(); + if (submitted.isEmpty) { + setState(() { + _attemptedSubmit = true; + }); + return; + } + Navigator.of(context).pop(submitted); + }, + child: const Text('Save'), + ), + ], + ); + } +} + +class _PersonEditorBottomSheet extends StatelessWidget { + const _PersonEditorBottomSheet({ + required this.title, + required this.existingPeople, + this.initial, + this.editingPersonId, + }); + + final String title; + final List existingPeople; + final _PersonDraft? initial; + final String? editingPersonId; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + left: 8, + right: 8, + bottom: MediaQuery.viewInsetsOf(context).bottom + 8, + ), + child: _PersonEditorPanel( + title: title, + initial: initial, + existingPeople: existingPeople, + editingPersonId: editingPersonId, + compact: true, + sheetStyle: true, + ), + ); + } +} + +class _PersonEditorPanel extends StatefulWidget { + const _PersonEditorPanel({ + required this.title, + required this.compact, + required this.sheetStyle, + required this.existingPeople, + this.initial, + this.editingPersonId, + }); + + final String title; + final bool compact; + final bool sheetStyle; + final List existingPeople; + final _PersonDraft? initial; + final String? editingPersonId; + + @override + State<_PersonEditorPanel> createState() => _PersonEditorPanelState(); +} + +class _PersonEditorPanelState extends State<_PersonEditorPanel> { + late final TextEditingController _nameController; + late final TextEditingController _relationshipController; + late final TextEditingController _locationController; + late final TextEditingController _aliasesController; + late final TextEditingController _tagsController; + late final TextEditingController _notesController; + bool _attemptedSubmit = false; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(text: widget.initial?.name ?? '') + ..addListener(_handlePreviewChanged); + _relationshipController = TextEditingController( + text: widget.initial?.relationship ?? '', + )..addListener(_handlePreviewChanged); + _locationController = TextEditingController( + text: widget.initial?.location ?? '', + )..addListener(_handlePreviewChanged); + _aliasesController = TextEditingController( + text: widget.initial?.aliases.join(', ') ?? '', + )..addListener(_handlePreviewChanged); + _tagsController = TextEditingController( + text: widget.initial?.tags.join(', ') ?? '', + )..addListener(_handlePreviewChanged); + _notesController = TextEditingController(text: widget.initial?.notes ?? ''); + } + + @override + void dispose() { + _nameController + ..removeListener(_handlePreviewChanged) + ..dispose(); + _relationshipController + ..removeListener(_handlePreviewChanged) + ..dispose(); + _locationController + ..removeListener(_handlePreviewChanged) + ..dispose(); + _aliasesController + ..removeListener(_handlePreviewChanged) + ..dispose(); + _tagsController + ..removeListener(_handlePreviewChanged) + ..dispose(); + _notesController.dispose(); + super.dispose(); + } + + void _handlePreviewChanged() { + if (mounted) { + setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + final String name = _nameController.text.trim(); + final String relationship = _relationshipController.text.trim(); + final bool nameMissing = _attemptedSubmit && name.isEmpty; + final bool relationshipMissing = _attemptedSubmit && relationship.isEmpty; + final String? location = _locationController.text.trim().isEmpty + ? null + : _locationController.text.trim(); + final List tags = _tagsController.text + .split(',') + .map((String tag) => tag.trim()) + .where((String tag) => tag.isNotEmpty) + .toList(growable: false); + final List duplicateMatches = _findDuplicateNameMatches( + name: name, + people: widget.existingPeople, + excludePersonId: widget.editingPersonId, + ); + final bool hasDuplicateWarning = duplicateMatches.isNotEmpty; + + final BorderRadius radius = BorderRadius.circular(widget.compact ? 24 : 22); + final double maxHeight = widget.compact ? 720 : 760; + + return Material( + color: Colors.transparent, + child: Container( + constraints: BoxConstraints( + maxHeight: maxHeight, + minHeight: widget.compact ? 360 : 420, + ), + decoration: BoxDecoration( + borderRadius: radius, + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFF7FBFD), Color(0xFFEAF3F9)], + ), + border: Border.all(color: Colors.white.withValues(alpha: 0.95)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 26, + offset: const Offset(0, 14), + ), + ], + ), + child: ClipRRect( + borderRadius: radius, + child: Column( + children: [ + if (widget.sheetStyle) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Container( + width: 42, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFCCD6DD), + borderRadius: BorderRadius.circular(99), + ), + ), + ), + Expanded( + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + widget.compact ? 14 : 18, + widget.compact ? 10 : 16, + widget.compact ? 14 : 18, + 12, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _PersonEditorHero( + title: widget.title, + compact: widget.compact, + previewName: name, + previewRelationship: relationship, + previewLocation: location, + previewTags: tags, + ), + if (hasDuplicateWarning) ...[ + const SizedBox(height: 10), + _PersonEditorWarningBanner(matches: duplicateMatches), + ], + const SizedBox(height: 12), + if (widget.compact) + Column( + children: [ + _PersonEditorSectionCard( + title: 'Identity', + icon: Icons.person_outline_rounded, + child: Column( + children: [ + TextField( + controller: _nameController, + autofocus: true, + decoration: InputDecoration( + labelText: 'Name', + errorText: nameMissing + ? 'Name is required' + : null, + ), + ), + const SizedBox(height: 10), + TextField( + controller: _relationshipController, + decoration: InputDecoration( + labelText: 'Relationship', + errorText: relationshipMissing + ? 'Relationship is required' + : null, + ), + ), + const SizedBox(height: 10), + TextField( + controller: _locationController, + decoration: const InputDecoration( + labelText: 'Location (optional)', + ), + ), + const SizedBox(height: 10), + TextField( + controller: _aliasesController, + decoration: const InputDecoration( + labelText: 'Aliases (comma separated)', + ), + ), + ], + ), + ), + const SizedBox(height: 10), + _PersonEditorSectionCard( + title: 'Context', + icon: Icons.auto_awesome_rounded, + child: Column( + children: [ + TextField( + controller: _tagsController, + decoration: const InputDecoration( + labelText: 'Tags (comma separated)', + ), + ), + const SizedBox(height: 10), + TextField( + controller: _notesController, + minLines: 3, + maxLines: 5, + decoration: const InputDecoration( + labelText: 'Notes', + hintText: + 'Preferences, boundaries, gift clues, recurring themes...', + ), + ), + ], + ), + ), + ], + ) + else + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _PersonEditorSectionCard( + title: 'Identity', + icon: Icons.person_outline_rounded, + child: Column( + children: [ + TextField( + controller: _nameController, + autofocus: true, + decoration: InputDecoration( + labelText: 'Name', + errorText: nameMissing + ? 'Name is required' + : null, + ), + ), + const SizedBox(height: 10), + TextField( + controller: _relationshipController, + decoration: InputDecoration( + labelText: 'Relationship', + errorText: relationshipMissing + ? 'Relationship is required' + : null, + ), + ), + const SizedBox(height: 10), + TextField( + controller: _locationController, + decoration: const InputDecoration( + labelText: 'Location (optional)', + ), + ), + const SizedBox(height: 10), + TextField( + controller: _aliasesController, + decoration: const InputDecoration( + labelText: 'Aliases (comma separated)', + ), + ), + ], + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _PersonEditorSectionCard( + title: 'Context', + icon: Icons.auto_awesome_rounded, + child: Column( + children: [ + TextField( + controller: _tagsController, + decoration: const InputDecoration( + labelText: 'Tags (comma separated)', + ), + ), + const SizedBox(height: 10), + TextField( + controller: _notesController, + minLines: 5, + maxLines: 8, + decoration: const InputDecoration( + labelText: 'Notes', + hintText: + 'Preferences, boundaries, gift clues, recurring themes...', + ), + ), + ], + ), + ), + ), + ], + ), + ], + ), + ), + ), + Container( + padding: EdgeInsets.fromLTRB( + widget.compact ? 14 : 18, + 10, + widget.compact ? 14 : 18, + widget.compact ? 14 : 16, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.75), + border: Border( + top: BorderSide(color: Colors.white.withValues(alpha: 0.9)), + ), + ), + child: Row( + children: [ + Expanded( + child: Text( + 'Required: name and relationship', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ), + const SizedBox(width: 8), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () { + final String submittedName = _nameController.text + .trim(); + final String submittedRelationship = + _relationshipController.text.trim(); + if (submittedName.isEmpty || + submittedRelationship.isEmpty) { + setState(() { + _attemptedSubmit = true; + }); + return; + } + + final _PersonDraft draft = _PersonDraft( + name: submittedName, + relationship: submittedRelationship, + location: _locationController.text.trim().isEmpty + ? null + : _locationController.text.trim(), + aliases: _aliasesController.text + .split(',') + .map((String value) => value.trim()) + .where((String value) => value.isNotEmpty) + .toList(growable: false), + notes: _notesController.text.trim(), + tags: _tagsController.text + .split(',') + .map((String tag) => tag.trim()) + .where((String tag) => tag.isNotEmpty) + .toList(growable: false), + ); + Navigator.of(context).pop(draft); + }, + child: const Text('Save'), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _PersonEditorHero extends StatelessWidget { + const _PersonEditorHero({ + required this.title, + required this.compact, + required this.previewName, + required this.previewRelationship, + required this.previewLocation, + required this.previewTags, + }); + + final String title; + final bool compact; + final String previewName; + final String previewRelationship; + final String? previewLocation; + final List previewTags; + + @override + Widget build(BuildContext context) { + final String displayName = previewName.isEmpty ? 'New person' : previewName; + final String displayRelationship = previewRelationship.isEmpty + ? 'Relationship type' + : previewRelationship; + + return Container( + width: double.infinity, + padding: EdgeInsets.all(compact ? 14 : 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: Colors.white.withValues(alpha: 0.72), + border: Border.all(color: Colors.white), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _AvatarSeed(name: displayName, size: compact ? 50 : 56), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 4), + Text( + 'Shape the profile first, then use quick-add from the People workspace.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _InlineMetaPill( + icon: Icons.person_outline_rounded, + label: displayName, + ), + _InlineMetaPill( + icon: Icons.favorite_outline_rounded, + label: displayRelationship, + ), + if (previewLocation != null) + _InlineMetaPill( + icon: Icons.location_on_outlined, + label: previewLocation!, + ), + ...previewTags + .take(compact ? 2 : 4) + .map( + (String tag) => + _InlineMetaPill(icon: Icons.sell_outlined, label: tag), + ), + ], + ), + ], + ), + ); + } +} + +class _PersonEditorSectionCard extends StatelessWidget { + const _PersonEditorSectionCard({ + required this.title, + required this.icon, + required this.child, + }); + + final String title; + final IconData icon; + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.78), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 18, color: AppTheme.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 10), + child, + ], + ), + ); + } +} + +class _PersonEditorWarningBanner extends StatelessWidget { + const _PersonEditorWarningBanner({required this.matches}); + + final List matches; + + @override + Widget build(BuildContext context) { + final String names = matches + .take(2) + .map( + (PersonProfile person) => '${person.name} (${person.relationship})', + ) + .join(', '); + final int extraCount = matches.length - 2; + final String suffix = extraCount > 0 ? ' +$extraCount more' : ''; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7E8), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFFFE2A8)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(top: 1), + child: Icon( + Icons.warning_amber_rounded, + size: 18, + color: Color(0xFFA56A00), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Possible duplicate profile name detected. Existing: $names$suffix. You can still save, then merge duplicates from People.', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: const Color(0xFF6F5200)), + ), + ), + ], + ), + ); + } +} + +class _PersonDraft { + const _PersonDraft({ + required this.name, + required this.relationship, + required this.notes, + required this.tags, + required this.aliases, + this.location, + }); + + final String name; + final String relationship; + final String notes; + final List tags; + final List aliases; + final String? location; +} + +List _findDuplicateNameMatches({ + required String name, + required List people, + String? excludePersonId, +}) { + final String normalized = _normalizePersonNameKey(name); + if (normalized.isEmpty) { + return const []; + } + + return people + .where((PersonProfile person) { + if (excludePersonId != null && person.id == excludePersonId) { + return false; + } + return _normalizePersonNameKey(person.name) == normalized; + }) + .toList(growable: false); +} + +String _normalizePersonNameKey(String value) { + return value + .trim() + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9]+'), ' ') + .replaceAll(RegExp(r'\s+'), ' '); +} + +class _MergePeopleDraft { + const _MergePeopleDraft({ + required this.sourcePersonId, + required this.targetPersonId, + }); + + final String sourcePersonId; + final String targetPersonId; +} + +class _MergePeopleDialog extends StatefulWidget { + const _MergePeopleDialog({required this.people}); + + final List people; + + @override + State<_MergePeopleDialog> createState() => _MergePeopleDialogState(); +} + +class _MergePeopleDialogState extends State<_MergePeopleDialog> { + late String _sourcePersonId; + late String _targetPersonId; + + @override + void initState() { + super.initState(); + _targetPersonId = widget.people.first.id; + _sourcePersonId = widget.people.length > 1 + ? widget.people[1].id + : widget.people.first.id; + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Merge Duplicate Profiles'), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Move captures, ideas, reminders, and share links from source into target profile.', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + key: ValueKey('target-$_targetPersonId'), + initialValue: _targetPersonId, + decoration: const InputDecoration( + labelText: 'Keep target profile', + ), + items: widget.people + .map( + (PersonProfile person) => DropdownMenuItem( + value: person.id, + child: Text('${person.name} · ${person.relationship}'), + ), + ) + .toList(growable: false), + onChanged: (String? value) { + if (value == null) { + return; + } + setState(() { + _targetPersonId = value; + if (_targetPersonId == _sourcePersonId) { + _sourcePersonId = widget.people + .firstWhere( + (PersonProfile person) => + person.id != _targetPersonId, + orElse: () => widget.people.first, + ) + .id; + } + }); + }, + ), + const SizedBox(height: 10), + DropdownButtonFormField( + key: ValueKey('source-$_targetPersonId-$_sourcePersonId'), + initialValue: _sourcePersonId, + decoration: const InputDecoration( + labelText: 'Merge and remove source profile', + ), + items: widget.people + .where((PersonProfile person) => person.id != _targetPersonId) + .map( + (PersonProfile person) => DropdownMenuItem( + value: person.id, + child: Text('${person.name} · ${person.relationship}'), + ), + ) + .toList(growable: false), + onChanged: (String? value) { + if (value == null) { + return; + } + setState(() { + _sourcePersonId = value; + }); + }, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + if (_sourcePersonId == _targetPersonId) { + return; + } + Navigator.of(context).pop( + _MergePeopleDraft( + sourcePersonId: _sourcePersonId, + targetPersonId: _targetPersonId, + ), + ); + }, + child: const Text('Merge'), + ), + ], + ); + } +} + +class _QuickIdeaDraft { + const _QuickIdeaDraft({ + required this.type, + required this.title, + required this.details, + }); + + final IdeaType type; + final String title; + final String details; +} + +class _QuickIdeaDialog extends StatefulWidget { + const _QuickIdeaDialog(); + + @override + State<_QuickIdeaDialog> createState() => _QuickIdeaDialogState(); +} + +class _QuickIdeaDialogState extends State<_QuickIdeaDialog> { + IdeaType _type = IdeaType.gift; + final TextEditingController _titleController = TextEditingController(); + final TextEditingController _detailsController = TextEditingController(); + + @override + void dispose() { + _titleController.dispose(); + _detailsController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Add Idea'), + content: SingleChildScrollView( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SegmentedButton( + segments: const >[ + ButtonSegment( + value: IdeaType.gift, + label: Text('Gift'), + ), + ButtonSegment( + value: IdeaType.event, + label: Text('Event'), + ), + ], + selected: {_type}, + onSelectionChanged: (Set values) { + setState(() { + _type = values.first; + }); + }, + ), + const SizedBox(height: 10), + TextField( + controller: _titleController, + decoration: const InputDecoration(labelText: 'Title'), + ), + TextField( + controller: _detailsController, + maxLines: 3, + decoration: const InputDecoration(labelText: 'Details'), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final String title = _titleController.text.trim(); + if (title.isEmpty) { + return; + } + Navigator.of(context).pop( + _QuickIdeaDraft( + type: _type, + title: title, + details: _detailsController.text.trim(), + ), + ); + }, + child: const Text('Add'), + ), + ], + ); + } +} + +class _QuickReminderDraft { + const _QuickReminderDraft({ + required this.title, + required this.cadence, + required this.nextAt, + }); + + final String title; + final ReminderCadence cadence; + final DateTime nextAt; +} + +class _QuickReminderDialog extends StatefulWidget { + const _QuickReminderDialog(); + + @override + State<_QuickReminderDialog> createState() => _QuickReminderDialogState(); +} + +class _QuickReminderDialogState extends State<_QuickReminderDialog> { + final TextEditingController _titleController = TextEditingController(); + ReminderCadence _cadence = ReminderCadence.weekly; + DateTime _nextAt = DateTime.now().add(const Duration(days: 1)); + + @override + void dispose() { + _titleController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Add Reminder'), + content: SingleChildScrollView( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _titleController, + decoration: const InputDecoration(labelText: 'Title'), + ), + const SizedBox(height: 10), + DropdownButtonFormField( + initialValue: _cadence, + decoration: const InputDecoration(labelText: 'Cadence'), + items: ReminderCadence.values + .map( + (ReminderCadence cadence) => + DropdownMenuItem( + value: cadence, + child: Text(cadence.name), + ), + ) + .toList(growable: false), + onChanged: (ReminderCadence? value) { + if (value == null) { + return; + } + setState(() { + _cadence = value; + }); + }, + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: Text( + 'Next: ${_nextAt.year}-${_nextAt.month.toString().padLeft(2, '0')}-${_nextAt.day.toString().padLeft(2, '0')} ${_nextAt.hour.toString().padLeft(2, '0')}:${_nextAt.minute.toString().padLeft(2, '0')}', + ), + ), + TextButton( + onPressed: () async { + final DateTime now = DateTime.now(); + final DateTime? date = await showDatePicker( + context: context, + firstDate: now, + lastDate: now.add(const Duration(days: 3650)), + initialDate: _nextAt, + ); + if (date == null || !context.mounted) { + return; + } + final TimeOfDay? time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(_nextAt), + ); + if (time == null) { + return; + } + setState(() { + _nextAt = DateTime( + date.year, + date.month, + date.day, + time.hour, + time.minute, + ); + }); + }, + child: const Text('Change'), + ), + ], + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final String title = _titleController.text.trim(); + if (title.isEmpty) { + return; + } + Navigator.of(context).pop( + _QuickReminderDraft( + title: title, + cadence: _cadence, + nextAt: _nextAt, + ), + ); + }, + child: const Text('Add'), + ), + ], + ); + } +} diff --git a/lib/features/people/presentation/people_view_list.dart b/lib/features/people/presentation/people_view_list.dart new file mode 100644 index 0000000..36daf56 --- /dev/null +++ b/lib/features/people/presentation/people_view_list.dart @@ -0,0 +1,528 @@ +part of 'people_view.dart'; + +class _EmptyPeopleView extends StatelessWidget { + const _EmptyPeopleView({required this.onAdd}); + + final VoidCallback onAdd; + + @override + Widget build(BuildContext context) { + return Center( + child: FrostedCard( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'No people yet', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + 'Add the first relationship profile to start tracking moments.', + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: onAdd, + icon: const Icon(Icons.person_add_alt_1_rounded), + label: const Text('Add Person'), + ), + ], + ), + ), + ); + } +} + +class _FilteredPeopleEmptyView extends StatelessWidget { + const _FilteredPeopleEmptyView({required this.onClear}); + + final VoidCallback onClear; + + @override + Widget build(BuildContext context) { + return Center( + child: FrostedCard( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.search_off_rounded, + size: 34, + color: AppTheme.textSecondary, + ), + const SizedBox(height: 10), + Text( + 'No matching people', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 6), + Text( + 'Try another search, relationship filter, or clear the current filters.', + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: onClear, + icon: const Icon(Icons.restart_alt_rounded), + label: const Text('Clear Filters'), + ), + ], + ), + ), + ); + } +} + +class _PeopleListControls extends ConsumerWidget { + const _PeopleListControls({ + required this.compact, + required this.relationshipOptions, + }); + + final bool compact; + final List relationshipOptions; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final String query = ref.watch(peopleSearchQueryProvider); + final String? selectedRelationship = ref.watch( + peopleRelationshipFilterProvider, + ); + final PeopleSortOption sortOption = ref.watch(peopleSortOptionProvider); + + return FrostedCard( + padding: EdgeInsets.all(compact ? 12 : 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: TextFormField( + key: ValueKey('people-search-$query'), + initialValue: query, + onChanged: (String value) { + ref.read(peopleSearchQueryProvider.notifier).set(value); + }, + decoration: const InputDecoration( + hintText: 'Search name, tags, notes, location', + prefixIcon: Icon(Icons.search_rounded), + isDense: true, + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 8), + PopupMenuButton( + tooltip: 'Sort', + onSelected: (PeopleSortOption value) { + ref.read(peopleSortOptionProvider.notifier).set(value); + }, + itemBuilder: (BuildContext context) => + >[ + const PopupMenuItem( + value: PeopleSortOption.affinity, + child: Text('Sort by affinity'), + ), + const PopupMenuItem( + value: PeopleSortOption.nextMoment, + child: Text('Sort by next moment'), + ), + const PopupMenuItem( + value: PeopleSortOption.alphabetical, + child: Text('Sort A-Z'), + ), + ], + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 10, + ), + decoration: BoxDecoration( + color: const Color(0xFFF3F8FB), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.sort_rounded, size: 18), + if (!compact) ...[ + const SizedBox(width: 6), + Text(switch (sortOption) { + PeopleSortOption.affinity => 'Affinity', + PeopleSortOption.nextMoment => 'Next', + PeopleSortOption.alphabetical => 'A-Z', + }), + ], + ], + ), + ), + ), + ], + ), + const SizedBox(height: 10), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _FilterChipButton( + label: 'All', + selected: selectedRelationship == null, + onTap: () { + ref + .read(peopleRelationshipFilterProvider.notifier) + .set(null); + }, + ), + ...relationshipOptions.map( + (String relationship) => Padding( + padding: const EdgeInsets.only(left: 8), + child: _FilterChipButton( + label: relationship, + selected: + selectedRelationship?.toLowerCase() == + relationship.toLowerCase(), + onTap: () { + final PeopleRelationshipFilterNotifier controller = ref + .read(peopleRelationshipFilterProvider.notifier); + final bool isSelected = + selectedRelationship?.toLowerCase() == + relationship.toLowerCase(); + controller.set(isSelected ? null : relationship); + }, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _FilterChipButton extends StatelessWidget { + const _FilterChipButton({ + required this.label, + required this.selected, + required this.onTap, + }); + + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(99), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: selected ? const Color(0xFFEAF7FB) : const Color(0xFFF4F8FB), + borderRadius: BorderRadius.circular(99), + border: Border.all( + color: selected + ? const Color(0xFFB8E4EE) + : Colors.white.withValues(alpha: 0.8), + ), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: selected ? AppTheme.primary : AppTheme.textSecondary, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + ), + ), + ), + ); + } +} + +class _PeopleHeader extends StatelessWidget { + const _PeopleHeader({ + required this.onAdd, + required this.onMerge, + required this.compact, + }); + + final VoidCallback onAdd; + final VoidCallback onMerge; + final bool compact; + + @override + Widget build(BuildContext context) { + final TextTheme textTheme = Theme.of(context).textTheme; + if (compact) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('People', style: textTheme.headlineMedium), + const SizedBox(height: 6), + Text( + 'Track what matters to each relationship and follow through.', + style: textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: onAdd, + icon: const Icon(Icons.person_add_alt_1_rounded), + label: const Text('Add person'), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: onMerge, + icon: const Icon(Icons.merge_type_rounded), + label: const Text('Merge duplicates'), + ), + ], + ); + } + + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('People', style: textTheme.headlineMedium), + const SizedBox(height: 6), + Text( + 'Track what matters to each relationship and follow through.', + style: textTheme.bodyLarge?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + FilledButton.icon( + onPressed: onAdd, + icon: const Icon(Icons.person_add_alt_1_rounded), + label: const Text('Add'), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: onMerge, + icon: const Icon(Icons.merge_type_rounded), + label: const Text('Merge'), + ), + ], + ); + } +} + +class _PersonListItem extends StatelessWidget { + const _PersonListItem({ + required this.person, + required this.selectedId, + required this.compact, + required this.onSelect, + }); + + final PersonProfile person; + final String selectedId; + final bool compact; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + final bool isSelected = person.id == selectedId; + return InkWell( + onTap: onSelect, + borderRadius: BorderRadius.circular(20), + child: FrostedCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _AvatarSeed(name: person.name), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + person.name, + style: Theme.of(context).textTheme.titleMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + person.relationship, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 8), + _ScorePill(score: person.affinityScore), + if (isSelected) + const Padding( + padding: EdgeInsets.only(left: 10), + child: Icon(Icons.check_circle, color: Color(0xFF1AB6C8)), + ), + ], + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _InlineMetaPill( + icon: Icons.schedule_rounded, + label: _dateTimeLabel(person.nextMoment), + ), + if ((person.location ?? '').trim().isNotEmpty) + _InlineMetaPill( + icon: Icons.location_on_outlined, + label: person.location!.trim(), + ), + ...person.tags + .take(compact ? 2 : 3) + .map( + (String tag) => _InlineMetaPill( + icon: Icons.sell_outlined, + label: tag, + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _InlineMetaPill extends StatelessWidget { + const _InlineMetaPill({required this.icon, required this.label}); + + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + decoration: BoxDecoration( + color: const Color(0xFFF1F7FA), + borderRadius: BorderRadius.circular(99), + border: Border.all(color: Colors.white), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: AppTheme.textSecondary), + const SizedBox(width: 6), + Flexible( + child: Text( + label, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary), + ), + ), + ], + ), + ); + } +} + +class _AvatarSeed extends StatelessWidget { + const _AvatarSeed({required this.name, this.size = 48}); + + final String name; + final double size; + + @override + Widget build(BuildContext context) { + final String initials = name + .split(' ') + .where((String part) => part.isNotEmpty) + .take(2) + .map((String part) => part[0].toUpperCase()) + .join(); + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(size / 2), + gradient: const LinearGradient( + colors: [Color(0xFF1AB6C8), Color(0xFF2274E0)], + ), + ), + alignment: Alignment.center, + child: Text( + initials, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +class _ScorePill extends StatelessWidget { + const _ScorePill({required this.score}); + + final int score; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFFEEF7F3), + borderRadius: BorderRadius.circular(99), + ), + child: Text( + '$score%', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: const Color(0xFF1D9C66), + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +String _dateTimeLabel(DateTime value) { + final DateTime local = value.toLocal(); + final String month = local.month.toString().padLeft(2, '0'); + final String day = local.day.toString().padLeft(2, '0'); + final String hour = local.hour.toString().padLeft(2, '0'); + final String minute = local.minute.toString().padLeft(2, '0'); + return '$month/$day $hour:$minute'; +} + +String _shortDateTimeLabel(DateTime value) { + final DateTime local = value.toLocal(); + final String year = local.year.toString(); + final String month = local.month.toString().padLeft(2, '0'); + final String day = local.day.toString().padLeft(2, '0'); + final String hour = local.hour.toString().padLeft(2, '0'); + final String minute = local.minute.toString().padLeft(2, '0'); + return '$year-$month-$day $hour:$minute'; +} diff --git a/lib/features/people/presentation/providers/README.md b/lib/features/people/presentation/providers/README.md new file mode 100644 index 0000000..6baef9f --- /dev/null +++ b/lib/features/people/presentation/providers/README.md @@ -0,0 +1,4 @@ +# People UI State + +This folder contains lightweight Riverpod state used only by the people +workspace, such as search text, selected profile, and sort/filter controls. diff --git a/lib/features/people/presentation/providers/people_ui_state.dart b/lib/features/people/presentation/providers/people_ui_state.dart new file mode 100644 index 0000000..1ca6989 --- /dev/null +++ b/lib/features/people/presentation/providers/people_ui_state.dart @@ -0,0 +1,70 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Current sort mode for the people list. +enum PeopleSortOption { affinity, nextMoment, alphabetical } + +/// Selected profile in the people workspace. +class SelectedPersonIdNotifier extends Notifier { + @override + String? build() => null; + + void select(String id) { + state = id; + } + + void clear() { + state = null; + } +} + +final NotifierProvider +selectedPersonIdProvider = NotifierProvider( + SelectedPersonIdNotifier.new, +); + +/// Search query applied to the people list. +class PeopleSearchQueryNotifier extends Notifier { + @override + String build() => ''; + + void set(String value) { + state = value; + } +} + +final NotifierProvider +peopleSearchQueryProvider = NotifierProvider( + PeopleSearchQueryNotifier.new, +); + +/// Relationship filter applied to the people list. +class PeopleRelationshipFilterNotifier extends Notifier { + @override + String? build() => null; + + void set(String? value) { + state = value; + } +} + +final NotifierProvider +peopleRelationshipFilterProvider = + NotifierProvider( + PeopleRelationshipFilterNotifier.new, + ); + +/// Selected sort mode for the people list. +class PeopleSortOptionNotifier extends Notifier { + @override + PeopleSortOption build() => PeopleSortOption.affinity; + + void set(PeopleSortOption value) { + state = value; + } +} + +final NotifierProvider +peopleSortOptionProvider = + NotifierProvider( + PeopleSortOptionNotifier.new, + ); diff --git a/lib/features/reminders/README.md b/lib/features/reminders/README.md new file mode 100644 index 0000000..a82d91c --- /dev/null +++ b/lib/features/reminders/README.md @@ -0,0 +1,9 @@ +# Reminders Slice + +This slice owns reminder rules and local scheduling behavior. + +- `domain/reminder_models.dart`: reminder cadence and rule models. +- `presentation/reminders_view.dart`: reminder management screen. +- `application/`: notification listeners, local scheduling, and providers. + +Work here when you change reminder UX or local notification behavior. diff --git a/lib/features/reminders/application/README.md b/lib/features/reminders/application/README.md new file mode 100644 index 0000000..4e2b7c6 --- /dev/null +++ b/lib/features/reminders/application/README.md @@ -0,0 +1,4 @@ +# Reminders Application + +Reminder scheduling orchestration and provider wiring live here. Start here when +the behavior of reminder execution or platform scheduling needs to change. diff --git a/lib/features/reminders/application/reminder_notification_intent_bus.dart b/lib/features/reminders/application/reminder_notification_intent_bus.dart new file mode 100644 index 0000000..5162518 --- /dev/null +++ b/lib/features/reminders/application/reminder_notification_intent_bus.dart @@ -0,0 +1,18 @@ +import 'dart:async'; + +/// Broadcasts reminder notification tap intents to the UI layer. +class ReminderNotificationIntentBus { + final StreamController _controller = + StreamController.broadcast(); + + Stream get intents => _controller.stream; + + void emitTap(String reminderId) { + if (reminderId.trim().isEmpty) { + return; + } + _controller.add(reminderId.trim()); + } + + Future close() => _controller.close(); +} diff --git a/lib/features/reminders/application/reminder_notification_listener.dart b/lib/features/reminders/application/reminder_notification_listener.dart new file mode 100644 index 0000000..215694b --- /dev/null +++ b/lib/features/reminders/application/reminder_notification_listener.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/features/reminders/reminders_view.dart'; +import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_provider.dart'; + +/// Listens for reminder notification taps and opens reminder management UI. +class ReminderNotificationListener extends ConsumerStatefulWidget { + const ReminderNotificationListener({required this.child, super.key}); + + final Widget child; + + @override + ConsumerState createState() => + _ReminderNotificationListenerState(); +} + +class _ReminderNotificationListenerState + extends ConsumerState { + StreamSubscription? _subscription; + + @override + void initState() { + super.initState(); + _subscription = ref + .read(reminderNotificationIntentBusProvider) + .intents + .listen(_handleIntent); + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; + + Future _handleIntent(String reminderId) async { + if (!mounted) { + return; + } + + await Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) => + _ReminderNotificationScreen(reminderId: reminderId), + ), + ); + } +} + +class _ReminderNotificationScreen extends StatelessWidget { + const _ReminderNotificationScreen({required this.reminderId}); + + final String reminderId; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Reminder $reminderId')), + body: const RemindersView(), + ); + } +} diff --git a/lib/features/reminders/application/reminder_scheduler.dart b/lib/features/reminders/application/reminder_scheduler.dart new file mode 100644 index 0000000..9a650d9 --- /dev/null +++ b/lib/features/reminders/application/reminder_scheduler.dart @@ -0,0 +1,27 @@ +import 'package:relationship_saver/features/local/local_models.dart'; + +/// Schedules and reconciles reminder deliveries for current local state. +abstract class ReminderScheduler { + /// Reconciles platform schedules to match current enabled reminders. + Future reconcile(List reminders); + + /// Clears all platform-scheduled reminder jobs. + Future clearAll(); + + /// Requests notification permissions where the platform requires them. + Future requestPermissions(); +} + +/// Default scheduler used until platform notifications are integrated. +class NoopReminderScheduler implements ReminderScheduler { + const NoopReminderScheduler(); + + @override + Future clearAll() async {} + + @override + Future reconcile(List reminders) async {} + + @override + Future requestPermissions() async => true; +} diff --git a/lib/features/reminders/application/reminder_scheduler_local_notifications.dart b/lib/features/reminders/application/reminder_scheduler_local_notifications.dart new file mode 100644 index 0000000..c4eb160 --- /dev/null +++ b/lib/features/reminders/application/reminder_scheduler_local_notifications.dart @@ -0,0 +1,240 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:relationship_saver/features/local/local_models.dart'; +import 'package:relationship_saver/features/reminders/scheduling/reminder_notification_intent_bus.dart'; +import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler.dart'; +import 'package:timezone/timezone.dart' as tz; + +const String _channelId = 'relationship_saver_reminders'; +const String _channelName = 'Relationship Reminders'; +const String _channelDescription = + 'Reminders for relationship plans and follow-ups'; + +/// Schedules reminders with local notifications where platform support exists. +class LocalNotificationsReminderScheduler implements ReminderScheduler { + LocalNotificationsReminderScheduler({ + ReminderNotificationsDriver? driver, + DateTime Function()? now, + ReminderNotificationIntentBus? intentBus, + }) : _driver = + driver ?? + FlutterReminderNotificationsDriver(onTapPayload: intentBus?.emitTap), + _now = now ?? DateTime.now; + + final ReminderNotificationsDriver _driver; + final DateTime Function() _now; + + @override + Future clearAll() async { + if (!_supportsSchedulingPlatform()) { + return; + } + await _driver.ensureInitialized(); + await _driver.cancelAll(); + } + + @override + Future reconcile(List reminders) async { + if (!_supportsSchedulingPlatform()) { + return; + } + await _driver.ensureInitialized(); + await _driver.cancelAll(); + + final DateTime now = _now(); + final List enabled = reminders + .where((ReminderRule reminder) => reminder.enabled) + .toList(growable: false); + + for (final ReminderRule reminder in enabled) { + final DateTimeComponents? repeating = _repeatComponents(reminder.cadence); + if (repeating == null && reminder.nextAt.isBefore(now)) { + continue; + } + + await _driver.schedule( + id: _notificationId(reminder.id), + title: reminder.title, + body: _buildBody(reminder), + scheduledAt: reminder.nextAt, + repeatComponents: repeating, + payload: reminder.id, + ); + } + } + + @override + Future requestPermissions() async { + if (!_supportsSchedulingPlatform()) { + return false; + } + await _driver.ensureInitialized(); + return _driver.requestPermissions(); + } + + DateTimeComponents? _repeatComponents(ReminderCadence cadence) { + return switch (cadence) { + ReminderCadence.daily => DateTimeComponents.time, + ReminderCadence.weekly => DateTimeComponents.dayOfWeekAndTime, + ReminderCadence.monthly => DateTimeComponents.dayOfMonthAndTime, + }; + } + + String _buildBody(ReminderRule reminder) { + final String frequency = switch (reminder.cadence) { + ReminderCadence.daily => 'daily', + ReminderCadence.weekly => 'weekly', + ReminderCadence.monthly => 'monthly', + }; + return 'Your $frequency reminder is due now.'; + } + + bool _supportsSchedulingPlatform() { + if (kIsWeb) { + return false; + } + + return defaultTargetPlatform != TargetPlatform.linux; + } + + int _notificationId(String raw) { + int hash = 0; + for (final int unit in raw.codeUnits) { + hash = ((hash * 31) + unit) & 0x7fffffff; + } + return hash; + } +} + +/// Driver wrapper around the notification plugin for easier testing. +abstract class ReminderNotificationsDriver { + Future ensureInitialized(); + + Future cancelAll(); + + Future schedule({ + required int id, + required String title, + required String body, + required DateTime scheduledAt, + required DateTimeComponents? repeatComponents, + required String payload, + }); + + Future requestPermissions(); +} + +class FlutterReminderNotificationsDriver + implements ReminderNotificationsDriver { + FlutterReminderNotificationsDriver({ + FlutterLocalNotificationsPlugin? plugin, + void Function(String payload)? onTapPayload, + }) : _plugin = plugin ?? FlutterLocalNotificationsPlugin(), + _onTapPayload = onTapPayload; + + final FlutterLocalNotificationsPlugin _plugin; + final void Function(String payload)? _onTapPayload; + Future? _initializeFuture; + + @override + Future ensureInitialized() { + return _initializeFuture ??= _initialize(); + } + + @override + Future cancelAll() async { + await _plugin.cancelAll(); + } + + @override + Future schedule({ + required int id, + required String title, + required String body, + required DateTime scheduledAt, + required DateTimeComponents? repeatComponents, + required String payload, + }) async { + await _plugin.zonedSchedule( + id: id, + title: title, + body: body, + scheduledDate: tz.TZDateTime.from(scheduledAt.toUtc(), tz.UTC), + notificationDetails: _notificationDetails, + androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, + matchDateTimeComponents: repeatComponents, + payload: payload, + ); + } + + @override + Future requestPermissions() async { + if (defaultTargetPlatform == TargetPlatform.android) { + return await _plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.requestNotificationsPermission() ?? + false; + } + + if (defaultTargetPlatform == TargetPlatform.iOS) { + return await _plugin + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin + >() + ?.requestPermissions(alert: true, badge: true, sound: true) ?? + false; + } + + if (defaultTargetPlatform == TargetPlatform.macOS) { + return await _plugin + .resolvePlatformSpecificImplementation< + MacOSFlutterLocalNotificationsPlugin + >() + ?.requestPermissions(alert: true, badge: true, sound: true) ?? + false; + } + + return true; + } + + Future _initialize() async { + await _plugin.initialize( + settings: const InitializationSettings( + android: AndroidInitializationSettings('@mipmap/ic_launcher'), + iOS: DarwinInitializationSettings(), + macOS: DarwinInitializationSettings(), + linux: LinuxInitializationSettings(defaultActionName: 'Open reminders'), + windows: WindowsInitializationSettings( + appName: 'Relationship Saver', + appUserModelId: 'com.relationshipsaver.app', + guid: 'ee23c0af-5898-4d96-b599-cf2d7d0d37d7', + ), + ), + onDidReceiveNotificationResponse: (NotificationResponse response) { + final String? payload = response.payload; + if (payload == null || payload.isEmpty) { + return; + } + _onTapPayload?.call(payload); + }, + ); + } + + NotificationDetails get _notificationDetails { + return const NotificationDetails( + android: AndroidNotificationDetails( + _channelId, + _channelName, + channelDescription: _channelDescription, + importance: Importance.high, + priority: Priority.high, + ), + iOS: DarwinNotificationDetails(), + macOS: DarwinNotificationDetails(), + linux: LinuxNotificationDetails(), + windows: WindowsNotificationDetails(), + ); + } +} diff --git a/lib/features/reminders/application/reminder_scheduler_provider.dart b/lib/features/reminders/application/reminder_scheduler_provider.dart new file mode 100644 index 0000000..4e4dd8e --- /dev/null +++ b/lib/features/reminders/application/reminder_scheduler_provider.dart @@ -0,0 +1,30 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/config/app_config.dart'; +import 'package:relationship_saver/features/reminders/scheduling/reminder_notification_intent_bus.dart'; +import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler.dart'; +import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_local_notifications.dart'; + +/// Broadcasts reminder notification tap intents to interested UI listeners. +final Provider +reminderNotificationIntentBusProvider = Provider( + (Ref ref) { + final ReminderNotificationIntentBus bus = ReminderNotificationIntentBus(); + ref.onDispose(() { + unawaited(bus.close()); + }); + return bus; + }, +); + +/// Provides reminder delivery scheduler implementation. +final Provider reminderSchedulerProvider = + Provider((Ref ref) { + if (AppConfig.enableLocalNotifications) { + return LocalNotificationsReminderScheduler( + intentBus: ref.watch(reminderNotificationIntentBusProvider), + ); + } + return const NoopReminderScheduler(); + }); diff --git a/lib/features/reminders/domain/README.md b/lib/features/reminders/domain/README.md new file mode 100644 index 0000000..aa4e1a7 --- /dev/null +++ b/lib/features/reminders/domain/README.md @@ -0,0 +1,4 @@ +# Reminders Domain + +Reminder models and cadence enums live here. Keep this layer free of platform +notification details so it stays easy to test and reuse. diff --git a/lib/features/reminders/domain/reminder_models.dart b/lib/features/reminders/domain/reminder_models.dart new file mode 100644 index 0000000..26bcf0b --- /dev/null +++ b/lib/features/reminders/domain/reminder_models.dart @@ -0,0 +1,71 @@ +// ignore_for_file: sort_constructors_first + +import 'package:flutter/foundation.dart'; + +/// Reminder cadence supported by the local scheduling layer. +enum ReminderCadence { daily, weekly, monthly } + +/// Reminder rule stored locally and optionally synced later. +@immutable +class ReminderRule { + const ReminderRule({ + required this.id, + required this.title, + required this.cadence, + required this.nextAt, + this.personId, + this.enabled = true, + }); + + final String id; + final String? personId; + final String title; + final ReminderCadence cadence; + final DateTime nextAt; + final bool enabled; + + ReminderRule copyWith({ + String? id, + String? personId, + String? title, + ReminderCadence? cadence, + DateTime? nextAt, + bool? enabled, + }) { + return ReminderRule( + id: id ?? this.id, + personId: personId ?? this.personId, + title: title ?? this.title, + cadence: cadence ?? this.cadence, + nextAt: nextAt ?? this.nextAt, + enabled: enabled ?? this.enabled, + ); + } + + Map toJson() { + return { + 'id': id, + 'personId': personId, + 'title': title, + 'cadence': cadence.name, + 'nextAt': nextAt.toUtc().toIso8601String(), + 'enabled': enabled, + }; + } + + factory ReminderRule.fromJson(Map json) { + final String cadenceName = + json['cadence'] as String? ?? ReminderCadence.weekly.name; + return ReminderRule( + id: json['id'] as String, + personId: json['personId'] as String?, + title: json['title'] as String, + cadence: ReminderCadence.values.firstWhere( + (ReminderCadence cadence) => cadence.name == cadenceName, + orElse: () => ReminderCadence.weekly, + ), + nextAt: DateTime.parse(json['nextAt'] as String).toLocal(), + enabled: json['enabled'] as bool? ?? true, + ); + } +} diff --git a/lib/features/reminders/presentation/README.md b/lib/features/reminders/presentation/README.md new file mode 100644 index 0000000..b572551 --- /dev/null +++ b/lib/features/reminders/presentation/README.md @@ -0,0 +1,4 @@ +# Reminders Presentation + +Reminder list and editor UI belong here. Use this folder for reminder-facing +widgets, not for scheduling adapters or background execution. diff --git a/lib/features/reminders/presentation/reminders_view.dart b/lib/features/reminders/presentation/reminders_view.dart new file mode 100644 index 0000000..05ad676 --- /dev/null +++ b/lib/features/reminders/presentation/reminders_view.dart @@ -0,0 +1,561 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/config/app_theme.dart'; +import 'package:relationship_saver/features/local/local_models.dart'; +import 'package:relationship_saver/features/local/local_repository.dart'; +import 'package:relationship_saver/features/shared/frosted_card.dart'; + +class RemindersView extends ConsumerWidget { + const RemindersView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final AsyncValue localData = ref.watch( + localRepositoryProvider, + ); + + return localData.when( + data: (LocalDataState data) { + final List reminders = data.reminders; + final Map peopleById = { + for (final PersonProfile person in data.people) person.id: person, + }; + + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final bool compact = constraints.maxWidth < 760; + return Padding( + padding: EdgeInsets.fromLTRB( + compact ? 16 : 28, + compact ? 16 : 24, + compact ? 16 : 28, + compact ? 20 : 28, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (compact) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Reminders', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 6), + Text( + 'Set recurring nudges so good intentions become habits.', + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: () => + _addReminder(context, ref, data.people), + icon: const Icon(Icons.alarm_add_rounded), + label: const Text('Add Reminder'), + ), + ], + ) + else + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Reminders', + style: Theme.of( + context, + ).textTheme.headlineMedium, + ), + const SizedBox(height: 6), + Text( + 'Set recurring nudges so good intentions become habits.', + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith(color: AppTheme.textSecondary), + ), + ], + ), + ), + FilledButton.icon( + onPressed: () => + _addReminder(context, ref, data.people), + icon: const Icon(Icons.alarm_add_rounded), + label: const Text('Add Reminder'), + ), + ], + ), + const SizedBox(height: 16), + Expanded( + child: ListView.separated( + itemCount: reminders.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (BuildContext context, int index) { + final ReminderRule reminder = reminders[index]; + final PersonProfile? person = reminder.personId == null + ? null + : peopleById[reminder.personId!]; + + return FrostedCard( + child: compact + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + reminder.title, + style: Theme.of( + context, + ).textTheme.titleMedium, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + Switch( + value: reminder.enabled, + onChanged: (_) { + ref + .read( + localRepositoryProvider + .notifier, + ) + .toggleReminderEnabled( + reminder.id, + ); + }, + ), + ], + ), + Text( + '${_labelForCadence(reminder.cadence)} • ${_formatDateTime(reminder.nextAt)}', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 4), + Text( + person == null + ? 'Unassigned' + : person.name, + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + OutlinedButton.icon( + onPressed: () => _editReminder( + context, + ref, + data.people, + reminder, + ), + icon: const Icon(Icons.edit_rounded), + label: const Text('Edit'), + ), + TextButton.icon( + onPressed: () { + ref + .read( + localRepositoryProvider + .notifier, + ) + .deleteReminder(reminder.id); + }, + icon: const Icon( + Icons.delete_outline_rounded, + ), + label: const Text('Delete'), + ), + ], + ), + ], + ) + : Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + reminder.title, + style: Theme.of( + context, + ).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + '${_labelForCadence(reminder.cadence)} • ${_formatDateTime(reminder.nextAt)}', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 4), + Text( + person == null + ? 'Unassigned' + : person.name, + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + Switch( + value: reminder.enabled, + onChanged: (_) { + ref + .read( + localRepositoryProvider.notifier, + ) + .toggleReminderEnabled(reminder.id); + }, + ), + IconButton( + onPressed: () => _editReminder( + context, + ref, + data.people, + reminder, + ), + icon: const Icon(Icons.edit_rounded), + tooltip: 'Edit', + ), + IconButton( + onPressed: () { + ref + .read( + localRepositoryProvider.notifier, + ) + .deleteReminder(reminder.id); + }, + icon: const Icon( + Icons.delete_outline_rounded, + ), + tooltip: 'Delete', + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (Object error, StackTrace stackTrace) { + return const Center(child: Text('Unable to load reminders')); + }, + ); + } + + Future _addReminder( + BuildContext context, + WidgetRef ref, + List people, + ) async { + final _ReminderDraft? draft = await showDialog<_ReminderDraft>( + context: context, + builder: (BuildContext context) => + _ReminderEditorDialog(title: 'Add Reminder', people: people), + ); + + if (draft == null) { + return; + } + + await ref + .read(localRepositoryProvider.notifier) + .addReminder( + title: draft.title, + cadence: draft.cadence, + nextAt: draft.nextAt, + personId: draft.personId, + ); + } + + Future _editReminder( + BuildContext context, + WidgetRef ref, + List people, + ReminderRule reminder, + ) async { + final _ReminderDraft? draft = await showDialog<_ReminderDraft>( + context: context, + builder: (BuildContext context) => _ReminderEditorDialog( + title: 'Edit Reminder', + people: people, + initial: _ReminderDraft( + personId: reminder.personId, + title: reminder.title, + cadence: reminder.cadence, + nextAt: reminder.nextAt, + ), + ), + ); + + if (draft == null) { + return; + } + + await ref + .read(localRepositoryProvider.notifier) + .updateReminder( + reminder.copyWith( + personId: draft.personId, + title: draft.title, + cadence: draft.cadence, + nextAt: draft.nextAt, + ), + ); + } +} + +class _ReminderEditorDialog extends StatefulWidget { + const _ReminderEditorDialog({ + required this.title, + required this.people, + this.initial, + }); + + final String title; + final List people; + final _ReminderDraft? initial; + + @override + State<_ReminderEditorDialog> createState() => _ReminderEditorDialogState(); +} + +class _ReminderEditorDialogState extends State<_ReminderEditorDialog> { + late final TextEditingController _titleController; + late ReminderCadence _cadence; + late DateTime _nextAt; + String? _personId; + + @override + void initState() { + super.initState(); + _titleController = TextEditingController(text: widget.initial?.title ?? ''); + _cadence = widget.initial?.cadence ?? ReminderCadence.weekly; + _nextAt = + widget.initial?.nextAt ?? DateTime.now().add(const Duration(days: 2)); + _personId = widget.initial?.personId; + } + + @override + void dispose() { + _titleController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.title), + content: SingleChildScrollView( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _titleController, + decoration: const InputDecoration(labelText: 'Title'), + ), + DropdownButtonFormField( + initialValue: _cadence, + items: ReminderCadence.values + .map( + (ReminderCadence cadence) => + DropdownMenuItem( + value: cadence, + child: Text(_labelForCadence(cadence)), + ), + ) + .toList(growable: false), + onChanged: (ReminderCadence? value) { + if (value == null) { + return; + } + setState(() { + _cadence = value; + }); + }, + decoration: const InputDecoration(labelText: 'Cadence'), + ), + DropdownButtonFormField( + initialValue: _personId, + items: >[ + const DropdownMenuItem( + value: null, + child: Text('Unassigned'), + ), + ...widget.people.map( + (PersonProfile person) => DropdownMenuItem( + value: person.id, + child: Text(person.name), + ), + ), + ], + onChanged: (String? value) { + setState(() { + _personId = value; + }); + }, + decoration: const InputDecoration(labelText: 'Person'), + ), + const SizedBox(height: 12), + LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final bool compact = constraints.maxWidth < 360; + if (compact) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _formatDateTime(_nextAt), + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: _pickDateTime, + icon: const Icon(Icons.schedule_rounded), + label: const Text('Pick Time'), + ), + ], + ); + } + + return Row( + children: [ + Expanded( + child: Text( + _formatDateTime(_nextAt), + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + OutlinedButton.icon( + onPressed: _pickDateTime, + icon: const Icon(Icons.schedule_rounded), + label: const Text('Pick Time'), + ), + ], + ); + }, + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final String title = _titleController.text.trim(); + if (title.isEmpty) { + return; + } + Navigator.of(context).pop( + _ReminderDraft( + personId: _personId, + title: title, + cadence: _cadence, + nextAt: _nextAt, + ), + ); + }, + child: const Text('Save'), + ), + ], + ); + } + + Future _pickDateTime() async { + final DateTime now = DateTime.now(); + final DateTime? date = await showDatePicker( + context: context, + initialDate: _nextAt, + firstDate: now.subtract(const Duration(days: 1)), + lastDate: now.add(const Duration(days: 3650)), + ); + + if (date == null || !mounted) { + return; + } + + final TimeOfDay? time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(_nextAt), + ); + if (time == null || !mounted) { + return; + } + + setState(() { + _nextAt = DateTime( + date.year, + date.month, + date.day, + time.hour, + time.minute, + ); + }); + } +} + +class _ReminderDraft { + const _ReminderDraft({ + required this.personId, + required this.title, + required this.cadence, + required this.nextAt, + }); + + final String? personId; + final String title; + final ReminderCadence cadence; + final DateTime nextAt; +} + +String _labelForCadence(ReminderCadence cadence) { + return switch (cadence) { + ReminderCadence.daily => 'Daily', + ReminderCadence.weekly => 'Weekly', + ReminderCadence.monthly => 'Monthly', + }; +} + +String _formatDateTime(DateTime value) { + final String month = value.month.toString().padLeft(2, '0'); + final String day = value.day.toString().padLeft(2, '0'); + final String hour = value.hour.toString().padLeft(2, '0'); + final String minute = value.minute.toString().padLeft(2, '0'); + return '$month/$day/${value.year} $hour:$minute'; +} diff --git a/lib/features/reminders/reminders_view.dart b/lib/features/reminders/reminders_view.dart index 05ad676..7e9b87c 100644 --- a/lib/features/reminders/reminders_view.dart +++ b/lib/features/reminders/reminders_view.dart @@ -1,561 +1,2 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:relationship_saver/core/config/app_theme.dart'; -import 'package:relationship_saver/features/local/local_models.dart'; -import 'package:relationship_saver/features/local/local_repository.dart'; -import 'package:relationship_saver/features/shared/frosted_card.dart'; - -class RemindersView extends ConsumerWidget { - const RemindersView({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final AsyncValue localData = ref.watch( - localRepositoryProvider, - ); - - return localData.when( - data: (LocalDataState data) { - final List reminders = data.reminders; - final Map peopleById = { - for (final PersonProfile person in data.people) person.id: person, - }; - - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - final bool compact = constraints.maxWidth < 760; - return Padding( - padding: EdgeInsets.fromLTRB( - compact ? 16 : 28, - compact ? 16 : 24, - compact ? 16 : 28, - compact ? 20 : 28, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (compact) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Reminders', - style: Theme.of(context).textTheme.headlineMedium, - ), - const SizedBox(height: 6), - Text( - 'Set recurring nudges so good intentions become habits.', - style: Theme.of(context).textTheme.bodyLarge - ?.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: 12), - FilledButton.icon( - onPressed: () => - _addReminder(context, ref, data.people), - icon: const Icon(Icons.alarm_add_rounded), - label: const Text('Add Reminder'), - ), - ], - ) - else - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Reminders', - style: Theme.of( - context, - ).textTheme.headlineMedium, - ), - const SizedBox(height: 6), - Text( - 'Set recurring nudges so good intentions become habits.', - style: Theme.of(context).textTheme.bodyLarge - ?.copyWith(color: AppTheme.textSecondary), - ), - ], - ), - ), - FilledButton.icon( - onPressed: () => - _addReminder(context, ref, data.people), - icon: const Icon(Icons.alarm_add_rounded), - label: const Text('Add Reminder'), - ), - ], - ), - const SizedBox(height: 16), - Expanded( - child: ListView.separated( - itemCount: reminders.length, - separatorBuilder: (_, _) => const SizedBox(height: 12), - itemBuilder: (BuildContext context, int index) { - final ReminderRule reminder = reminders[index]; - final PersonProfile? person = reminder.personId == null - ? null - : peopleById[reminder.personId!]; - - return FrostedCard( - child: compact - ? Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - reminder.title, - style: Theme.of( - context, - ).textTheme.titleMedium, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - Switch( - value: reminder.enabled, - onChanged: (_) { - ref - .read( - localRepositoryProvider - .notifier, - ) - .toggleReminderEnabled( - reminder.id, - ); - }, - ), - ], - ), - Text( - '${_labelForCadence(reminder.cadence)} • ${_formatDateTime(reminder.nextAt)}', - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith( - color: AppTheme.textSecondary, - ), - ), - const SizedBox(height: 4), - Text( - person == null - ? 'Unassigned' - : person.name, - style: Theme.of(context) - .textTheme - .labelLarge - ?.copyWith( - color: AppTheme.textSecondary, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: [ - OutlinedButton.icon( - onPressed: () => _editReminder( - context, - ref, - data.people, - reminder, - ), - icon: const Icon(Icons.edit_rounded), - label: const Text('Edit'), - ), - TextButton.icon( - onPressed: () { - ref - .read( - localRepositoryProvider - .notifier, - ) - .deleteReminder(reminder.id); - }, - icon: const Icon( - Icons.delete_outline_rounded, - ), - label: const Text('Delete'), - ), - ], - ), - ], - ) - : Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - reminder.title, - style: Theme.of( - context, - ).textTheme.titleMedium, - ), - const SizedBox(height: 4), - Text( - '${_labelForCadence(reminder.cadence)} • ${_formatDateTime(reminder.nextAt)}', - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith( - color: AppTheme.textSecondary, - ), - ), - const SizedBox(height: 4), - Text( - person == null - ? 'Unassigned' - : person.name, - style: Theme.of(context) - .textTheme - .labelLarge - ?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ], - ), - ), - Switch( - value: reminder.enabled, - onChanged: (_) { - ref - .read( - localRepositoryProvider.notifier, - ) - .toggleReminderEnabled(reminder.id); - }, - ), - IconButton( - onPressed: () => _editReminder( - context, - ref, - data.people, - reminder, - ), - icon: const Icon(Icons.edit_rounded), - tooltip: 'Edit', - ), - IconButton( - onPressed: () { - ref - .read( - localRepositoryProvider.notifier, - ) - .deleteReminder(reminder.id); - }, - icon: const Icon( - Icons.delete_outline_rounded, - ), - tooltip: 'Delete', - ), - ], - ), - ); - }, - ), - ), - ], - ), - ); - }, - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (Object error, StackTrace stackTrace) { - return const Center(child: Text('Unable to load reminders')); - }, - ); - } - - Future _addReminder( - BuildContext context, - WidgetRef ref, - List people, - ) async { - final _ReminderDraft? draft = await showDialog<_ReminderDraft>( - context: context, - builder: (BuildContext context) => - _ReminderEditorDialog(title: 'Add Reminder', people: people), - ); - - if (draft == null) { - return; - } - - await ref - .read(localRepositoryProvider.notifier) - .addReminder( - title: draft.title, - cadence: draft.cadence, - nextAt: draft.nextAt, - personId: draft.personId, - ); - } - - Future _editReminder( - BuildContext context, - WidgetRef ref, - List people, - ReminderRule reminder, - ) async { - final _ReminderDraft? draft = await showDialog<_ReminderDraft>( - context: context, - builder: (BuildContext context) => _ReminderEditorDialog( - title: 'Edit Reminder', - people: people, - initial: _ReminderDraft( - personId: reminder.personId, - title: reminder.title, - cadence: reminder.cadence, - nextAt: reminder.nextAt, - ), - ), - ); - - if (draft == null) { - return; - } - - await ref - .read(localRepositoryProvider.notifier) - .updateReminder( - reminder.copyWith( - personId: draft.personId, - title: draft.title, - cadence: draft.cadence, - nextAt: draft.nextAt, - ), - ); - } -} - -class _ReminderEditorDialog extends StatefulWidget { - const _ReminderEditorDialog({ - required this.title, - required this.people, - this.initial, - }); - - final String title; - final List people; - final _ReminderDraft? initial; - - @override - State<_ReminderEditorDialog> createState() => _ReminderEditorDialogState(); -} - -class _ReminderEditorDialogState extends State<_ReminderEditorDialog> { - late final TextEditingController _titleController; - late ReminderCadence _cadence; - late DateTime _nextAt; - String? _personId; - - @override - void initState() { - super.initState(); - _titleController = TextEditingController(text: widget.initial?.title ?? ''); - _cadence = widget.initial?.cadence ?? ReminderCadence.weekly; - _nextAt = - widget.initial?.nextAt ?? DateTime.now().add(const Duration(days: 2)); - _personId = widget.initial?.personId; - } - - @override - void dispose() { - _titleController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: Text(widget.title), - content: SingleChildScrollView( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: _titleController, - decoration: const InputDecoration(labelText: 'Title'), - ), - DropdownButtonFormField( - initialValue: _cadence, - items: ReminderCadence.values - .map( - (ReminderCadence cadence) => - DropdownMenuItem( - value: cadence, - child: Text(_labelForCadence(cadence)), - ), - ) - .toList(growable: false), - onChanged: (ReminderCadence? value) { - if (value == null) { - return; - } - setState(() { - _cadence = value; - }); - }, - decoration: const InputDecoration(labelText: 'Cadence'), - ), - DropdownButtonFormField( - initialValue: _personId, - items: >[ - const DropdownMenuItem( - value: null, - child: Text('Unassigned'), - ), - ...widget.people.map( - (PersonProfile person) => DropdownMenuItem( - value: person.id, - child: Text(person.name), - ), - ), - ], - onChanged: (String? value) { - setState(() { - _personId = value; - }); - }, - decoration: const InputDecoration(labelText: 'Person'), - ), - const SizedBox(height: 12), - LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - final bool compact = constraints.maxWidth < 360; - if (compact) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _formatDateTime(_nextAt), - style: Theme.of(context).textTheme.bodyLarge, - ), - const SizedBox(height: 8), - OutlinedButton.icon( - onPressed: _pickDateTime, - icon: const Icon(Icons.schedule_rounded), - label: const Text('Pick Time'), - ), - ], - ); - } - - return Row( - children: [ - Expanded( - child: Text( - _formatDateTime(_nextAt), - style: Theme.of(context).textTheme.bodyLarge, - ), - ), - OutlinedButton.icon( - onPressed: _pickDateTime, - icon: const Icon(Icons.schedule_rounded), - label: const Text('Pick Time'), - ), - ], - ); - }, - ), - ], - ), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - final String title = _titleController.text.trim(); - if (title.isEmpty) { - return; - } - Navigator.of(context).pop( - _ReminderDraft( - personId: _personId, - title: title, - cadence: _cadence, - nextAt: _nextAt, - ), - ); - }, - child: const Text('Save'), - ), - ], - ); - } - - Future _pickDateTime() async { - final DateTime now = DateTime.now(); - final DateTime? date = await showDatePicker( - context: context, - initialDate: _nextAt, - firstDate: now.subtract(const Duration(days: 1)), - lastDate: now.add(const Duration(days: 3650)), - ); - - if (date == null || !mounted) { - return; - } - - final TimeOfDay? time = await showTimePicker( - context: context, - initialTime: TimeOfDay.fromDateTime(_nextAt), - ); - if (time == null || !mounted) { - return; - } - - setState(() { - _nextAt = DateTime( - date.year, - date.month, - date.day, - time.hour, - time.minute, - ); - }); - } -} - -class _ReminderDraft { - const _ReminderDraft({ - required this.personId, - required this.title, - required this.cadence, - required this.nextAt, - }); - - final String? personId; - final String title; - final ReminderCadence cadence; - final DateTime nextAt; -} - -String _labelForCadence(ReminderCadence cadence) { - return switch (cadence) { - ReminderCadence.daily => 'Daily', - ReminderCadence.weekly => 'Weekly', - ReminderCadence.monthly => 'Monthly', - }; -} - -String _formatDateTime(DateTime value) { - final String month = value.month.toString().padLeft(2, '0'); - final String day = value.day.toString().padLeft(2, '0'); - final String hour = value.hour.toString().padLeft(2, '0'); - final String minute = value.minute.toString().padLeft(2, '0'); - return '$month/$day/${value.year} $hour:$minute'; -} +// Legacy compatibility export for the reminders presentation entry. +export 'package:relationship_saver/features/reminders/presentation/reminders_view.dart'; diff --git a/lib/features/reminders/scheduling/README.md b/lib/features/reminders/scheduling/README.md new file mode 100644 index 0000000..2c0ceff --- /dev/null +++ b/lib/features/reminders/scheduling/README.md @@ -0,0 +1,4 @@ +# Reminder Scheduling + +Platform notification adapters and scheduling listeners live here. This is the +boundary between reminder domain logic and device-specific behavior. diff --git a/lib/features/reminders/scheduling/reminder_notification_intent_bus.dart b/lib/features/reminders/scheduling/reminder_notification_intent_bus.dart index 5162518..1125eb5 100644 --- a/lib/features/reminders/scheduling/reminder_notification_intent_bus.dart +++ b/lib/features/reminders/scheduling/reminder_notification_intent_bus.dart @@ -1,18 +1,2 @@ -import 'dart:async'; - -/// Broadcasts reminder notification tap intents to the UI layer. -class ReminderNotificationIntentBus { - final StreamController _controller = - StreamController.broadcast(); - - Stream get intents => _controller.stream; - - void emitTap(String reminderId) { - if (reminderId.trim().isEmpty) { - return; - } - _controller.add(reminderId.trim()); - } - - Future close() => _controller.close(); -} +// Legacy compatibility export for reminder notification intent handling. +export 'package:relationship_saver/features/reminders/application/reminder_notification_intent_bus.dart'; diff --git a/lib/features/reminders/scheduling/reminder_notification_listener.dart b/lib/features/reminders/scheduling/reminder_notification_listener.dart index 215694b..3714a00 100644 --- a/lib/features/reminders/scheduling/reminder_notification_listener.dart +++ b/lib/features/reminders/scheduling/reminder_notification_listener.dart @@ -1,67 +1,2 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:relationship_saver/features/reminders/reminders_view.dart'; -import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_provider.dart'; - -/// Listens for reminder notification taps and opens reminder management UI. -class ReminderNotificationListener extends ConsumerStatefulWidget { - const ReminderNotificationListener({required this.child, super.key}); - - final Widget child; - - @override - ConsumerState createState() => - _ReminderNotificationListenerState(); -} - -class _ReminderNotificationListenerState - extends ConsumerState { - StreamSubscription? _subscription; - - @override - void initState() { - super.initState(); - _subscription = ref - .read(reminderNotificationIntentBusProvider) - .intents - .listen(_handleIntent); - } - - @override - void dispose() { - _subscription?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) => widget.child; - - Future _handleIntent(String reminderId) async { - if (!mounted) { - return; - } - - await Navigator.of(context).push( - MaterialPageRoute( - builder: (BuildContext context) => - _ReminderNotificationScreen(reminderId: reminderId), - ), - ); - } -} - -class _ReminderNotificationScreen extends StatelessWidget { - const _ReminderNotificationScreen({required this.reminderId}); - - final String reminderId; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: Text('Reminder $reminderId')), - body: const RemindersView(), - ); - } -} +// Legacy compatibility export for the reminder notification listener. +export 'package:relationship_saver/features/reminders/application/reminder_notification_listener.dart'; diff --git a/lib/features/reminders/scheduling/reminder_scheduler.dart b/lib/features/reminders/scheduling/reminder_scheduler.dart index 9a650d9..f0c8f71 100644 --- a/lib/features/reminders/scheduling/reminder_scheduler.dart +++ b/lib/features/reminders/scheduling/reminder_scheduler.dart @@ -1,27 +1,2 @@ -import 'package:relationship_saver/features/local/local_models.dart'; - -/// Schedules and reconciles reminder deliveries for current local state. -abstract class ReminderScheduler { - /// Reconciles platform schedules to match current enabled reminders. - Future reconcile(List reminders); - - /// Clears all platform-scheduled reminder jobs. - Future clearAll(); - - /// Requests notification permissions where the platform requires them. - Future requestPermissions(); -} - -/// Default scheduler used until platform notifications are integrated. -class NoopReminderScheduler implements ReminderScheduler { - const NoopReminderScheduler(); - - @override - Future clearAll() async {} - - @override - Future reconcile(List reminders) async {} - - @override - Future requestPermissions() async => true; -} +// Legacy compatibility export for the reminders scheduling service. +export 'package:relationship_saver/features/reminders/application/reminder_scheduler.dart'; diff --git a/lib/features/reminders/scheduling/reminder_scheduler_local_notifications.dart b/lib/features/reminders/scheduling/reminder_scheduler_local_notifications.dart index c4eb160..75e5025 100644 --- a/lib/features/reminders/scheduling/reminder_scheduler_local_notifications.dart +++ b/lib/features/reminders/scheduling/reminder_scheduler_local_notifications.dart @@ -1,240 +1,2 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:relationship_saver/features/local/local_models.dart'; -import 'package:relationship_saver/features/reminders/scheduling/reminder_notification_intent_bus.dart'; -import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler.dart'; -import 'package:timezone/timezone.dart' as tz; - -const String _channelId = 'relationship_saver_reminders'; -const String _channelName = 'Relationship Reminders'; -const String _channelDescription = - 'Reminders for relationship plans and follow-ups'; - -/// Schedules reminders with local notifications where platform support exists. -class LocalNotificationsReminderScheduler implements ReminderScheduler { - LocalNotificationsReminderScheduler({ - ReminderNotificationsDriver? driver, - DateTime Function()? now, - ReminderNotificationIntentBus? intentBus, - }) : _driver = - driver ?? - FlutterReminderNotificationsDriver(onTapPayload: intentBus?.emitTap), - _now = now ?? DateTime.now; - - final ReminderNotificationsDriver _driver; - final DateTime Function() _now; - - @override - Future clearAll() async { - if (!_supportsSchedulingPlatform()) { - return; - } - await _driver.ensureInitialized(); - await _driver.cancelAll(); - } - - @override - Future reconcile(List reminders) async { - if (!_supportsSchedulingPlatform()) { - return; - } - await _driver.ensureInitialized(); - await _driver.cancelAll(); - - final DateTime now = _now(); - final List enabled = reminders - .where((ReminderRule reminder) => reminder.enabled) - .toList(growable: false); - - for (final ReminderRule reminder in enabled) { - final DateTimeComponents? repeating = _repeatComponents(reminder.cadence); - if (repeating == null && reminder.nextAt.isBefore(now)) { - continue; - } - - await _driver.schedule( - id: _notificationId(reminder.id), - title: reminder.title, - body: _buildBody(reminder), - scheduledAt: reminder.nextAt, - repeatComponents: repeating, - payload: reminder.id, - ); - } - } - - @override - Future requestPermissions() async { - if (!_supportsSchedulingPlatform()) { - return false; - } - await _driver.ensureInitialized(); - return _driver.requestPermissions(); - } - - DateTimeComponents? _repeatComponents(ReminderCadence cadence) { - return switch (cadence) { - ReminderCadence.daily => DateTimeComponents.time, - ReminderCadence.weekly => DateTimeComponents.dayOfWeekAndTime, - ReminderCadence.monthly => DateTimeComponents.dayOfMonthAndTime, - }; - } - - String _buildBody(ReminderRule reminder) { - final String frequency = switch (reminder.cadence) { - ReminderCadence.daily => 'daily', - ReminderCadence.weekly => 'weekly', - ReminderCadence.monthly => 'monthly', - }; - return 'Your $frequency reminder is due now.'; - } - - bool _supportsSchedulingPlatform() { - if (kIsWeb) { - return false; - } - - return defaultTargetPlatform != TargetPlatform.linux; - } - - int _notificationId(String raw) { - int hash = 0; - for (final int unit in raw.codeUnits) { - hash = ((hash * 31) + unit) & 0x7fffffff; - } - return hash; - } -} - -/// Driver wrapper around the notification plugin for easier testing. -abstract class ReminderNotificationsDriver { - Future ensureInitialized(); - - Future cancelAll(); - - Future schedule({ - required int id, - required String title, - required String body, - required DateTime scheduledAt, - required DateTimeComponents? repeatComponents, - required String payload, - }); - - Future requestPermissions(); -} - -class FlutterReminderNotificationsDriver - implements ReminderNotificationsDriver { - FlutterReminderNotificationsDriver({ - FlutterLocalNotificationsPlugin? plugin, - void Function(String payload)? onTapPayload, - }) : _plugin = plugin ?? FlutterLocalNotificationsPlugin(), - _onTapPayload = onTapPayload; - - final FlutterLocalNotificationsPlugin _plugin; - final void Function(String payload)? _onTapPayload; - Future? _initializeFuture; - - @override - Future ensureInitialized() { - return _initializeFuture ??= _initialize(); - } - - @override - Future cancelAll() async { - await _plugin.cancelAll(); - } - - @override - Future schedule({ - required int id, - required String title, - required String body, - required DateTime scheduledAt, - required DateTimeComponents? repeatComponents, - required String payload, - }) async { - await _plugin.zonedSchedule( - id: id, - title: title, - body: body, - scheduledDate: tz.TZDateTime.from(scheduledAt.toUtc(), tz.UTC), - notificationDetails: _notificationDetails, - androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, - matchDateTimeComponents: repeatComponents, - payload: payload, - ); - } - - @override - Future requestPermissions() async { - if (defaultTargetPlatform == TargetPlatform.android) { - return await _plugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >() - ?.requestNotificationsPermission() ?? - false; - } - - if (defaultTargetPlatform == TargetPlatform.iOS) { - return await _plugin - .resolvePlatformSpecificImplementation< - IOSFlutterLocalNotificationsPlugin - >() - ?.requestPermissions(alert: true, badge: true, sound: true) ?? - false; - } - - if (defaultTargetPlatform == TargetPlatform.macOS) { - return await _plugin - .resolvePlatformSpecificImplementation< - MacOSFlutterLocalNotificationsPlugin - >() - ?.requestPermissions(alert: true, badge: true, sound: true) ?? - false; - } - - return true; - } - - Future _initialize() async { - await _plugin.initialize( - settings: const InitializationSettings( - android: AndroidInitializationSettings('@mipmap/ic_launcher'), - iOS: DarwinInitializationSettings(), - macOS: DarwinInitializationSettings(), - linux: LinuxInitializationSettings(defaultActionName: 'Open reminders'), - windows: WindowsInitializationSettings( - appName: 'Relationship Saver', - appUserModelId: 'com.relationshipsaver.app', - guid: 'ee23c0af-5898-4d96-b599-cf2d7d0d37d7', - ), - ), - onDidReceiveNotificationResponse: (NotificationResponse response) { - final String? payload = response.payload; - if (payload == null || payload.isEmpty) { - return; - } - _onTapPayload?.call(payload); - }, - ); - } - - NotificationDetails get _notificationDetails { - return const NotificationDetails( - android: AndroidNotificationDetails( - _channelId, - _channelName, - channelDescription: _channelDescription, - importance: Importance.high, - priority: Priority.high, - ), - iOS: DarwinNotificationDetails(), - macOS: DarwinNotificationDetails(), - linux: LinuxNotificationDetails(), - windows: WindowsNotificationDetails(), - ); - } -} +// Legacy compatibility export for the local notifications reminder backend. +export 'package:relationship_saver/features/reminders/application/reminder_scheduler_local_notifications.dart'; diff --git a/lib/features/reminders/scheduling/reminder_scheduler_provider.dart b/lib/features/reminders/scheduling/reminder_scheduler_provider.dart index 4e4dd8e..c5e074f 100644 --- a/lib/features/reminders/scheduling/reminder_scheduler_provider.dart +++ b/lib/features/reminders/scheduling/reminder_scheduler_provider.dart @@ -1,30 +1,2 @@ -import 'dart:async'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:relationship_saver/core/config/app_config.dart'; -import 'package:relationship_saver/features/reminders/scheduling/reminder_notification_intent_bus.dart'; -import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler.dart'; -import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_local_notifications.dart'; - -/// Broadcasts reminder notification tap intents to interested UI listeners. -final Provider -reminderNotificationIntentBusProvider = Provider( - (Ref ref) { - final ReminderNotificationIntentBus bus = ReminderNotificationIntentBus(); - ref.onDispose(() { - unawaited(bus.close()); - }); - return bus; - }, -); - -/// Provides reminder delivery scheduler implementation. -final Provider reminderSchedulerProvider = - Provider((Ref ref) { - if (AppConfig.enableLocalNotifications) { - return LocalNotificationsReminderScheduler( - intentBus: ref.watch(reminderNotificationIntentBusProvider), - ); - } - return const NoopReminderScheduler(); - }); +// Legacy compatibility export for the reminder scheduler provider. +export 'package:relationship_saver/features/reminders/application/reminder_scheduler_provider.dart'; diff --git a/lib/features/settings/README.md b/lib/features/settings/README.md new file mode 100644 index 0000000..7a18774 --- /dev/null +++ b/lib/features/settings/README.md @@ -0,0 +1,6 @@ +# Settings Slice + +This slice owns settings and trust/privacy messaging. + +The screen is intentionally simple. This is the right place for local-first +copy, future AI-sharing controls, and developer/founder-mode toggles. diff --git a/lib/features/settings/presentation/README.md b/lib/features/settings/presentation/README.md new file mode 100644 index 0000000..3c216f8 --- /dev/null +++ b/lib/features/settings/presentation/README.md @@ -0,0 +1,4 @@ +# Settings Presentation + +Settings UI belongs here. This is the place for privacy wording, local-first +trust messaging, and user-facing controls around app behavior. diff --git a/lib/features/settings/presentation/settings_view.dart b/lib/features/settings/presentation/settings_view.dart new file mode 100644 index 0000000..8e55c63 --- /dev/null +++ b/lib/features/settings/presentation/settings_view.dart @@ -0,0 +1,691 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/config/app_config.dart'; +import 'package:relationship_saver/core/config/app_theme.dart'; +import 'package:relationship_saver/core/llm/llm_config.dart'; +import 'package:relationship_saver/features/ai_digest/application/llm_digest_background_scheduler.dart'; +import 'package:relationship_saver/features/ai_digest/application/llm_digest_orchestrator.dart'; +import 'package:relationship_saver/features/ai_digest/data/llm_digest_config.dart'; +import 'package:relationship_saver/features/ai_digest/presentation/ai_digest_review_view.dart'; +import 'package:relationship_saver/features/auth/session_controller.dart'; +import 'package:relationship_saver/features/local/local_models.dart'; +import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_provider.dart'; +import 'package:relationship_saver/features/share_intake/share_capture_flow.dart'; +import 'package:relationship_saver/features/share_intake/share_capture_models.dart'; +import 'package:relationship_saver/features/share_intake/share_inbox_view.dart'; +import 'package:relationship_saver/features/shared/frosted_card.dart'; +import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; + +class SettingsView extends ConsumerWidget { + const SettingsView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final AuthSession? session = ref + .watch(sessionControllerProvider) + .asData + ?.value; + final LlmDigestConfigState digestConfig = ref.watch( + llmDigestConfigProvider, + ); + + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final bool compact = constraints.maxWidth < 760; + return Padding( + padding: EdgeInsets.fromLTRB( + compact ? 16 : 28, + compact ? 16 : 24, + compact ? 16 : 28, + compact ? 20 : 28, + ), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Settings', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 6), + Text( + 'Environment and integration configuration for this build.', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 16), + FrostedCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SettingRow( + compact: compact, + label: 'Signed in as', + value: + session?.user.email ?? + session?.user.id ?? + 'Not signed in', + ), + const SizedBox(height: 12), + _SettingRow( + compact: compact, + label: 'Gateway mode', + value: AppConfig.useFakeBackend + ? 'Fake (offline-safe)' + : 'REST', + ), + const SizedBox(height: 12), + _SettingRow( + compact: compact, + label: 'Base URL', + value: AppConfig.backendBaseUrl, + ), + const SizedBox(height: 12), + _SettingRow( + compact: compact, + label: 'Realtime', + value: 'Planned later (SSE/WebSocket)', + ), + const SizedBox(height: 12), + _SettingRow( + compact: compact, + label: 'Local notifications', + value: AppConfig.enableLocalNotifications + ? 'Enabled' + : 'Disabled', + ), + const SizedBox(height: 12), + _SettingRow( + compact: compact, + label: 'LLM AI', + value: ref.watch(llmConfigProvider).isConfigured + ? '${_providerLabel(ref.watch(llmConfigProvider).provider)} (configured)' + : 'Not configured', + ), + const SizedBox(height: 12), + _SettingRow( + compact: compact, + label: 'AI digest', + value: digestConfig.enabled + ? 'Weekly, ${_weekdayLabel(digestConfig.preferredWeekday)} ${digestConfig.preferredHour.toString().padLeft(2, '0')}:00' + : 'Off', + ), + const SizedBox(height: 12), + _SettingRow( + compact: compact, + label: 'AI network', + value: + '${digestConfig.requireWifi ? 'Wi-Fi' : 'Any network'} + ${digestConfig.requireCharging ? 'charging' : 'battery allowed'}', + ), + const SizedBox(height: 14), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlinedButton.icon( + onPressed: () => _configureLlm(context, ref), + icon: const Icon(Icons.smart_toy_rounded), + label: Text( + ref.watch(llmConfigProvider).isConfigured + ? 'Configure AI' + : 'Setup AI', + ), + ), + OutlinedButton.icon( + onPressed: () => _toggleDigest(context, ref), + icon: Icon( + digestConfig.enabled + ? Icons.event_busy_rounded + : Icons.event_repeat_rounded, + ), + label: Text( + digestConfig.enabled + ? 'Disable Weekly AI Digest' + : 'Enable Weekly AI Digest', + ), + ), + OutlinedButton.icon( + onPressed: ref.watch(llmConfigProvider).isConfigured + ? () => _runPrivateDigest(context, ref) + : null, + icon: const Icon(Icons.auto_awesome_rounded), + label: const Text('Run Private Digest Now'), + ), + OutlinedButton.icon( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) => + const AiDigestReviewView(), + ), + ), + icon: const Icon(Icons.rate_review_rounded), + label: const Text('Open AI Review'), + ), + OutlinedButton.icon( + onPressed: session == null + ? null + : () => _signOut(context, ref), + icon: const Icon(Icons.logout_rounded), + label: const Text('Sign Out'), + ), + OutlinedButton.icon( + onPressed: session == null + ? null + : () { + ref + .read( + sessionControllerProvider.notifier, + ) + .refreshProfile(); + }, + icon: const Icon(Icons.refresh_rounded), + label: const Text('Refresh Profile'), + ), + OutlinedButton.icon( + onPressed: AppConfig.enableLocalNotifications + ? () => _requestNotificationPermissions( + context, + ref, + ) + : null, + icon: const Icon( + Icons.notifications_active_rounded, + ), + label: const Text('Request Notification Access'), + ), + OutlinedButton.icon( + onPressed: () => + _simulateShareCapture(context, ref), + icon: const Icon(Icons.message_rounded), + label: const Text('Simulate Share Capture'), + ), + OutlinedButton.icon( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) => + const ShareInboxView(), + ), + ), + icon: const Icon(Icons.inbox_rounded), + label: const Text('Open Share Inbox'), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 16), + FrostedCard( + child: Text( + 'Local-first privacy\n• Relationship details stay on this device in the current build.\n• Scheduled AI uses pseudonymous person tokens and a review inbox.\n• Names, aliases, sender names, URLs, and raw shared text are not sent in the digest payload.\n• iOS background digest runs are best-effort; manual runs are available here.\n\nRun options\n• Fake mode: flutter run --dart-define=USE_FAKE_BACKEND=true\n• REST mode: flutter run --dart-define=USE_FAKE_BACKEND=false --dart-define=BACKEND_BASE_URL=https://your-api', + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ], + ), + ), + ); + }, + ); + } + + String _providerLabel(LlmProvider provider) { + switch (provider) { + case LlmProvider.openai: + return 'OpenAI'; + case LlmProvider.anthropic: + return 'Anthropic'; + case LlmProvider.google: + return 'Google AI'; + } + } + + String _weekdayLabel(int weekday) { + return switch (weekday) { + DateTime.monday => 'Mon', + DateTime.tuesday => 'Tue', + DateTime.wednesday => 'Wed', + DateTime.thursday => 'Thu', + DateTime.friday => 'Fri', + DateTime.saturday => 'Sat', + DateTime.sunday => 'Sun', + _ => 'Sun', + }; + } + + Future _configureLlm(BuildContext context, WidgetRef ref) async { + final LlmConfigState current = ref.read(llmConfigProvider); + final _LlmConfigDraft? draft = await showDialog<_LlmConfigDraft>( + context: context, + builder: (BuildContext context) => _LlmConfigDialog( + currentProvider: current.provider, + hasApiKey: current.apiKeyConfigured, + ), + ); + if (draft == null || !context.mounted) { + return; + } + + await ref.read(llmConfigProvider.notifier).setProvider(draft.provider); + if (draft.clearApiKey) { + await ref.read(llmConfigProvider.notifier).clearApiKey(); + } else if (draft.apiKey.isNotEmpty) { + await ref.read(llmConfigProvider.notifier).setApiKey(draft.apiKey); + } + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + draft.apiKey.isNotEmpty + ? 'AI configuration saved.' + : draft.clearApiKey + ? 'AI API key cleared.' + : 'AI configuration saved.', + ), + ), + ); + } + } + + Future _toggleDigest(BuildContext context, WidgetRef ref) async { + final LlmDigestConfigState current = ref.read(llmDigestConfigProvider); + final bool nextEnabled = !current.enabled; + await ref.read(llmDigestConfigProvider.notifier).setEnabled(nextEnabled); + await ref + .read(llmDigestBackgroundSchedulerProvider) + .configure(ref.read(llmDigestConfigProvider)); + if (!context.mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + nextEnabled + ? 'Weekly private AI digest enabled.' + : 'Weekly private AI digest disabled.', + ), + ), + ); + } + + Future _runPrivateDigest(BuildContext context, WidgetRef ref) async { + final ScaffoldMessengerState messenger = ScaffoldMessenger.of(context); + messenger.showSnackBar( + const SnackBar(content: Text('Running private AI digest...')), + ); + final LlmDigestRunResult result = await ref + .read(llmDigestOrchestratorProvider) + .runManualDigest(); + if (!context.mounted) { + return; + } + messenger.showSnackBar( + SnackBar( + content: Text( + result.completed + ? 'Private digest created ${result.createdDraftCount} suggestions.' + : result.reason ?? 'Private digest did not complete.', + ), + ), + ); + } + + Future _signOut(BuildContext context, WidgetRef ref) async { + await ref.read(sessionControllerProvider.notifier).signOut(); + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Signed out.'))); + } + } + + Future _requestNotificationPermissions( + BuildContext context, + WidgetRef ref, + ) async { + final bool granted = await ref + .read(reminderSchedulerProvider) + .requestPermissions(); + if (!context.mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + granted + ? 'Notification access granted.' + : 'Notification access not granted.', + ), + ), + ); + } + + Future _simulateShareCapture( + BuildContext context, + WidgetRef ref, + ) async { + final String platformName = Theme.of(context).platform.name; + final _ShareSimulationDraft? draft = + await showDialog<_ShareSimulationDraft>( + context: context, + builder: (BuildContext context) => const _ShareSimulationDialog(), + ); + if (draft == null) { + return; + } + if (!context.mounted) { + return; + } + + final SharedPayload? payload = buildSharedPayloadFromRawText( + rawText: draft.rawText, + platform: platformName, + sourceAppHint: draft.sourceAppHint, + ); + if (payload == null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Shared payload is empty.'))); + return; + } + + final SharedMessageIngestResult? result = await startShareCaptureReview( + context, + ref: ref, + payload: payload, + ); + + if (!context.mounted || result == null) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(shareCaptureResultMessage(result)), + action: SnackBarAction( + label: result.isQueuedForResolution ? 'Open Inbox' : 'Open Profile', + onPressed: () => + openShareCaptureDestination(context, ref: ref, result: result), + ), + ), + ); + } +} + +class _SettingRow extends StatelessWidget { + const _SettingRow({ + required this.label, + required this.value, + required this.compact, + }); + + final String label; + final String value; + final bool compact; + + @override + Widget build(BuildContext context) { + if (compact) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.bodyLarge), + ], + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 140, + child: Text( + label, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(color: AppTheme.textSecondary), + ), + ), + Expanded( + child: Text(value, style: Theme.of(context).textTheme.bodyLarge), + ), + ], + ); + } +} + +class _LlmConfigDraft { + const _LlmConfigDraft({ + required this.provider, + required this.apiKey, + this.clearApiKey = false, + }); + + final LlmProvider provider; + final String apiKey; + final bool clearApiKey; +} + +class _LlmConfigDialog extends StatefulWidget { + const _LlmConfigDialog({ + required this.currentProvider, + required this.hasApiKey, + }); + + final LlmProvider currentProvider; + final bool hasApiKey; + + @override + State<_LlmConfigDialog> createState() => _LlmConfigDialogState(); +} + +class _LlmConfigDialogState extends State<_LlmConfigDialog> { + late LlmProvider _selectedProvider; + late final TextEditingController _apiKeyController; + + @override + void initState() { + super.initState(); + _selectedProvider = widget.currentProvider; + _apiKeyController = TextEditingController(); + } + + @override + void dispose() { + _apiKeyController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Configure AI Provider'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Provider'), + const SizedBox(height: 8), + DropdownButtonFormField( + initialValue: _selectedProvider, + decoration: const InputDecoration(border: OutlineInputBorder()), + items: const >[ + DropdownMenuItem( + value: LlmProvider.openai, + child: Text('OpenAI'), + ), + DropdownMenuItem( + value: LlmProvider.anthropic, + child: Text('Anthropic (Claude)'), + ), + DropdownMenuItem( + value: LlmProvider.google, + child: Text('Google AI'), + ), + ], + onChanged: (LlmProvider? value) { + if (value != null) { + setState(() { + _selectedProvider = value; + }); + } + }, + ), + const SizedBox(height: 16), + const Text('API Key'), + const SizedBox(height: 8), + TextField( + controller: _apiKeyController, + obscureText: true, + decoration: InputDecoration( + hintText: widget.hasApiKey + ? 'Leave empty to keep current' + : 'Enter your API key', + border: const OutlineInputBorder(), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + if (widget.hasApiKey) + TextButton( + onPressed: () { + Navigator.of(context).pop( + _LlmConfigDraft( + provider: _selectedProvider, + apiKey: '', + clearApiKey: true, + ), + ); + }, + child: const Text('Clear Key'), + ), + FilledButton( + onPressed: () { + Navigator.of(context).pop( + _LlmConfigDraft( + provider: _selectedProvider, + apiKey: _apiKeyController.text.trim(), + ), + ); + }, + child: const Text('Save'), + ), + ], + ); + } +} + +class _ShareSimulationDraft { + const _ShareSimulationDraft({ + required this.rawText, + required this.sourceAppHint, + }); + + final String rawText; + final String sourceAppHint; +} + +class _ShareSimulationDialog extends StatefulWidget { + const _ShareSimulationDialog(); + + @override + State<_ShareSimulationDialog> createState() => _ShareSimulationDialogState(); +} + +class _ShareSimulationDialogState extends State<_ShareSimulationDialog> { + late final TextEditingController _controller; + String _selectedSourceApp = 'whatsapp'; + + @override + void initState() { + super.initState(); + _controller = TextEditingController( + text: '[2/18/26, 20:11] Ava Hart: Can we do coffee this Saturday?', + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Simulate Share Capture'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButtonFormField( + initialValue: _selectedSourceApp, + decoration: const InputDecoration(labelText: 'Source app'), + items: const >[ + DropdownMenuItem( + value: 'whatsapp', + child: Text('WhatsApp'), + ), + DropdownMenuItem(value: 'signal', child: Text('Signal')), + DropdownMenuItem(value: 'notes', child: Text('Notes')), + DropdownMenuItem( + value: 'share_sheet', + child: Text('Generic text/link'), + ), + ], + onChanged: (String? value) { + if (value == null) { + return; + } + setState(() { + _selectedSourceApp = value; + }); + }, + ), + const SizedBox(height: 12), + TextField( + controller: _controller, + minLines: 3, + maxLines: 6, + decoration: const InputDecoration( + hintText: 'Paste shared text or URL payload', + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final String rawText = _controller.text.trim(); + if (rawText.isEmpty) { + return; + } + Navigator.of(context).pop( + _ShareSimulationDraft( + rawText: rawText, + sourceAppHint: _selectedSourceApp, + ), + ); + }, + child: const Text('Import'), + ), + ], + ); + } +} diff --git a/lib/features/settings/settings_view.dart b/lib/features/settings/settings_view.dart index 0d383ce..59564c1 100644 --- a/lib/features/settings/settings_view.dart +++ b/lib/features/settings/settings_view.dart @@ -1,386 +1,2 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:relationship_saver/core/config/app_config.dart'; -import 'package:relationship_saver/core/config/app_theme.dart'; -import 'package:relationship_saver/features/auth/session_controller.dart'; -import 'package:relationship_saver/features/local/local_repository.dart'; -import 'package:relationship_saver/features/people/people_view.dart'; -import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_provider.dart'; -import 'package:relationship_saver/features/share_intake/share_inbox_view.dart'; -import 'package:relationship_saver/features/share_intake/whatsapp_share_parser.dart'; -import 'package:relationship_saver/features/shared/frosted_card.dart'; -import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; - -class SettingsView extends ConsumerWidget { - const SettingsView({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final AuthSession? session = ref - .watch(sessionControllerProvider) - .asData - ?.value; - - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - final bool compact = constraints.maxWidth < 760; - return Padding( - padding: EdgeInsets.fromLTRB( - compact ? 16 : 28, - compact ? 16 : 24, - compact ? 16 : 28, - compact ? 20 : 28, - ), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Settings', - style: Theme.of(context).textTheme.headlineMedium, - ), - const SizedBox(height: 6), - Text( - 'Environment and integration configuration for this build.', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: AppTheme.textSecondary, - ), - ), - const SizedBox(height: 16), - FrostedCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SettingRow( - compact: compact, - label: 'Signed in as', - value: - session?.user.email ?? - session?.user.id ?? - 'Not signed in', - ), - const SizedBox(height: 12), - _SettingRow( - compact: compact, - label: 'Gateway mode', - value: AppConfig.useFakeBackend - ? 'Fake (offline-safe)' - : 'REST', - ), - const SizedBox(height: 12), - _SettingRow( - compact: compact, - label: 'Base URL', - value: AppConfig.backendBaseUrl, - ), - const SizedBox(height: 12), - _SettingRow( - compact: compact, - label: 'Realtime', - value: 'Planned later (SSE/WebSocket)', - ), - const SizedBox(height: 12), - _SettingRow( - compact: compact, - label: 'Local notifications', - value: AppConfig.enableLocalNotifications - ? 'Enabled' - : 'Disabled', - ), - const SizedBox(height: 14), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - FilledButton.icon( - onPressed: session == null - ? null - : () => _signOut(context, ref), - icon: const Icon(Icons.logout_rounded), - label: const Text('Sign Out'), - ), - OutlinedButton.icon( - onPressed: session == null - ? null - : () { - ref - .read( - sessionControllerProvider.notifier, - ) - .refreshProfile(); - }, - icon: const Icon(Icons.refresh_rounded), - label: const Text('Refresh Profile'), - ), - OutlinedButton.icon( - onPressed: AppConfig.enableLocalNotifications - ? () => _requestNotificationPermissions( - context, - ref, - ) - : null, - icon: const Icon( - Icons.notifications_active_rounded, - ), - label: const Text('Request Notification Access'), - ), - OutlinedButton.icon( - onPressed: () => - _simulateWhatsAppShare(context, ref), - icon: const Icon(Icons.message_rounded), - label: const Text('Simulate WhatsApp Share'), - ), - OutlinedButton.icon( - onPressed: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (BuildContext context) => - const ShareInboxView(), - ), - ), - icon: const Icon(Icons.inbox_rounded), - label: const Text('Open Share Inbox'), - ), - ], - ), - ], - ), - ), - const SizedBox(height: 16), - FrostedCard( - child: Text( - 'Run options\n• Fake mode: flutter run --dart-define=USE_FAKE_BACKEND=true\n• REST mode: flutter run --dart-define=USE_FAKE_BACKEND=false --dart-define=BACKEND_BASE_URL=https://your-api', - style: Theme.of(context).textTheme.bodyLarge, - ), - ), - ], - ), - ), - ); - }, - ); - } - - Future _signOut(BuildContext context, WidgetRef ref) async { - await ref.read(sessionControllerProvider.notifier).signOut(); - if (context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Signed out.'))); - } - } - - Future _requestNotificationPermissions( - BuildContext context, - WidgetRef ref, - ) async { - final bool granted = await ref - .read(reminderSchedulerProvider) - .requestPermissions(); - if (!context.mounted) { - return; - } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - granted - ? 'Notification access granted.' - : 'Notification access not granted.', - ), - ), - ); - } - - Future _simulateWhatsAppShare( - BuildContext context, - WidgetRef ref, - ) async { - final _ShareSimulationDraft? draft = - await showDialog<_ShareSimulationDraft>( - context: context, - builder: (BuildContext context) => const _ShareSimulationDialog(), - ); - if (draft == null) { - return; - } - - final WhatsAppSharePayload parsed = WhatsAppShareParser.parse( - draft.rawText, - ); - if (parsed.messageText.trim().isEmpty) { - if (!context.mounted) { - return; - } - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Message text is empty.'))); - return; - } - - final SharedMessageIngestResult result = await ref - .read(localRepositoryProvider.notifier) - .ingestSharedMessage( - SharedMessageIngestInput( - sourceApp: 'whatsapp', - messageText: parsed.messageText, - sourceDisplayName: parsed.sourceDisplayName, - sourceUserId: parsed.sourceUserId, - sourceThreadId: parsed.sourceThreadId, - sharedAt: DateTime.now(), - ), - ); - - if (!context.mounted) { - return; - } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - result.isQueuedForResolution - ? 'Imported to Share Inbox for profile resolution.' - : result.createdProfile - ? 'Imported and created ${result.profileName}.' - : 'Imported to ${result.profileName}.', - ), - action: SnackBarAction( - label: result.isQueuedForResolution ? 'Open Inbox' : 'Open Profile', - onPressed: () => _openShareDestination(context, ref, result), - ), - ), - ); - } - - void _openShareDestination( - BuildContext context, - WidgetRef ref, - SharedMessageIngestResult result, - ) { - if (result.isQueuedForResolution) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (BuildContext context) => const ShareInboxView(), - ), - ); - return; - } - - if (result.profileId != null) { - ref.read(selectedPersonIdProvider.notifier).select(result.profileId!); - } - Navigator.of(context).push( - MaterialPageRoute( - builder: (BuildContext context) => const PeopleView(), - ), - ); - } -} - -class _SettingRow extends StatelessWidget { - const _SettingRow({ - required this.label, - required this.value, - required this.compact, - }); - - final String label; - final String value; - final bool compact; - - @override - Widget build(BuildContext context) { - if (compact) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: 4), - Text(value, style: Theme.of(context).textTheme.bodyLarge), - ], - ); - } - - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 140, - child: Text( - label, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(color: AppTheme.textSecondary), - ), - ), - Expanded( - child: Text(value, style: Theme.of(context).textTheme.bodyLarge), - ), - ], - ); - } -} - -class _ShareSimulationDraft { - const _ShareSimulationDraft({required this.rawText}); - - final String rawText; -} - -class _ShareSimulationDialog extends StatefulWidget { - const _ShareSimulationDialog(); - - @override - State<_ShareSimulationDialog> createState() => _ShareSimulationDialogState(); -} - -class _ShareSimulationDialogState extends State<_ShareSimulationDialog> { - late final TextEditingController _controller; - - @override - void initState() { - super.initState(); - _controller = TextEditingController( - text: '[2/18/26, 20:11] Ava Hart: Can we do coffee this Saturday?', - ); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text('Simulate WhatsApp Share'), - content: TextField( - controller: _controller, - minLines: 3, - maxLines: 6, - decoration: const InputDecoration( - hintText: 'Paste shared WhatsApp text payload', - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - final String rawText = _controller.text.trim(); - if (rawText.isEmpty) { - return; - } - Navigator.of(context).pop(_ShareSimulationDraft(rawText: rawText)); - }, - child: const Text('Import'), - ), - ], - ); - } -} +// Legacy compatibility export for the settings presentation entry. +export 'package:relationship_saver/features/settings/presentation/settings_view.dart'; diff --git a/lib/features/share_intake/README.md b/lib/features/share_intake/README.md new file mode 100644 index 0000000..b93c01b --- /dev/null +++ b/lib/features/share_intake/README.md @@ -0,0 +1,12 @@ +# Share Intake Slice + +This slice owns the highest-friction capture path: content arriving from other +apps. + +- `domain/share_models.dart`: normalized share payloads, drafts, inbox entries. +- `domain/*parser*.dart`: payload parsing and source-specific parsing helpers. +- `application/share_capture_*.dart`: orchestration contracts and routing flow. +- `presentation/`: listener widgets, review sheet, inbox UI. + +If you want to improve share UX, ambiguity handling, or payload normalization, +start here. diff --git a/lib/features/share_intake/application/README.md b/lib/features/share_intake/application/README.md new file mode 100644 index 0000000..32d9a69 --- /dev/null +++ b/lib/features/share_intake/application/README.md @@ -0,0 +1,4 @@ +# Share Intake Application + +This layer coordinates share-entry routing and the review flow. If content comes +in from outside the app, this folder decides how it enters the local workflow. diff --git a/lib/features/share_intake/application/share_capture_flow.dart b/lib/features/share_intake/application/share_capture_flow.dart new file mode 100644 index 0000000..130006d --- /dev/null +++ b/lib/features/share_intake/application/share_capture_flow.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/features/local/local_models.dart'; +import 'package:relationship_saver/features/people/presentation/people_view.dart'; +import 'package:relationship_saver/features/people/presentation/providers/people_ui_state.dart'; +import 'package:relationship_saver/features/share_intake/share_capture_models.dart'; +import 'package:relationship_saver/features/share_intake/share_capture_review_sheet.dart'; +import 'package:relationship_saver/features/share_intake/share_inbox_view.dart'; +import 'package:relationship_saver/features/share_intake/share_payload_parser.dart'; + +SharedPayload? buildSharedPayloadFromRawText({ + required String rawText, + required String platform, + String? sourceAppHint, +}) { + final String trimmed = rawText.trim(); + if (trimmed.isEmpty) { + return null; + } + final String sourceApp = SharePayloadParser.detectSourceApp( + rawText: trimmed, + sourceAppHint: sourceAppHint, + ); + final SharedPayload payload = SharePayloadParser.parse( + rawText: trimmed, + sourceApp: sourceApp, + platform: platform, + createdAt: DateTime.now(), + receivedAt: DateTime.now(), + ); + if (payload.rawText.trim().isEmpty && (payload.url?.trim().isEmpty ?? true)) { + return null; + } + return payload; +} + +Future startShareCaptureReview( + BuildContext context, { + required WidgetRef ref, + required SharedPayload payload, +}) { + return showShareCaptureReviewSheet( + context, + ref: ref, + payload: payload, + initialPersonId: ref.read(selectedPersonIdProvider), + ); +} + +void openShareCaptureDestination( + BuildContext context, { + required WidgetRef ref, + required SharedMessageIngestResult result, +}) { + if (result.isQueuedForResolution) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) => const ShareInboxView(), + ), + ); + return; + } + + if (result.profileId != null) { + ref.read(selectedPersonIdProvider.notifier).select(result.profileId!); + } + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) => const PeopleView(), + ), + ); +} + +String shareCaptureResultMessage(SharedMessageIngestResult result) { + if (result.isQueuedForResolution) { + return 'Saved shared capture to inbox.'; + } + if (result.createdProfile) { + return 'Saved shared capture and created ${result.profileName}.'; + } + return 'Saved shared capture to ${result.profileName}.'; +} diff --git a/lib/features/share_intake/application/share_capture_models.dart b/lib/features/share_intake/application/share_capture_models.dart new file mode 100644 index 0000000..1a70ebd --- /dev/null +++ b/lib/features/share_intake/application/share_capture_models.dart @@ -0,0 +1,60 @@ +class SharedMessageIngestInput { + const SharedMessageIngestInput({ + required this.sourceApp, + required this.messageText, + this.sourceDisplayName, + this.sourceUserId, + this.sourceThreadId, + this.sharedAt, + }); + + final String sourceApp; + final String messageText; + final String? sourceDisplayName; + final String? sourceUserId; + final String? sourceThreadId; + final DateTime? sharedAt; +} + +enum SharedMessageIngestStatus { imported, queuedForResolution } + +class SharedMessageIngestResult { + const SharedMessageIngestResult({ + required this.status, + required this.createdProfile, + required this.createdSourceLink, + this.profileId, + this.profileName, + this.momentId, + this.inboxEntryId, + }); + + const SharedMessageIngestResult.imported({ + required this.profileId, + required this.profileName, + required this.createdProfile, + required this.createdSourceLink, + required this.momentId, + }) : status = SharedMessageIngestStatus.imported, + inboxEntryId = null; + + const SharedMessageIngestResult.queuedForResolution({ + required this.inboxEntryId, + }) : status = SharedMessageIngestStatus.queuedForResolution, + createdProfile = false, + createdSourceLink = false, + profileId = null, + profileName = null, + momentId = null; + + final SharedMessageIngestStatus status; + final String? profileId; + final String? profileName; + final bool createdProfile; + final bool createdSourceLink; + final String? momentId; + final String? inboxEntryId; + + bool get isQueuedForResolution => + status == SharedMessageIngestStatus.queuedForResolution; +} diff --git a/lib/features/share_intake/domain/README.md b/lib/features/share_intake/domain/README.md new file mode 100644 index 0000000..7423609 --- /dev/null +++ b/lib/features/share_intake/domain/README.md @@ -0,0 +1,4 @@ +# Share Intake Domain + +Normalized payloads, inbox entries, fact drafts, and parsing helpers live here. +Start here when changing how raw shared content becomes structured app data. diff --git a/lib/features/share_intake/domain/share_capture_draft_suggester.dart b/lib/features/share_intake/domain/share_capture_draft_suggester.dart new file mode 100644 index 0000000..abaf03a --- /dev/null +++ b/lib/features/share_intake/domain/share_capture_draft_suggester.dart @@ -0,0 +1,232 @@ +import 'package:relationship_saver/features/local/local_models.dart'; + +class ShareCaptureDraftSuggester { + const ShareCaptureDraftSuggester._(); + + static final RegExp _birthdayPattern = RegExp( + r'\bbirthday\b', + caseSensitive: false, + ); + static final RegExp _anniversaryPattern = RegExp( + r'\banniversary\b', + caseSensitive: false, + ); + static final RegExp _giftPattern = RegExp( + r'\b(gift|present|flowers|scarf|book for her|book for him)\b', + caseSensitive: false, + ); + static final RegExp _placePattern = RegExp( + r'\b(restaurant|cafe|coffee shop|bar|bistro|museum|park|hotel|beach|address|reservation)\b', + caseSensitive: false, + ); + static final RegExp _activityPattern = RegExp( + r'\b(concert|movie|hike|walk|trip|class|event|show|plan|weekend|tickets?)\b', + caseSensitive: false, + ); + static final RegExp _negativePreferencePattern = RegExp( + r"\b(hates?|dislikes?|allergic to|doesn't like|does not like|dont like)\b", + caseSensitive: false, + ); + static final RegExp _positivePreferencePattern = RegExp( + r'\b(loves?|likes?|favorite|prefers?|enjoys?)\b', + caseSensitive: false, + ); + static final RegExp _isoDatePattern = RegExp( + r'\b(\d{4})-(\d{1,2})-(\d{1,2})\b', + ); + static final RegExp _dotDatePattern = RegExp( + r'\b(\d{1,2})\.(\d{1,2})\.(\d{2,4})\b', + ); + static final RegExp _monthNameDatePattern = RegExp( + r'\b(january|february|march|april|may|june|july|august|september|october|november|december)\s+(\d{1,2})(?:,\s*(\d{4}))?\b', + caseSensitive: false, + ); + + static CapturedFactDraft suggestForPayload(SharedPayload payload) { + final String text = payload.rawText.trim().isNotEmpty + ? payload.rawText.trim() + : (payload.url ?? '').trim(); + final String normalized = text.toLowerCase(); + final Uri? uri = payload.url == null ? null : Uri.tryParse(payload.url!); + final String host = uri?.host.toLowerCase() ?? ''; + + final _SuggestedImportantDate? importantDate = _suggestImportantDate(text); + if (importantDate != null) { + return CapturedFactDraft( + type: CapturedFactType.importantDate, + text: importantDate.label, + label: importantDate.label, + dateValue: importantDate.date, + isSensitive: false, + needsReview: true, + ); + } + + if (_giftPattern.hasMatch(normalized)) { + return CapturedFactDraft( + type: CapturedFactType.giftIdea, + text: text, + label: 'Gift idea', + isSensitive: false, + needsReview: true, + ); + } + + if (_looksLikePlace(normalized, host)) { + return CapturedFactDraft( + type: CapturedFactType.placeIdea, + text: text, + label: 'Place idea', + isSensitive: false, + needsReview: true, + ); + } + + if (_looksLikeActivity(normalized, host)) { + return CapturedFactDraft( + type: CapturedFactType.activityIdea, + text: text, + label: 'Activity idea', + isSensitive: false, + needsReview: true, + ); + } + + if (_negativePreferencePattern.hasMatch(normalized)) { + return CapturedFactDraft( + type: CapturedFactType.dislike, + text: text, + label: 'Preference', + isSensitive: normalized.contains('allergic'), + needsReview: true, + ); + } + + if (_positivePreferencePattern.hasMatch(normalized)) { + return CapturedFactDraft( + type: CapturedFactType.like, + text: text, + label: 'Preference', + isSensitive: false, + needsReview: true, + ); + } + + return CapturedFactDraft( + type: CapturedFactType.note, + text: text, + label: payload.url == null ? null : 'Shared link', + isSensitive: false, + needsReview: payload.url != null, + ); + } + + static bool _looksLikePlace(String normalized, String host) { + if (_placePattern.hasMatch(normalized)) { + return true; + } + return host.contains('maps.apple.com') || + host.contains('google.com') && normalized.contains('maps') || + host.contains('tripadvisor.') || + host.contains('opentable.') || + host.contains('thefork.'); + } + + static bool _looksLikeActivity(String normalized, String host) { + if (_activityPattern.hasMatch(normalized)) { + return true; + } + return host.contains('eventbrite.') || + host.contains('meetup.') || + host.contains('ticketmaster.'); + } + + static _SuggestedImportantDate? _suggestImportantDate(String text) { + final DateTime? date = _parseDate(text); + if (date == null) { + return null; + } + + final String normalized = text.toLowerCase(); + if (_birthdayPattern.hasMatch(normalized)) { + return _SuggestedImportantDate(date: date, label: 'Birthday'); + } + if (_anniversaryPattern.hasMatch(normalized)) { + return _SuggestedImportantDate(date: date, label: 'Anniversary'); + } + return null; + } + + static DateTime? _parseDate(String text) { + final RegExpMatch? isoMatch = _isoDatePattern.firstMatch(text); + if (isoMatch != null) { + return _safeDate( + int.parse(isoMatch.group(1)!), + int.parse(isoMatch.group(2)!), + int.parse(isoMatch.group(3)!), + ); + } + + final RegExpMatch? dotMatch = _dotDatePattern.firstMatch(text); + if (dotMatch != null) { + final int year = int.parse(dotMatch.group(3)!); + return _safeDate( + year < 100 ? 2000 + year : year, + int.parse(dotMatch.group(2)!), + int.parse(dotMatch.group(1)!), + ); + } + + final RegExpMatch? monthNameMatch = _monthNameDatePattern.firstMatch(text); + if (monthNameMatch != null) { + final int? month = _monthNameToInt(monthNameMatch.group(1)!); + if (month == null) { + return null; + } + final int day = int.parse(monthNameMatch.group(2)!); + final int year = monthNameMatch.group(3) == null + ? DateTime.now().year + : int.parse(monthNameMatch.group(3)!); + return _safeDate(year, month, day); + } + + return null; + } + + static DateTime? _safeDate(int year, int month, int day) { + try { + final DateTime value = DateTime(year, month, day); + if (value.year != year || value.month != month || value.day != day) { + return null; + } + return value; + } catch (_) { + return null; + } + } + + static int? _monthNameToInt(String input) { + return switch (input.toLowerCase()) { + 'january' => 1, + 'february' => 2, + 'march' => 3, + 'april' => 4, + 'may' => 5, + 'june' => 6, + 'july' => 7, + 'august' => 8, + 'september' => 9, + 'october' => 10, + 'november' => 11, + 'december' => 12, + _ => null, + }; + } +} + +class _SuggestedImportantDate { + const _SuggestedImportantDate({required this.date, required this.label}); + + final DateTime date; + final String label; +} diff --git a/lib/features/share_intake/domain/share_models.dart b/lib/features/share_intake/domain/share_models.dart new file mode 100644 index 0000000..fc1eb0a --- /dev/null +++ b/lib/features/share_intake/domain/share_models.dart @@ -0,0 +1,456 @@ +// ignore_for_file: sort_constructors_first + +import 'package:flutter/foundation.dart'; + +/// Supported raw payload shapes entering the app from the share pipeline. +enum SharedPayloadType { text, url, textWithUrl } + +/// User-facing assignment outcome for a shared payload. +enum ShareAssignmentStatus { + needsPersonSelection, + assignedDirectly, + savedToInbox, + discarded, +} + +/// Structured capture types that shared or manual content can resolve into. +enum CapturedFactType { + note, + like, + dislike, + importantDate, + giftIdea, + placeIdea, + activityIdea, + misc, +} + +/// Tracks how a fact or date entered local storage. +enum CaptureSourceKind { manual, shareSheet, shareInbox } + +/// Normalized input payload shared from another app. +@immutable +class SharedPayload { + const SharedPayload({ + required this.sourceApp, + required this.payloadType, + required this.rawText, + required this.createdAt, + required this.receivedAt, + required this.platform, + this.url, + this.sourceDisplayName, + this.sourceUserId, + this.sourceThreadId, + this.parsingHints = const [], + this.metadata = const {}, + this.dedupeKey, + }); + + final String sourceApp; + final SharedPayloadType payloadType; + final String rawText; + final String? url; + final DateTime createdAt; + final DateTime receivedAt; + final String platform; + final String? sourceDisplayName; + final String? sourceUserId; + final String? sourceThreadId; + final List parsingHints; + final Map metadata; + final String? dedupeKey; + + SharedPayload copyWith({ + String? sourceApp, + SharedPayloadType? payloadType, + String? rawText, + String? url, + DateTime? createdAt, + DateTime? receivedAt, + String? platform, + String? sourceDisplayName, + String? sourceUserId, + String? sourceThreadId, + List? parsingHints, + Map? metadata, + String? dedupeKey, + }) { + return SharedPayload( + sourceApp: sourceApp ?? this.sourceApp, + payloadType: payloadType ?? this.payloadType, + rawText: rawText ?? this.rawText, + url: url ?? this.url, + createdAt: createdAt ?? this.createdAt, + receivedAt: receivedAt ?? this.receivedAt, + platform: platform ?? this.platform, + sourceDisplayName: sourceDisplayName ?? this.sourceDisplayName, + sourceUserId: sourceUserId ?? this.sourceUserId, + sourceThreadId: sourceThreadId ?? this.sourceThreadId, + parsingHints: parsingHints ?? this.parsingHints, + metadata: metadata ?? this.metadata, + dedupeKey: dedupeKey ?? this.dedupeKey, + ); + } + + Map toJson() { + return { + 'sourceApp': sourceApp, + 'payloadType': payloadType.name, + 'rawText': rawText, + 'url': url, + 'createdAt': createdAt.toUtc().toIso8601String(), + 'receivedAt': receivedAt.toUtc().toIso8601String(), + 'platform': platform, + 'sourceDisplayName': sourceDisplayName, + 'sourceUserId': sourceUserId, + 'sourceThreadId': sourceThreadId, + 'parsingHints': parsingHints, + 'metadata': metadata, + 'dedupeKey': dedupeKey, + }; + } + + factory SharedPayload.fromJson(Map json) { + final String payloadTypeName = + json['payloadType'] as String? ?? SharedPayloadType.text.name; + return SharedPayload( + sourceApp: json['sourceApp'] as String? ?? 'share_sheet', + payloadType: SharedPayloadType.values.firstWhere( + (SharedPayloadType value) => value.name == payloadTypeName, + orElse: () => SharedPayloadType.text, + ), + rawText: json['rawText'] as String? ?? '', + url: json['url'] as String?, + createdAt: DateTime.parse( + json['createdAt'] as String? ?? + DateTime.now().toUtc().toIso8601String(), + ).toLocal(), + receivedAt: DateTime.parse( + json['receivedAt'] as String? ?? + DateTime.now().toUtc().toIso8601String(), + ).toLocal(), + platform: json['platform'] as String? ?? 'unknown', + sourceDisplayName: json['sourceDisplayName'] as String?, + sourceUserId: json['sourceUserId'] as String?, + sourceThreadId: json['sourceThreadId'] as String?, + parsingHints: (json['parsingHints'] as List? ?? []) + .map((dynamic item) => '$item') + .toList(growable: false), + metadata: + (json['metadata'] as Map? ?? {}) + .map( + (dynamic key, dynamic value) => + MapEntry('$key', '$value'), + ), + dedupeKey: json['dedupeKey'] as String?, + ); + } +} + +/// Draft extracted from a shared payload before it becomes a persistent fact. +@immutable +class CapturedFactDraft { + const CapturedFactDraft({ + required this.type, + required this.text, + this.label, + this.dateValue, + this.confidence, + this.isSensitive = false, + this.needsReview = false, + }); + + final CapturedFactType type; + final String text; + final String? label; + final DateTime? dateValue; + final double? confidence; + final bool isSensitive; + final bool needsReview; + + CapturedFactDraft copyWith({ + CapturedFactType? type, + String? text, + String? label, + DateTime? dateValue, + double? confidence, + bool? isSensitive, + bool? needsReview, + }) { + return CapturedFactDraft( + type: type ?? this.type, + text: text ?? this.text, + label: label ?? this.label, + dateValue: dateValue ?? this.dateValue, + confidence: confidence ?? this.confidence, + isSensitive: isSensitive ?? this.isSensitive, + needsReview: needsReview ?? this.needsReview, + ); + } + + Map toJson() { + return { + 'type': type.name, + 'text': text, + 'label': label, + 'dateValue': dateValue?.toUtc().toIso8601String(), + 'confidence': confidence, + 'isSensitive': isSensitive, + 'needsReview': needsReview, + }; + } + + factory CapturedFactDraft.fromJson(Map json) { + final String typeName = + json['type'] as String? ?? CapturedFactType.note.name; + return CapturedFactDraft( + type: CapturedFactType.values.firstWhere( + (CapturedFactType value) => value.name == typeName, + orElse: () => CapturedFactType.note, + ), + text: json['text'] as String? ?? '', + label: json['label'] as String?, + dateValue: json['dateValue'] == null + ? null + : DateTime.parse(json['dateValue'] as String).toLocal(), + confidence: (json['confidence'] as num?)?.toDouble(), + isSensitive: json['isSensitive'] as bool? ?? false, + needsReview: json['needsReview'] as bool? ?? false, + ); + } +} + +/// Durable record of a share that was already attached to a person. +@immutable +class SharedMessageEntry { + const SharedMessageEntry({ + required this.id, + required this.profileId, + required this.payload, + required this.importedAt, + required this.resolvedAutomatically, + this.assignmentStatus = ShareAssignmentStatus.assignedDirectly, + this.draft, + }); + + final String id; + final String profileId; + final SharedPayload payload; + final DateTime importedAt; + final bool resolvedAutomatically; + final ShareAssignmentStatus assignmentStatus; + final CapturedFactDraft? draft; + + String get sourceApp => payload.sourceApp; + String get messageText => payload.rawText; + DateTime get sharedAt => payload.createdAt; + SharedPayloadType get payloadType => payload.payloadType; + String? get url => payload.url; + String? get sourceDisplayName => payload.sourceDisplayName; + String? get sourceUserId => payload.sourceUserId; + String? get sourceThreadId => payload.sourceThreadId; + + SharedMessageEntry copyWith({ + String? id, + String? profileId, + SharedPayload? payload, + DateTime? importedAt, + bool? resolvedAutomatically, + ShareAssignmentStatus? assignmentStatus, + CapturedFactDraft? draft, + }) { + return SharedMessageEntry( + id: id ?? this.id, + profileId: profileId ?? this.profileId, + payload: payload ?? this.payload, + importedAt: importedAt ?? this.importedAt, + resolvedAutomatically: + resolvedAutomatically ?? this.resolvedAutomatically, + assignmentStatus: assignmentStatus ?? this.assignmentStatus, + draft: draft ?? this.draft, + ); + } + + Map toJson() { + return { + 'id': id, + 'profileId': profileId, + 'payload': payload.toJson(), + 'importedAt': importedAt.toUtc().toIso8601String(), + 'resolvedAutomatically': resolvedAutomatically, + 'assignmentStatus': assignmentStatus.name, + 'draft': draft?.toJson(), + }; + } + + factory SharedMessageEntry.fromJson(Map json) { + final String assignmentStatusName = + json['assignmentStatus'] as String? ?? + ShareAssignmentStatus.assignedDirectly.name; + final SharedPayload payload = json['payload'] is Map + ? SharedPayload.fromJson(json['payload'] as Map) + : SharedPayload( + sourceApp: json['sourceApp'] as String? ?? 'share_sheet', + payloadType: SharedPayloadType.text, + rawText: json['messageText'] as String? ?? '', + createdAt: DateTime.parse( + json['sharedAt'] as String? ?? + DateTime.now().toUtc().toIso8601String(), + ).toLocal(), + receivedAt: DateTime.parse( + json['importedAt'] as String? ?? + DateTime.now().toUtc().toIso8601String(), + ).toLocal(), + platform: 'legacy', + sourceDisplayName: json['sourceDisplayName'] as String?, + sourceUserId: json['sourceUserId'] as String?, + sourceThreadId: json['sourceThreadId'] as String?, + ); + return SharedMessageEntry( + id: json['id'] as String, + profileId: json['profileId'] as String, + payload: payload, + importedAt: DateTime.parse(json['importedAt'] as String).toLocal(), + resolvedAutomatically: json['resolvedAutomatically'] as bool? ?? true, + assignmentStatus: ShareAssignmentStatus.values.firstWhere( + (ShareAssignmentStatus value) => value.name == assignmentStatusName, + orElse: () => ShareAssignmentStatus.assignedDirectly, + ), + draft: json['draft'] is Map + ? CapturedFactDraft.fromJson(json['draft'] as Map) + : null, + ); + } +} + +/// Why the share flow parked a payload in the inbox instead of saving directly. +enum SharedInboxReason { + ambiguousProfileMatch, + nearProfileConflict, + missingIdentity, +} + +/// Unresolved share payload waiting for manual triage. +@immutable +class SharedInboxEntry { + const SharedInboxEntry({ + required this.id, + required this.payload, + required this.reason, + required this.candidateProfileIds, + required this.normalizedDisplayName, + this.assignmentStatus = ShareAssignmentStatus.needsPersonSelection, + this.targetPersonId, + this.sourceFingerprint, + this.draft, + }); + + final String id; + final SharedPayload payload; + final SharedInboxReason reason; + final List candidateProfileIds; + final String normalizedDisplayName; + final ShareAssignmentStatus assignmentStatus; + final String? targetPersonId; + final String? sourceFingerprint; + final CapturedFactDraft? draft; + + String get sourceApp => payload.sourceApp; + String get messageText => payload.rawText; + DateTime get sharedAt => payload.createdAt; + DateTime get receivedAt => payload.receivedAt; + SharedPayloadType get payloadType => payload.payloadType; + String? get url => payload.url; + String? get sourceDisplayName => payload.sourceDisplayName; + String? get sourceUserId => payload.sourceUserId; + String? get sourceThreadId => payload.sourceThreadId; + + SharedInboxEntry copyWith({ + String? id, + SharedPayload? payload, + SharedInboxReason? reason, + List? candidateProfileIds, + String? normalizedDisplayName, + ShareAssignmentStatus? assignmentStatus, + String? targetPersonId, + String? sourceFingerprint, + CapturedFactDraft? draft, + }) { + return SharedInboxEntry( + id: id ?? this.id, + payload: payload ?? this.payload, + reason: reason ?? this.reason, + candidateProfileIds: candidateProfileIds ?? this.candidateProfileIds, + normalizedDisplayName: + normalizedDisplayName ?? this.normalizedDisplayName, + assignmentStatus: assignmentStatus ?? this.assignmentStatus, + targetPersonId: targetPersonId ?? this.targetPersonId, + sourceFingerprint: sourceFingerprint ?? this.sourceFingerprint, + draft: draft ?? this.draft, + ); + } + + Map toJson() { + return { + 'id': id, + 'payload': payload.toJson(), + 'reason': reason.name, + 'candidateProfileIds': candidateProfileIds, + 'normalizedDisplayName': normalizedDisplayName, + 'assignmentStatus': assignmentStatus.name, + 'targetPersonId': targetPersonId, + 'sourceFingerprint': sourceFingerprint, + 'draft': draft?.toJson(), + }; + } + + factory SharedInboxEntry.fromJson(Map json) { + final String reasonName = + json['reason'] as String? ?? SharedInboxReason.missingIdentity.name; + final String assignmentStatusName = + json['assignmentStatus'] as String? ?? + ShareAssignmentStatus.needsPersonSelection.name; + final SharedPayload payload = json['payload'] is Map + ? SharedPayload.fromJson(json['payload'] as Map) + : SharedPayload( + sourceApp: json['sourceApp'] as String? ?? 'share_sheet', + payloadType: SharedPayloadType.text, + rawText: json['messageText'] as String? ?? '', + createdAt: DateTime.parse( + json['sharedAt'] as String? ?? + DateTime.now().toUtc().toIso8601String(), + ).toLocal(), + receivedAt: DateTime.parse( + json['receivedAt'] as String? ?? + DateTime.now().toUtc().toIso8601String(), + ).toLocal(), + platform: 'legacy', + sourceDisplayName: json['sourceDisplayName'] as String?, + sourceUserId: json['sourceUserId'] as String?, + sourceThreadId: json['sourceThreadId'] as String?, + ); + return SharedInboxEntry( + id: json['id'] as String, + payload: payload, + reason: SharedInboxReason.values.firstWhere( + (SharedInboxReason item) => item.name == reasonName, + orElse: () => SharedInboxReason.missingIdentity, + ), + candidateProfileIds: + (json['candidateProfileIds'] as List? ?? []) + .map((dynamic id) => '$id') + .toList(growable: false), + normalizedDisplayName: json['normalizedDisplayName'] as String? ?? '', + assignmentStatus: ShareAssignmentStatus.values.firstWhere( + (ShareAssignmentStatus value) => value.name == assignmentStatusName, + orElse: () => ShareAssignmentStatus.needsPersonSelection, + ), + targetPersonId: json['targetPersonId'] as String?, + sourceFingerprint: json['sourceFingerprint'] as String?, + draft: json['draft'] is Map + ? CapturedFactDraft.fromJson(json['draft'] as Map) + : null, + ); + } +} diff --git a/lib/features/share_intake/domain/share_payload_parser.dart b/lib/features/share_intake/domain/share_payload_parser.dart new file mode 100644 index 0000000..25c0ab5 --- /dev/null +++ b/lib/features/share_intake/domain/share_payload_parser.dart @@ -0,0 +1,178 @@ +import 'package:relationship_saver/features/local/local_models.dart'; +import 'package:relationship_saver/features/share_intake/domain/signal_share_parser.dart'; +import 'package:relationship_saver/features/share_intake/whatsapp_share_parser.dart'; + +class SharePayloadParser { + const SharePayloadParser._(); + + static final RegExp _urlPattern = RegExp( + r'(https?:\/\/[^\s]+|www\.[^\s]+)', + caseSensitive: false, + ); + + static SharedPayload parse({ + required String rawText, + required String sourceApp, + required String platform, + DateTime? createdAt, + DateTime? receivedAt, + }) { + final DateTime now = DateTime.now(); + final String normalizedSourceApp = _normalizeSourceApp(sourceApp); + final String trimmed = rawText.trim(); + final _ParsedSourceMetadata? sourceMetadata = _parseSourceMetadata( + sourceApp: normalizedSourceApp, + rawText: trimmed, + ); + final String candidateText = sourceMetadata?.messageText.trim() ?? trimmed; + final RegExpMatch? urlMatch = _urlPattern.firstMatch(candidateText); + final String? url = urlMatch == null + ? null + : _normalizeUrl(urlMatch.group(0)); + final String cleanedText = url == null + ? candidateText + : candidateText.replaceFirst(urlMatch!.group(0)!, '').trim(); + final SharedPayloadType payloadType; + if (url != null && cleanedText.isNotEmpty) { + payloadType = SharedPayloadType.textWithUrl; + } else if (url != null) { + payloadType = SharedPayloadType.url; + } else { + payloadType = SharedPayloadType.text; + } + + return SharedPayload( + sourceApp: normalizedSourceApp, + payloadType: payloadType, + rawText: cleanedText, + url: url, + createdAt: createdAt ?? now, + receivedAt: receivedAt ?? now, + platform: platform, + sourceDisplayName: sourceMetadata?.sourceDisplayName, + sourceUserId: sourceMetadata?.sourceUserId, + sourceThreadId: sourceMetadata?.sourceThreadId, + parsingHints: [ + if (normalizedSourceApp != 'share_sheet') + 'source_app_$normalizedSourceApp', + if (url != null) 'contains_url', + if (sourceMetadata?.sourceDisplayName case final String _) + 'sender_detected', + ], + metadata: { + if (url case final String value) 'primaryUrl': value, + }, + ); + } + + static String detectSourceApp({ + required String rawText, + String? sourceAppHint, + }) { + final String normalizedHint = _normalizeSourceApp(sourceAppHint); + if (normalizedHint != 'share_sheet') { + return normalizedHint; + } + + final String trimmed = rawText.trim(); + if (_shouldUseWhatsAppParser(normalizedHint, trimmed)) { + return 'whatsapp'; + } + + return 'share_sheet'; + } + + static bool _shouldUseWhatsAppParser(String sourceApp, String rawText) { + if (sourceApp.contains('whatsapp')) { + return true; + } + if (sourceApp != 'share_sheet') { + return false; + } + if (rawText.contains(': ') && rawText.length < 4000) { + return WhatsAppShareParser.looksLikeWhatsAppMessage(rawText); + } + return false; + } + + static bool _shouldUseSignalParser(String sourceApp) { + return sourceApp.contains('signal'); + } + + static _ParsedSourceMetadata? _parseSourceMetadata({ + required String sourceApp, + required String rawText, + }) { + if (_shouldUseWhatsAppParser(sourceApp, rawText)) { + final WhatsAppSharePayload payload = WhatsAppShareParser.parse(rawText); + return _ParsedSourceMetadata( + messageText: payload.messageText, + sourceDisplayName: payload.sourceDisplayName, + sourceUserId: payload.sourceUserId, + sourceThreadId: payload.sourceThreadId, + ); + } + + if (_shouldUseSignalParser(sourceApp) && + SignalShareParser.looksLikeSignalMessage(rawText)) { + final SignalSharePayload payload = SignalShareParser.parse(rawText); + return _ParsedSourceMetadata( + messageText: payload.messageText, + sourceDisplayName: payload.sourceDisplayName, + sourceUserId: payload.sourceUserId, + sourceThreadId: payload.sourceThreadId, + ); + } + + return null; + } + + static String _normalizeSourceApp(String? raw) { + final String value = raw?.trim().toLowerCase() ?? ''; + if (value.isEmpty || + value == 'generic' || + value == 'text' || + value == 'share sheet') { + return 'share_sheet'; + } + if (value.contains('whatsapp')) { + return 'whatsapp'; + } + if (value.contains('signal')) { + return 'signal'; + } + if (value.contains('note')) { + return 'notes'; + } + return value; + } + + static String? _normalizeUrl(String? raw) { + if (raw == null) { + return null; + } + final String value = raw.trim(); + if (value.isEmpty) { + return null; + } + if (value.toLowerCase().startsWith('http://') || + value.toLowerCase().startsWith('https://')) { + return value; + } + return 'https://$value'; + } +} + +class _ParsedSourceMetadata { + const _ParsedSourceMetadata({ + required this.messageText, + this.sourceDisplayName, + this.sourceUserId, + this.sourceThreadId, + }); + + final String messageText; + final String? sourceDisplayName; + final String? sourceUserId; + final String? sourceThreadId; +} diff --git a/lib/features/share_intake/domain/signal_share_parser.dart b/lib/features/share_intake/domain/signal_share_parser.dart new file mode 100644 index 0000000..c8702cf --- /dev/null +++ b/lib/features/share_intake/domain/signal_share_parser.dart @@ -0,0 +1,72 @@ +class SignalSharePayload { + const SignalSharePayload({ + required this.messageText, + this.sourceDisplayName, + this.sourceUserId, + this.sourceThreadId, + }); + + final String messageText; + final String? sourceDisplayName; + final String? sourceUserId; + final String? sourceThreadId; +} + +/// Parses raw shared text from Signal into normalized sender/message fields. +class SignalShareParser { + const SignalShareParser._(); + + static final RegExp _namePrefixPattern = RegExp( + r'^([^:\n]{2,80}):\s*(.+)$', + dotAll: true, + ); + + static SignalSharePayload parse(String raw) { + final String input = raw.trim(); + if (input.isEmpty) { + return const SignalSharePayload(messageText: ''); + } + + final RegExpMatch? match = _namePrefixPattern.firstMatch(input); + if (match == null) { + return SignalSharePayload(messageText: input); + } + + final String? senderName = _cleanSender(match.group(1)); + final String messageText = match.group(2)?.trim() ?? input; + return SignalSharePayload( + messageText: messageText, + sourceDisplayName: senderName, + sourceUserId: _normalizedKey(senderName), + sourceThreadId: null, + ); + } + + static bool looksLikeSignalMessage(String raw) { + final String input = raw.trim(); + if (input.isEmpty) { + return false; + } + return _namePrefixPattern.hasMatch(input); + } + + static String? _cleanSender(String? input) { + if (input == null) { + return null; + } + final String cleaned = input.trim(); + return cleaned.isEmpty ? null : cleaned; + } + + static String? _normalizedKey(String? input) { + if (input == null) { + return null; + } + final String key = input + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9]+'), ' ') + .trim() + .replaceAll(RegExp(r'\s+'), ' '); + return key.isEmpty ? null : 'signal:$key'; + } +} diff --git a/lib/features/share_intake/domain/whatsapp_share_parser.dart b/lib/features/share_intake/domain/whatsapp_share_parser.dart new file mode 100644 index 0000000..f325584 --- /dev/null +++ b/lib/features/share_intake/domain/whatsapp_share_parser.dart @@ -0,0 +1,87 @@ +class WhatsAppSharePayload { + const WhatsAppSharePayload({ + required this.messageText, + this.sourceDisplayName, + this.sourceUserId, + this.sourceThreadId, + }); + + final String messageText; + final String? sourceDisplayName; + final String? sourceUserId; + final String? sourceThreadId; +} + +/// Parses raw shared text from WhatsApp into normalized fields. +class WhatsAppShareParser { + const WhatsAppShareParser._(); + + static final RegExp _datePrefixPattern = RegExp( + r'^\[[^\]]+\]\s*([^:\n]{2,80}):\s*(.+)$', + dotAll: true, + ); + + static final RegExp _namePrefixPattern = RegExp( + r'^([^:\n]{2,80}):\s*(.+)$', + dotAll: true, + ); + + static WhatsAppSharePayload parse(String raw) { + final String input = raw.trim(); + if (input.isEmpty) { + return const WhatsAppSharePayload(messageText: ''); + } + + String? senderName; + String messageText = input; + + final RegExpMatch? dateMatch = _datePrefixPattern.firstMatch(input); + if (dateMatch != null) { + senderName = _cleanSender(dateMatch.group(1)); + messageText = dateMatch.group(2)?.trim() ?? input; + } else { + final RegExpMatch? nameMatch = _namePrefixPattern.firstMatch(input); + if (nameMatch != null) { + senderName = _cleanSender(nameMatch.group(1)); + messageText = nameMatch.group(2)?.trim() ?? input; + } + } + + final String? sourceUserId = _normalizedKey(senderName); + return WhatsAppSharePayload( + messageText: messageText, + sourceDisplayName: senderName, + sourceUserId: sourceUserId, + sourceThreadId: null, + ); + } + + static bool looksLikeWhatsAppMessage(String raw) { + final String input = raw.trim(); + if (input.isEmpty) { + return false; + } + return _datePrefixPattern.hasMatch(input) || + _namePrefixPattern.hasMatch(input); + } + + static String? _cleanSender(String? input) { + if (input == null) { + return null; + } + final String cleaned = input.trim(); + return cleaned.isEmpty ? null : cleaned; + } + + static String? _normalizedKey(String? input) { + if (input == null) { + return null; + } + final String key = input + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9]+'), ' ') + .trim() + .replaceAll(RegExp(r'\s+'), ' '); + return key.isEmpty ? null : 'wa:$key'; + } +} diff --git a/lib/features/share_intake/presentation/README.md b/lib/features/share_intake/presentation/README.md new file mode 100644 index 0000000..18e1109 --- /dev/null +++ b/lib/features/share_intake/presentation/README.md @@ -0,0 +1,4 @@ +# Share Intake Presentation + +The listener widgets, review sheet, and inbox UI live here. Use this folder when +you want to change the share-capture UX without touching parsing or persistence. diff --git a/lib/features/share_intake/presentation/incoming_share_listener.dart b/lib/features/share_intake/presentation/incoming_share_listener.dart new file mode 100644 index 0000000..8386234 --- /dev/null +++ b/lib/features/share_intake/presentation/incoming_share_listener.dart @@ -0,0 +1,176 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:receive_sharing_intent/receive_sharing_intent.dart'; +import 'package:relationship_saver/core/config/app_config.dart'; +import 'package:relationship_saver/features/local/local_models.dart'; +import 'package:relationship_saver/features/share_intake/share_capture_flow.dart'; +import 'package:relationship_saver/features/share_intake/share_capture_models.dart'; + +/// Listens for native share intents and routes incoming content through review. +class IncomingShareListener extends ConsumerStatefulWidget { + const IncomingShareListener({required this.child, super.key}); + + final Widget child; + + @override + ConsumerState createState() => + _IncomingShareListenerState(); +} + +class _IncomingShareListenerState extends ConsumerState { + StreamSubscription>? _mediaSubscription; + String? _lastToken; + + @override + void initState() { + super.initState(); + _start(); + } + + @override + void dispose() { + _mediaSubscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; + + Future _start() async { + if (!AppConfig.enableWhatsAppShareIntake || kIsWeb) { + return; + } + + try { + _mediaSubscription = ReceiveSharingIntent.instance + .getMediaStream() + .listen( + (List files) => + _handleMediaList(files, autoOpenDestination: false), + onError: (_) {}, + ); + + final List initialMedia = await ReceiveSharingIntent + .instance + .getInitialMedia(); + if (initialMedia.isNotEmpty) { + await _handleMediaList(initialMedia, autoOpenDestination: true); + } + } catch (_) { + // Unsupported platform/plugin state should not break app usage. + } + } + + Future _handleMediaList( + List files, { + required bool autoOpenDestination, + }) async { + for (final SharedMediaFile file in files) { + final String? rawText = _extractText(file); + if (rawText == null) { + continue; + } + await _handleRawText( + rawText, + autoOpenDestination: autoOpenDestination, + sourceAppHint: _inferSourceAppHint(file, rawText), + ); + } + } + + String? _extractText(SharedMediaFile file) { + if (file.type == SharedMediaType.text) { + final String text = file.path.trim(); + if (text.isNotEmpty) { + return text; + } + } + + final String? mimeType = file.mimeType?.toLowerCase(); + if (mimeType != null && mimeType.startsWith('text/')) { + final String text = file.path.trim(); + if (text.isNotEmpty) { + return text; + } + } + + final String? message = file.message?.trim(); + if (message != null && message.isNotEmpty) { + return message; + } + + return null; + } + + String? _inferSourceAppHint(SharedMediaFile file, String rawText) { + final String? mimeType = file.mimeType?.toLowerCase(); + if (mimeType == 'text/plain' && rawText.contains('Notes')) { + return 'notes'; + } + return null; + } + + Future _handleRawText( + String rawText, { + required bool autoOpenDestination, + String? sourceAppHint, + }) async { + final String trimmed = rawText.trim(); + if (trimmed.isEmpty) { + return; + } + + final SharedPayload? payload = buildSharedPayloadFromRawText( + rawText: trimmed, + platform: defaultTargetPlatform.name, + sourceAppHint: sourceAppHint, + ); + if (payload == null) { + return; + } + + final String token = + '${payload.sourceApp}:${payload.sourceUserId ?? payload.sourceDisplayName ?? ''}:${payload.rawText}:${payload.url ?? ''}'; + if (_lastToken == token) { + return; + } + _lastToken = token; + + if (!mounted) { + return; + } + final SharedMessageIngestResult? result = await startShareCaptureReview( + context, + ref: ref, + payload: payload, + ); + + if (!mounted || result == null) { + return; + } + + if (autoOpenDestination) { + openShareCaptureDestination(context, ref: ref, result: result); + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(shareCaptureResultMessage(result)), + action: SnackBarAction( + label: result.isQueuedForResolution ? 'Open Inbox' : 'Open Profile', + onPressed: () => + openShareCaptureDestination(context, ref: ref, result: result), + ), + ), + ); + + try { + await ReceiveSharingIntent.instance.reset(); + } catch (_) { + // Best effort. + } + } +} diff --git a/lib/features/share_intake/presentation/share_capture_review_sheet.dart b/lib/features/share_intake/presentation/share_capture_review_sheet.dart new file mode 100644 index 0000000..2bf5b02 --- /dev/null +++ b/lib/features/share_intake/presentation/share_capture_review_sheet.dart @@ -0,0 +1,574 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/config/app_theme.dart'; +import 'package:relationship_saver/features/local/local_models.dart'; +import 'package:relationship_saver/features/local/local_repository.dart'; +import 'package:relationship_saver/features/share_intake/domain/share_capture_draft_suggester.dart'; +import 'package:relationship_saver/features/share_intake/share_capture_models.dart'; + +Future showShareCaptureReviewSheet( + BuildContext context, { + required WidgetRef ref, + required SharedPayload payload, + String? initialPersonId, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + showDragHandle: true, + builder: (BuildContext context) { + return _ShareCaptureReviewSheet( + payload: payload, + initialPersonId: initialPersonId, + ); + }, + ); +} + +class _ShareCaptureReviewSheet extends ConsumerStatefulWidget { + const _ShareCaptureReviewSheet({required this.payload, this.initialPersonId}); + + final SharedPayload payload; + final String? initialPersonId; + + @override + ConsumerState<_ShareCaptureReviewSheet> createState() => + _ShareCaptureReviewSheetState(); +} + +class _ShareCaptureReviewSheetState + extends ConsumerState<_ShareCaptureReviewSheet> { + late final TextEditingController _textController; + late final TextEditingController _labelController; + late CapturedFactType _type; + String? _selectedPersonId; + DateTime? _selectedDate; + late bool _isSensitive; + bool _submitting = false; + + @override + void initState() { + super.initState(); + final CapturedFactDraft suggestedDraft = + ShareCaptureDraftSuggester.suggestForPayload(widget.payload); + _selectedPersonId = widget.initialPersonId; + _type = suggestedDraft.type; + _selectedDate = suggestedDraft.dateValue; + _isSensitive = suggestedDraft.isSensitive; + _textController = TextEditingController(text: suggestedDraft.text); + _labelController = TextEditingController(text: suggestedDraft.label ?? ''); + } + + @override + void dispose() { + _textController.dispose(); + _labelController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final AsyncValue localData = ref.watch( + localRepositoryProvider, + ); + return localData.when( + data: (LocalDataState state) { + final List people = state.people.toList(growable: false) + ..sort(_recentPeopleSort); + return Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 8, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Save Shared Capture', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 6), + Text( + 'Review the incoming text or link, then assign it safely.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 14), + _PayloadPreview(payload: widget.payload), + const SizedBox(height: 14), + Text( + 'What kind of info is this?', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: CapturedFactType.values + .map((CapturedFactType type) { + return ChoiceChip( + label: Text(_factTypeLabel(type)), + selected: _type == type, + onSelected: (_) { + setState(() { + _type = type; + }); + }, + ); + }) + .toList(growable: false), + ), + const SizedBox(height: 14), + TextField( + controller: _textController, + minLines: 2, + maxLines: 4, + decoration: InputDecoration( + labelText: _type == CapturedFactType.importantDate + ? 'Date note' + : 'Captured text', + hintText: + 'Adjust the note, preference, or idea before saving.', + ), + ), + const SizedBox(height: 10), + TextField( + controller: _labelController, + decoration: InputDecoration( + labelText: _type == CapturedFactType.importantDate + ? 'Date label' + : 'Optional label', + ), + ), + if (_type == CapturedFactType.importantDate) ...[ + const SizedBox(height: 10), + OutlinedButton.icon( + onPressed: _pickDate, + icon: const Icon(Icons.event_outlined), + label: Text( + _selectedDate == null + ? 'Choose date' + : 'Date: ${_dateLabel(_selectedDate!)}', + ), + ), + ], + const SizedBox(height: 12), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('Mark as sensitive'), + subtitle: const Text( + 'Useful for future privacy controls and review.', + ), + value: _isSensitive, + onChanged: (bool value) { + setState(() { + _isSensitive = value; + }); + }, + ), + const SizedBox(height: 8), + Text( + 'Assign to person', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + if (people.isEmpty) + Text( + 'No people yet. Create one or save this to the inbox.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.textSecondary, + ), + ) + else + Wrap( + spacing: 8, + runSpacing: 8, + children: people + .take(6) + .map((PersonProfile person) { + return ChoiceChip( + label: Text(person.name), + selected: _selectedPersonId == person.id, + onSelected: (_) { + setState(() { + _selectedPersonId = person.id; + }); + }, + ); + }) + .toList(growable: false), + ), + if (people.isNotEmpty) ...[ + const SizedBox(height: 10), + DropdownButtonFormField( + initialValue: _selectedPersonId, + decoration: const InputDecoration( + labelText: 'Or choose from all people', + ), + items: people + .map( + (PersonProfile person) => DropdownMenuItem( + value: person.id, + child: Text( + '${person.name} • ${person.relationship}', + ), + ), + ) + .toList(growable: false), + onChanged: (String? value) { + setState(() { + _selectedPersonId = value; + }); + }, + ), + ], + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _submitting ? null : _saveToInbox, + child: const Text('Save To Inbox'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton.tonal( + onPressed: _submitting ? null : _createPerson, + child: const Text('Create Person'), + ), + ), + ], + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _submitting || _selectedPersonId == null + ? null + : _saveToSelectedPerson, + child: const Text('Save To Person'), + ), + ), + ], + ), + ), + ); + }, + loading: () => const Padding( + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator()), + ), + error: (_, _) => const Padding( + padding: EdgeInsets.all(24), + child: Text('Unable to load people for share review.'), + ), + ); + } + + int _recentPeopleSort(PersonProfile a, PersonProfile b) { + final DateTime? left = a.lastInteractedAt ?? a.lastUpdatedAt; + final DateTime? right = b.lastInteractedAt ?? b.lastUpdatedAt; + if (left == null && right != null) { + return 1; + } + if (left != null && right == null) { + return -1; + } + if (left != null && right != null) { + final int byDate = right.compareTo(left); + if (byDate != 0) { + return byDate; + } + } + return b.affinityScore.compareTo(a.affinityScore); + } + + CapturedFactDraft _buildDraft() { + return CapturedFactDraft( + type: _type, + text: _textController.text.trim(), + label: _labelController.text.trim().isEmpty + ? null + : _labelController.text.trim(), + dateValue: _selectedDate, + isSensitive: _isSensitive, + needsReview: false, + ); + } + + Future _saveToSelectedPerson() async { + final String? personId = _selectedPersonId; + if (personId == null) { + return; + } + await _runAction(() async { + final SharedMessageIngestResult result = await ref + .read(localRepositoryProvider.notifier) + .captureSharedPayloadToExistingProfile( + payload: widget.payload, + profileId: personId, + draft: _buildDraft(), + resolvedAutomatically: false, + ); + if (!mounted) { + return; + } + Navigator.of(context).pop(result); + }); + } + + Future _saveToInbox() async { + await _runAction(() async { + final SharedMessageIngestResult result = await ref + .read(localRepositoryProvider.notifier) + .saveSharedPayloadToInbox( + payload: widget.payload, + draft: _buildDraft(), + targetPersonId: _selectedPersonId, + ); + if (!mounted) { + return; + } + Navigator.of(context).pop(result); + }); + } + + Future _createPerson() async { + final _CreatePersonFromShareDraft? draft = + await showDialog<_CreatePersonFromShareDraft>( + context: context, + builder: (BuildContext context) => _CreatePersonFromShareDialog( + initialName: + widget.payload.sourceDisplayName ?? + widget.payload.rawText.split(RegExp(r'\s+')).take(2).join(' '), + ), + ); + if (draft == null) { + return; + } + await _runAction(() async { + final SharedMessageIngestResult result = await ref + .read(localRepositoryProvider.notifier) + .captureSharedPayloadByCreatingProfile( + payload: widget.payload, + name: draft.name, + relationship: draft.relationship, + aliases: draft.aliases, + draft: _buildDraft(), + ); + if (!mounted) { + return; + } + Navigator.of(context).pop(result); + }); + } + + Future _pickDate() async { + final DateTime now = DateTime.now(); + final DateTime? date = await showDatePicker( + context: context, + firstDate: DateTime(now.year - 5), + lastDate: DateTime(now.year + 10), + initialDate: _selectedDate ?? now, + ); + if (date == null) { + return; + } + setState(() { + _selectedDate = date; + }); + } + + Future _runAction(Future Function() action) async { + setState(() { + _submitting = true; + }); + try { + await action(); + } finally { + if (mounted) { + setState(() { + _submitting = false; + }); + } + } + } +} + +String _factTypeLabel(CapturedFactType type) { + return switch (type) { + CapturedFactType.note => 'Note', + CapturedFactType.like => 'Like', + CapturedFactType.dislike => 'Dislike', + CapturedFactType.importantDate => 'Date', + CapturedFactType.giftIdea => 'Gift', + CapturedFactType.placeIdea => 'Place', + CapturedFactType.activityIdea => 'Activity', + CapturedFactType.misc => 'Misc', + }; +} + +String _dateLabel(DateTime value) { + return '${value.year}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}'; +} + +class _PayloadPreview extends StatelessWidget { + const _PayloadPreview({required this.payload}); + + final SharedPayload payload; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE5EDF2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + payload.sourceApp.toUpperCase(), + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary), + ), + if (payload.sourceDisplayName != null) ...[ + const SizedBox(height: 4), + Text( + 'Sender: ${payload.sourceDisplayName}', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), + ), + ], + if (payload.rawText.trim().isNotEmpty) ...[ + const SizedBox(height: 8), + Text(payload.rawText.trim()), + ], + if (payload.url != null) ...[ + const SizedBox(height: 8), + SelectableText( + payload.url!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.primary, + decoration: TextDecoration.underline, + ), + ), + ], + ], + ), + ); + } +} + +class _CreatePersonFromShareDraft { + const _CreatePersonFromShareDraft({ + required this.name, + required this.relationship, + required this.aliases, + }); + + final String name; + final String relationship; + final List aliases; +} + +class _CreatePersonFromShareDialog extends StatefulWidget { + const _CreatePersonFromShareDialog({required this.initialName}); + + final String initialName; + + @override + State<_CreatePersonFromShareDialog> createState() => + _CreatePersonFromShareDialogState(); +} + +class _CreatePersonFromShareDialogState + extends State<_CreatePersonFromShareDialog> { + late final TextEditingController _nameController; + final TextEditingController _relationshipController = TextEditingController( + text: 'Contact', + ); + final TextEditingController _aliasesController = TextEditingController(); + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(text: widget.initialName); + } + + @override + void dispose() { + _nameController.dispose(); + _relationshipController.dispose(); + _aliasesController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Create Person'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _nameController, + decoration: const InputDecoration(labelText: 'Name'), + ), + const SizedBox(height: 10), + TextField( + controller: _relationshipController, + decoration: const InputDecoration(labelText: 'Relationship'), + ), + const SizedBox(height: 10), + TextField( + controller: _aliasesController, + decoration: const InputDecoration( + labelText: 'Aliases (comma separated)', + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final String name = _nameController.text.trim(); + final String relationship = _relationshipController.text.trim(); + if (name.isEmpty || relationship.isEmpty) { + return; + } + Navigator.of(context).pop( + _CreatePersonFromShareDraft( + name: name, + relationship: relationship, + aliases: _aliasesController.text + .split(',') + .map((String value) => value.trim()) + .where((String value) => value.isNotEmpty) + .toList(growable: false), + ), + ); + }, + child: const Text('Create'), + ), + ], + ); + } +} diff --git a/lib/features/share_intake/presentation/share_inbox_view.dart b/lib/features/share_intake/presentation/share_inbox_view.dart new file mode 100644 index 0000000..af1d85b --- /dev/null +++ b/lib/features/share_intake/presentation/share_inbox_view.dart @@ -0,0 +1,1236 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/config/app_theme.dart'; +import 'package:relationship_saver/features/local/local_models.dart'; +import 'package:relationship_saver/features/local/local_repository.dart'; +import 'package:relationship_saver/features/share_intake/share_capture_models.dart'; +import 'package:relationship_saver/features/shared/frosted_card.dart'; + +/// Review unresolved shared messages and map them to a person profile. +class ShareInboxView extends ConsumerStatefulWidget { + const ShareInboxView({super.key}); + + @override + ConsumerState createState() => _ShareInboxViewState(); +} + +class _ShareInboxViewState extends ConsumerState { + bool _submitting = false; + + @override + Widget build(BuildContext context) { + final AsyncValue state = ref.watch(localRepositoryProvider); + + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final bool compact = constraints.maxWidth < 760; + return Padding( + padding: EdgeInsets.fromLTRB( + compact ? 16 : 28, + compact ? 14 : 20, + compact ? 16 : 28, + compact ? 16 : 20, + ), + child: state.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (Object error, StackTrace stackTrace) { + return Center( + child: Text( + 'Failed to load Share Inbox.', + style: Theme.of(context).textTheme.titleMedium, + ), + ); + }, + data: (LocalDataState localState) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Share Inbox', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 6), + Text( + 'Resolve shared messages that could not be matched confidently.', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 14), + Expanded( + child: localState.sharedInbox.isEmpty + ? _EmptyInbox(compact: compact) + : ListView.separated( + itemCount: localState.sharedInbox.length, + separatorBuilder: (_, _) => + const SizedBox(height: 10), + itemBuilder: (BuildContext context, int index) { + final SharedInboxEntry entry = + localState.sharedInbox[index]; + final List options = + _orderedPeopleForEntry(entry, localState); + return _InboxEntryCard( + entry: entry, + compact: compact, + submitting: _submitting, + suggestedMatches: options, + onResolveToExisting: () => + _resolveToExisting(entry, options), + onCreateProfile: () => _createProfile(entry), + onDismiss: () => _dismiss(entry.id), + ); + }, + ), + ), + ], + ); + }, + ), + ); + }, + ); + } + + List _orderedPeopleForEntry( + SharedInboxEntry entry, + LocalDataState state, + ) { + final Map peopleById = { + for (final PersonProfile person in state.people) person.id: person, + }; + final List candidates = []; + final Set usedIds = {}; + for (final String id in entry.candidateProfileIds) { + final PersonProfile? person = peopleById[id]; + if (person != null) { + candidates.add(person); + usedIds.add(person.id); + } + } + + final List others = state.people + .where((PersonProfile person) => !usedIds.contains(person.id)) + .toList(growable: false); + return [...candidates, ...others]; + } + + Future _resolveToExisting( + SharedInboxEntry entry, + List options, + ) async { + if (_submitting || options.isEmpty) { + return; + } + final CapturedFactDraft? factDraft = await _editDraft(entry); + if (factDraft == null || !mounted) { + return; + } + final List<_CandidateReview> reviews = _buildCandidateReviews( + entry: entry, + options: options, + ); + if (reviews.isEmpty) { + return; + } + + final String? selectedId = await showDialog( + context: context, + builder: (BuildContext context) { + return _ConflictReviewDialog( + entry: entry, + reviews: reviews, + initialsOf: _initials, + ); + }, + ); + if (selectedId == null || !mounted) { + return; + } + + await _runRepositoryAction(() async { + final SharedMessageIngestResult result = await ref + .read(localRepositoryProvider.notifier) + .resolveSharedInboxToExistingProfile( + inboxEntryId: entry.id, + profileId: selectedId, + draft: factDraft, + ); + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Imported to ${result.profileName ?? 'selected profile'}.', + ), + ), + ); + }); + } + + Future _createProfile(SharedInboxEntry entry) async { + if (_submitting) { + return; + } + final CapturedFactDraft? factDraft = await _editDraft(entry); + if (factDraft == null || !mounted) { + return; + } + final _CreateProfileDraft? draft = await showDialog<_CreateProfileDraft>( + context: context, + builder: (BuildContext context) => _CreateProfileDialog( + initialName: + entry.sourceDisplayName ?? + entry.sourceUserId ?? + entry.sourceThreadId ?? + '', + ), + ); + if (draft == null || !mounted) { + return; + } + + await _runRepositoryAction(() async { + final SharedMessageIngestResult result = await ref + .read(localRepositoryProvider.notifier) + .resolveSharedInboxByCreatingProfile( + inboxEntryId: entry.id, + name: draft.name, + relationship: draft.relationship, + location: draft.location, + aliases: draft.aliases, + draft: factDraft, + ); + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Created and imported to ${result.profileName ?? 'profile'}.', + ), + ), + ); + }); + } + + Future _dismiss(String inboxEntryId) async { + if (_submitting) { + return; + } + + await _runRepositoryAction(() async { + await ref + .read(localRepositoryProvider.notifier) + .dismissSharedInboxEntry(inboxEntryId); + if (!mounted) { + return; + } + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Inbox item dismissed.'))); + }); + } + + Future _runRepositoryAction(Future Function() action) async { + setState(() { + _submitting = true; + }); + try { + await action(); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Action failed. Please try again.')), + ); + } + } finally { + if (mounted) { + setState(() { + _submitting = false; + }); + } + } + } + + Future _editDraft(SharedInboxEntry entry) { + return showDialog( + context: context, + builder: (BuildContext context) { + return _DraftMappingDialog( + entry: entry, + initialDraft: + entry.draft ?? + CapturedFactDraft( + type: CapturedFactType.note, + text: entry.messageText.trim().isNotEmpty + ? entry.messageText + : (entry.url ?? ''), + ), + ); + }, + ); + } + + String _initials(String name) { + final List parts = name + .split(RegExp(r'\s+')) + .where((String value) => value.trim().isNotEmpty) + .toList(growable: false); + if (parts.isEmpty) { + return '?'; + } + if (parts.length == 1) { + return parts.first.substring(0, 1).toUpperCase(); + } + return '${parts.first.substring(0, 1).toUpperCase()}${parts[1].substring(0, 1).toUpperCase()}'; + } + + List<_CandidateReview> _buildCandidateReviews({ + required SharedInboxEntry entry, + required List options, + }) { + final List<_CandidateReview> reviews = <_CandidateReview>[]; + for (final PersonProfile person in options) { + final int suggestionIndex = entry.candidateProfileIds.indexOf(person.id); + final bool suggested = suggestionIndex >= 0; + final double confidence = _confidenceScore( + entry: entry, + person: person, + suggested: suggested, + suggestionIndex: suggestionIndex, + ); + reviews.add( + _CandidateReview( + person: person, + confidence: confidence, + suggested: suggested, + reasons: _confidenceReasons( + entry: entry, + person: person, + confidence: confidence, + suggested: suggested, + ), + ), + ); + } + + reviews.sort((_CandidateReview left, _CandidateReview right) { + final int compare = right.confidence.compareTo(left.confidence); + if (compare != 0) { + return compare; + } + return left.person.name.length.compareTo(right.person.name.length); + }); + + return reviews; + } + + List _confidenceReasons({ + required SharedInboxEntry entry, + required PersonProfile person, + required double confidence, + required bool suggested, + }) { + final List reasons = []; + if (suggested) { + reasons.add('Previously suggested by identity matching.'); + } + final String normalizedSender = _normalizedNameForEntry(entry); + final String normalizedPerson = _normalizeForScore(person.name); + if (normalizedSender.isNotEmpty && normalizedPerson.isNotEmpty) { + if (normalizedSender == normalizedPerson) { + reasons.add('Exact normalized name match.'); + } else if (normalizedSender.contains(normalizedPerson) || + normalizedPerson.contains(normalizedSender)) { + reasons.add('One name contains the other.'); + } + } + if ((entry.sourceFingerprint ?? '').isNotEmpty) { + reasons.add('Share includes stable source fingerprint.'); + } + if (confidence >= 0.9) { + reasons.add('Very high confidence score.'); + } else if (confidence >= 0.75) { + reasons.add('Strong confidence score.'); + } else { + reasons.add('Review manually before confirming.'); + } + return reasons; + } + + double _confidenceScore({ + required SharedInboxEntry entry, + required PersonProfile person, + required bool suggested, + required int suggestionIndex, + }) { + final String senderName = _normalizedNameForEntry(entry); + final String personName = _normalizeForScore(person.name); + + double similarity = 0.42; + if (senderName.isNotEmpty && personName.isNotEmpty) { + final int distance = _levenshteinDistance(senderName, personName); + final int maxLen = senderName.length > personName.length + ? senderName.length + : personName.length; + if (maxLen > 0) { + similarity = 1 - (distance / maxLen); + } + if (similarity < 0) { + similarity = 0; + } + } + + double score = (similarity * 0.72); + if (suggested) { + score += 0.2; + if (suggestionIndex == 0) { + score += 0.05; + } + } + if ((entry.sourceUserId ?? '').isNotEmpty || + (entry.sourceThreadId ?? '').isNotEmpty) { + score += 0.04; + } + if ((entry.sourceFingerprint ?? '').isNotEmpty) { + score += 0.02; + } + if (score > 0.99) { + return 0.99; + } + if (score < 0.01) { + return 0.01; + } + return score; + } + + String _normalizedNameForEntry(SharedInboxEntry entry) { + if (entry.normalizedDisplayName.isNotEmpty) { + return entry.normalizedDisplayName; + } + return _normalizeForScore(entry.sourceDisplayName ?? ''); + } + + String _normalizeForScore(String value) { + return value + .trim() + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9]+'), ' ') + .replaceAll(RegExp(r'\s+'), ' '); + } + + int _levenshteinDistance(String left, String right) { + if (left == right) { + return 0; + } + if (left.isEmpty) { + return right.length; + } + if (right.isEmpty) { + return left.length; + } + + List previous = List.generate( + right.length + 1, + (int index) => index, + growable: false, + ); + for (int i = 1; i <= left.length; i += 1) { + final List current = List.filled(right.length + 1, 0); + current[0] = i; + for (int j = 1; j <= right.length; j += 1) { + final int substitutionCost = left[i - 1] == right[j - 1] ? 0 : 1; + final int deletion = previous[j] + 1; + final int insertion = current[j - 1] + 1; + final int substitution = previous[j - 1] + substitutionCost; + final int value = deletion < insertion ? deletion : insertion; + current[j] = value < substitution ? value : substitution; + } + previous = current; + } + return previous[right.length]; + } +} + +class _CandidateReview { + const _CandidateReview({ + required this.person, + required this.confidence, + required this.suggested, + required this.reasons, + }); + + final PersonProfile person; + final double confidence; + final bool suggested; + final List reasons; +} + +class _ConflictReviewDialog extends StatefulWidget { + const _ConflictReviewDialog({ + required this.entry, + required this.reviews, + required this.initialsOf, + }); + + final SharedInboxEntry entry; + final List<_CandidateReview> reviews; + final String Function(String name) initialsOf; + + @override + State<_ConflictReviewDialog> createState() => _ConflictReviewDialogState(); +} + +class _ConflictReviewDialogState extends State<_ConflictReviewDialog> { + late String _selectedProfileId; + + @override + void initState() { + super.initState(); + _selectedProfileId = widget.reviews.first.person.id; + } + + @override + Widget build(BuildContext context) { + final _CandidateReview selected = widget.reviews.firstWhere( + (_CandidateReview review) => review.person.id == _selectedProfileId, + orElse: () => widget.reviews.first, + ); + final String sender = + widget.entry.sourceDisplayName ?? + widget.entry.sourceUserId ?? + 'Unknown sender'; + + return AlertDialog( + title: const Text('Review Match'), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 780), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Compare incoming share identity with a profile before linking.', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 12), + if (MediaQuery.sizeOf(context).width < 760) + Column( + children: [ + _IncomingSharePanel( + entry: widget.entry, + sender: sender, + messagePreview: widget.entry.messageText, + ), + const SizedBox(height: 8), + _CandidatePanel( + review: selected, + initials: widget.initialsOf(selected.person.name), + ), + ], + ) + else + Row( + children: [ + Expanded( + child: _IncomingSharePanel( + entry: widget.entry, + sender: sender, + messagePreview: widget.entry.messageText, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _CandidatePanel( + review: selected, + initials: widget.initialsOf(selected.person.name), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + 'Candidate profiles', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Column( + children: widget.reviews + .map((_CandidateReview review) { + final bool selectedTile = + review.person.id == _selectedProfileId; + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: ListTile( + selected: selectedTile, + selectedTileColor: const Color(0xFFEAF7FB), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + leading: CircleAvatar( + child: Text(widget.initialsOf(review.person.name)), + ), + title: Text(review.person.name), + subtitle: Text( + '${review.person.relationship} · ${_formatPercent(review.confidence)} confidence', + ), + trailing: review.suggested + ? const Icon( + Icons.star_rounded, + color: Color(0xFF1AB6C8), + ) + : null, + onTap: () { + setState(() { + _selectedProfileId = review.person.id; + }); + }, + ), + ); + }) + .toList(growable: false), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(_selectedProfileId), + child: const Text('Confirm Match'), + ), + ], + ); + } +} + +class _IncomingSharePanel extends StatelessWidget { + const _IncomingSharePanel({ + required this.entry, + required this.sender, + required this.messagePreview, + }); + + final SharedInboxEntry entry; + final String sender; + final String messagePreview; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF7FBFF), + borderRadius: BorderRadius.circular(14), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Incoming Share', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + _kv('Sender', sender), + _kv('Source User ID', entry.sourceUserId ?? '-'), + _kv('Source Thread', entry.sourceThreadId ?? '-'), + _kv('Fingerprint', entry.sourceFingerprint ?? '-'), + const SizedBox(height: 8), + Text( + 'Message', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 4), + Text( + messagePreview, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ); + } +} + +class _CandidatePanel extends StatelessWidget { + const _CandidatePanel({required this.review, required this.initials}); + + final _CandidateReview review; + final String initials; + + @override + Widget build(BuildContext context) { + final PersonProfile person = review.person; + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF4FAF8), + borderRadius: BorderRadius.circular(14), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar(child: Text(initials)), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + person.name, + style: Theme.of(context).textTheme.titleMedium, + ), + Text( + person.relationship, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + 'Confidence ${_formatPercent(review.confidence)}', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 4), + LinearProgressIndicator(value: review.confidence), + const SizedBox(height: 10), + if ((person.location ?? '').trim().isNotEmpty) + _kv('Location', person.location!.trim()), + if (person.tags.isNotEmpty) + _kv('Tags', person.tags.take(4).join(', ')), + const SizedBox(height: 4), + ...review.reasons.map( + (String reason) => Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + '- $reason', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), + ), + ), + ), + ], + ), + ); + } +} + +Widget _kv(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: RichText( + text: TextSpan( + style: const TextStyle(color: AppTheme.textSecondary, fontSize: 12), + children: [ + TextSpan( + text: '$label: ', + style: const TextStyle( + color: AppTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + TextSpan(text: value), + ], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ); +} + +String _formatPercent(double value) { + final int percent = (value * 100).round().clamp(1, 99); + return '$percent%'; +} + +class _InboxEntryCard extends StatelessWidget { + const _InboxEntryCard({ + required this.entry, + required this.compact, + required this.submitting, + required this.suggestedMatches, + required this.onResolveToExisting, + required this.onCreateProfile, + required this.onDismiss, + }); + + final SharedInboxEntry entry; + final bool compact; + final bool submitting; + final List suggestedMatches; + final VoidCallback onResolveToExisting; + final VoidCallback onCreateProfile; + final VoidCallback onDismiss; + + @override + Widget build(BuildContext context) { + final String sender = + entry.sourceDisplayName ?? entry.sourceUserId ?? 'Unknown sender'; + final bool hasCandidates = suggestedMatches.isNotEmpty; + final String reasonLabel = switch (entry.reason) { + SharedInboxReason.ambiguousProfileMatch => 'Ambiguous match', + SharedInboxReason.nearProfileConflict => 'Near match conflict', + SharedInboxReason.missingIdentity => 'Missing identity', + }; + + return FrostedCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + sender, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + color: const Color(0xFFEFF5FA), + ), + child: Text( + reasonLabel, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text(entry.messageText, style: Theme.of(context).textTheme.bodyLarge), + if (entry.url != null) ...[ + const SizedBox(height: 8), + Text( + entry.url!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.primary, + decoration: TextDecoration.underline, + ), + ), + ], + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _InlineMetaChip(label: _payloadTypeLabel(entry.payloadType)), + _InlineMetaChip( + label: entry.draft == null + ? 'Draft note' + : _factTypeLabel(entry.draft!.type), + ), + ], + ), + const SizedBox(height: 10), + Text( + 'Shared ${_formatDateTime(entry.sharedAt)}', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton.icon( + onPressed: submitting || !hasCandidates + ? null + : onResolveToExisting, + icon: const Icon(Icons.person_search_rounded), + label: Text(hasCandidates ? 'Review & Save' : 'No Matches'), + ), + OutlinedButton.icon( + onPressed: submitting ? null : onCreateProfile, + icon: const Icon(Icons.person_add_alt_1_rounded), + label: const Text('Create Person'), + ), + TextButton.icon( + onPressed: submitting ? null : onDismiss, + icon: const Icon(Icons.close_rounded), + label: Text(compact ? 'Dismiss' : 'Dismiss Item'), + ), + ], + ), + ], + ), + ); + } +} + +class _InlineMetaChip extends StatelessWidget { + const _InlineMetaChip({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: const Color(0xFFF1F6FA), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + label, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), + ), + ); + } +} + +class _DraftMappingDialog extends StatefulWidget { + const _DraftMappingDialog({required this.entry, required this.initialDraft}); + + final SharedInboxEntry entry; + final CapturedFactDraft initialDraft; + + @override + State<_DraftMappingDialog> createState() => _DraftMappingDialogState(); +} + +class _DraftMappingDialogState extends State<_DraftMappingDialog> { + late CapturedFactType _type; + late final TextEditingController _textController; + late final TextEditingController _labelController; + DateTime? _dateValue; + bool _isSensitive = false; + + @override + void initState() { + super.initState(); + _type = widget.initialDraft.type; + _textController = TextEditingController(text: widget.initialDraft.text); + _labelController = TextEditingController( + text: widget.initialDraft.label ?? '', + ); + _dateValue = widget.initialDraft.dateValue; + _isSensitive = widget.initialDraft.isSensitive; + } + + @override + void dispose() { + _textController.dispose(); + _labelController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Review Capture Mapping'), + content: SingleChildScrollView( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: CapturedFactType.values + .map((CapturedFactType type) { + return ChoiceChip( + label: Text(_factTypeLabel(type)), + selected: _type == type, + onSelected: (_) { + setState(() { + _type = type; + }); + }, + ); + }) + .toList(growable: false), + ), + const SizedBox(height: 12), + TextField( + controller: _textController, + minLines: 2, + maxLines: 4, + decoration: const InputDecoration(labelText: 'Captured text'), + ), + const SizedBox(height: 10), + TextField( + controller: _labelController, + decoration: const InputDecoration(labelText: 'Optional label'), + ), + if (_type == CapturedFactType.importantDate) ...[ + const SizedBox(height: 10), + OutlinedButton.icon( + onPressed: _pickDate, + icon: const Icon(Icons.event_outlined), + label: Text( + _dateValue == null + ? 'Choose date' + : _formatDateShort(_dateValue!), + ), + ), + ], + const SizedBox(height: 10), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('Sensitive'), + value: _isSensitive, + onChanged: (bool value) { + setState(() { + _isSensitive = value; + }); + }, + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + Navigator.of(context).pop( + CapturedFactDraft( + type: _type, + text: _textController.text.trim(), + label: _labelController.text.trim().isEmpty + ? null + : _labelController.text.trim(), + dateValue: _dateValue, + isSensitive: _isSensitive, + ), + ); + }, + child: const Text('Use Mapping'), + ), + ], + ); + } + + Future _pickDate() async { + final DateTime now = DateTime.now(); + final DateTime? date = await showDatePicker( + context: context, + firstDate: DateTime(now.year - 5), + lastDate: DateTime(now.year + 10), + initialDate: _dateValue ?? now, + ); + if (date == null) { + return; + } + setState(() { + _dateValue = date; + }); + } +} + +String _payloadTypeLabel(SharedPayloadType type) { + return switch (type) { + SharedPayloadType.text => 'Text', + SharedPayloadType.url => 'URL', + SharedPayloadType.textWithUrl => 'Text + URL', + }; +} + +String _factTypeLabel(CapturedFactType type) { + return switch (type) { + CapturedFactType.note => 'Note', + CapturedFactType.like => 'Like', + CapturedFactType.dislike => 'Dislike', + CapturedFactType.importantDate => 'Date', + CapturedFactType.giftIdea => 'Gift', + CapturedFactType.placeIdea => 'Place', + CapturedFactType.activityIdea => 'Activity', + CapturedFactType.misc => 'Misc', + }; +} + +class _EmptyInbox extends StatelessWidget { + const _EmptyInbox({required this.compact}); + + final bool compact; + + @override + Widget build(BuildContext context) { + return FrostedCard( + child: SizedBox( + width: double.infinity, + child: Padding( + padding: EdgeInsets.symmetric( + vertical: compact ? 18 : 30, + horizontal: compact ? 6 : 14, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.inbox_rounded, + size: 40, + color: AppTheme.primary, + ), + const SizedBox(height: 10), + Text( + 'No unresolved shares.', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 6), + Text( + 'Incoming shared captures that need manual person mapping or fact triage will appear here.', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ); + } +} + +class _CreateProfileDraft { + const _CreateProfileDraft({ + required this.name, + required this.relationship, + required this.aliases, + this.location, + }); + + final String name; + final String relationship; + final List aliases; + final String? location; +} + +class _CreateProfileDialog extends StatefulWidget { + const _CreateProfileDialog({required this.initialName}); + + final String initialName; + + @override + State<_CreateProfileDialog> createState() => _CreateProfileDialogState(); +} + +class _CreateProfileDialogState extends State<_CreateProfileDialog> { + late final TextEditingController _nameController; + late final TextEditingController _relationshipController; + late final TextEditingController _locationController; + late final TextEditingController _aliasesController; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(text: widget.initialName); + _relationshipController = TextEditingController(text: 'Contact'); + _locationController = TextEditingController(); + _aliasesController = TextEditingController(); + } + + @override + void dispose() { + _nameController.dispose(); + _relationshipController.dispose(); + _locationController.dispose(); + _aliasesController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Create Profile'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _nameController, + decoration: const InputDecoration(labelText: 'Name'), + ), + const SizedBox(height: 8), + TextField( + controller: _relationshipController, + decoration: const InputDecoration(labelText: 'Relationship'), + ), + const SizedBox(height: 8), + TextField( + controller: _aliasesController, + decoration: const InputDecoration( + labelText: 'Aliases (comma separated)', + ), + ), + const SizedBox(height: 8), + TextField( + controller: _locationController, + decoration: const InputDecoration( + labelText: 'Location (optional)', + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + Navigator.of(context).pop( + _CreateProfileDraft( + name: _nameController.text, + relationship: _relationshipController.text, + aliases: _aliasesController.text + .split(',') + .map((String value) => value.trim()) + .where((String value) => value.isNotEmpty) + .toList(growable: false), + location: _locationController.text, + ), + ); + }, + child: const Text('Create'), + ), + ], + ); + } +} + +String _formatDateTime(DateTime value) { + String twoDigits(int number) => number.toString().padLeft(2, '0'); + return '${value.year}-${twoDigits(value.month)}-${twoDigits(value.day)} ${twoDigits(value.hour)}:${twoDigits(value.minute)}'; +} + +String _formatDateShort(DateTime value) { + String twoDigits(int number) => number.toString().padLeft(2, '0'); + return '${value.year}-${twoDigits(value.month)}-${twoDigits(value.day)}'; +} diff --git a/lib/features/share_intake/presentation/whatsapp_share_listener.dart b/lib/features/share_intake/presentation/whatsapp_share_listener.dart new file mode 100644 index 0000000..beda74e --- /dev/null +++ b/lib/features/share_intake/presentation/whatsapp_share_listener.dart @@ -0,0 +1,2 @@ +// Legacy compatibility export for the generic incoming share listener. +export 'package:relationship_saver/features/share_intake/presentation/incoming_share_listener.dart'; diff --git a/lib/features/share_intake/share_capture_flow.dart b/lib/features/share_intake/share_capture_flow.dart new file mode 100644 index 0000000..3ebcd07 --- /dev/null +++ b/lib/features/share_intake/share_capture_flow.dart @@ -0,0 +1,2 @@ +// Legacy compatibility export for the share capture application flow. +export 'package:relationship_saver/features/share_intake/application/share_capture_flow.dart'; diff --git a/lib/features/share_intake/share_capture_models.dart b/lib/features/share_intake/share_capture_models.dart new file mode 100644 index 0000000..195cb96 --- /dev/null +++ b/lib/features/share_intake/share_capture_models.dart @@ -0,0 +1,2 @@ +// Legacy compatibility export for share capture flow contracts. +export 'package:relationship_saver/features/share_intake/application/share_capture_models.dart'; diff --git a/lib/features/share_intake/share_capture_review_sheet.dart b/lib/features/share_intake/share_capture_review_sheet.dart new file mode 100644 index 0000000..d4284a2 --- /dev/null +++ b/lib/features/share_intake/share_capture_review_sheet.dart @@ -0,0 +1,2 @@ +// Legacy compatibility export for the share capture review presentation. +export 'package:relationship_saver/features/share_intake/presentation/share_capture_review_sheet.dart'; diff --git a/lib/features/share_intake/share_inbox_view.dart b/lib/features/share_intake/share_inbox_view.dart index 0b9968f..4ffe4ee 100644 --- a/lib/features/share_intake/share_inbox_view.dart +++ b/lib/features/share_intake/share_inbox_view.dart @@ -1,971 +1,2 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:relationship_saver/core/config/app_theme.dart'; -import 'package:relationship_saver/features/local/local_models.dart'; -import 'package:relationship_saver/features/local/local_repository.dart'; -import 'package:relationship_saver/features/shared/frosted_card.dart'; - -/// Review unresolved shared messages and map them to a person profile. -class ShareInboxView extends ConsumerStatefulWidget { - const ShareInboxView({super.key}); - - @override - ConsumerState createState() => _ShareInboxViewState(); -} - -class _ShareInboxViewState extends ConsumerState { - bool _submitting = false; - - @override - Widget build(BuildContext context) { - final AsyncValue state = ref.watch(localRepositoryProvider); - - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - final bool compact = constraints.maxWidth < 760; - return Padding( - padding: EdgeInsets.fromLTRB( - compact ? 16 : 28, - compact ? 14 : 20, - compact ? 16 : 28, - compact ? 16 : 20, - ), - child: state.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (Object error, StackTrace stackTrace) { - return Center( - child: Text( - 'Failed to load Share Inbox.', - style: Theme.of(context).textTheme.titleMedium, - ), - ); - }, - data: (LocalDataState localState) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Share Inbox', - style: Theme.of(context).textTheme.headlineMedium, - ), - const SizedBox(height: 6), - Text( - 'Resolve shared messages that could not be matched confidently.', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: AppTheme.textSecondary, - ), - ), - const SizedBox(height: 14), - Expanded( - child: localState.sharedInbox.isEmpty - ? _EmptyInbox(compact: compact) - : ListView.separated( - itemCount: localState.sharedInbox.length, - separatorBuilder: (_, _) => - const SizedBox(height: 10), - itemBuilder: (BuildContext context, int index) { - final SharedInboxEntry entry = - localState.sharedInbox[index]; - final List options = - _orderedPeopleForEntry(entry, localState); - return _InboxEntryCard( - entry: entry, - compact: compact, - submitting: _submitting, - suggestedMatches: options, - onResolveToExisting: () => - _resolveToExisting(entry, options), - onCreateProfile: () => _createProfile(entry), - onDismiss: () => _dismiss(entry.id), - ); - }, - ), - ), - ], - ); - }, - ), - ); - }, - ); - } - - List _orderedPeopleForEntry( - SharedInboxEntry entry, - LocalDataState state, - ) { - final Map peopleById = { - for (final PersonProfile person in state.people) person.id: person, - }; - final List candidates = []; - final Set usedIds = {}; - for (final String id in entry.candidateProfileIds) { - final PersonProfile? person = peopleById[id]; - if (person != null) { - candidates.add(person); - usedIds.add(person.id); - } - } - - final List others = state.people - .where((PersonProfile person) => !usedIds.contains(person.id)) - .toList(growable: false); - return [...candidates, ...others]; - } - - Future _resolveToExisting( - SharedInboxEntry entry, - List options, - ) async { - if (_submitting || options.isEmpty) { - return; - } - final List<_CandidateReview> reviews = _buildCandidateReviews( - entry: entry, - options: options, - ); - if (reviews.isEmpty) { - return; - } - - final String? selectedId = await showDialog( - context: context, - builder: (BuildContext context) { - return _ConflictReviewDialog( - entry: entry, - reviews: reviews, - initialsOf: _initials, - ); - }, - ); - if (selectedId == null || !mounted) { - return; - } - - await _runRepositoryAction(() async { - final SharedMessageIngestResult result = await ref - .read(localRepositoryProvider.notifier) - .resolveSharedInboxToExistingProfile( - inboxEntryId: entry.id, - profileId: selectedId, - ); - if (!mounted) { - return; - } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Imported to ${result.profileName ?? 'selected profile'}.', - ), - ), - ); - }); - } - - Future _createProfile(SharedInboxEntry entry) async { - if (_submitting) { - return; - } - final _CreateProfileDraft? draft = await showDialog<_CreateProfileDraft>( - context: context, - builder: (BuildContext context) => _CreateProfileDialog( - initialName: - entry.sourceDisplayName ?? - entry.sourceUserId ?? - entry.sourceThreadId ?? - '', - ), - ); - if (draft == null || !mounted) { - return; - } - - await _runRepositoryAction(() async { - final SharedMessageIngestResult result = await ref - .read(localRepositoryProvider.notifier) - .resolveSharedInboxByCreatingProfile( - inboxEntryId: entry.id, - name: draft.name, - relationship: draft.relationship, - location: draft.location, - ); - if (!mounted) { - return; - } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Created and imported to ${result.profileName ?? 'profile'}.', - ), - ), - ); - }); - } - - Future _dismiss(String inboxEntryId) async { - if (_submitting) { - return; - } - - await _runRepositoryAction(() async { - await ref - .read(localRepositoryProvider.notifier) - .dismissSharedInboxEntry(inboxEntryId); - if (!mounted) { - return; - } - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Inbox item dismissed.'))); - }); - } - - Future _runRepositoryAction(Future Function() action) async { - setState(() { - _submitting = true; - }); - try { - await action(); - } catch (_) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Action failed. Please try again.')), - ); - } - } finally { - if (mounted) { - setState(() { - _submitting = false; - }); - } - } - } - - String _initials(String name) { - final List parts = name - .split(RegExp(r'\s+')) - .where((String value) => value.trim().isNotEmpty) - .toList(growable: false); - if (parts.isEmpty) { - return '?'; - } - if (parts.length == 1) { - return parts.first.substring(0, 1).toUpperCase(); - } - return '${parts.first.substring(0, 1).toUpperCase()}${parts[1].substring(0, 1).toUpperCase()}'; - } - - List<_CandidateReview> _buildCandidateReviews({ - required SharedInboxEntry entry, - required List options, - }) { - final List<_CandidateReview> reviews = <_CandidateReview>[]; - for (final PersonProfile person in options) { - final int suggestionIndex = entry.candidateProfileIds.indexOf(person.id); - final bool suggested = suggestionIndex >= 0; - final double confidence = _confidenceScore( - entry: entry, - person: person, - suggested: suggested, - suggestionIndex: suggestionIndex, - ); - reviews.add( - _CandidateReview( - person: person, - confidence: confidence, - suggested: suggested, - reasons: _confidenceReasons( - entry: entry, - person: person, - confidence: confidence, - suggested: suggested, - ), - ), - ); - } - - reviews.sort((_CandidateReview left, _CandidateReview right) { - final int compare = right.confidence.compareTo(left.confidence); - if (compare != 0) { - return compare; - } - return left.person.name.length.compareTo(right.person.name.length); - }); - - return reviews; - } - - List _confidenceReasons({ - required SharedInboxEntry entry, - required PersonProfile person, - required double confidence, - required bool suggested, - }) { - final List reasons = []; - if (suggested) { - reasons.add('Previously suggested by identity matching.'); - } - final String normalizedSender = _normalizedNameForEntry(entry); - final String normalizedPerson = _normalizeForScore(person.name); - if (normalizedSender.isNotEmpty && normalizedPerson.isNotEmpty) { - if (normalizedSender == normalizedPerson) { - reasons.add('Exact normalized name match.'); - } else if (normalizedSender.contains(normalizedPerson) || - normalizedPerson.contains(normalizedSender)) { - reasons.add('One name contains the other.'); - } - } - if ((entry.sourceFingerprint ?? '').isNotEmpty) { - reasons.add('Share includes stable source fingerprint.'); - } - if (confidence >= 0.9) { - reasons.add('Very high confidence score.'); - } else if (confidence >= 0.75) { - reasons.add('Strong confidence score.'); - } else { - reasons.add('Review manually before confirming.'); - } - return reasons; - } - - double _confidenceScore({ - required SharedInboxEntry entry, - required PersonProfile person, - required bool suggested, - required int suggestionIndex, - }) { - final String senderName = _normalizedNameForEntry(entry); - final String personName = _normalizeForScore(person.name); - - double similarity = 0.42; - if (senderName.isNotEmpty && personName.isNotEmpty) { - final int distance = _levenshteinDistance(senderName, personName); - final int maxLen = senderName.length > personName.length - ? senderName.length - : personName.length; - if (maxLen > 0) { - similarity = 1 - (distance / maxLen); - } - if (similarity < 0) { - similarity = 0; - } - } - - double score = (similarity * 0.72); - if (suggested) { - score += 0.2; - if (suggestionIndex == 0) { - score += 0.05; - } - } - if ((entry.sourceUserId ?? '').isNotEmpty || - (entry.sourceThreadId ?? '').isNotEmpty) { - score += 0.04; - } - if ((entry.sourceFingerprint ?? '').isNotEmpty) { - score += 0.02; - } - if (score > 0.99) { - return 0.99; - } - if (score < 0.01) { - return 0.01; - } - return score; - } - - String _normalizedNameForEntry(SharedInboxEntry entry) { - if (entry.normalizedDisplayName.isNotEmpty) { - return entry.normalizedDisplayName; - } - return _normalizeForScore(entry.sourceDisplayName ?? ''); - } - - String _normalizeForScore(String value) { - return value - .trim() - .toLowerCase() - .replaceAll(RegExp(r'[^a-z0-9]+'), ' ') - .replaceAll(RegExp(r'\s+'), ' '); - } - - int _levenshteinDistance(String left, String right) { - if (left == right) { - return 0; - } - if (left.isEmpty) { - return right.length; - } - if (right.isEmpty) { - return left.length; - } - - List previous = List.generate( - right.length + 1, - (int index) => index, - growable: false, - ); - for (int i = 1; i <= left.length; i += 1) { - final List current = List.filled(right.length + 1, 0); - current[0] = i; - for (int j = 1; j <= right.length; j += 1) { - final int substitutionCost = left[i - 1] == right[j - 1] ? 0 : 1; - final int deletion = previous[j] + 1; - final int insertion = current[j - 1] + 1; - final int substitution = previous[j - 1] + substitutionCost; - final int value = deletion < insertion ? deletion : insertion; - current[j] = value < substitution ? value : substitution; - } - previous = current; - } - return previous[right.length]; - } -} - -class _CandidateReview { - const _CandidateReview({ - required this.person, - required this.confidence, - required this.suggested, - required this.reasons, - }); - - final PersonProfile person; - final double confidence; - final bool suggested; - final List reasons; -} - -class _ConflictReviewDialog extends StatefulWidget { - const _ConflictReviewDialog({ - required this.entry, - required this.reviews, - required this.initialsOf, - }); - - final SharedInboxEntry entry; - final List<_CandidateReview> reviews; - final String Function(String name) initialsOf; - - @override - State<_ConflictReviewDialog> createState() => _ConflictReviewDialogState(); -} - -class _ConflictReviewDialogState extends State<_ConflictReviewDialog> { - late String _selectedProfileId; - - @override - void initState() { - super.initState(); - _selectedProfileId = widget.reviews.first.person.id; - } - - @override - Widget build(BuildContext context) { - final _CandidateReview selected = widget.reviews.firstWhere( - (_CandidateReview review) => review.person.id == _selectedProfileId, - orElse: () => widget.reviews.first, - ); - final String sender = - widget.entry.sourceDisplayName ?? - widget.entry.sourceUserId ?? - 'Unknown sender'; - - return AlertDialog( - title: const Text('Review Match'), - content: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 780), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Compare incoming share identity with a profile before linking.', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: 12), - if (MediaQuery.sizeOf(context).width < 760) - Column( - children: [ - _IncomingSharePanel( - entry: widget.entry, - sender: sender, - messagePreview: widget.entry.messageText, - ), - const SizedBox(height: 8), - _CandidatePanel( - review: selected, - initials: widget.initialsOf(selected.person.name), - ), - ], - ) - else - Row( - children: [ - Expanded( - child: _IncomingSharePanel( - entry: widget.entry, - sender: sender, - messagePreview: widget.entry.messageText, - ), - ), - const SizedBox(width: 8), - Expanded( - child: _CandidatePanel( - review: selected, - initials: widget.initialsOf(selected.person.name), - ), - ), - ], - ), - const SizedBox(height: 12), - Text( - 'Candidate profiles', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - Column( - children: widget.reviews - .map((_CandidateReview review) { - final bool selectedTile = - review.person.id == _selectedProfileId; - return Padding( - padding: const EdgeInsets.only(bottom: 4), - child: ListTile( - selected: selectedTile, - selectedTileColor: const Color(0xFFEAF7FB), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - leading: CircleAvatar( - child: Text(widget.initialsOf(review.person.name)), - ), - title: Text(review.person.name), - subtitle: Text( - '${review.person.relationship} · ${_formatPercent(review.confidence)} confidence', - ), - trailing: review.suggested - ? const Icon( - Icons.star_rounded, - color: Color(0xFF1AB6C8), - ) - : null, - onTap: () { - setState(() { - _selectedProfileId = review.person.id; - }); - }, - ), - ); - }) - .toList(growable: false), - ), - ], - ), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.of(context).pop(_selectedProfileId), - child: const Text('Confirm Match'), - ), - ], - ); - } -} - -class _IncomingSharePanel extends StatelessWidget { - const _IncomingSharePanel({ - required this.entry, - required this.sender, - required this.messagePreview, - }); - - final SharedInboxEntry entry; - final String sender; - final String messagePreview; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFFF7FBFF), - borderRadius: BorderRadius.circular(14), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Incoming Share', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - _kv('Sender', sender), - _kv('Source User ID', entry.sourceUserId ?? '-'), - _kv('Source Thread', entry.sourceThreadId ?? '-'), - _kv('Fingerprint', entry.sourceFingerprint ?? '-'), - const SizedBox(height: 8), - Text( - 'Message', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: 4), - Text( - messagePreview, - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ), - ); - } -} - -class _CandidatePanel extends StatelessWidget { - const _CandidatePanel({required this.review, required this.initials}); - - final _CandidateReview review; - final String initials; - - @override - Widget build(BuildContext context) { - final PersonProfile person = review.person; - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFFF4FAF8), - borderRadius: BorderRadius.circular(14), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - CircleAvatar(child: Text(initials)), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - person.name, - style: Theme.of(context).textTheme.titleMedium, - ), - Text( - person.relationship, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 10), - Text( - 'Confidence ${_formatPercent(review.confidence)}', - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: 4), - LinearProgressIndicator(value: review.confidence), - const SizedBox(height: 10), - if ((person.location ?? '').trim().isNotEmpty) - _kv('Location', person.location!.trim()), - if (person.tags.isNotEmpty) - _kv('Tags', person.tags.take(4).join(', ')), - const SizedBox(height: 4), - ...review.reasons.map( - (String reason) => Padding( - padding: const EdgeInsets.only(bottom: 2), - child: Text( - '- $reason', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), - ), - ), - ), - ], - ), - ); - } -} - -Widget _kv(String label, String value) { - return Padding( - padding: const EdgeInsets.only(bottom: 4), - child: RichText( - text: TextSpan( - style: const TextStyle(color: AppTheme.textSecondary, fontSize: 12), - children: [ - TextSpan( - text: '$label: ', - style: const TextStyle( - color: AppTheme.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - TextSpan(text: value), - ], - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ); -} - -String _formatPercent(double value) { - final int percent = (value * 100).round().clamp(1, 99); - return '$percent%'; -} - -class _InboxEntryCard extends StatelessWidget { - const _InboxEntryCard({ - required this.entry, - required this.compact, - required this.submitting, - required this.suggestedMatches, - required this.onResolveToExisting, - required this.onCreateProfile, - required this.onDismiss, - }); - - final SharedInboxEntry entry; - final bool compact; - final bool submitting; - final List suggestedMatches; - final VoidCallback onResolveToExisting; - final VoidCallback onCreateProfile; - final VoidCallback onDismiss; - - @override - Widget build(BuildContext context) { - final String sender = - entry.sourceDisplayName ?? entry.sourceUserId ?? 'Unknown sender'; - final bool hasCandidates = suggestedMatches.isNotEmpty; - final String reasonLabel = switch (entry.reason) { - SharedInboxReason.ambiguousProfileMatch => 'Ambiguous match', - SharedInboxReason.nearProfileConflict => 'Near match conflict', - SharedInboxReason.missingIdentity => 'Missing identity', - }; - - return FrostedCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - sender, - style: Theme.of(context).textTheme.titleLarge, - ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(999), - color: const Color(0xFFEFF5FA), - ), - child: Text( - reasonLabel, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ), - ], - ), - const SizedBox(height: 8), - Text(entry.messageText, style: Theme.of(context).textTheme.bodyLarge), - const SizedBox(height: 10), - Text( - 'Shared ${_formatDateTime(entry.sharedAt)}', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - FilledButton.icon( - onPressed: submitting || !hasCandidates - ? null - : onResolveToExisting, - icon: const Icon(Icons.person_search_rounded), - label: Text(hasCandidates ? 'Review & Match' : 'No Matches'), - ), - OutlinedButton.icon( - onPressed: submitting ? null : onCreateProfile, - icon: const Icon(Icons.person_add_alt_1_rounded), - label: const Text('Create Profile'), - ), - TextButton.icon( - onPressed: submitting ? null : onDismiss, - icon: const Icon(Icons.close_rounded), - label: Text(compact ? 'Dismiss' : 'Dismiss Item'), - ), - ], - ), - ], - ), - ); - } -} - -class _EmptyInbox extends StatelessWidget { - const _EmptyInbox({required this.compact}); - - final bool compact; - - @override - Widget build(BuildContext context) { - return FrostedCard( - child: SizedBox( - width: double.infinity, - child: Padding( - padding: EdgeInsets.symmetric( - vertical: compact ? 18 : 30, - horizontal: compact ? 6 : 14, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.inbox_rounded, - size: 40, - color: AppTheme.primary, - ), - const SizedBox(height: 10), - Text( - 'No unresolved shares.', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 6), - Text( - 'Incoming WhatsApp shares that need manual profile mapping will appear here.', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), - textAlign: TextAlign.center, - ), - ], - ), - ), - ), - ); - } -} - -class _CreateProfileDraft { - const _CreateProfileDraft({ - required this.name, - required this.relationship, - this.location, - }); - - final String name; - final String relationship; - final String? location; -} - -class _CreateProfileDialog extends StatefulWidget { - const _CreateProfileDialog({required this.initialName}); - - final String initialName; - - @override - State<_CreateProfileDialog> createState() => _CreateProfileDialogState(); -} - -class _CreateProfileDialogState extends State<_CreateProfileDialog> { - late final TextEditingController _nameController; - late final TextEditingController _relationshipController; - late final TextEditingController _locationController; - - @override - void initState() { - super.initState(); - _nameController = TextEditingController(text: widget.initialName); - _relationshipController = TextEditingController(text: 'WhatsApp Contact'); - _locationController = TextEditingController(); - } - - @override - void dispose() { - _nameController.dispose(); - _relationshipController.dispose(); - _locationController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text('Create Profile'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: _nameController, - decoration: const InputDecoration(labelText: 'Name'), - ), - const SizedBox(height: 8), - TextField( - controller: _relationshipController, - decoration: const InputDecoration(labelText: 'Relationship'), - ), - const SizedBox(height: 8), - TextField( - controller: _locationController, - decoration: const InputDecoration( - labelText: 'Location (optional)', - ), - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - Navigator.of(context).pop( - _CreateProfileDraft( - name: _nameController.text, - relationship: _relationshipController.text, - location: _locationController.text, - ), - ); - }, - child: const Text('Create'), - ), - ], - ); - } -} - -String _formatDateTime(DateTime value) { - String twoDigits(int number) => number.toString().padLeft(2, '0'); - return '${value.year}-${twoDigits(value.month)}-${twoDigits(value.day)} ${twoDigits(value.hour)}:${twoDigits(value.minute)}'; -} +// Legacy compatibility export for the share inbox presentation entry. +export 'package:relationship_saver/features/share_intake/presentation/share_inbox_view.dart'; diff --git a/lib/features/share_intake/share_payload_parser.dart b/lib/features/share_intake/share_payload_parser.dart new file mode 100644 index 0000000..8853188 --- /dev/null +++ b/lib/features/share_intake/share_payload_parser.dart @@ -0,0 +1,2 @@ +// Legacy compatibility export for the share payload parser. +export 'package:relationship_saver/features/share_intake/domain/share_payload_parser.dart'; diff --git a/lib/features/share_intake/whatsapp_share_listener.dart b/lib/features/share_intake/whatsapp_share_listener.dart index 1d57fc0..d3217d9 100644 --- a/lib/features/share_intake/whatsapp_share_listener.dart +++ b/lib/features/share_intake/whatsapp_share_listener.dart @@ -1,192 +1,2 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:receive_sharing_intent/receive_sharing_intent.dart'; -import 'package:relationship_saver/core/config/app_config.dart'; -import 'package:relationship_saver/features/local/local_repository.dart'; -import 'package:relationship_saver/features/people/people_view.dart'; -import 'package:relationship_saver/features/share_intake/share_inbox_view.dart'; -import 'package:relationship_saver/features/share_intake/whatsapp_share_parser.dart'; - -/// Listens for share intents and ingests WhatsApp text into local data. -class WhatsAppShareListener extends ConsumerStatefulWidget { - const WhatsAppShareListener({required this.child, super.key}); - - final Widget child; - - @override - ConsumerState createState() => - _WhatsAppShareListenerState(); -} - -class _WhatsAppShareListenerState extends ConsumerState { - StreamSubscription>? _mediaSubscription; - String? _lastToken; - - @override - void initState() { - super.initState(); - _start(); - } - - @override - void dispose() { - _mediaSubscription?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) => widget.child; - - Future _start() async { - if (!AppConfig.enableWhatsAppShareIntake || kIsWeb) { - return; - } - - try { - _mediaSubscription = ReceiveSharingIntent.instance - .getMediaStream() - .listen( - (List files) => - _handleMediaList(files, autoOpenDestination: false), - onError: (_) {}, - ); - - final List initialMedia = await ReceiveSharingIntent - .instance - .getInitialMedia(); - if (initialMedia.isNotEmpty) { - await _handleMediaList(initialMedia, autoOpenDestination: true); - } - } catch (_) { - // Unsupported platform/plugin state should not break app usage. - } - } - - Future _handleMediaList( - List files, { - required bool autoOpenDestination, - }) async { - for (final SharedMediaFile file in files) { - final String? rawText = _extractText(file); - if (rawText == null) { - continue; - } - await _handleRawText(rawText, autoOpenDestination: autoOpenDestination); - } - } - - String? _extractText(SharedMediaFile file) { - if (file.type == SharedMediaType.text) { - final String text = file.path.trim(); - if (text.isNotEmpty) { - return text; - } - } - - final String? mimeType = file.mimeType?.toLowerCase(); - if (mimeType != null && mimeType.startsWith('text/')) { - final String text = file.path.trim(); - if (text.isNotEmpty) { - return text; - } - } - - final String? message = file.message?.trim(); - if (message != null && message.isNotEmpty) { - return message; - } - - return null; - } - - Future _handleRawText( - String rawText, { - required bool autoOpenDestination, - }) async { - final String trimmed = rawText.trim(); - if (trimmed.isEmpty) { - return; - } - - final WhatsAppSharePayload parsed = WhatsAppShareParser.parse(trimmed); - if (parsed.messageText.trim().isEmpty) { - return; - } - - final String token = - '${parsed.sourceUserId ?? parsed.sourceDisplayName ?? ''}:${parsed.messageText}'; - if (_lastToken == token) { - return; - } - _lastToken = token; - - final SharedMessageIngestResult result = await ref - .read(localRepositoryProvider.notifier) - .ingestSharedMessage( - SharedMessageIngestInput( - sourceApp: 'whatsapp', - messageText: parsed.messageText, - sourceDisplayName: parsed.sourceDisplayName, - sourceUserId: parsed.sourceUserId, - sourceThreadId: parsed.sourceThreadId, - sharedAt: DateTime.now(), - ), - ); - - if (!mounted) { - return; - } - - if (autoOpenDestination) { - _openShareDestination(result); - } - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - result.isQueuedForResolution - ? 'WhatsApp share needs profile resolution.' - : result.createdProfile - ? 'Imported from WhatsApp and created profile ${result.profileName}.' - : 'Imported from WhatsApp to ${result.profileName}.', - ), - action: SnackBarAction( - label: result.isQueuedForResolution ? 'Open Inbox' : 'Open Profile', - onPressed: () => _openShareDestination(result), - ), - ), - ); - - try { - await ReceiveSharingIntent.instance.reset(); - } catch (_) { - // Best effort. - } - } - - void _openShareDestination(SharedMessageIngestResult result) { - if (!mounted) { - return; - } - if (result.isQueuedForResolution) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (BuildContext context) => const ShareInboxView(), - ), - ); - return; - } - - if (result.profileId != null) { - ref.read(selectedPersonIdProvider.notifier).select(result.profileId!); - } - Navigator.of(context).push( - MaterialPageRoute( - builder: (BuildContext context) => const PeopleView(), - ), - ); - } -} +// Legacy compatibility export for the share listener presentation entry. +export 'package:relationship_saver/features/share_intake/presentation/whatsapp_share_listener.dart'; diff --git a/lib/features/share_intake/whatsapp_share_parser.dart b/lib/features/share_intake/whatsapp_share_parser.dart index dbb7752..d08d167 100644 --- a/lib/features/share_intake/whatsapp_share_parser.dart +++ b/lib/features/share_intake/whatsapp_share_parser.dart @@ -1,78 +1,2 @@ -class WhatsAppSharePayload { - const WhatsAppSharePayload({ - required this.messageText, - this.sourceDisplayName, - this.sourceUserId, - this.sourceThreadId, - }); - - final String messageText; - final String? sourceDisplayName; - final String? sourceUserId; - final String? sourceThreadId; -} - -/// Parses raw shared text from WhatsApp into normalized fields. -class WhatsAppShareParser { - const WhatsAppShareParser._(); - - static final RegExp _datePrefixPattern = RegExp( - r'^\[[^\]]+\]\s*([^:\n]{2,80}):\s*(.+)$', - dotAll: true, - ); - - static final RegExp _namePrefixPattern = RegExp( - r'^([^:\n]{2,80}):\s*(.+)$', - dotAll: true, - ); - - static WhatsAppSharePayload parse(String raw) { - final String input = raw.trim(); - if (input.isEmpty) { - return const WhatsAppSharePayload(messageText: ''); - } - - String? senderName; - String messageText = input; - - final RegExpMatch? dateMatch = _datePrefixPattern.firstMatch(input); - if (dateMatch != null) { - senderName = _cleanSender(dateMatch.group(1)); - messageText = dateMatch.group(2)?.trim() ?? input; - } else { - final RegExpMatch? nameMatch = _namePrefixPattern.firstMatch(input); - if (nameMatch != null) { - senderName = _cleanSender(nameMatch.group(1)); - messageText = nameMatch.group(2)?.trim() ?? input; - } - } - - final String? sourceUserId = _normalizedKey(senderName); - return WhatsAppSharePayload( - messageText: messageText, - sourceDisplayName: senderName, - sourceUserId: sourceUserId, - sourceThreadId: null, - ); - } - - static String? _cleanSender(String? input) { - if (input == null) { - return null; - } - final String cleaned = input.trim(); - return cleaned.isEmpty ? null : cleaned; - } - - static String? _normalizedKey(String? input) { - if (input == null) { - return null; - } - final String key = input - .toLowerCase() - .replaceAll(RegExp(r'[^a-z0-9]+'), ' ') - .trim() - .replaceAll(RegExp(r'\s+'), ' '); - return key.isEmpty ? null : 'wa:$key'; - } -} +// Legacy compatibility export for the WhatsApp share parser. +export 'package:relationship_saver/features/share_intake/domain/whatsapp_share_parser.dart'; diff --git a/lib/features/shared/README.md b/lib/features/shared/README.md new file mode 100644 index 0000000..0c5f93f --- /dev/null +++ b/lib/features/shared/README.md @@ -0,0 +1,4 @@ +# Shared Feature UI + +Only genuinely reusable feature-facing widgets belong here. If a widget is owned +by one slice, keep it inside that slice to avoid horizontal drift. diff --git a/lib/features/shared/frosted_card.dart b/lib/features/shared/frosted_card.dart index 21b3bdd..1787148 100644 --- a/lib/features/shared/frosted_card.dart +++ b/lib/features/shared/frosted_card.dart @@ -1,40 +1,2 @@ -import 'dart:ui'; - -import 'package:flutter/material.dart'; - -class FrostedCard extends StatelessWidget { - const FrostedCard({ - required this.child, - super.key, - this.padding = const EdgeInsets.all(20), - }); - - final Widget child; - final EdgeInsetsGeometry padding; - - @override - Widget build(BuildContext context) { - return ClipRRect( - borderRadius: BorderRadius.circular(24), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), - child: Container( - padding: padding, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.72), - borderRadius: BorderRadius.circular(24), - border: Border.all(color: Colors.white.withValues(alpha: 0.6)), - boxShadow: const [ - BoxShadow( - color: Color(0x14000000), - blurRadius: 24, - offset: Offset(0, 12), - ), - ], - ), - child: child, - ), - ), - ); - } -} +// Legacy compatibility export for the shared frosted card widget. +export 'package:relationship_saver/core/presentation/frosted_card.dart'; diff --git a/lib/features/signals/README.md b/lib/features/signals/README.md new file mode 100644 index 0000000..173020a --- /dev/null +++ b/lib/features/signals/README.md @@ -0,0 +1,14 @@ +# Signals Slice + +This slice owns the derived relationship signal feed. + +Use it for product logic that turns stored context into suggestions, signals, +or future recommendation hooks. + +Current signal sources: + +- local synthesized follow-through suggestions derived from people, reminders, + ideas, dates, and preference signals +- optional LLM-generated signals when configured +- backend feed items when running against a real gateway or when no better + local feed exists diff --git a/lib/features/signals/domain/signals_feed_synthesizer.dart b/lib/features/signals/domain/signals_feed_synthesizer.dart new file mode 100644 index 0000000..65ed13b --- /dev/null +++ b/lib/features/signals/domain/signals_feed_synthesizer.dart @@ -0,0 +1,369 @@ +import 'package:relationship_saver/app/state/local_data_state.dart'; +import 'package:relationship_saver/features/ideas/domain/idea_models.dart'; +import 'package:relationship_saver/features/people/domain/person_models.dart'; +import 'package:relationship_saver/features/reminders/domain/reminder_models.dart'; +import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; + +const String signalMetadataSourceKey = 'source'; +const String signalMetadataPersonNameKey = 'personName'; +const String signalMetadataActionKey = 'action'; +const String signalMetadataReminderTitleKey = 'reminderTitle'; +const String signalMetadataReminderCadenceKey = 'reminderCadence'; +const String signalMetadataReminderNextAtKey = 'reminderNextAt'; + +const String localSignalSource = 'local'; +const String createReminderSignalAction = 'createReminder'; + +class SignalsFeedSynthesizer { + const SignalsFeedSynthesizer._(); + + static List synthesize({ + required LocalDataState state, + DateTime? now, + int limit = 12, + }) { + final DateTime baseline = now ?? DateTime.now(); + final List<_SignalCandidate> candidates = <_SignalCandidate>[]; + final Map> ideasByPerson = + >{}; + final Map> remindersByPerson = + >{}; + final Map> datesByPerson = + >{}; + final Map> preferencesByPerson = + >{}; + + for (final RelationshipIdea idea in state.ideas) { + if (idea.personId == null || idea.isArchived) { + continue; + } + ideasByPerson + .putIfAbsent(idea.personId!, () => []) + .add(idea); + } + for (final ReminderRule reminder in state.reminders) { + if (reminder.personId == null) { + continue; + } + remindersByPerson + .putIfAbsent(reminder.personId!, () => []) + .add(reminder); + } + for (final PersonImportantDate value in state.importantDates) { + datesByPerson + .putIfAbsent(value.personId, () => []) + .add(value); + } + for (final PersonPreferenceSignal signal in state.preferenceSignals) { + if (signal.status == PreferenceSignalStatus.dismissed) { + continue; + } + preferencesByPerson + .putIfAbsent(signal.personId, () => []) + .add(signal); + } + + for (final PersonProfile person in state.people) { + final List personReminders = + remindersByPerson[person.id] ?? const []; + final List personIdeas = + ideasByPerson[person.id] ?? const []; + final List personDates = + datesByPerson[person.id] ?? const []; + final List preferences = + preferencesByPerson[person.id] ?? const []; + + final _SignalCandidate? checkIn = _buildCheckInCandidate( + person: person, + reminders: personReminders, + now: baseline, + ); + if (checkIn != null) { + candidates.add(checkIn); + } + + final _SignalCandidate? dateSignal = _buildUpcomingDateCandidate( + person: person, + dates: personDates, + ideas: personIdeas, + now: baseline, + ); + if (dateSignal != null) { + candidates.add(dateSignal); + } + + final _SignalCandidate? ideaSignal = _buildIdeaFollowThroughCandidate( + person: person, + ideas: personIdeas, + reminders: personReminders, + now: baseline, + ); + if (ideaSignal != null) { + candidates.add(ideaSignal); + } + + final _SignalCandidate? preferenceSignal = _buildPreferenceCandidate( + person: person, + preferences: preferences, + ideas: personIdeas, + ); + if (preferenceSignal != null) { + candidates.add(preferenceSignal); + } + } + + candidates.sort((_SignalCandidate a, _SignalCandidate b) { + final int priority = a.priority.compareTo(b.priority); + if (priority != 0) { + return priority; + } + return b.item.createdAt.compareTo(a.item.createdAt); + }); + + return candidates + .take(limit) + .map((_SignalCandidate candidate) => candidate.item) + .where((SignalItem item) => !state.dismissedSignalIds.contains(item.id)) + .toList(growable: false); + } + + static _SignalCandidate? _buildCheckInCandidate({ + required PersonProfile person, + required List reminders, + required DateTime now, + }) { + final DateTime? lastTouch = person.lastInteractedAt; + if (lastTouch == null) { + return null; + } + + final int daysSince = now.difference(lastTouch).inDays; + if (daysSince < 7) { + return null; + } + + final bool hasNearReminder = reminders.any((ReminderRule reminder) { + if (!reminder.enabled) { + return false; + } + final int daysUntil = reminder.nextAt.difference(now).inDays; + return daysUntil <= 7; + }); + if (hasNearReminder) { + return null; + } + + final DateTime reminderAt = DateTime( + now.year, + now.month, + now.day, + 19, + ).add(const Duration(days: 1)); + return _SignalCandidate( + priority: 0, + item: SignalItem( + id: 'local-checkin-${person.id}-${lastTouch.millisecondsSinceEpoch}', + type: 'recommendation', + title: 'Check in with ${person.name}', + description: + 'It has been $daysSince days since the last logged interaction. A short message would keep the relationship warm.', + personId: person.id, + createdAt: lastTouch.toUtc(), + metadata: { + signalMetadataSourceKey: localSignalSource, + signalMetadataPersonNameKey: person.name, + signalMetadataActionKey: createReminderSignalAction, + signalMetadataReminderTitleKey: 'Check in with ${person.name}', + signalMetadataReminderCadenceKey: ReminderCadence.weekly.name, + signalMetadataReminderNextAtKey: reminderAt.toUtc().toIso8601String(), + }, + ), + ); + } + + static _SignalCandidate? _buildUpcomingDateCandidate({ + required PersonProfile person, + required List dates, + required List ideas, + required DateTime now, + }) { + if (dates.isEmpty) { + return null; + } + + final List upcoming = + dates + .where((PersonImportantDate date) { + final int daysUntil = date.date.difference(now).inDays; + return daysUntil >= 0 && daysUntil <= 21; + }) + .toList(growable: false) + ..sort( + (PersonImportantDate a, PersonImportantDate b) => + a.date.compareTo(b.date), + ); + if (upcoming.isEmpty) { + return null; + } + + final PersonImportantDate date = upcoming.first; + final int daysUntil = date.date.difference(now).inDays; + final int ideaCount = ideas.length; + final String timing = switch (daysUntil) { + 0 => 'is today', + 1 => 'is tomorrow', + _ => 'is in $daysUntil days', + }; + final String ideaText = ideaCount == 0 + ? 'No saved idea is linked to this yet.' + : '$ideaCount saved idea${ideaCount == 1 ? '' : 's'} already exist.'; + + return _SignalCandidate( + priority: daysUntil <= 3 ? 1 : 2, + item: SignalItem( + id: 'local-date-${date.id}', + type: 'date', + title: '${date.label} for ${person.name} $timing', + description: '$ideaText Review the profile and turn one into a plan.', + personId: person.id, + createdAt: date.updatedAt.toUtc(), + metadata: { + signalMetadataSourceKey: localSignalSource, + signalMetadataPersonNameKey: person.name, + }, + ), + ); + } + + static _SignalCandidate? _buildIdeaFollowThroughCandidate({ + required PersonProfile person, + required List ideas, + required List reminders, + required DateTime now, + }) { + if (ideas.isEmpty) { + return null; + } + + final RelationshipIdea? oldestIdea = + (ideas.toList(growable: false) + ..sort((RelationshipIdea a, RelationshipIdea b) { + return a.createdAt.compareTo(b.createdAt); + })) + .firstOrNull; + if (oldestIdea == null) { + return null; + } + + if (now.difference(oldestIdea.createdAt).inDays < 3) { + return null; + } + + final bool hasSimilarReminder = reminders.any((ReminderRule reminder) { + final String title = reminder.title.toLowerCase(); + return title.contains(person.name.toLowerCase()) || + title.contains(oldestIdea.title.toLowerCase()); + }); + if (hasSimilarReminder) { + return null; + } + + final DateTime reminderAt = DateTime( + now.year, + now.month, + now.day, + 18, + ).add(const Duration(days: 1)); + final String typeLabel = oldestIdea.type == IdeaType.gift ? 'gift' : 'plan'; + + return _SignalCandidate( + priority: 3, + item: SignalItem( + id: 'local-idea-${oldestIdea.id}', + type: 'recommendation', + title: 'Turn "${oldestIdea.title}" into a real $typeLabel', + description: + 'This idea for ${person.name} has been sitting in the backlog since ${oldestIdea.createdAt.month}/${oldestIdea.createdAt.day}.', + personId: person.id, + createdAt: oldestIdea.createdAt.toUtc(), + metadata: { + signalMetadataSourceKey: localSignalSource, + signalMetadataPersonNameKey: person.name, + signalMetadataActionKey: createReminderSignalAction, + signalMetadataReminderTitleKey: + 'Follow through on ${oldestIdea.title} for ${person.name}', + signalMetadataReminderCadenceKey: ReminderCadence.weekly.name, + signalMetadataReminderNextAtKey: reminderAt.toUtc().toIso8601String(), + }, + ), + ); + } + + static _SignalCandidate? _buildPreferenceCandidate({ + required PersonProfile person, + required List preferences, + required List ideas, + }) { + if (preferences.isEmpty) { + return null; + } + + final List ranked = + preferences + .where( + (PersonPreferenceSignal signal) => + signal.polarity == PreferenceSignalPolarity.like && + signal.confidence >= 0.7, + ) + .toList(growable: false) + ..sort((PersonPreferenceSignal a, PersonPreferenceSignal b) { + final int confidence = b.confidence.compareTo(a.confidence); + if (confidence != 0) { + return confidence; + } + return b.lastSeenAt.compareTo(a.lastSeenAt); + }); + if (ranked.isEmpty) { + return null; + } + + final PersonPreferenceSignal signal = ranked.first; + final bool alreadyCovered = ideas.any((RelationshipIdea idea) { + final String haystack = '${idea.title} ${idea.details}' + .toLowerCase() + .trim(); + return haystack.contains(signal.label.toLowerCase()); + }); + if (alreadyCovered) { + return null; + } + + return _SignalCandidate( + priority: 4, + item: SignalItem( + id: 'local-preference-${signal.id}', + type: 'preference', + title: '${signal.label} is a strong hook for ${person.name}', + description: + 'Captured ${signal.occurrenceCount}x across ${signal.sourceApps.isEmpty ? 'shared messages' : signal.sourceApps.join(', ')}. Use it in your next plan or gift.', + personId: person.id, + createdAt: signal.lastSeenAt.toUtc(), + metadata: { + signalMetadataSourceKey: localSignalSource, + signalMetadataPersonNameKey: person.name, + }, + ), + ); + } +} + +class _SignalCandidate { + const _SignalCandidate({required this.priority, required this.item}); + + final int priority; + final SignalItem item; +} + +extension on List { + T? get firstOrNull => isEmpty ? null : first; +} diff --git a/lib/features/signals/presentation/README.md b/lib/features/signals/presentation/README.md new file mode 100644 index 0000000..81ee043 --- /dev/null +++ b/lib/features/signals/presentation/README.md @@ -0,0 +1,4 @@ +# Signals Presentation + +This folder contains UI for reviewing inferred preference signals and related +trust/confirmation flows. It should stay close to user confirmation behavior. diff --git a/lib/features/signals/presentation/signals_view.dart b/lib/features/signals/presentation/signals_view.dart new file mode 100644 index 0000000..38412bd --- /dev/null +++ b/lib/features/signals/presentation/signals_view.dart @@ -0,0 +1,238 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/config/app_theme.dart'; +import 'package:relationship_saver/features/shared/frosted_card.dart'; +import 'package:relationship_saver/features/signals/domain/signals_feed_synthesizer.dart'; +import 'package:relationship_saver/features/signals/signals_controller.dart'; +import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; + +class SignalsView extends ConsumerWidget { + const SignalsView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final AsyncValue signals = ref.watch( + signalsControllerProvider, + ); + + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final bool compact = constraints.maxWidth < 760; + return Padding( + padding: EdgeInsets.fromLTRB( + compact ? 16 : 28, + compact ? 16 : 24, + compact ? 16 : 28, + compact ? 20 : 28, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (compact) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Signals', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 6), + Text( + 'Local and backend suggestions you can turn into follow-through.', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 10), + IconButton.filledTonal( + onPressed: () { + ref.read(signalsControllerProvider.notifier).refresh(); + }, + icon: const Icon(Icons.refresh_rounded), + tooltip: 'Refresh feed', + ), + ], + ) + else + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Signals', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 6), + Text( + 'Local and backend suggestions you can turn into follow-through.', + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith(color: AppTheme.textSecondary), + ), + ], + ), + ), + IconButton.filledTonal( + onPressed: () { + ref.read(signalsControllerProvider.notifier).refresh(); + }, + icon: const Icon(Icons.refresh_rounded), + tooltip: 'Refresh feed', + ), + ], + ), + const SizedBox(height: 16), + Expanded( + child: signals.when( + data: (SignalsFeed feed) { + if (feed.items.isEmpty) { + return const Center(child: Text('No signals right now.')); + } + + return ListView.separated( + itemCount: feed.items.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (BuildContext context, int index) { + final SignalItem item = feed.items[index]; + return FrostedCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: const Color(0xFFEAF8FB), + borderRadius: BorderRadius.circular(99), + ), + child: Text( + item.type.toUpperCase(), + style: Theme.of(context) + .textTheme + .labelMedium + ?.copyWith( + color: AppTheme.primary, + fontWeight: FontWeight.w700, + ), + ), + ), + const Spacer(), + Text( + '${item.createdAt.month}/${item.createdAt.day}', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + item.title, + style: Theme.of(context).textTheme.titleLarge, + ), + if (item.description + case final String description) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + description, + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ), + if (item.metadata?[signalMetadataPersonNameKey] + case final String personName) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + personName, + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ), + const SizedBox(height: 14), + Wrap( + spacing: 8, + runSpacing: 6, + children: [ + OutlinedButton.icon( + onPressed: () { + ref + .read( + signalsControllerProvider.notifier, + ) + .acknowledge( + item.id, + SignalAction.saved, + ); + }, + icon: Icon(_primaryActionIcon(item)), + label: Text(_primaryActionLabel(item)), + ), + TextButton.icon( + onPressed: () { + ref + .read( + signalsControllerProvider.notifier, + ) + .acknowledge( + item.id, + SignalAction.dismissed, + ); + }, + icon: const Icon(Icons.close_rounded), + label: const Text('Dismiss'), + ), + ], + ), + ], + ), + ); + }, + ); + }, + error: (Object error, StackTrace stackTrace) { + return Center( + child: Text( + 'Unable to load signals. Try refresh.', + style: Theme.of(context).textTheme.bodyLarge, + ), + ); + }, + loading: () => + const Center(child: CircularProgressIndicator()), + ), + ), + ], + ), + ); + }, + ); + } +} + +String _primaryActionLabel(SignalItem item) { + if (item.metadata?[signalMetadataActionKey] == createReminderSignalAction) { + return 'Create Reminder'; + } + return 'Save'; +} + +IconData _primaryActionIcon(SignalItem item) { + if (item.metadata?[signalMetadataActionKey] == createReminderSignalAction) { + return Icons.notifications_active_outlined; + } + return Icons.bookmark_add_outlined; +} diff --git a/lib/features/signals/signals_controller.dart b/lib/features/signals/signals_controller.dart index 12d0a43..11a9d0c 100644 --- a/lib/features/signals/signals_controller.dart +++ b/lib/features/signals/signals_controller.dart @@ -1,19 +1,29 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/config/app_config.dart'; import 'package:relationship_saver/features/local/local_models.dart'; import 'package:relationship_saver/features/local/local_repository.dart'; +import 'package:relationship_saver/features/signals/domain/signals_feed_synthesizer.dart'; import 'package:relationship_saver/integrations/backend/backend_gateway_provider.dart'; import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; class SignalsController extends AsyncNotifier { + final Set _hiddenSignalIds = {}; + @override Future build() async { - return _load(); + final LocalDataState localData = await ref.watch( + localRepositoryProvider.future, + ); + return _load(localData: localData); } Future refresh() async { state = const AsyncLoading(); try { - final SignalsFeed feed = await _load(); + final LocalDataState localData = await ref.read( + localRepositoryProvider.future, + ); + final SignalsFeed feed = await _load(localData: localData); state = AsyncData(feed); } catch (error, stackTrace) { state = AsyncError(error, stackTrace); @@ -26,9 +36,25 @@ class SignalsController extends AsyncNotifier { return; } - await ref - .read(backendGatewayProvider) - .acknowledgeSignal(signalId: id, action: action); + final SignalItem? item = current.items + .where((SignalItem value) => value.id == id) + .cast() + .firstOrNull; + if (item == null) { + return; + } + + if (_isLocalSignal(item)) { + if (action == SignalAction.saved) { + await _materializeLocalSignal(item); + } + await ref.read(localRepositoryProvider.notifier).dismissLocalSignal(id); + } else { + await ref + .read(backendGatewayProvider) + .acknowledgeSignal(signalId: id, action: action); + _hiddenSignalIds.add(id); + } final List updated = current.items .where((SignalItem item) => item.id != id) @@ -36,51 +62,114 @@ class SignalsController extends AsyncNotifier { state = AsyncData(current.copyWith(items: updated)); } - Future _load() async { - try { - return await ref.read(backendGatewayProvider).getSignalsFeed(limit: 20); - } catch (_) { - final LocalDataState localData = await ref.read( - localRepositoryProvider.future, - ); - final List people = localData.people; - final PersonProfile defaultPerson = people.isNotEmpty - ? people.first - : PersonProfile( - id: 'person-default', - name: 'Someone Important', - relationship: 'Relationship', - affinityScore: 70, - nextMoment: DateTime(2026, 2, 20), - tags: [], - notes: '', - ); - final PersonProfile secondaryPerson = people.length > 1 - ? people[1] - : defaultPerson; - final DateTime now = DateTime.now().toUtc(); - return SignalsFeed( - cursor: 'fallback-signals', - items: [ - SignalItem( - id: 'fallback-1', - type: 'recommendation', - title: 'Check in with ${defaultPerson.name} tonight', - description: 'A short voice memo can keep momentum.', - personId: defaultPerson.id, - createdAt: now, - ), - SignalItem( - id: 'fallback-2', - type: 'trend', - title: 'Gift idea: practical + personal is trending', - description: 'Combine utility with one sentimental detail.', - personId: secondaryPerson.id, - createdAt: now.subtract(const Duration(hours: 2)), - ), - ], - ); + Future _load({required LocalDataState localData}) async { + final List people = localData.people; + final List items = [ + ...SignalsFeedSynthesizer.synthesize(state: localData), + ]; + + String? cursor; + if (!AppConfig.useFakeBackend || items.isEmpty) { + try { + final SignalsFeed remote = await ref + .read(backendGatewayProvider) + .getSignalsFeed(limit: 20); + cursor = remote.cursor; + items.addAll(remote.items); + } catch (_) { + if (items.isEmpty) { + items.addAll(_fallbackSignals(people)); + cursor = 'fallback-signals'; + } + } } + + final List visible = items + .where((SignalItem item) => !_hiddenSignalIds.contains(item.id)) + .toList(growable: false); + return SignalsFeed(cursor: cursor, items: visible); + } + + bool _isLocalSignal(SignalItem item) { + return item.metadata?[signalMetadataSourceKey] == localSignalSource; + } + + Future _materializeLocalSignal(SignalItem item) async { + final Map? metadata = item.metadata; + if (metadata == null) { + return; + } + if (metadata[signalMetadataActionKey] != createReminderSignalAction) { + return; + } + + final String? title = metadata[signalMetadataReminderTitleKey] as String?; + final String? cadenceName = + metadata[signalMetadataReminderCadenceKey] as String?; + final String? nextAtRaw = + metadata[signalMetadataReminderNextAtKey] as String?; + if (title == null || cadenceName == null || nextAtRaw == null) { + return; + } + + final LocalDataState localData = await ref.read( + localRepositoryProvider.future, + ); + final bool duplicate = localData.reminders.any((ReminderRule reminder) { + return reminder.personId == item.personId && + reminder.title.trim().toLowerCase() == title.trim().toLowerCase(); + }); + if (duplicate) { + return; + } + + await ref + .read(localRepositoryProvider.notifier) + .addReminder( + title: title, + cadence: ReminderCadence.values.firstWhere( + (ReminderCadence value) => value.name == cadenceName, + orElse: () => ReminderCadence.weekly, + ), + nextAt: DateTime.parse(nextAtRaw).toLocal(), + personId: item.personId, + ); + } + + List _fallbackSignals(List people) { + final PersonProfile defaultPerson = people.isNotEmpty + ? people.first + : PersonProfile( + id: 'person-default', + name: 'Someone Important', + relationship: 'Relationship', + affinityScore: 70, + nextMoment: DateTime(2026, 2, 20), + tags: [], + notes: '', + ); + final PersonProfile secondaryPerson = people.length > 1 + ? people[1] + : defaultPerson; + final DateTime now = DateTime.now().toUtc(); + return [ + SignalItem( + id: 'fallback-1', + type: 'recommendation', + title: 'Check in with ${defaultPerson.name} tonight', + description: 'A short voice memo can keep momentum.', + personId: defaultPerson.id, + createdAt: now, + ), + SignalItem( + id: 'fallback-2', + type: 'trend', + title: 'Gift idea: practical + personal is trending', + description: 'Combine utility with one sentimental detail.', + personId: secondaryPerson.id, + createdAt: now.subtract(const Duration(hours: 2)), + ), + ]; } } @@ -89,3 +178,7 @@ signalsControllerProvider = AsyncNotifierProvider( SignalsController.new, ); + +extension on Iterable { + T? get firstOrNull => isEmpty ? null : first; +} diff --git a/lib/features/signals/signals_view.dart b/lib/features/signals/signals_view.dart index 63f613e..abeff5e 100644 --- a/lib/features/signals/signals_view.dart +++ b/lib/features/signals/signals_view.dart @@ -1,211 +1,2 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:relationship_saver/core/config/app_theme.dart'; -import 'package:relationship_saver/features/shared/frosted_card.dart'; -import 'package:relationship_saver/features/signals/signals_controller.dart'; -import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; - -class SignalsView extends ConsumerWidget { - const SignalsView({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final AsyncValue signals = ref.watch( - signalsControllerProvider, - ); - - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - final bool compact = constraints.maxWidth < 760; - return Padding( - padding: EdgeInsets.fromLTRB( - compact ? 16 : 28, - compact ? 16 : 24, - compact ? 16 : 28, - compact ? 20 : 28, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (compact) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Signals', - style: Theme.of(context).textTheme.headlineMedium, - ), - const SizedBox(height: 6), - Text( - 'Backend-driven recommendations and trends you can act on.', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: AppTheme.textSecondary, - ), - ), - const SizedBox(height: 10), - IconButton.filledTonal( - onPressed: () { - ref.read(signalsControllerProvider.notifier).refresh(); - }, - icon: const Icon(Icons.refresh_rounded), - tooltip: 'Refresh feed', - ), - ], - ) - else - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Signals', - style: Theme.of(context).textTheme.headlineMedium, - ), - const SizedBox(height: 6), - Text( - 'Backend-driven recommendations and trends you can act on.', - style: Theme.of(context).textTheme.bodyLarge - ?.copyWith(color: AppTheme.textSecondary), - ), - ], - ), - ), - IconButton.filledTonal( - onPressed: () { - ref.read(signalsControllerProvider.notifier).refresh(); - }, - icon: const Icon(Icons.refresh_rounded), - tooltip: 'Refresh feed', - ), - ], - ), - const SizedBox(height: 16), - Expanded( - child: signals.when( - data: (SignalsFeed feed) { - if (feed.items.isEmpty) { - return const Center(child: Text('No signals right now.')); - } - - return ListView.separated( - itemCount: feed.items.length, - separatorBuilder: (_, _) => const SizedBox(height: 12), - itemBuilder: (BuildContext context, int index) { - final SignalItem item = feed.items[index]; - return FrostedCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - decoration: BoxDecoration( - color: const Color(0xFFEAF8FB), - borderRadius: BorderRadius.circular(99), - ), - child: Text( - item.type.toUpperCase(), - style: Theme.of(context) - .textTheme - .labelMedium - ?.copyWith( - color: AppTheme.primary, - fontWeight: FontWeight.w700, - ), - ), - ), - const Spacer(), - Text( - '${item.createdAt.month}/${item.createdAt.day}', - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ], - ), - const SizedBox(height: 12), - Text( - item.title, - style: Theme.of(context).textTheme.titleLarge, - ), - if (item.description - case final String description) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - description, - style: Theme.of(context).textTheme.bodyLarge - ?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ), - const SizedBox(height: 14), - Wrap( - spacing: 8, - runSpacing: 6, - children: [ - OutlinedButton.icon( - onPressed: () { - ref - .read( - signalsControllerProvider.notifier, - ) - .acknowledge( - item.id, - SignalAction.saved, - ); - }, - icon: const Icon( - Icons.bookmark_add_outlined, - ), - label: const Text('Save'), - ), - TextButton.icon( - onPressed: () { - ref - .read( - signalsControllerProvider.notifier, - ) - .acknowledge( - item.id, - SignalAction.dismissed, - ); - }, - icon: const Icon(Icons.close_rounded), - label: const Text('Dismiss'), - ), - ], - ), - ], - ), - ); - }, - ); - }, - error: (Object error, StackTrace stackTrace) { - return Center( - child: Text( - 'Unable to load signals. Try refresh.', - style: Theme.of(context).textTheme.bodyLarge, - ), - ); - }, - loading: () => - const Center(child: CircularProgressIndicator()), - ), - ), - ], - ), - ); - }, - ); - } -} +// Legacy compatibility export for the signals presentation entry. +export 'package:relationship_saver/features/signals/presentation/signals_view.dart'; diff --git a/lib/features/sync/README.md b/lib/features/sync/README.md new file mode 100644 index 0000000..47059ad --- /dev/null +++ b/lib/features/sync/README.md @@ -0,0 +1,10 @@ +# Sync Slice + +This slice owns queued mutation persistence and backend sync orchestration. + +- `application/`: background runner, coordinator, and trigger logic. +- `data/`: sync queue repository and persistence adapters. +- `presentation/sync_view.dart`: manual sync UI and diagnostics. + +This slice is where future backend sessions should focus once the client data +model is stable enough to sync. diff --git a/lib/features/sync/application/README.md b/lib/features/sync/application/README.md new file mode 100644 index 0000000..155777c --- /dev/null +++ b/lib/features/sync/application/README.md @@ -0,0 +1,4 @@ +# Sync Application + +Sync coordination, background triggers, and orchestration logic live here. Use +this folder when you need to change how local changes are pushed or retried. diff --git a/lib/features/sync/application/sync_auto_trigger_controller.dart b/lib/features/sync/application/sync_auto_trigger_controller.dart new file mode 100644 index 0000000..eb8af6e --- /dev/null +++ b/lib/features/sync/application/sync_auto_trigger_controller.dart @@ -0,0 +1,84 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:relationship_saver/features/sync/sync_coordinator.dart'; + +typedef SyncNowRunner = Future Function(); +typedef TriggerGate = Future Function(); + +/// Coordinates auto-triggered sync calls with cooldown and concurrency guards. +class SyncAutoTriggerController { + SyncAutoTriggerController({ + required SyncNowRunner syncNow, + required DateTime Function() now, + TriggerGate? canTrigger, + this.minimumInterval = const Duration(minutes: 3), + }) : _syncNow = syncNow, + _now = now, + _canTrigger = canTrigger ?? _alwaysAllowed; + + final SyncNowRunner _syncNow; + final DateTime Function() _now; + final TriggerGate _canTrigger; + final Duration minimumInterval; + + bool _running = false; + DateTime? _lastTriggeredAt; + int _consecutiveFailures = 0; + + @visibleForTesting + bool get isRunning => _running; + + @visibleForTesting + DateTime? get lastTriggeredAt => _lastTriggeredAt; + + @visibleForTesting + int get consecutiveFailures => _consecutiveFailures; + + /// Triggers `syncNow` when not already running and outside cooldown window. + Future trigger({bool ignoreCooldown = false}) async { + if (_running) { + return false; + } + + final DateTime now = _now(); + final DateTime? last = _lastTriggeredAt; + if (!ignoreCooldown && + last != null && + now.difference(last) < _cooldownForCurrentState()) { + return false; + } + + if (!await _canTrigger()) { + return false; + } + + _running = true; + _lastTriggeredAt = now; + try { + final SyncRunResult result = await _syncNow(); + if (result.success) { + _consecutiveFailures = 0; + } else { + _consecutiveFailures += 1; + } + return true; + } catch (_) { + _consecutiveFailures += 1; + return true; + } finally { + _running = false; + } + } + + Duration _cooldownForCurrentState() { + if (_consecutiveFailures <= 0) { + return minimumInterval; + } + final int cappedFailures = math.min(_consecutiveFailures, 4); + final int multiplier = 1 << cappedFailures; + return minimumInterval * multiplier; + } + + static Future _alwaysAllowed() async => true; +} diff --git a/lib/features/sync/application/sync_background_runner.dart b/lib/features/sync/application/sync_background_runner.dart new file mode 100644 index 0000000..6eae24d --- /dev/null +++ b/lib/features/sync/application/sync_background_runner.dart @@ -0,0 +1,107 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/config/app_config.dart'; +import 'package:relationship_saver/core/network/reachability/network_reachability.dart'; +import 'package:relationship_saver/core/network/reachability/network_reachability_provider.dart'; +import 'package:relationship_saver/features/sync/sync_auto_trigger_controller.dart'; +import 'package:relationship_saver/features/sync/sync_coordinator.dart'; + +/// Runs lightweight background sync triggers while authenticated. +class SyncBackgroundRunner extends ConsumerStatefulWidget { + const SyncBackgroundRunner({required this.child, super.key}); + + final Widget child; + + @override + ConsumerState createState() => + _SyncBackgroundRunnerState(); +} + +class _SyncBackgroundRunnerState extends ConsumerState + with WidgetsBindingObserver { + late final SyncAutoTriggerController _controller; + Timer? _periodicTimer; + StreamSubscription? _reachabilitySubscription; + bool? _lastReachable; + + @override + void initState() { + super.initState(); + final reachability = ref.read(networkReachabilityProvider); + _controller = SyncAutoTriggerController( + syncNow: () => ref.read(syncCoordinatorProvider).syncNow(), + now: DateTime.now, + minimumInterval: Duration( + seconds: AppConfig.backgroundSyncIntervalSeconds, + ), + canTrigger: () async { + if (AppConfig.useFakeBackend) { + return true; + } + return reachability.isReachable(); + }, + ); + WidgetsBinding.instance.addObserver(this); + _configurePeriodicRunner(); + _configureReachabilityRunner(reachability); + if (AppConfig.enableBackgroundSync) { + unawaited(_controller.trigger(ignoreCooldown: true)); + } + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (!AppConfig.enableBackgroundSync) { + return; + } + if (state == AppLifecycleState.resumed) { + unawaited(_controller.trigger(ignoreCooldown: true)); + } + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _periodicTimer?.cancel(); + _reachabilitySubscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; + + void _configurePeriodicRunner() { + if (!AppConfig.enableBackgroundSync) { + return; + } + + final int seconds = AppConfig.backgroundSyncIntervalSeconds; + if (seconds <= 0) { + return; + } + + _periodicTimer = Timer.periodic(Duration(seconds: seconds), (_) { + unawaited(_controller.trigger()); + }); + } + + void _configureReachabilityRunner(NetworkReachability reachability) { + if (!AppConfig.enableBackgroundSync) { + return; + } + _reachabilitySubscription = reachability.watch().listen((bool reachable) { + final bool? previous = _lastReachable; + _lastReachable = reachable; + if (reachable && previous == false) { + unawaited(_controller.trigger(ignoreCooldown: true)); + } + }); + unawaited(_primeReachabilityState(reachability)); + } + + Future _primeReachabilityState(NetworkReachability reachability) async { + _lastReachable = await reachability.isReachable(); + } +} diff --git a/lib/features/sync/application/sync_coordinator.dart b/lib/features/sync/application/sync_coordinator.dart new file mode 100644 index 0000000..84b6f78 --- /dev/null +++ b/lib/features/sync/application/sync_coordinator.dart @@ -0,0 +1,179 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/features/local/local_repository.dart'; +import 'package:relationship_saver/features/sync/sync_queue_repository.dart'; +import 'package:relationship_saver/features/sync/sync_state.dart'; +import 'package:relationship_saver/integrations/backend/backend_gateway_provider.dart'; + +/// Result details for one sync action. +@immutable +class SyncRunResult { + const SyncRunResult({ + required this.success, + required this.operation, + required this.message, + required this.pendingAfter, + this.cursor, + this.accepted = 0, + this.rejected = 0, + this.pulled = 0, + this.error, + }); + + final bool success; + final String operation; + final String message; + final int pendingAfter; + final String? cursor; + final int accepted; + final int rejected; + final int pulled; + final Object? error; +} + +/// Coordinates push/pull sync operations across queue, gateway, and local store. +class SyncCoordinator { + const SyncCoordinator(this._ref); + + final Ref _ref; + + Future pushPending() async { + final SyncState queueState = await _ref.read( + syncQueueRepositoryProvider.future, + ); + if (queueState.pendingChanges.isEmpty) { + return SyncRunResult( + success: true, + operation: 'push', + message: 'No pending local changes to push.', + pendingAfter: 0, + cursor: queueState.cursor, + ); + } + + final DateTime now = DateTime.now(); + final syncQueue = _ref.read(syncQueueRepositoryProvider.notifier); + try { + final result = await _ref + .read(backendGatewayProvider) + .pushChanges( + changes: queueState.pendingChanges, + cursor: queueState.cursor, + ); + await syncQueue.applyPushResult(result, at: now); + final SyncState after = await _ref.read( + syncQueueRepositoryProvider.future, + ); + return SyncRunResult( + success: true, + operation: 'push', + message: + 'Push complete. accepted=${result.accepted.length}, rejected=${result.rejected.length}.', + accepted: result.accepted.length, + rejected: result.rejected.length, + pendingAfter: after.pendingChanges.length, + cursor: after.cursor, + ); + } catch (error) { + await syncQueue.markFailure(error, at: now); + final SyncState after = await _ref.read( + syncQueueRepositoryProvider.future, + ); + return SyncRunResult( + success: false, + operation: 'push', + message: 'Push failed. Local changes stay queued.', + pendingAfter: after.pendingChanges.length, + cursor: after.cursor, + error: error, + ); + } + } + + Future pullRemote() async { + final SyncState queueState = await _ref.read( + syncQueueRepositoryProvider.future, + ); + final DateTime now = DateTime.now(); + final syncQueue = _ref.read(syncQueueRepositoryProvider.notifier); + + try { + final result = await _ref + .read(backendGatewayProvider) + .pullChanges(cursor: queueState.cursor); + await _ref + .read(localRepositoryProvider.notifier) + .applyRemoteChanges(result.changes); + await syncQueue.applyPullResult(result, at: now); + final SyncState after = await _ref.read( + syncQueueRepositoryProvider.future, + ); + return SyncRunResult( + success: true, + operation: 'pull', + message: 'Pulled ${result.changes.length} change(s) from backend.', + pulled: result.changes.length, + pendingAfter: after.pendingChanges.length, + cursor: after.cursor, + ); + } catch (error) { + await syncQueue.markFailure(error, at: now); + final SyncState after = await _ref.read( + syncQueueRepositoryProvider.future, + ); + return SyncRunResult( + success: false, + operation: 'pull', + message: 'Pull failed. Staying in local-first mode.', + pendingAfter: after.pendingChanges.length, + cursor: after.cursor, + error: error, + ); + } + } + + Future syncNow() async { + final SyncRunResult pushResult = await pushPending(); + if (!pushResult.success) { + return SyncRunResult( + success: false, + operation: 'sync', + message: 'Sync paused at push step. ${pushResult.message}', + pendingAfter: pushResult.pendingAfter, + cursor: pushResult.cursor, + accepted: pushResult.accepted, + rejected: pushResult.rejected, + error: pushResult.error, + ); + } + + final SyncRunResult pullResult = await pullRemote(); + if (!pullResult.success) { + return SyncRunResult( + success: false, + operation: 'sync', + message: 'Sync paused at pull step. ${pullResult.message}', + pendingAfter: pullResult.pendingAfter, + cursor: pullResult.cursor, + accepted: pushResult.accepted, + rejected: pushResult.rejected, + error: pullResult.error, + ); + } + + return SyncRunResult( + success: true, + operation: 'sync', + message: + 'Sync complete. accepted=${pushResult.accepted}, rejected=${pushResult.rejected}, pulled=${pullResult.pulled}.', + pendingAfter: pullResult.pendingAfter, + cursor: pullResult.cursor, + accepted: pushResult.accepted, + rejected: pushResult.rejected, + pulled: pullResult.pulled, + ); + } +} + +final Provider syncCoordinatorProvider = + Provider(SyncCoordinator.new); diff --git a/lib/features/sync/data/README.md b/lib/features/sync/data/README.md new file mode 100644 index 0000000..ad7b83c --- /dev/null +++ b/lib/features/sync/data/README.md @@ -0,0 +1,4 @@ +# Sync Data + +Sync repositories and storage adapters live here. This is the persistence layer +for sync state, separate from the app's main local relationship store. diff --git a/lib/features/sync/data/storage/README.md b/lib/features/sync/data/storage/README.md new file mode 100644 index 0000000..6b3002b --- /dev/null +++ b/lib/features/sync/data/storage/README.md @@ -0,0 +1,4 @@ +# Sync Data Storage + +This folder contains the concrete persistence adapters for sync-specific state. +Keep it storage-focused and free of higher-level retry policy logic. diff --git a/lib/features/sync/data/storage/sync_state_store.dart b/lib/features/sync/data/storage/sync_state_store.dart new file mode 100644 index 0000000..37e8820 --- /dev/null +++ b/lib/features/sync/data/storage/sync_state_store.dart @@ -0,0 +1,18 @@ +/// Raw persisted payload for sync queue state. +class SyncStateRecord { + const SyncStateRecord({required this.rawState}); + + final String rawState; +} + +/// Persistence boundary for sync queue metadata and pending changes. +abstract interface class SyncStateStore { + /// Reads stored sync state payload. + Future read(); + + /// Writes sync state payload. + Future write({required String rawState}); + + /// Clears stored sync state payload. + Future clear(); +} diff --git a/lib/features/sync/data/storage/sync_state_store_hive.dart b/lib/features/sync/data/storage/sync_state_store_hive.dart new file mode 100644 index 0000000..88d98ad --- /dev/null +++ b/lib/features/sync/data/storage/sync_state_store_hive.dart @@ -0,0 +1,46 @@ +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:relationship_saver/features/sync/data/storage/sync_state_store.dart'; + +/// Hive-backed sync state store. +class HiveSyncStateStore implements SyncStateStore { + static const String _boxName = 'relationship_saver_sync'; + static const String _stateKey = 'sync_state_json'; + + static bool _initialized = false; + + @override + Future clear() async { + final Box box = await _openBox(); + await box.delete(_stateKey); + } + + @override + Future read() async { + final Box box = await _openBox(); + final String? rawState = box.get(_stateKey) as String?; + if (rawState == null || rawState.isEmpty) { + return null; + } + + return SyncStateRecord(rawState: rawState); + } + + @override + Future write({required String rawState}) async { + final Box box = await _openBox(); + await box.put(_stateKey, rawState); + } + + Future> _openBox() async { + if (!_initialized) { + await Hive.initFlutter(); + _initialized = true; + } + + if (!Hive.isBoxOpen(_boxName)) { + return Hive.openBox(_boxName); + } + + return Hive.box(_boxName); + } +} diff --git a/lib/features/sync/data/storage/sync_state_store_in_memory.dart b/lib/features/sync/data/storage/sync_state_store_in_memory.dart new file mode 100644 index 0000000..dcfc83a --- /dev/null +++ b/lib/features/sync/data/storage/sync_state_store_in_memory.dart @@ -0,0 +1,19 @@ +import 'package:relationship_saver/features/sync/data/storage/sync_state_store.dart'; + +/// In-memory sync state store for tests. +class InMemorySyncStateStore implements SyncStateStore { + SyncStateRecord? _record; + + @override + Future clear() async { + _record = null; + } + + @override + Future read() async => _record; + + @override + Future write({required String rawState}) async { + _record = SyncStateRecord(rawState: rawState); + } +} diff --git a/lib/features/sync/data/storage/sync_state_store_provider.dart b/lib/features/sync/data/storage/sync_state_store_provider.dart new file mode 100644 index 0000000..fe22a7c --- /dev/null +++ b/lib/features/sync/data/storage/sync_state_store_provider.dart @@ -0,0 +1,14 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/config/app_config.dart'; +import 'package:relationship_saver/features/sync/data/storage/sync_state_store.dart'; +import 'package:relationship_saver/features/sync/data/storage/sync_state_store_hive.dart'; +import 'package:relationship_saver/features/sync/data/storage/sync_state_store_shared_prefs.dart'; + +/// Chooses sync queue persistence backend. +final Provider syncStateStoreProvider = + Provider((Ref ref) { + if (AppConfig.useHiveLocalDb) { + return HiveSyncStateStore(); + } + return SharedPrefsSyncStateStore(); + }); diff --git a/lib/features/sync/data/storage/sync_state_store_shared_prefs.dart b/lib/features/sync/data/storage/sync_state_store_shared_prefs.dart new file mode 100644 index 0000000..c8f0e2a --- /dev/null +++ b/lib/features/sync/data/storage/sync_state_store_shared_prefs.dart @@ -0,0 +1,32 @@ +import 'package:relationship_saver/features/sync/data/storage/sync_state_store.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// shared_preferences-backed sync state store. +class SharedPrefsSyncStateStore implements SyncStateStore { + SharedPrefsSyncStateStore({this.stateKey = 'sync_queue_state_v1'}); + + final String stateKey; + + @override + Future clear() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.remove(stateKey); + } + + @override + Future read() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + final String? rawState = prefs.getString(stateKey); + if (rawState == null || rawState.isEmpty) { + return null; + } + + return SyncStateRecord(rawState: rawState); + } + + @override + Future write({required String rawState}) async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setString(stateKey, rawState); + } +} diff --git a/lib/features/sync/data/sync_queue_repository.dart b/lib/features/sync/data/sync_queue_repository.dart new file mode 100644 index 0000000..ed76990 --- /dev/null +++ b/lib/features/sync/data/sync_queue_repository.dart @@ -0,0 +1,220 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/config/app_config.dart'; +import 'package:relationship_saver/features/sync/data/storage/sync_state_store.dart'; +import 'package:relationship_saver/features/sync/data/storage/sync_state_store_hive.dart'; +import 'package:relationship_saver/features/sync/data/storage/sync_state_store_provider.dart'; +import 'package:relationship_saver/features/sync/data/storage/sync_state_store_shared_prefs.dart'; +import 'package:relationship_saver/features/sync/sync_state.dart'; +import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; + +/// Persists queued local mutations and sync cursor metadata. +class SyncQueueRepository extends AsyncNotifier { + @override + Future build() async { + final SyncStateStore store = ref.read(syncStateStoreProvider); + SyncStateRecord? record = await store.read(); + + if ((record == null || record.rawState.isEmpty) && + AppConfig.useHiveLocalDb && + store is HiveSyncStateStore) { + final SharedPrefsSyncStateStore legacyStore = SharedPrefsSyncStateStore(); + final SyncStateRecord? legacy = await legacyStore.read(); + if (legacy != null && legacy.rawState.isNotEmpty) { + await store.write(rawState: legacy.rawState); + await legacyStore.clear(); + record = await store.read(); + } + } + + if (record == null || record.rawState.isEmpty) { + return SyncState.empty; + } + + try { + final Map json = + jsonDecode(record.rawState) as Map; + return SyncState.fromJson(json); + } on FormatException { + await store.clear(); + return SyncState.empty; + } + } + + Future enqueue(ChangeEnvelope change) async { + final SyncState current = await _currentState(); + final List pending = [ + change, + ...current.pendingChanges, + ]; + await _setState(current.copyWith(pendingChanges: pending)); + } + + Future enqueueAll(Iterable changes) async { + final List values = changes.toList(growable: false); + if (values.isEmpty) { + return; + } + + final SyncState current = await _currentState(); + final List pending = [ + ...values, + ...current.pendingChanges, + ]; + await _setState(current.copyWith(pendingChanges: pending)); + } + + Future applyPushResult(SyncPushResult result, {DateTime? at}) async { + final SyncState current = await _currentState(); + final DateTime now = at ?? DateTime.now(); + final Set rejectedMutationIds = { + ...result.rejected.map( + (MutationRejection rejection) => rejection.clientMutationId, + ), + }; + final Set completedMutationIds = { + ...result.accepted.map((MutationAck ack) => ack.clientMutationId), + ...rejectedMutationIds, + }; + final List rejectedChanges = current.pendingChanges + .where( + (ChangeEnvelope change) => + rejectedMutationIds.contains(change.clientMutationId), + ) + .toList(growable: false); + final List pending = current.pendingChanges + .where( + (ChangeEnvelope change) => + !completedMutationIds.contains(change.clientMutationId), + ) + .toList(growable: false); + final List dedupedRejectedChanges = rejectedChanges + .where( + (ChangeEnvelope change) => !pending.any( + (ChangeEnvelope pendingChange) => + pendingChange.clientMutationId == change.clientMutationId, + ), + ) + .toList(growable: false); + + final String? rejectionMessage = result.rejected.isEmpty + ? null + : 'Push rejected ${result.rejected.length} change(s). Fix locally and retry.'; + + await _setState( + current.copyWith( + cursor: result.cursor, + pendingChanges: pending, + lastAttemptAt: now, + lastSyncAt: now, + lastError: rejectionMessage, + lastRejected: result.rejected, + lastRejectedChanges: dedupedRejectedChanges, + ), + ); + } + + /// Requeues rejected changes to pending sync list for manual retry. + Future requeueRejectedChanges() async { + final SyncState current = await _currentState(); + if (current.lastRejectedChanges.isEmpty) { + return; + } + + final List pending = [ + ...current.lastRejectedChanges, + ...current.pendingChanges, + ]; + final List dedupedPending = _dedupeByMutationId(pending); + await _setState( + current.copyWith( + pendingChanges: dedupedPending, + lastRejected: const [], + lastRejectedChanges: const [], + lastError: null, + ), + ); + } + + Future applyPullResult(SyncPullResult result, {DateTime? at}) async { + final SyncState current = await _currentState(); + final DateTime now = at ?? DateTime.now(); + await _setState( + current.copyWith( + cursor: result.cursor, + lastAttemptAt: now, + lastSyncAt: now, + lastError: null, + lastRejected: const [], + lastRejectedChanges: const [], + ), + ); + } + + Future markFailure(Object error, {DateTime? at}) async { + final SyncState current = await _currentState(); + final DateTime now = at ?? DateTime.now(); + await _setState(current.copyWith(lastAttemptAt: now, lastError: '$error')); + } + + Future clearQueue() async { + final SyncState current = await _currentState(); + await _setState( + current.copyWith( + pendingChanges: const [], + lastRejected: const [], + lastRejectedChanges: const [], + ), + ); + } + + /// Clears the latest rejection payload shown to the user. + Future clearRejections() async { + final SyncState current = await _currentState(); + final String? lastError = + current.lastError != null && + current.lastError!.startsWith('Push rejected') + ? null + : current.lastError; + await _setState( + current.copyWith( + lastRejected: const [], + lastRejectedChanges: const [], + lastError: lastError, + ), + ); + } + + List _dedupeByMutationId(List changes) { + final Set seen = {}; + final List deduped = []; + for (final ChangeEnvelope change in changes) { + if (seen.add(change.clientMutationId)) { + deduped.add(change); + } + } + return deduped; + } + + Future _currentState() async { + final SyncState? value = state.asData?.value; + if (value != null) { + return value; + } + return future; + } + + Future _setState(SyncState next) async { + state = AsyncData(next); + await ref + .read(syncStateStoreProvider) + .write(rawState: jsonEncode(next.toJson())); + } +} + +final AsyncNotifierProvider +syncQueueRepositoryProvider = + AsyncNotifierProvider( + SyncQueueRepository.new, + ); diff --git a/lib/features/sync/presentation/README.md b/lib/features/sync/presentation/README.md new file mode 100644 index 0000000..cb05523 --- /dev/null +++ b/lib/features/sync/presentation/README.md @@ -0,0 +1,4 @@ +# Sync Presentation + +Sync status screens and user-facing repair flows belong here. Use this folder for +visibility and control, not for queue or transport internals. diff --git a/lib/features/sync/presentation/sync_view.dart b/lib/features/sync/presentation/sync_view.dart new file mode 100644 index 0000000..f72a8dd --- /dev/null +++ b/lib/features/sync/presentation/sync_view.dart @@ -0,0 +1,1393 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/config/app_theme.dart'; +import 'package:relationship_saver/features/local/local_models.dart'; +import 'package:relationship_saver/features/local/local_repository.dart'; +import 'package:relationship_saver/features/shared/frosted_card.dart'; +import 'package:relationship_saver/features/sync/sync_coordinator.dart'; +import 'package:relationship_saver/features/sync/sync_queue_repository.dart'; +import 'package:relationship_saver/features/sync/sync_state.dart'; +import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; + +class SyncView extends ConsumerStatefulWidget { + const SyncView({super.key}); + + @override + ConsumerState createState() => _SyncViewState(); +} + +class _SyncViewState extends ConsumerState { + bool _busy = false; + String _status = 'Ready. Local-first mode keeps app usable without sync.'; + final Set _expandedRejections = {}; + + @override + Widget build(BuildContext context) { + final AsyncValue syncState = ref.watch( + syncQueueRepositoryProvider, + ); + + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final bool compact = constraints.maxWidth < 760; + return Padding( + padding: EdgeInsets.fromLTRB( + compact ? 16 : 28, + compact ? 16 : 24, + compact ? 16 : 28, + compact ? 20 : 28, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Sync', style: Theme.of(context).textTheme.headlineMedium), + const SizedBox(height: 6), + Text( + 'Queue local changes, then push and pull when network is available.', + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 16), + Expanded( + child: syncState.when( + data: (SyncState state) => + _buildBody(context, state, compact), + loading: () => + const Center(child: CircularProgressIndicator()), + error: (Object error, StackTrace stackTrace) { + return Center( + child: Text( + 'Unable to load sync state. $error', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ); + }, + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildBody(BuildContext context, SyncState state, bool compact) { + return ListView( + children: [ + FrostedCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + FilledButton.icon( + onPressed: _busy ? null : _syncNow, + icon: const Icon(Icons.sync_rounded), + label: const Text('Sync Now'), + ), + OutlinedButton.icon( + onPressed: _busy ? null : _pushPending, + icon: const Icon(Icons.upload_rounded), + label: const Text('Push Pending'), + ), + OutlinedButton.icon( + onPressed: _busy ? null : _pullChanges, + icon: const Icon(Icons.download_rounded), + label: const Text('Pull Changes'), + ), + TextButton.icon( + onPressed: _busy ? null : _clearQueue, + icon: const Icon(Icons.clear_all_rounded), + label: const Text('Clear Queue'), + ), + ], + ), + const SizedBox(height: 16), + Text( + _status, + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), + ), + ], + ), + ), + const SizedBox(height: 16), + FrostedCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Queue Status', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 10), + _infoRow( + label: 'Pending changes', + value: '${state.pendingChanges.length}', + compact: compact, + ), + _infoRow( + label: 'Cursor', + value: state.cursor ?? 'not set', + compact: compact, + ), + _infoRow( + label: 'Last sync', + value: _formatDateTime(state.lastSyncAt), + compact: compact, + ), + _infoRow( + label: 'Last attempt', + value: _formatDateTime(state.lastAttemptAt), + compact: compact, + ), + _infoRow( + label: 'Last rejected count', + value: '${state.lastRejected.length}', + compact: compact, + ), + _infoRow( + label: 'Last error', + value: state.lastError ?? 'none', + compact: compact, + ), + ], + ), + ), + const SizedBox(height: 16), + if (state.lastRejected.isNotEmpty) ...[ + FrostedCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + 'Rejected Changes', + style: Theme.of(context).textTheme.titleMedium, + ), + TextButton.icon( + onPressed: _busy ? null : _requeueRejected, + icon: const Icon(Icons.replay_rounded), + label: const Text('Requeue'), + ), + TextButton.icon( + onPressed: _busy ? null : _dismissRejections, + icon: const Icon(Icons.done_all_rounded), + label: const Text('Dismiss'), + ), + ], + ), + const SizedBox(height: 8), + ...state.lastRejected.map((MutationRejection rejection) { + final ChangeEnvelope? matched = _findRejectedChange( + state.lastRejectedChanges, + rejection.clientMutationId, + ); + final bool expanded = _expandedRejections.contains( + rejection.clientMutationId, + ); + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _rejectionTile( + rejection, + matched: matched, + expanded: expanded, + onToggleExpanded: matched?.payload == null + ? null + : () => _toggleRejectionExpanded( + rejection.clientMutationId, + ), + onOpenEntity: + matched != null && + _supportsEntityRepair(matched.entityType) + ? () => _openEntityViewFor(matched) + : null, + ), + ); + }), + ], + ), + ), + const SizedBox(height: 16), + ], + FrostedCard( + child: Text( + 'Sync policy\n• Core usage never blocks on backend\n• Local changes queue first and remain safe offline\n• Pull applies backend envelopes into local state', + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ], + ); + } + + Widget _infoRow({ + required String label, + required String value, + required bool compact, + }) { + if (compact) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 2), + Text(value, style: Theme.of(context).textTheme.bodyMedium), + ], + ), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 150, child: Text(label)), + Expanded( + child: Text( + value, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), + ), + ), + ], + ), + ); + } + + Widget _rejectionTile( + MutationRejection rejection, { + required ChangeEnvelope? matched, + required bool expanded, + required VoidCallback? onToggleExpanded, + required VoidCallback? onOpenEntity, + }) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFEAF7FB), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + rejection.code, + style: Theme.of( + context, + ).textTheme.labelMedium?.copyWith(color: AppTheme.primary), + ), + ), + Text( + 'Mutation ${rejection.clientMutationId}', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), + ), + if (matched != null) + Text( + '${matched.entityType}:${matched.entityId}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + if (onToggleExpanded != null) + IconButton( + onPressed: onToggleExpanded, + tooltip: expanded ? 'Hide payload' : 'Show payload', + icon: Icon( + expanded + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + color: AppTheme.textSecondary, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + rejection.message, + style: Theme.of(context).textTheme.bodyMedium, + ), + if (expanded && matched?.payload != null) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFFF3F8FB), + borderRadius: BorderRadius.circular(10), + ), + child: SelectableText( + const JsonEncoder.withIndent(' ').convert(matched!.payload), + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + if (onOpenEntity != null) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + TextButton.icon( + onPressed: onOpenEntity, + icon: const Icon(Icons.open_in_new_rounded), + label: const Text('Open Related Screen'), + ), + ], + ), + ], + ], + ), + ); + } + + ChangeEnvelope? _findRejectedChange( + List changes, + String clientMutationId, + ) { + for (final ChangeEnvelope change in changes) { + if (change.clientMutationId == clientMutationId) { + return change; + } + } + return null; + } + + String _formatDateTime(DateTime? value) { + if (value == null) { + return 'never'; + } + + final DateTime local = value.toLocal(); + final String month = local.month.toString().padLeft(2, '0'); + final String day = local.day.toString().padLeft(2, '0'); + final String hour = local.hour.toString().padLeft(2, '0'); + final String minute = local.minute.toString().padLeft(2, '0'); + return '${local.year}-$month-$day $hour:$minute'; + } + + Future _syncNow() async { + await _run((SyncCoordinator coordinator) => coordinator.syncNow()); + } + + Future _pushPending() async { + await _run((SyncCoordinator coordinator) => coordinator.pushPending()); + } + + Future _pullChanges() async { + await _run((SyncCoordinator coordinator) => coordinator.pullRemote()); + } + + Future _clearQueue() async { + setState(() { + _busy = true; + }); + + try { + await ref.read(syncQueueRepositoryProvider.notifier).clearQueue(); + setState(() { + _status = 'Cleared pending sync queue.'; + }); + } finally { + if (mounted) { + setState(() { + _busy = false; + }); + } + } + } + + Future _dismissRejections() async { + setState(() { + _busy = true; + }); + + try { + await ref.read(syncQueueRepositoryProvider.notifier).clearRejections(); + setState(() { + _status = 'Dismissed latest rejected-mutation details.'; + }); + } finally { + if (mounted) { + setState(() { + _busy = false; + }); + } + } + } + + Future _requeueRejected() async { + setState(() { + _busy = true; + }); + + try { + await ref + .read(syncQueueRepositoryProvider.notifier) + .requeueRejectedChanges(); + setState(() { + _status = 'Requeued rejected changes. Sync again after local fixes.'; + }); + } finally { + if (mounted) { + setState(() { + _busy = false; + }); + } + } + } + + Future _run( + Future Function(SyncCoordinator coordinator) action, + ) async { + setState(() { + _busy = true; + _status = 'Running sync operation...'; + }); + + try { + final SyncRunResult result = await action( + ref.read(syncCoordinatorProvider), + ); + setState(() { + _status = result.message; + }); + } catch (error) { + setState(() { + _status = 'Sync operation failed unexpectedly. ($error)'; + }); + } finally { + if (mounted) { + setState(() { + _busy = false; + }); + } + } + } + + void _toggleRejectionExpanded(String clientMutationId) { + setState(() { + if (_expandedRejections.contains(clientMutationId)) { + _expandedRejections.remove(clientMutationId); + } else { + _expandedRejections.add(clientMutationId); + } + }); + } + + Future _openEntityViewFor(ChangeEnvelope matched) async { + if (!_supportsEntityRepair(matched.entityType)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('No editor available for ${matched.entityType}.'), + ), + ); + return; + } + + await Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) => _EntityResolveScreen( + title: _titleForEntityType(matched.entityType), + entityType: matched.entityType, + entityId: matched.entityId, + ), + ), + ); + } + + bool _supportsEntityRepair(String entityType) { + switch (entityType) { + case 'person': + case 'capture': + case 'giftIdea': + case 'eventIdea': + case 'reminderRule': + return true; + default: + return false; + } + } + + String _titleForEntityType(String entityType) { + switch (entityType) { + case 'person': + return 'Resolve Person'; + case 'capture': + return 'Resolve Moment'; + case 'giftIdea': + case 'eventIdea': + return 'Resolve Idea'; + case 'reminderRule': + return 'Resolve Reminder'; + default: + return 'Resolve Item'; + } + } +} + +class _EntityResolveScreen extends ConsumerWidget { + const _EntityResolveScreen({ + required this.title, + required this.entityType, + required this.entityId, + }); + + final String title; + final String entityType; + final String entityId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final AsyncValue localData = ref.watch( + localRepositoryProvider, + ); + + return Scaffold( + appBar: AppBar(title: Text(title)), + body: Column( + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + color: const Color(0xFFEAF7FB), + child: Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text('Focus: $entityType:$entityId'), + TextButton.icon( + onPressed: () async { + await Clipboard.setData( + ClipboardData(text: '$entityType:$entityId'), + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Entity ID copied.')), + ); + } + }, + icon: const Icon(Icons.copy_rounded), + label: const Text('Copy'), + ), + ], + ), + ), + Expanded( + child: localData.when( + data: (LocalDataState data) => _buildEditor(context, ref, data), + loading: () => const Center(child: CircularProgressIndicator()), + error: (Object error, StackTrace stackTrace) => + const Center(child: Text('Unable to load local data')), + ), + ), + ], + ), + ); + } + + Widget _buildEditor( + BuildContext context, + WidgetRef ref, + LocalDataState data, + ) { + switch (entityType) { + case 'person': + final PersonProfile? person = _findPersonById(data.people, entityId); + if (person == null) { + return _MissingEntityView(entityType: entityType, entityId: entityId); + } + return _PersonRepairForm( + person: person, + onSave: (PersonProfile updated) async { + await ref + .read(localRepositoryProvider.notifier) + .updatePerson(updated); + }, + ); + case 'capture': + final RelationshipMoment? moment = _findMomentById( + data.moments, + entityId, + ); + if (moment == null) { + return _MissingEntityView(entityType: entityType, entityId: entityId); + } + return _MomentRepairForm( + moment: moment, + people: data.people, + onSave: (RelationshipMoment updated) async { + await ref + .read(localRepositoryProvider.notifier) + .updateMoment(updated); + }, + ); + case 'giftIdea': + case 'eventIdea': + final RelationshipIdea? idea = _findIdeaById(data.ideas, entityId); + if (idea == null) { + return _MissingEntityView(entityType: entityType, entityId: entityId); + } + return _IdeaRepairForm( + idea: idea, + people: data.people, + onSave: (RelationshipIdea updated) async { + await ref + .read(localRepositoryProvider.notifier) + .updateIdea(updated); + }, + ); + case 'reminderRule': + final ReminderRule? reminder = _findReminderById( + data.reminders, + entityId, + ); + if (reminder == null) { + return _MissingEntityView(entityType: entityType, entityId: entityId); + } + return _ReminderRepairForm( + reminder: reminder, + people: data.people, + onSave: (ReminderRule updated) async { + await ref + .read(localRepositoryProvider.notifier) + .updateReminder(updated); + }, + ); + default: + return _MissingEntityView(entityType: entityType, entityId: entityId); + } + } + + PersonProfile? _findPersonById(List people, String id) { + for (final PersonProfile person in people) { + if (person.id == id) { + return person; + } + } + return null; + } + + RelationshipMoment? _findMomentById( + List moments, + String id, + ) { + for (final RelationshipMoment moment in moments) { + if (moment.id == id) { + return moment; + } + } + return null; + } + + RelationshipIdea? _findIdeaById(List ideas, String id) { + for (final RelationshipIdea idea in ideas) { + if (idea.id == id) { + return idea; + } + } + return null; + } + + ReminderRule? _findReminderById(List reminders, String id) { + for (final ReminderRule reminder in reminders) { + if (reminder.id == id) { + return reminder; + } + } + return null; + } +} + +class _MissingEntityView extends StatelessWidget { + const _MissingEntityView({required this.entityType, required this.entityId}); + + final String entityType; + final String entityId; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(20), + child: Text( + 'Could not find $entityType:$entityId in local data.', + textAlign: TextAlign.center, + ), + ), + ); + } +} + +class _PersonRepairForm extends StatefulWidget { + const _PersonRepairForm({required this.person, required this.onSave}); + + final PersonProfile person; + final Future Function(PersonProfile updated) onSave; + + @override + State<_PersonRepairForm> createState() => _PersonRepairFormState(); +} + +class _PersonRepairFormState extends State<_PersonRepairForm> { + late final TextEditingController _nameController; + late final TextEditingController _relationshipController; + late final TextEditingController _tagsController; + late final TextEditingController _notesController; + bool _saving = false; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(text: widget.person.name); + _relationshipController = TextEditingController( + text: widget.person.relationship, + ); + _tagsController = TextEditingController( + text: widget.person.tags.join(', '), + ); + _notesController = TextEditingController(text: widget.person.notes); + } + + @override + void dispose() { + _nameController.dispose(); + _relationshipController.dispose(); + _tagsController.dispose(); + _notesController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _RepairFormScaffold( + children: [ + TextField( + controller: _nameController, + decoration: const InputDecoration(labelText: 'Name'), + ), + TextField( + controller: _relationshipController, + decoration: const InputDecoration(labelText: 'Relationship'), + ), + TextField( + controller: _tagsController, + decoration: const InputDecoration( + labelText: 'Tags (comma-separated)', + ), + ), + TextField( + controller: _notesController, + maxLines: 3, + decoration: const InputDecoration(labelText: 'Notes'), + ), + FilledButton.icon( + onPressed: _saving ? null : _save, + icon: _saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_rounded), + label: const Text('Save Changes'), + ), + ], + ); + } + + Future _save() async { + final String name = _nameController.text.trim(); + final String relationship = _relationshipController.text.trim(); + if (name.isEmpty || relationship.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Name and relationship are required.')), + ); + return; + } + + setState(() { + _saving = true; + }); + try { + await widget.onSave( + widget.person.copyWith( + name: name, + relationship: relationship, + tags: _tagsController.text + .split(',') + .map((String e) => e.trim()) + .where((String e) => e.isNotEmpty) + .toList(growable: false), + notes: _notesController.text.trim(), + ), + ); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Person updated.'))); + } + } finally { + if (mounted) { + setState(() { + _saving = false; + }); + } + } + } +} + +class _MomentRepairForm extends StatefulWidget { + const _MomentRepairForm({ + required this.moment, + required this.people, + required this.onSave, + }); + + final RelationshipMoment moment; + final List people; + final Future Function(RelationshipMoment updated) onSave; + + @override + State<_MomentRepairForm> createState() => _MomentRepairFormState(); +} + +class _MomentRepairFormState extends State<_MomentRepairForm> { + late final TextEditingController _titleController; + late final TextEditingController _summaryController; + late String _personId; + bool _saving = false; + + @override + void initState() { + super.initState(); + _titleController = TextEditingController(text: widget.moment.title); + _summaryController = TextEditingController(text: widget.moment.summary); + _personId = + widget.people.any((PersonProfile p) => p.id == widget.moment.personId) + ? widget.moment.personId + : (widget.people.isEmpty + ? widget.moment.personId + : widget.people.first.id); + } + + @override + void dispose() { + _titleController.dispose(); + _summaryController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _RepairFormScaffold( + children: [ + if (widget.people.isNotEmpty) + DropdownButtonFormField( + initialValue: _personId, + items: widget.people + .map( + (PersonProfile person) => DropdownMenuItem( + value: person.id, + child: Text(person.name), + ), + ) + .toList(growable: false), + onChanged: (String? value) { + if (value == null) { + return; + } + setState(() { + _personId = value; + }); + }, + decoration: const InputDecoration(labelText: 'Person'), + ), + TextField( + controller: _titleController, + decoration: const InputDecoration(labelText: 'Title'), + ), + TextField( + controller: _summaryController, + maxLines: 4, + decoration: const InputDecoration(labelText: 'Summary'), + ), + FilledButton.icon( + onPressed: _saving ? null : _save, + icon: _saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_rounded), + label: const Text('Save Changes'), + ), + ], + ); + } + + Future _save() async { + final String summary = _summaryController.text.trim(); + if (summary.isEmpty) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Summary is required.'))); + return; + } + + setState(() { + _saving = true; + }); + try { + await widget.onSave( + widget.moment.copyWith( + personId: _personId, + title: _titleController.text.trim().isEmpty + ? widget.moment.title + : _titleController.text.trim(), + summary: summary, + ), + ); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Moment updated.'))); + } + } finally { + if (mounted) { + setState(() { + _saving = false; + }); + } + } + } +} + +class _IdeaRepairForm extends StatefulWidget { + const _IdeaRepairForm({ + required this.idea, + required this.people, + required this.onSave, + }); + + final RelationshipIdea idea; + final List people; + final Future Function(RelationshipIdea updated) onSave; + + @override + State<_IdeaRepairForm> createState() => _IdeaRepairFormState(); +} + +class _IdeaRepairFormState extends State<_IdeaRepairForm> { + late final TextEditingController _titleController; + late final TextEditingController _detailsController; + late IdeaType _type; + String? _personId; + late bool _archived; + bool _saving = false; + + @override + void initState() { + super.initState(); + _titleController = TextEditingController(text: widget.idea.title); + _detailsController = TextEditingController(text: widget.idea.details); + _type = widget.idea.type; + _personId = widget.idea.personId; + _archived = widget.idea.isArchived; + } + + @override + void dispose() { + _titleController.dispose(); + _detailsController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _RepairFormScaffold( + children: [ + TextField( + controller: _titleController, + decoration: const InputDecoration(labelText: 'Title'), + ), + DropdownButtonFormField( + initialValue: _type, + items: IdeaType.values + .map( + (IdeaType type) => DropdownMenuItem( + value: type, + child: Text(type == IdeaType.gift ? 'Gift' : 'Event'), + ), + ) + .toList(growable: false), + onChanged: (IdeaType? value) { + if (value == null) { + return; + } + setState(() { + _type = value; + }); + }, + decoration: const InputDecoration(labelText: 'Type'), + ), + DropdownButtonFormField( + initialValue: _personId, + items: >[ + const DropdownMenuItem( + value: null, + child: Text('Unassigned'), + ), + ...widget.people.map( + (PersonProfile person) => DropdownMenuItem( + value: person.id, + child: Text(person.name), + ), + ), + ], + onChanged: (String? value) { + setState(() { + _personId = value; + }); + }, + decoration: const InputDecoration(labelText: 'Person'), + ), + TextField( + controller: _detailsController, + maxLines: 4, + decoration: const InputDecoration(labelText: 'Details'), + ), + SwitchListTile.adaptive( + value: _archived, + onChanged: (bool value) { + setState(() { + _archived = value; + }); + }, + title: const Text('Archived'), + contentPadding: EdgeInsets.zero, + ), + FilledButton.icon( + onPressed: _saving ? null : _save, + icon: _saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_rounded), + label: const Text('Save Changes'), + ), + ], + ); + } + + Future _save() async { + final String title = _titleController.text.trim(); + if (title.isEmpty) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Title is required.'))); + return; + } + + setState(() { + _saving = true; + }); + try { + await widget.onSave( + widget.idea.copyWith( + title: title, + details: _detailsController.text.trim(), + type: _type, + personId: _personId, + isArchived: _archived, + ), + ); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Idea updated.'))); + } + } finally { + if (mounted) { + setState(() { + _saving = false; + }); + } + } + } +} + +class _ReminderRepairForm extends StatefulWidget { + const _ReminderRepairForm({ + required this.reminder, + required this.people, + required this.onSave, + }); + + final ReminderRule reminder; + final List people; + final Future Function(ReminderRule updated) onSave; + + @override + State<_ReminderRepairForm> createState() => _ReminderRepairFormState(); +} + +class _ReminderRepairFormState extends State<_ReminderRepairForm> { + late final TextEditingController _titleController; + late ReminderCadence _cadence; + late DateTime _nextAt; + late bool _enabled; + String? _personId; + bool _saving = false; + + @override + void initState() { + super.initState(); + _titleController = TextEditingController(text: widget.reminder.title); + _cadence = widget.reminder.cadence; + _nextAt = widget.reminder.nextAt; + _enabled = widget.reminder.enabled; + _personId = widget.reminder.personId; + } + + @override + void dispose() { + _titleController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _RepairFormScaffold( + children: [ + TextField( + controller: _titleController, + decoration: const InputDecoration(labelText: 'Title'), + ), + DropdownButtonFormField( + initialValue: _cadence, + items: ReminderCadence.values + .map( + (ReminderCadence cadence) => DropdownMenuItem( + value: cadence, + child: Text(_labelForCadence(cadence)), + ), + ) + .toList(growable: false), + onChanged: (ReminderCadence? value) { + if (value == null) { + return; + } + setState(() { + _cadence = value; + }); + }, + decoration: const InputDecoration(labelText: 'Cadence'), + ), + DropdownButtonFormField( + initialValue: _personId, + items: >[ + const DropdownMenuItem( + value: null, + child: Text('Unassigned'), + ), + ...widget.people.map( + (PersonProfile person) => DropdownMenuItem( + value: person.id, + child: Text(person.name), + ), + ), + ], + onChanged: (String? value) { + setState(() { + _personId = value; + }); + }, + decoration: const InputDecoration(labelText: 'Person'), + ), + Wrap( + spacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text('Next at: ${_formatDateTime(_nextAt)}'), + TextButton.icon( + onPressed: _pickDateTime, + icon: const Icon(Icons.schedule_rounded), + label: const Text('Change'), + ), + ], + ), + SwitchListTile.adaptive( + value: _enabled, + onChanged: (bool value) { + setState(() { + _enabled = value; + }); + }, + title: const Text('Enabled'), + contentPadding: EdgeInsets.zero, + ), + FilledButton.icon( + onPressed: _saving ? null : _save, + icon: _saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_rounded), + label: const Text('Save Changes'), + ), + ], + ); + } + + Future _pickDateTime() async { + final DateTime? date = await showDatePicker( + context: context, + initialDate: _nextAt, + firstDate: DateTime.now().subtract(const Duration(days: 365 * 3)), + lastDate: DateTime.now().add(const Duration(days: 365 * 10)), + ); + if (date == null || !mounted) { + return; + } + + final TimeOfDay? time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(_nextAt), + ); + if (time == null || !mounted) { + return; + } + + setState(() { + _nextAt = DateTime( + date.year, + date.month, + date.day, + time.hour, + time.minute, + ); + }); + } + + Future _save() async { + final String title = _titleController.text.trim(); + if (title.isEmpty) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Title is required.'))); + return; + } + + setState(() { + _saving = true; + }); + try { + await widget.onSave( + widget.reminder.copyWith( + title: title, + cadence: _cadence, + nextAt: _nextAt, + enabled: _enabled, + personId: _personId, + ), + ); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Reminder updated.'))); + } + } finally { + if (mounted) { + setState(() { + _saving = false; + }); + } + } + } +} + +class _RepairFormScaffold extends StatelessWidget { + const _RepairFormScaffold({required this.children}); + + final List children; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 20), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 680), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: children + .expand((Widget child) sync* { + yield child; + yield const SizedBox(height: 12); + }) + .toList(growable: false), + ), + ), + ); + } +} + +String _labelForCadence(ReminderCadence cadence) { + switch (cadence) { + case ReminderCadence.daily: + return 'Daily'; + case ReminderCadence.weekly: + return 'Weekly'; + case ReminderCadence.monthly: + return 'Monthly'; + } +} + +String _formatDateTime(DateTime value) { + final DateTime local = value.toLocal(); + final String month = local.month.toString().padLeft(2, '0'); + final String day = local.day.toString().padLeft(2, '0'); + final String hour = local.hour.toString().padLeft(2, '0'); + final String minute = local.minute.toString().padLeft(2, '0'); + return '${local.year}-$month-$day $hour:$minute'; +} diff --git a/lib/features/sync/storage/README.md b/lib/features/sync/storage/README.md new file mode 100644 index 0000000..2fb4621 --- /dev/null +++ b/lib/features/sync/storage/README.md @@ -0,0 +1,4 @@ +# Legacy Sync Storage Exports + +These files are compatibility exports. The canonical sync storage code now lives +under `lib/features/sync/data/storage/`, so prefer editing that folder instead. diff --git a/lib/features/sync/storage/sync_state_store.dart b/lib/features/sync/storage/sync_state_store.dart index 37e8820..243df45 100644 --- a/lib/features/sync/storage/sync_state_store.dart +++ b/lib/features/sync/storage/sync_state_store.dart @@ -1,18 +1,2 @@ -/// Raw persisted payload for sync queue state. -class SyncStateRecord { - const SyncStateRecord({required this.rawState}); - - final String rawState; -} - -/// Persistence boundary for sync queue metadata and pending changes. -abstract interface class SyncStateStore { - /// Reads stored sync state payload. - Future read(); - - /// Writes sync state payload. - Future write({required String rawState}); - - /// Clears stored sync state payload. - Future clear(); -} +// Legacy compatibility export for the sync state store contract. +export 'package:relationship_saver/features/sync/data/storage/sync_state_store.dart'; diff --git a/lib/features/sync/storage/sync_state_store_hive.dart b/lib/features/sync/storage/sync_state_store_hive.dart index 1bce059..c3b4d90 100644 --- a/lib/features/sync/storage/sync_state_store_hive.dart +++ b/lib/features/sync/storage/sync_state_store_hive.dart @@ -1,46 +1,2 @@ -import 'package:hive_flutter/hive_flutter.dart'; -import 'package:relationship_saver/features/sync/storage/sync_state_store.dart'; - -/// Hive-backed sync state store. -class HiveSyncStateStore implements SyncStateStore { - static const String _boxName = 'relationship_saver_sync'; - static const String _stateKey = 'sync_state_json'; - - static bool _initialized = false; - - @override - Future clear() async { - final Box box = await _openBox(); - await box.delete(_stateKey); - } - - @override - Future read() async { - final Box box = await _openBox(); - final String? rawState = box.get(_stateKey) as String?; - if (rawState == null || rawState.isEmpty) { - return null; - } - - return SyncStateRecord(rawState: rawState); - } - - @override - Future write({required String rawState}) async { - final Box box = await _openBox(); - await box.put(_stateKey, rawState); - } - - Future> _openBox() async { - if (!_initialized) { - await Hive.initFlutter(); - _initialized = true; - } - - if (!Hive.isBoxOpen(_boxName)) { - return Hive.openBox(_boxName); - } - - return Hive.box(_boxName); - } -} +// Legacy compatibility export for the Hive sync state store. +export 'package:relationship_saver/features/sync/data/storage/sync_state_store_hive.dart'; diff --git a/lib/features/sync/storage/sync_state_store_in_memory.dart b/lib/features/sync/storage/sync_state_store_in_memory.dart index 6b57488..637b5d3 100644 --- a/lib/features/sync/storage/sync_state_store_in_memory.dart +++ b/lib/features/sync/storage/sync_state_store_in_memory.dart @@ -1,19 +1,2 @@ -import 'package:relationship_saver/features/sync/storage/sync_state_store.dart'; - -/// In-memory sync state store for tests. -class InMemorySyncStateStore implements SyncStateStore { - SyncStateRecord? _record; - - @override - Future clear() async { - _record = null; - } - - @override - Future read() async => _record; - - @override - Future write({required String rawState}) async { - _record = SyncStateRecord(rawState: rawState); - } -} +// Legacy compatibility export for the in-memory sync state store. +export 'package:relationship_saver/features/sync/data/storage/sync_state_store_in_memory.dart'; diff --git a/lib/features/sync/storage/sync_state_store_provider.dart b/lib/features/sync/storage/sync_state_store_provider.dart index 8cec632..036583e 100644 --- a/lib/features/sync/storage/sync_state_store_provider.dart +++ b/lib/features/sync/storage/sync_state_store_provider.dart @@ -1,14 +1,2 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:relationship_saver/core/config/app_config.dart'; -import 'package:relationship_saver/features/sync/storage/sync_state_store.dart'; -import 'package:relationship_saver/features/sync/storage/sync_state_store_hive.dart'; -import 'package:relationship_saver/features/sync/storage/sync_state_store_shared_prefs.dart'; - -/// Chooses sync queue persistence backend. -final Provider syncStateStoreProvider = - Provider((Ref ref) { - if (AppConfig.useHiveLocalDb) { - return HiveSyncStateStore(); - } - return SharedPrefsSyncStateStore(); - }); +// Legacy compatibility export for the sync state store provider. +export 'package:relationship_saver/features/sync/data/storage/sync_state_store_provider.dart'; diff --git a/lib/features/sync/storage/sync_state_store_shared_prefs.dart b/lib/features/sync/storage/sync_state_store_shared_prefs.dart index 2ce0195..a990ca3 100644 --- a/lib/features/sync/storage/sync_state_store_shared_prefs.dart +++ b/lib/features/sync/storage/sync_state_store_shared_prefs.dart @@ -1,32 +1,2 @@ -import 'package:relationship_saver/features/sync/storage/sync_state_store.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -/// shared_preferences-backed sync state store. -class SharedPrefsSyncStateStore implements SyncStateStore { - SharedPrefsSyncStateStore({this.stateKey = 'sync_queue_state_v1'}); - - final String stateKey; - - @override - Future clear() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.remove(stateKey); - } - - @override - Future read() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - final String? rawState = prefs.getString(stateKey); - if (rawState == null || rawState.isEmpty) { - return null; - } - - return SyncStateRecord(rawState: rawState); - } - - @override - Future write({required String rawState}) async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.setString(stateKey, rawState); - } -} +// Legacy compatibility export for the shared preferences sync state store. +export 'package:relationship_saver/features/sync/data/storage/sync_state_store_shared_prefs.dart'; diff --git a/lib/features/sync/sync_auto_trigger_controller.dart b/lib/features/sync/sync_auto_trigger_controller.dart index eb8af6e..6e253a0 100644 --- a/lib/features/sync/sync_auto_trigger_controller.dart +++ b/lib/features/sync/sync_auto_trigger_controller.dart @@ -1,84 +1,2 @@ -import 'dart:math' as math; - -import 'package:flutter/foundation.dart'; -import 'package:relationship_saver/features/sync/sync_coordinator.dart'; - -typedef SyncNowRunner = Future Function(); -typedef TriggerGate = Future Function(); - -/// Coordinates auto-triggered sync calls with cooldown and concurrency guards. -class SyncAutoTriggerController { - SyncAutoTriggerController({ - required SyncNowRunner syncNow, - required DateTime Function() now, - TriggerGate? canTrigger, - this.minimumInterval = const Duration(minutes: 3), - }) : _syncNow = syncNow, - _now = now, - _canTrigger = canTrigger ?? _alwaysAllowed; - - final SyncNowRunner _syncNow; - final DateTime Function() _now; - final TriggerGate _canTrigger; - final Duration minimumInterval; - - bool _running = false; - DateTime? _lastTriggeredAt; - int _consecutiveFailures = 0; - - @visibleForTesting - bool get isRunning => _running; - - @visibleForTesting - DateTime? get lastTriggeredAt => _lastTriggeredAt; - - @visibleForTesting - int get consecutiveFailures => _consecutiveFailures; - - /// Triggers `syncNow` when not already running and outside cooldown window. - Future trigger({bool ignoreCooldown = false}) async { - if (_running) { - return false; - } - - final DateTime now = _now(); - final DateTime? last = _lastTriggeredAt; - if (!ignoreCooldown && - last != null && - now.difference(last) < _cooldownForCurrentState()) { - return false; - } - - if (!await _canTrigger()) { - return false; - } - - _running = true; - _lastTriggeredAt = now; - try { - final SyncRunResult result = await _syncNow(); - if (result.success) { - _consecutiveFailures = 0; - } else { - _consecutiveFailures += 1; - } - return true; - } catch (_) { - _consecutiveFailures += 1; - return true; - } finally { - _running = false; - } - } - - Duration _cooldownForCurrentState() { - if (_consecutiveFailures <= 0) { - return minimumInterval; - } - final int cappedFailures = math.min(_consecutiveFailures, 4); - final int multiplier = 1 << cappedFailures; - return minimumInterval * multiplier; - } - - static Future _alwaysAllowed() async => true; -} +// Legacy compatibility export for the sync auto-trigger controller. +export 'package:relationship_saver/features/sync/application/sync_auto_trigger_controller.dart'; diff --git a/lib/features/sync/sync_background_runner.dart b/lib/features/sync/sync_background_runner.dart index 6eae24d..ae56eea 100644 --- a/lib/features/sync/sync_background_runner.dart +++ b/lib/features/sync/sync_background_runner.dart @@ -1,107 +1,2 @@ -import 'dart:async'; - -import 'package:flutter/widgets.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:relationship_saver/core/config/app_config.dart'; -import 'package:relationship_saver/core/network/reachability/network_reachability.dart'; -import 'package:relationship_saver/core/network/reachability/network_reachability_provider.dart'; -import 'package:relationship_saver/features/sync/sync_auto_trigger_controller.dart'; -import 'package:relationship_saver/features/sync/sync_coordinator.dart'; - -/// Runs lightweight background sync triggers while authenticated. -class SyncBackgroundRunner extends ConsumerStatefulWidget { - const SyncBackgroundRunner({required this.child, super.key}); - - final Widget child; - - @override - ConsumerState createState() => - _SyncBackgroundRunnerState(); -} - -class _SyncBackgroundRunnerState extends ConsumerState - with WidgetsBindingObserver { - late final SyncAutoTriggerController _controller; - Timer? _periodicTimer; - StreamSubscription? _reachabilitySubscription; - bool? _lastReachable; - - @override - void initState() { - super.initState(); - final reachability = ref.read(networkReachabilityProvider); - _controller = SyncAutoTriggerController( - syncNow: () => ref.read(syncCoordinatorProvider).syncNow(), - now: DateTime.now, - minimumInterval: Duration( - seconds: AppConfig.backgroundSyncIntervalSeconds, - ), - canTrigger: () async { - if (AppConfig.useFakeBackend) { - return true; - } - return reachability.isReachable(); - }, - ); - WidgetsBinding.instance.addObserver(this); - _configurePeriodicRunner(); - _configureReachabilityRunner(reachability); - if (AppConfig.enableBackgroundSync) { - unawaited(_controller.trigger(ignoreCooldown: true)); - } - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (!AppConfig.enableBackgroundSync) { - return; - } - if (state == AppLifecycleState.resumed) { - unawaited(_controller.trigger(ignoreCooldown: true)); - } - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - _periodicTimer?.cancel(); - _reachabilitySubscription?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) => widget.child; - - void _configurePeriodicRunner() { - if (!AppConfig.enableBackgroundSync) { - return; - } - - final int seconds = AppConfig.backgroundSyncIntervalSeconds; - if (seconds <= 0) { - return; - } - - _periodicTimer = Timer.periodic(Duration(seconds: seconds), (_) { - unawaited(_controller.trigger()); - }); - } - - void _configureReachabilityRunner(NetworkReachability reachability) { - if (!AppConfig.enableBackgroundSync) { - return; - } - _reachabilitySubscription = reachability.watch().listen((bool reachable) { - final bool? previous = _lastReachable; - _lastReachable = reachable; - if (reachable && previous == false) { - unawaited(_controller.trigger(ignoreCooldown: true)); - } - }); - unawaited(_primeReachabilityState(reachability)); - } - - Future _primeReachabilityState(NetworkReachability reachability) async { - _lastReachable = await reachability.isReachable(); - } -} +// Legacy compatibility export for the sync background runner. +export 'package:relationship_saver/features/sync/application/sync_background_runner.dart'; diff --git a/lib/features/sync/sync_coordinator.dart b/lib/features/sync/sync_coordinator.dart index 84b6f78..48a569b 100644 --- a/lib/features/sync/sync_coordinator.dart +++ b/lib/features/sync/sync_coordinator.dart @@ -1,179 +1,2 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:relationship_saver/features/local/local_repository.dart'; -import 'package:relationship_saver/features/sync/sync_queue_repository.dart'; -import 'package:relationship_saver/features/sync/sync_state.dart'; -import 'package:relationship_saver/integrations/backend/backend_gateway_provider.dart'; - -/// Result details for one sync action. -@immutable -class SyncRunResult { - const SyncRunResult({ - required this.success, - required this.operation, - required this.message, - required this.pendingAfter, - this.cursor, - this.accepted = 0, - this.rejected = 0, - this.pulled = 0, - this.error, - }); - - final bool success; - final String operation; - final String message; - final int pendingAfter; - final String? cursor; - final int accepted; - final int rejected; - final int pulled; - final Object? error; -} - -/// Coordinates push/pull sync operations across queue, gateway, and local store. -class SyncCoordinator { - const SyncCoordinator(this._ref); - - final Ref _ref; - - Future pushPending() async { - final SyncState queueState = await _ref.read( - syncQueueRepositoryProvider.future, - ); - if (queueState.pendingChanges.isEmpty) { - return SyncRunResult( - success: true, - operation: 'push', - message: 'No pending local changes to push.', - pendingAfter: 0, - cursor: queueState.cursor, - ); - } - - final DateTime now = DateTime.now(); - final syncQueue = _ref.read(syncQueueRepositoryProvider.notifier); - try { - final result = await _ref - .read(backendGatewayProvider) - .pushChanges( - changes: queueState.pendingChanges, - cursor: queueState.cursor, - ); - await syncQueue.applyPushResult(result, at: now); - final SyncState after = await _ref.read( - syncQueueRepositoryProvider.future, - ); - return SyncRunResult( - success: true, - operation: 'push', - message: - 'Push complete. accepted=${result.accepted.length}, rejected=${result.rejected.length}.', - accepted: result.accepted.length, - rejected: result.rejected.length, - pendingAfter: after.pendingChanges.length, - cursor: after.cursor, - ); - } catch (error) { - await syncQueue.markFailure(error, at: now); - final SyncState after = await _ref.read( - syncQueueRepositoryProvider.future, - ); - return SyncRunResult( - success: false, - operation: 'push', - message: 'Push failed. Local changes stay queued.', - pendingAfter: after.pendingChanges.length, - cursor: after.cursor, - error: error, - ); - } - } - - Future pullRemote() async { - final SyncState queueState = await _ref.read( - syncQueueRepositoryProvider.future, - ); - final DateTime now = DateTime.now(); - final syncQueue = _ref.read(syncQueueRepositoryProvider.notifier); - - try { - final result = await _ref - .read(backendGatewayProvider) - .pullChanges(cursor: queueState.cursor); - await _ref - .read(localRepositoryProvider.notifier) - .applyRemoteChanges(result.changes); - await syncQueue.applyPullResult(result, at: now); - final SyncState after = await _ref.read( - syncQueueRepositoryProvider.future, - ); - return SyncRunResult( - success: true, - operation: 'pull', - message: 'Pulled ${result.changes.length} change(s) from backend.', - pulled: result.changes.length, - pendingAfter: after.pendingChanges.length, - cursor: after.cursor, - ); - } catch (error) { - await syncQueue.markFailure(error, at: now); - final SyncState after = await _ref.read( - syncQueueRepositoryProvider.future, - ); - return SyncRunResult( - success: false, - operation: 'pull', - message: 'Pull failed. Staying in local-first mode.', - pendingAfter: after.pendingChanges.length, - cursor: after.cursor, - error: error, - ); - } - } - - Future syncNow() async { - final SyncRunResult pushResult = await pushPending(); - if (!pushResult.success) { - return SyncRunResult( - success: false, - operation: 'sync', - message: 'Sync paused at push step. ${pushResult.message}', - pendingAfter: pushResult.pendingAfter, - cursor: pushResult.cursor, - accepted: pushResult.accepted, - rejected: pushResult.rejected, - error: pushResult.error, - ); - } - - final SyncRunResult pullResult = await pullRemote(); - if (!pullResult.success) { - return SyncRunResult( - success: false, - operation: 'sync', - message: 'Sync paused at pull step. ${pullResult.message}', - pendingAfter: pullResult.pendingAfter, - cursor: pullResult.cursor, - accepted: pushResult.accepted, - rejected: pushResult.rejected, - error: pullResult.error, - ); - } - - return SyncRunResult( - success: true, - operation: 'sync', - message: - 'Sync complete. accepted=${pushResult.accepted}, rejected=${pushResult.rejected}, pulled=${pullResult.pulled}.', - pendingAfter: pullResult.pendingAfter, - cursor: pullResult.cursor, - accepted: pushResult.accepted, - rejected: pushResult.rejected, - pulled: pullResult.pulled, - ); - } -} - -final Provider syncCoordinatorProvider = - Provider(SyncCoordinator.new); +// Legacy compatibility export for the sync coordinator. +export 'package:relationship_saver/features/sync/application/sync_coordinator.dart'; diff --git a/lib/features/sync/sync_queue_repository.dart b/lib/features/sync/sync_queue_repository.dart index a6a9d44..8694c57 100644 --- a/lib/features/sync/sync_queue_repository.dart +++ b/lib/features/sync/sync_queue_repository.dart @@ -1,220 +1,2 @@ -import 'dart:convert'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:relationship_saver/core/config/app_config.dart'; -import 'package:relationship_saver/features/sync/storage/sync_state_store.dart'; -import 'package:relationship_saver/features/sync/storage/sync_state_store_hive.dart'; -import 'package:relationship_saver/features/sync/storage/sync_state_store_provider.dart'; -import 'package:relationship_saver/features/sync/storage/sync_state_store_shared_prefs.dart'; -import 'package:relationship_saver/features/sync/sync_state.dart'; -import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; - -/// Persists queued local mutations and sync cursor metadata. -class SyncQueueRepository extends AsyncNotifier { - @override - Future build() async { - final SyncStateStore store = ref.read(syncStateStoreProvider); - SyncStateRecord? record = await store.read(); - - if ((record == null || record.rawState.isEmpty) && - AppConfig.useHiveLocalDb && - store is HiveSyncStateStore) { - final SharedPrefsSyncStateStore legacyStore = SharedPrefsSyncStateStore(); - final SyncStateRecord? legacy = await legacyStore.read(); - if (legacy != null && legacy.rawState.isNotEmpty) { - await store.write(rawState: legacy.rawState); - await legacyStore.clear(); - record = await store.read(); - } - } - - if (record == null || record.rawState.isEmpty) { - return SyncState.empty; - } - - try { - final Map json = - jsonDecode(record.rawState) as Map; - return SyncState.fromJson(json); - } on FormatException { - await store.clear(); - return SyncState.empty; - } - } - - Future enqueue(ChangeEnvelope change) async { - final SyncState current = await _currentState(); - final List pending = [ - change, - ...current.pendingChanges, - ]; - await _setState(current.copyWith(pendingChanges: pending)); - } - - Future enqueueAll(Iterable changes) async { - final List values = changes.toList(growable: false); - if (values.isEmpty) { - return; - } - - final SyncState current = await _currentState(); - final List pending = [ - ...values, - ...current.pendingChanges, - ]; - await _setState(current.copyWith(pendingChanges: pending)); - } - - Future applyPushResult(SyncPushResult result, {DateTime? at}) async { - final SyncState current = await _currentState(); - final DateTime now = at ?? DateTime.now(); - final Set rejectedMutationIds = { - ...result.rejected.map( - (MutationRejection rejection) => rejection.clientMutationId, - ), - }; - final Set completedMutationIds = { - ...result.accepted.map((MutationAck ack) => ack.clientMutationId), - ...rejectedMutationIds, - }; - final List rejectedChanges = current.pendingChanges - .where( - (ChangeEnvelope change) => - rejectedMutationIds.contains(change.clientMutationId), - ) - .toList(growable: false); - final List pending = current.pendingChanges - .where( - (ChangeEnvelope change) => - !completedMutationIds.contains(change.clientMutationId), - ) - .toList(growable: false); - final List dedupedRejectedChanges = rejectedChanges - .where( - (ChangeEnvelope change) => !pending.any( - (ChangeEnvelope pendingChange) => - pendingChange.clientMutationId == change.clientMutationId, - ), - ) - .toList(growable: false); - - final String? rejectionMessage = result.rejected.isEmpty - ? null - : 'Push rejected ${result.rejected.length} change(s). Fix locally and retry.'; - - await _setState( - current.copyWith( - cursor: result.cursor, - pendingChanges: pending, - lastAttemptAt: now, - lastSyncAt: now, - lastError: rejectionMessage, - lastRejected: result.rejected, - lastRejectedChanges: dedupedRejectedChanges, - ), - ); - } - - /// Requeues rejected changes to pending sync list for manual retry. - Future requeueRejectedChanges() async { - final SyncState current = await _currentState(); - if (current.lastRejectedChanges.isEmpty) { - return; - } - - final List pending = [ - ...current.lastRejectedChanges, - ...current.pendingChanges, - ]; - final List dedupedPending = _dedupeByMutationId(pending); - await _setState( - current.copyWith( - pendingChanges: dedupedPending, - lastRejected: const [], - lastRejectedChanges: const [], - lastError: null, - ), - ); - } - - Future applyPullResult(SyncPullResult result, {DateTime? at}) async { - final SyncState current = await _currentState(); - final DateTime now = at ?? DateTime.now(); - await _setState( - current.copyWith( - cursor: result.cursor, - lastAttemptAt: now, - lastSyncAt: now, - lastError: null, - lastRejected: const [], - lastRejectedChanges: const [], - ), - ); - } - - Future markFailure(Object error, {DateTime? at}) async { - final SyncState current = await _currentState(); - final DateTime now = at ?? DateTime.now(); - await _setState(current.copyWith(lastAttemptAt: now, lastError: '$error')); - } - - Future clearQueue() async { - final SyncState current = await _currentState(); - await _setState( - current.copyWith( - pendingChanges: const [], - lastRejected: const [], - lastRejectedChanges: const [], - ), - ); - } - - /// Clears the latest rejection payload shown to the user. - Future clearRejections() async { - final SyncState current = await _currentState(); - final String? lastError = - current.lastError != null && - current.lastError!.startsWith('Push rejected') - ? null - : current.lastError; - await _setState( - current.copyWith( - lastRejected: const [], - lastRejectedChanges: const [], - lastError: lastError, - ), - ); - } - - List _dedupeByMutationId(List changes) { - final Set seen = {}; - final List deduped = []; - for (final ChangeEnvelope change in changes) { - if (seen.add(change.clientMutationId)) { - deduped.add(change); - } - } - return deduped; - } - - Future _currentState() async { - final SyncState? value = state.asData?.value; - if (value != null) { - return value; - } - return future; - } - - Future _setState(SyncState next) async { - state = AsyncData(next); - await ref - .read(syncStateStoreProvider) - .write(rawState: jsonEncode(next.toJson())); - } -} - -final AsyncNotifierProvider -syncQueueRepositoryProvider = - AsyncNotifierProvider( - SyncQueueRepository.new, - ); +// Legacy compatibility export for the sync queue data store. +export 'package:relationship_saver/features/sync/data/sync_queue_repository.dart'; diff --git a/lib/features/sync/sync_view.dart b/lib/features/sync/sync_view.dart index f72a8dd..3eba775 100644 --- a/lib/features/sync/sync_view.dart +++ b/lib/features/sync/sync_view.dart @@ -1,1393 +1,2 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:relationship_saver/core/config/app_theme.dart'; -import 'package:relationship_saver/features/local/local_models.dart'; -import 'package:relationship_saver/features/local/local_repository.dart'; -import 'package:relationship_saver/features/shared/frosted_card.dart'; -import 'package:relationship_saver/features/sync/sync_coordinator.dart'; -import 'package:relationship_saver/features/sync/sync_queue_repository.dart'; -import 'package:relationship_saver/features/sync/sync_state.dart'; -import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; - -class SyncView extends ConsumerStatefulWidget { - const SyncView({super.key}); - - @override - ConsumerState createState() => _SyncViewState(); -} - -class _SyncViewState extends ConsumerState { - bool _busy = false; - String _status = 'Ready. Local-first mode keeps app usable without sync.'; - final Set _expandedRejections = {}; - - @override - Widget build(BuildContext context) { - final AsyncValue syncState = ref.watch( - syncQueueRepositoryProvider, - ); - - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - final bool compact = constraints.maxWidth < 760; - return Padding( - padding: EdgeInsets.fromLTRB( - compact ? 16 : 28, - compact ? 16 : 24, - compact ? 16 : 28, - compact ? 20 : 28, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Sync', style: Theme.of(context).textTheme.headlineMedium), - const SizedBox(height: 6), - Text( - 'Queue local changes, then push and pull when network is available.', - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: 16), - Expanded( - child: syncState.when( - data: (SyncState state) => - _buildBody(context, state, compact), - loading: () => - const Center(child: CircularProgressIndicator()), - error: (Object error, StackTrace stackTrace) { - return Center( - child: Text( - 'Unable to load sync state. $error', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ); - }, - ), - ), - ], - ), - ); - }, - ); - } - - Widget _buildBody(BuildContext context, SyncState state, bool compact) { - return ListView( - children: [ - FrostedCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - spacing: 10, - runSpacing: 10, - children: [ - FilledButton.icon( - onPressed: _busy ? null : _syncNow, - icon: const Icon(Icons.sync_rounded), - label: const Text('Sync Now'), - ), - OutlinedButton.icon( - onPressed: _busy ? null : _pushPending, - icon: const Icon(Icons.upload_rounded), - label: const Text('Push Pending'), - ), - OutlinedButton.icon( - onPressed: _busy ? null : _pullChanges, - icon: const Icon(Icons.download_rounded), - label: const Text('Pull Changes'), - ), - TextButton.icon( - onPressed: _busy ? null : _clearQueue, - icon: const Icon(Icons.clear_all_rounded), - label: const Text('Clear Queue'), - ), - ], - ), - const SizedBox(height: 16), - Text( - _status, - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), - ), - ], - ), - ), - const SizedBox(height: 16), - FrostedCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Queue Status', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 10), - _infoRow( - label: 'Pending changes', - value: '${state.pendingChanges.length}', - compact: compact, - ), - _infoRow( - label: 'Cursor', - value: state.cursor ?? 'not set', - compact: compact, - ), - _infoRow( - label: 'Last sync', - value: _formatDateTime(state.lastSyncAt), - compact: compact, - ), - _infoRow( - label: 'Last attempt', - value: _formatDateTime(state.lastAttemptAt), - compact: compact, - ), - _infoRow( - label: 'Last rejected count', - value: '${state.lastRejected.length}', - compact: compact, - ), - _infoRow( - label: 'Last error', - value: state.lastError ?? 'none', - compact: compact, - ), - ], - ), - ), - const SizedBox(height: 16), - if (state.lastRejected.isNotEmpty) ...[ - FrostedCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text( - 'Rejected Changes', - style: Theme.of(context).textTheme.titleMedium, - ), - TextButton.icon( - onPressed: _busy ? null : _requeueRejected, - icon: const Icon(Icons.replay_rounded), - label: const Text('Requeue'), - ), - TextButton.icon( - onPressed: _busy ? null : _dismissRejections, - icon: const Icon(Icons.done_all_rounded), - label: const Text('Dismiss'), - ), - ], - ), - const SizedBox(height: 8), - ...state.lastRejected.map((MutationRejection rejection) { - final ChangeEnvelope? matched = _findRejectedChange( - state.lastRejectedChanges, - rejection.clientMutationId, - ); - final bool expanded = _expandedRejections.contains( - rejection.clientMutationId, - ); - return Padding( - padding: const EdgeInsets.only(bottom: 10), - child: _rejectionTile( - rejection, - matched: matched, - expanded: expanded, - onToggleExpanded: matched?.payload == null - ? null - : () => _toggleRejectionExpanded( - rejection.clientMutationId, - ), - onOpenEntity: - matched != null && - _supportsEntityRepair(matched.entityType) - ? () => _openEntityViewFor(matched) - : null, - ), - ); - }), - ], - ), - ), - const SizedBox(height: 16), - ], - FrostedCard( - child: Text( - 'Sync policy\n• Core usage never blocks on backend\n• Local changes queue first and remain safe offline\n• Pull applies backend envelopes into local state', - style: Theme.of(context).textTheme.bodyLarge, - ), - ), - ], - ); - } - - Widget _infoRow({ - required String label, - required String value, - required bool compact, - }) { - if (compact) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: Theme.of( - context, - ).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: 2), - Text(value, style: Theme.of(context).textTheme.bodyMedium), - ], - ), - ); - } - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox(width: 150, child: Text(label)), - Expanded( - child: Text( - value, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), - ), - ), - ], - ), - ); - } - - Widget _rejectionTile( - MutationRejection rejection, { - required ChangeEnvelope? matched, - required bool expanded, - required VoidCallback? onToggleExpanded, - required VoidCallback? onOpenEntity, - }) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.72), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: const Color(0xFFEAF7FB), - borderRadius: BorderRadius.circular(999), - ), - child: Text( - rejection.code, - style: Theme.of( - context, - ).textTheme.labelMedium?.copyWith(color: AppTheme.primary), - ), - ), - Text( - 'Mutation ${rejection.clientMutationId}', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), - ), - if (matched != null) - Text( - '${matched.entityType}:${matched.entityId}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textSecondary, - ), - ), - if (onToggleExpanded != null) - IconButton( - onPressed: onToggleExpanded, - tooltip: expanded ? 'Hide payload' : 'Show payload', - icon: Icon( - expanded - ? Icons.keyboard_arrow_up_rounded - : Icons.keyboard_arrow_down_rounded, - color: AppTheme.textSecondary, - ), - ), - ], - ), - const SizedBox(height: 6), - Text( - rejection.message, - style: Theme.of(context).textTheme.bodyMedium, - ), - if (expanded && matched?.payload != null) ...[ - const SizedBox(height: 10), - Container( - width: double.infinity, - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: const Color(0xFFF3F8FB), - borderRadius: BorderRadius.circular(10), - ), - child: SelectableText( - const JsonEncoder.withIndent(' ').convert(matched!.payload), - style: Theme.of(context).textTheme.bodySmall, - ), - ), - ], - if (onOpenEntity != null) ...[ - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: [ - TextButton.icon( - onPressed: onOpenEntity, - icon: const Icon(Icons.open_in_new_rounded), - label: const Text('Open Related Screen'), - ), - ], - ), - ], - ], - ), - ); - } - - ChangeEnvelope? _findRejectedChange( - List changes, - String clientMutationId, - ) { - for (final ChangeEnvelope change in changes) { - if (change.clientMutationId == clientMutationId) { - return change; - } - } - return null; - } - - String _formatDateTime(DateTime? value) { - if (value == null) { - return 'never'; - } - - final DateTime local = value.toLocal(); - final String month = local.month.toString().padLeft(2, '0'); - final String day = local.day.toString().padLeft(2, '0'); - final String hour = local.hour.toString().padLeft(2, '0'); - final String minute = local.minute.toString().padLeft(2, '0'); - return '${local.year}-$month-$day $hour:$minute'; - } - - Future _syncNow() async { - await _run((SyncCoordinator coordinator) => coordinator.syncNow()); - } - - Future _pushPending() async { - await _run((SyncCoordinator coordinator) => coordinator.pushPending()); - } - - Future _pullChanges() async { - await _run((SyncCoordinator coordinator) => coordinator.pullRemote()); - } - - Future _clearQueue() async { - setState(() { - _busy = true; - }); - - try { - await ref.read(syncQueueRepositoryProvider.notifier).clearQueue(); - setState(() { - _status = 'Cleared pending sync queue.'; - }); - } finally { - if (mounted) { - setState(() { - _busy = false; - }); - } - } - } - - Future _dismissRejections() async { - setState(() { - _busy = true; - }); - - try { - await ref.read(syncQueueRepositoryProvider.notifier).clearRejections(); - setState(() { - _status = 'Dismissed latest rejected-mutation details.'; - }); - } finally { - if (mounted) { - setState(() { - _busy = false; - }); - } - } - } - - Future _requeueRejected() async { - setState(() { - _busy = true; - }); - - try { - await ref - .read(syncQueueRepositoryProvider.notifier) - .requeueRejectedChanges(); - setState(() { - _status = 'Requeued rejected changes. Sync again after local fixes.'; - }); - } finally { - if (mounted) { - setState(() { - _busy = false; - }); - } - } - } - - Future _run( - Future Function(SyncCoordinator coordinator) action, - ) async { - setState(() { - _busy = true; - _status = 'Running sync operation...'; - }); - - try { - final SyncRunResult result = await action( - ref.read(syncCoordinatorProvider), - ); - setState(() { - _status = result.message; - }); - } catch (error) { - setState(() { - _status = 'Sync operation failed unexpectedly. ($error)'; - }); - } finally { - if (mounted) { - setState(() { - _busy = false; - }); - } - } - } - - void _toggleRejectionExpanded(String clientMutationId) { - setState(() { - if (_expandedRejections.contains(clientMutationId)) { - _expandedRejections.remove(clientMutationId); - } else { - _expandedRejections.add(clientMutationId); - } - }); - } - - Future _openEntityViewFor(ChangeEnvelope matched) async { - if (!_supportsEntityRepair(matched.entityType)) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('No editor available for ${matched.entityType}.'), - ), - ); - return; - } - - await Navigator.of(context).push( - MaterialPageRoute( - builder: (BuildContext context) => _EntityResolveScreen( - title: _titleForEntityType(matched.entityType), - entityType: matched.entityType, - entityId: matched.entityId, - ), - ), - ); - } - - bool _supportsEntityRepair(String entityType) { - switch (entityType) { - case 'person': - case 'capture': - case 'giftIdea': - case 'eventIdea': - case 'reminderRule': - return true; - default: - return false; - } - } - - String _titleForEntityType(String entityType) { - switch (entityType) { - case 'person': - return 'Resolve Person'; - case 'capture': - return 'Resolve Moment'; - case 'giftIdea': - case 'eventIdea': - return 'Resolve Idea'; - case 'reminderRule': - return 'Resolve Reminder'; - default: - return 'Resolve Item'; - } - } -} - -class _EntityResolveScreen extends ConsumerWidget { - const _EntityResolveScreen({ - required this.title, - required this.entityType, - required this.entityId, - }); - - final String title; - final String entityType; - final String entityId; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final AsyncValue localData = ref.watch( - localRepositoryProvider, - ); - - return Scaffold( - appBar: AppBar(title: Text(title)), - body: Column( - children: [ - Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), - color: const Color(0xFFEAF7FB), - child: Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text('Focus: $entityType:$entityId'), - TextButton.icon( - onPressed: () async { - await Clipboard.setData( - ClipboardData(text: '$entityType:$entityId'), - ); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Entity ID copied.')), - ); - } - }, - icon: const Icon(Icons.copy_rounded), - label: const Text('Copy'), - ), - ], - ), - ), - Expanded( - child: localData.when( - data: (LocalDataState data) => _buildEditor(context, ref, data), - loading: () => const Center(child: CircularProgressIndicator()), - error: (Object error, StackTrace stackTrace) => - const Center(child: Text('Unable to load local data')), - ), - ), - ], - ), - ); - } - - Widget _buildEditor( - BuildContext context, - WidgetRef ref, - LocalDataState data, - ) { - switch (entityType) { - case 'person': - final PersonProfile? person = _findPersonById(data.people, entityId); - if (person == null) { - return _MissingEntityView(entityType: entityType, entityId: entityId); - } - return _PersonRepairForm( - person: person, - onSave: (PersonProfile updated) async { - await ref - .read(localRepositoryProvider.notifier) - .updatePerson(updated); - }, - ); - case 'capture': - final RelationshipMoment? moment = _findMomentById( - data.moments, - entityId, - ); - if (moment == null) { - return _MissingEntityView(entityType: entityType, entityId: entityId); - } - return _MomentRepairForm( - moment: moment, - people: data.people, - onSave: (RelationshipMoment updated) async { - await ref - .read(localRepositoryProvider.notifier) - .updateMoment(updated); - }, - ); - case 'giftIdea': - case 'eventIdea': - final RelationshipIdea? idea = _findIdeaById(data.ideas, entityId); - if (idea == null) { - return _MissingEntityView(entityType: entityType, entityId: entityId); - } - return _IdeaRepairForm( - idea: idea, - people: data.people, - onSave: (RelationshipIdea updated) async { - await ref - .read(localRepositoryProvider.notifier) - .updateIdea(updated); - }, - ); - case 'reminderRule': - final ReminderRule? reminder = _findReminderById( - data.reminders, - entityId, - ); - if (reminder == null) { - return _MissingEntityView(entityType: entityType, entityId: entityId); - } - return _ReminderRepairForm( - reminder: reminder, - people: data.people, - onSave: (ReminderRule updated) async { - await ref - .read(localRepositoryProvider.notifier) - .updateReminder(updated); - }, - ); - default: - return _MissingEntityView(entityType: entityType, entityId: entityId); - } - } - - PersonProfile? _findPersonById(List people, String id) { - for (final PersonProfile person in people) { - if (person.id == id) { - return person; - } - } - return null; - } - - RelationshipMoment? _findMomentById( - List moments, - String id, - ) { - for (final RelationshipMoment moment in moments) { - if (moment.id == id) { - return moment; - } - } - return null; - } - - RelationshipIdea? _findIdeaById(List ideas, String id) { - for (final RelationshipIdea idea in ideas) { - if (idea.id == id) { - return idea; - } - } - return null; - } - - ReminderRule? _findReminderById(List reminders, String id) { - for (final ReminderRule reminder in reminders) { - if (reminder.id == id) { - return reminder; - } - } - return null; - } -} - -class _MissingEntityView extends StatelessWidget { - const _MissingEntityView({required this.entityType, required this.entityId}); - - final String entityType; - final String entityId; - - @override - Widget build(BuildContext context) { - return Center( - child: Padding( - padding: const EdgeInsets.all(20), - child: Text( - 'Could not find $entityType:$entityId in local data.', - textAlign: TextAlign.center, - ), - ), - ); - } -} - -class _PersonRepairForm extends StatefulWidget { - const _PersonRepairForm({required this.person, required this.onSave}); - - final PersonProfile person; - final Future Function(PersonProfile updated) onSave; - - @override - State<_PersonRepairForm> createState() => _PersonRepairFormState(); -} - -class _PersonRepairFormState extends State<_PersonRepairForm> { - late final TextEditingController _nameController; - late final TextEditingController _relationshipController; - late final TextEditingController _tagsController; - late final TextEditingController _notesController; - bool _saving = false; - - @override - void initState() { - super.initState(); - _nameController = TextEditingController(text: widget.person.name); - _relationshipController = TextEditingController( - text: widget.person.relationship, - ); - _tagsController = TextEditingController( - text: widget.person.tags.join(', '), - ); - _notesController = TextEditingController(text: widget.person.notes); - } - - @override - void dispose() { - _nameController.dispose(); - _relationshipController.dispose(); - _tagsController.dispose(); - _notesController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return _RepairFormScaffold( - children: [ - TextField( - controller: _nameController, - decoration: const InputDecoration(labelText: 'Name'), - ), - TextField( - controller: _relationshipController, - decoration: const InputDecoration(labelText: 'Relationship'), - ), - TextField( - controller: _tagsController, - decoration: const InputDecoration( - labelText: 'Tags (comma-separated)', - ), - ), - TextField( - controller: _notesController, - maxLines: 3, - decoration: const InputDecoration(labelText: 'Notes'), - ), - FilledButton.icon( - onPressed: _saving ? null : _save, - icon: _saving - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.save_rounded), - label: const Text('Save Changes'), - ), - ], - ); - } - - Future _save() async { - final String name = _nameController.text.trim(); - final String relationship = _relationshipController.text.trim(); - if (name.isEmpty || relationship.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Name and relationship are required.')), - ); - return; - } - - setState(() { - _saving = true; - }); - try { - await widget.onSave( - widget.person.copyWith( - name: name, - relationship: relationship, - tags: _tagsController.text - .split(',') - .map((String e) => e.trim()) - .where((String e) => e.isNotEmpty) - .toList(growable: false), - notes: _notesController.text.trim(), - ), - ); - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Person updated.'))); - } - } finally { - if (mounted) { - setState(() { - _saving = false; - }); - } - } - } -} - -class _MomentRepairForm extends StatefulWidget { - const _MomentRepairForm({ - required this.moment, - required this.people, - required this.onSave, - }); - - final RelationshipMoment moment; - final List people; - final Future Function(RelationshipMoment updated) onSave; - - @override - State<_MomentRepairForm> createState() => _MomentRepairFormState(); -} - -class _MomentRepairFormState extends State<_MomentRepairForm> { - late final TextEditingController _titleController; - late final TextEditingController _summaryController; - late String _personId; - bool _saving = false; - - @override - void initState() { - super.initState(); - _titleController = TextEditingController(text: widget.moment.title); - _summaryController = TextEditingController(text: widget.moment.summary); - _personId = - widget.people.any((PersonProfile p) => p.id == widget.moment.personId) - ? widget.moment.personId - : (widget.people.isEmpty - ? widget.moment.personId - : widget.people.first.id); - } - - @override - void dispose() { - _titleController.dispose(); - _summaryController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return _RepairFormScaffold( - children: [ - if (widget.people.isNotEmpty) - DropdownButtonFormField( - initialValue: _personId, - items: widget.people - .map( - (PersonProfile person) => DropdownMenuItem( - value: person.id, - child: Text(person.name), - ), - ) - .toList(growable: false), - onChanged: (String? value) { - if (value == null) { - return; - } - setState(() { - _personId = value; - }); - }, - decoration: const InputDecoration(labelText: 'Person'), - ), - TextField( - controller: _titleController, - decoration: const InputDecoration(labelText: 'Title'), - ), - TextField( - controller: _summaryController, - maxLines: 4, - decoration: const InputDecoration(labelText: 'Summary'), - ), - FilledButton.icon( - onPressed: _saving ? null : _save, - icon: _saving - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.save_rounded), - label: const Text('Save Changes'), - ), - ], - ); - } - - Future _save() async { - final String summary = _summaryController.text.trim(); - if (summary.isEmpty) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Summary is required.'))); - return; - } - - setState(() { - _saving = true; - }); - try { - await widget.onSave( - widget.moment.copyWith( - personId: _personId, - title: _titleController.text.trim().isEmpty - ? widget.moment.title - : _titleController.text.trim(), - summary: summary, - ), - ); - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Moment updated.'))); - } - } finally { - if (mounted) { - setState(() { - _saving = false; - }); - } - } - } -} - -class _IdeaRepairForm extends StatefulWidget { - const _IdeaRepairForm({ - required this.idea, - required this.people, - required this.onSave, - }); - - final RelationshipIdea idea; - final List people; - final Future Function(RelationshipIdea updated) onSave; - - @override - State<_IdeaRepairForm> createState() => _IdeaRepairFormState(); -} - -class _IdeaRepairFormState extends State<_IdeaRepairForm> { - late final TextEditingController _titleController; - late final TextEditingController _detailsController; - late IdeaType _type; - String? _personId; - late bool _archived; - bool _saving = false; - - @override - void initState() { - super.initState(); - _titleController = TextEditingController(text: widget.idea.title); - _detailsController = TextEditingController(text: widget.idea.details); - _type = widget.idea.type; - _personId = widget.idea.personId; - _archived = widget.idea.isArchived; - } - - @override - void dispose() { - _titleController.dispose(); - _detailsController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return _RepairFormScaffold( - children: [ - TextField( - controller: _titleController, - decoration: const InputDecoration(labelText: 'Title'), - ), - DropdownButtonFormField( - initialValue: _type, - items: IdeaType.values - .map( - (IdeaType type) => DropdownMenuItem( - value: type, - child: Text(type == IdeaType.gift ? 'Gift' : 'Event'), - ), - ) - .toList(growable: false), - onChanged: (IdeaType? value) { - if (value == null) { - return; - } - setState(() { - _type = value; - }); - }, - decoration: const InputDecoration(labelText: 'Type'), - ), - DropdownButtonFormField( - initialValue: _personId, - items: >[ - const DropdownMenuItem( - value: null, - child: Text('Unassigned'), - ), - ...widget.people.map( - (PersonProfile person) => DropdownMenuItem( - value: person.id, - child: Text(person.name), - ), - ), - ], - onChanged: (String? value) { - setState(() { - _personId = value; - }); - }, - decoration: const InputDecoration(labelText: 'Person'), - ), - TextField( - controller: _detailsController, - maxLines: 4, - decoration: const InputDecoration(labelText: 'Details'), - ), - SwitchListTile.adaptive( - value: _archived, - onChanged: (bool value) { - setState(() { - _archived = value; - }); - }, - title: const Text('Archived'), - contentPadding: EdgeInsets.zero, - ), - FilledButton.icon( - onPressed: _saving ? null : _save, - icon: _saving - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.save_rounded), - label: const Text('Save Changes'), - ), - ], - ); - } - - Future _save() async { - final String title = _titleController.text.trim(); - if (title.isEmpty) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Title is required.'))); - return; - } - - setState(() { - _saving = true; - }); - try { - await widget.onSave( - widget.idea.copyWith( - title: title, - details: _detailsController.text.trim(), - type: _type, - personId: _personId, - isArchived: _archived, - ), - ); - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Idea updated.'))); - } - } finally { - if (mounted) { - setState(() { - _saving = false; - }); - } - } - } -} - -class _ReminderRepairForm extends StatefulWidget { - const _ReminderRepairForm({ - required this.reminder, - required this.people, - required this.onSave, - }); - - final ReminderRule reminder; - final List people; - final Future Function(ReminderRule updated) onSave; - - @override - State<_ReminderRepairForm> createState() => _ReminderRepairFormState(); -} - -class _ReminderRepairFormState extends State<_ReminderRepairForm> { - late final TextEditingController _titleController; - late ReminderCadence _cadence; - late DateTime _nextAt; - late bool _enabled; - String? _personId; - bool _saving = false; - - @override - void initState() { - super.initState(); - _titleController = TextEditingController(text: widget.reminder.title); - _cadence = widget.reminder.cadence; - _nextAt = widget.reminder.nextAt; - _enabled = widget.reminder.enabled; - _personId = widget.reminder.personId; - } - - @override - void dispose() { - _titleController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return _RepairFormScaffold( - children: [ - TextField( - controller: _titleController, - decoration: const InputDecoration(labelText: 'Title'), - ), - DropdownButtonFormField( - initialValue: _cadence, - items: ReminderCadence.values - .map( - (ReminderCadence cadence) => DropdownMenuItem( - value: cadence, - child: Text(_labelForCadence(cadence)), - ), - ) - .toList(growable: false), - onChanged: (ReminderCadence? value) { - if (value == null) { - return; - } - setState(() { - _cadence = value; - }); - }, - decoration: const InputDecoration(labelText: 'Cadence'), - ), - DropdownButtonFormField( - initialValue: _personId, - items: >[ - const DropdownMenuItem( - value: null, - child: Text('Unassigned'), - ), - ...widget.people.map( - (PersonProfile person) => DropdownMenuItem( - value: person.id, - child: Text(person.name), - ), - ), - ], - onChanged: (String? value) { - setState(() { - _personId = value; - }); - }, - decoration: const InputDecoration(labelText: 'Person'), - ), - Wrap( - spacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text('Next at: ${_formatDateTime(_nextAt)}'), - TextButton.icon( - onPressed: _pickDateTime, - icon: const Icon(Icons.schedule_rounded), - label: const Text('Change'), - ), - ], - ), - SwitchListTile.adaptive( - value: _enabled, - onChanged: (bool value) { - setState(() { - _enabled = value; - }); - }, - title: const Text('Enabled'), - contentPadding: EdgeInsets.zero, - ), - FilledButton.icon( - onPressed: _saving ? null : _save, - icon: _saving - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.save_rounded), - label: const Text('Save Changes'), - ), - ], - ); - } - - Future _pickDateTime() async { - final DateTime? date = await showDatePicker( - context: context, - initialDate: _nextAt, - firstDate: DateTime.now().subtract(const Duration(days: 365 * 3)), - lastDate: DateTime.now().add(const Duration(days: 365 * 10)), - ); - if (date == null || !mounted) { - return; - } - - final TimeOfDay? time = await showTimePicker( - context: context, - initialTime: TimeOfDay.fromDateTime(_nextAt), - ); - if (time == null || !mounted) { - return; - } - - setState(() { - _nextAt = DateTime( - date.year, - date.month, - date.day, - time.hour, - time.minute, - ); - }); - } - - Future _save() async { - final String title = _titleController.text.trim(); - if (title.isEmpty) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Title is required.'))); - return; - } - - setState(() { - _saving = true; - }); - try { - await widget.onSave( - widget.reminder.copyWith( - title: title, - cadence: _cadence, - nextAt: _nextAt, - enabled: _enabled, - personId: _personId, - ), - ); - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Reminder updated.'))); - } - } finally { - if (mounted) { - setState(() { - _saving = false; - }); - } - } - } -} - -class _RepairFormScaffold extends StatelessWidget { - const _RepairFormScaffold({required this.children}); - - final List children; - - @override - Widget build(BuildContext context) { - return SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 20), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 680), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: children - .expand((Widget child) sync* { - yield child; - yield const SizedBox(height: 12); - }) - .toList(growable: false), - ), - ), - ); - } -} - -String _labelForCadence(ReminderCadence cadence) { - switch (cadence) { - case ReminderCadence.daily: - return 'Daily'; - case ReminderCadence.weekly: - return 'Weekly'; - case ReminderCadence.monthly: - return 'Monthly'; - } -} - -String _formatDateTime(DateTime value) { - final DateTime local = value.toLocal(); - final String month = local.month.toString().padLeft(2, '0'); - final String day = local.day.toString().padLeft(2, '0'); - final String hour = local.hour.toString().padLeft(2, '0'); - final String minute = local.minute.toString().padLeft(2, '0'); - return '${local.year}-$month-$day $hour:$minute'; -} +// Legacy compatibility export for the sync presentation entry. +export 'package:relationship_saver/features/sync/presentation/sync_view.dart'; diff --git a/lib/integrations/backend/README.md b/lib/integrations/backend/README.md new file mode 100644 index 0000000..130c57e --- /dev/null +++ b/lib/integrations/backend/README.md @@ -0,0 +1,4 @@ +# Backend Integration Boundary + +This folder contains backend-facing contracts and mapping code. Keep it isolated +from presentation so future sync or LLM work can evolve without UI rewrites. diff --git a/lib/integrations/backend/models/README.md b/lib/integrations/backend/models/README.md new file mode 100644 index 0000000..8647c82 --- /dev/null +++ b/lib/integrations/backend/models/README.md @@ -0,0 +1,4 @@ +# Backend Models + +DTOs and backend contract shapes live here. They should mirror transport needs +and stay separate from the app's local-first domain models. diff --git a/lib/main.dart b/lib/main.dart index 45d4f60..d2fb030 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,44 +1,60 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:relationship_saver/core/config/app_theme.dart'; -import 'package:relationship_saver/features/auth/session_controller.dart'; -import 'package:relationship_saver/features/auth/sign_in_view.dart'; -import 'package:relationship_saver/features/home/app_shell.dart'; -import 'package:relationship_saver/features/reminders/scheduling/reminder_notification_listener.dart'; -import 'package:relationship_saver/features/share_intake/whatsapp_share_listener.dart'; -import 'package:relationship_saver/features/sync/sync_background_runner.dart'; -import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; +import 'package:relationship_saver/app/relationship_saver_app.dart'; +import 'package:relationship_saver/core/observability/sentry_init.dart'; +import 'package:relationship_saver/features/ai_digest/application/llm_digest_background_scheduler.dart'; +import 'package:relationship_saver/features/ai_digest/application/llm_digest_orchestrator.dart'; +import 'package:workmanager/workmanager.dart'; -void main() { - runApp(const ProviderScope(child: RelationshipSaverApp())); +export 'package:relationship_saver/app/relationship_saver_app.dart'; + +@pragma('vm:entry-point') +void llmDigestCallbackDispatcher() { + Workmanager().executeTask(( + String taskName, + Map? inputData, + ) async { + if (taskName != llmDigestTaskName && + taskName != Workmanager.iOSBackgroundTask) { + return true; + } + WidgetsFlutterBinding.ensureInitialized(); + final ProviderContainer container = ProviderContainer(); + try { + await container.read(llmDigestOrchestratorProvider).runScheduledDigest(); + return true; + } finally { + container.dispose(); + } + }); } -class RelationshipSaverApp extends ConsumerWidget { - const RelationshipSaverApp({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final AsyncValue sessionState = ref.watch( - sessionControllerProvider, - ); - - return MaterialApp( - title: 'Relationship Saver', - debugShowCheckedModeBanner: false, - theme: AppTheme.light(), - home: Scaffold( - body: sessionState.when( - data: (session) => session == null - ? const SignInView() - : const WhatsAppShareListener( - child: ReminderNotificationListener( - child: SyncBackgroundRunner(child: AppShell()), - ), - ), - loading: () => const Center(child: CircularProgressIndicator()), - error: (Object error, StackTrace stackTrace) => const SignInView(), - ), - ), - ); +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + if (_supportsWorkmanagerPlatform()) { + await Workmanager().initialize(llmDigestCallbackDispatcher); } + + final SentryInit sentry = SentryInit(); + await sentry.initialize( + appRunner: () { + runApp( + ProviderScope( + overrides: [sentryInitProvider.overrideWithValue(sentry)], + child: const RelationshipSaverApp(), + ), + ); + }, + ); +} + +bool _supportsWorkmanagerPlatform() { + if (kIsWeb) { + return false; + } + return defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.android; } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index d0e7f79..0a1722c 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,9 +7,13 @@ #include "generated_plugin_registrant.h" #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) sentry_flutter_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "SentryFlutterPlugin"); + sentry_flutter_plugin_register_with_registrar(sentry_flutter_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index b29e9ba..fbd7764 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_linux + sentry_flutter ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 4ce058e..5a4d532 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,14 +5,20 @@ import FlutterMacOS import Foundation +import battery_plus import connectivity_plus import flutter_local_notifications import flutter_secure_storage_darwin +import package_info_plus +import sentry_flutter import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + BatteryPlusMacosPlugin.register(with: registry.registrar(forPlugin: "BatteryPlusMacosPlugin")) ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + SentryFlutterPlugin.register(with: registry.registrar(forPlugin: "SentryFlutterPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 92bedfe..784921e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,6 +33,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + battery_plus: + dependency: "direct main" + description: + name: battery_plus + sha256: ad16fcb55b7384be6b4bbc763d5e2031ac7ea62b2d9b6b661490c7b9741155bf + url: "https://pub.dev" + source: hosted + version: "7.0.0" + battery_plus_platform_interface: + dependency: transitive + description: + name: battery_plus_platform_interface + sha256: e8342c0f32de4b1dfd0223114b6785e48e579bfc398da9471c9179b907fa4910 + url: "https://pub.dev" + source: hosted + version: "2.0.1" boolean_selector: dependency: transitive description: @@ -608,6 +624,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" + url: "https://pub.dev" + source: hosted + version: "9.0.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: transitive description: @@ -744,6 +776,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.1" + sentry: + dependency: transitive + description: + name: sentry + sha256: "599701ca0693a74da361bc780b0752e1abc98226cf5095f6b069648116c896bb" + url: "https://pub.dev" + source: hosted + version: "8.14.2" + sentry_flutter: + dependency: "direct main" + description: + name: sentry_flutter + sha256: "5ba2cf40646a77d113b37a07bd69f61bb3ec8a73cbabe5537b05a7c89d2656f8" + url: "https://pub.dev" + source: hosted + version: "8.14.2" shared_preferences: dependency: "direct main" description: @@ -965,6 +1013,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + upower: + dependency: transitive + description: + name: upower + sha256: cf042403154751180affa1d15614db7fa50234bc2373cd21c3db666c38543ebf + url: "https://pub.dev" + source: hosted + version: "0.7.0" uri: dependency: transitive description: @@ -1045,6 +1101,38 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" + workmanager: + dependency: "direct main" + description: + name: workmanager + sha256: "065673b2a465865183093806925419d311a9a5e0995aa74ccf8920fd695e2d10" + url: "https://pub.dev" + source: hosted + version: "0.9.0+3" + workmanager_android: + dependency: transitive + description: + name: workmanager_android + sha256: "9ae744db4ef891f5fcd2fb8671fccc712f4f96489a487a1411e0c8675e5e8cb7" + url: "https://pub.dev" + source: hosted + version: "0.9.0+2" + workmanager_apple: + dependency: transitive + description: + name: workmanager_apple + sha256: "1cc12ae3cbf5535e72f7ba4fde0c12dd11b757caf493a28e22d684052701f2ca" + url: "https://pub.dev" + source: hosted + version: "0.9.1+2" + workmanager_platform_interface: + dependency: transitive + description: + name: workmanager_platform_interface + sha256: f40422f10b970c67abb84230b44da22b075147637532ac501729256fcea10a47 + url: "https://pub.dev" + source: hosted + version: "0.9.1+1" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d044a75..f0d94bd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,9 +44,12 @@ dependencies: shared_preferences: ^2.5.4 hive_flutter: ^1.1.0 connectivity_plus: ^7.0.0 + battery_plus: ^7.0.0 receive_sharing_intent: ^1.8.1 timezone: ^0.10.1 flutter_local_notifications: ^20.1.0 + workmanager: ^0.9.0+3 + sentry_flutter: ^8.14.2 dev_dependencies: flutter_test: diff --git a/test/core/llm/captured_fact_draft_parser_test.dart b/test/core/llm/captured_fact_draft_parser_test.dart new file mode 100644 index 0000000..28aaf1e --- /dev/null +++ b/test/core/llm/captured_fact_draft_parser_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:relationship_saver/core/llm/captured_fact_draft_parser.dart'; +import 'package:relationship_saver/features/share_intake/domain/share_models.dart'; + +void main() { + test('parses a JSON suggestion into a captured draft', () { + final SharedPayload payload = SharedPayload( + sourceApp: 'signal', + payloadType: SharedPayloadType.text, + rawText: 'Mila birthday is on 2026-05-20', + createdAt: DateTime.utc(2026, 4, 17), + receivedAt: DateTime.utc(2026, 4, 17), + platform: 'android', + ); + final CapturedFactDraft? result = parseCapturedFactDraftSuggestion( + '{"type":"importantDate","text":"Birthday to remember","label":"Birthday","dateValue":"2026-05-20","confidence":0.88,"isSensitive":false}', + fallbackPayload: payload, + ); + + expect(result, isNotNull); + expect(result!.type, CapturedFactType.importantDate); + expect(result.label, 'Birthday'); + expect(result.dateValue, DateTime(2026, 5, 20)); + expect(result.confidence, 0.88); + expect(result.needsReview, isTrue); + }); + + test('falls back to payload text when suggestion text is missing', () { + final SharedPayload payload = SharedPayload( + sourceApp: 'signal', + payloadType: SharedPayloadType.text, + rawText: 'Mila birthday is on 2026-05-20', + createdAt: DateTime.utc(2026, 4, 17), + receivedAt: DateTime.utc(2026, 4, 17), + platform: 'android', + ); + final CapturedFactDraft? result = parseCapturedFactDraftSuggestion( + '{"type":"note","label":""}', + fallbackPayload: payload, + ); + + expect(result, isNotNull); + expect(result!.text, payload.rawText); + expect(result.label, isNull); + }); + + test('returns null for non-json responses', () { + final SharedPayload payload = SharedPayload( + sourceApp: 'signal', + payloadType: SharedPayloadType.text, + rawText: 'Mila birthday is on 2026-05-20', + createdAt: DateTime.utc(2026, 4, 17), + receivedAt: DateTime.utc(2026, 4, 17), + platform: 'android', + ); + final CapturedFactDraft? result = parseCapturedFactDraftSuggestion( + 'No structured output available.', + fallbackPayload: payload, + ); + + expect(result, isNull); + }); +} diff --git a/test/features/ai_digest/ai_digest_repository_test.dart b/test/features/ai_digest/ai_digest_repository_test.dart new file mode 100644 index 0000000..ca1c252 --- /dev/null +++ b/test/features/ai_digest/ai_digest_repository_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:relationship_saver/app/data/relationship_repository.dart'; +import 'package:relationship_saver/app/data/storage/local_data_store_in_memory.dart'; +import 'package:relationship_saver/app/data/storage/local_data_store_provider.dart'; +import 'package:relationship_saver/app/state/local_data_state.dart'; +import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart'; +import 'package:relationship_saver/features/ideas/domain/idea_models.dart'; +import 'package:relationship_saver/features/reminders/application/reminder_scheduler.dart'; +import 'package:relationship_saver/features/reminders/application/reminder_scheduler_provider.dart'; +import 'package:relationship_saver/features/sync/data/storage/sync_state_store_in_memory.dart'; +import 'package:relationship_saver/features/sync/data/storage/sync_state_store_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('accepting gift draft creates idea and marks draft accepted', () async { + final ProviderContainer container = _createContainer(); + addTearDown(container.dispose); + + final LocalDataState initial = await container.read( + localRepositoryProvider.future, + ); + final String personId = initial.people.first.id; + + await container + .read(localRepositoryProvider.notifier) + .saveAiSuggestionDrafts([ + AiSuggestionDraft( + id: 'ai-1', + personId: personId, + kind: AiSuggestionKind.giftIdea, + title: 'Coffee sampler', + details: 'Small local roaster pack.', + suggestedAt: DateTime(2026, 5, 16), + confidence: 0.8, + status: AiSuggestionStatus.pending, + sourceRunId: 'run-1', + ), + ]); + + await container + .read(localRepositoryProvider.notifier) + .acceptAiSuggestionDraft('ai-1'); + + final LocalDataState after = await container.read( + localRepositoryProvider.future, + ); + expect(after.aiSuggestionDrafts.single.status, AiSuggestionStatus.accepted); + expect( + after.ideas.any( + (RelationshipIdea idea) => + idea.personId == personId && + idea.type == IdeaType.gift && + idea.title == 'Coffee sampler', + ), + isTrue, + ); + }); + + test('dismissing draft does not create local artifacts', () async { + final ProviderContainer container = _createContainer(); + addTearDown(container.dispose); + + final LocalDataState initial = await container.read( + localRepositoryProvider.future, + ); + final String personId = initial.people.first.id; + final int ideaCount = initial.ideas.length; + + await container + .read(localRepositoryProvider.notifier) + .saveAiSuggestionDrafts([ + AiSuggestionDraft( + id: 'ai-2', + personId: personId, + kind: AiSuggestionKind.eventIdea, + title: 'Museum afternoon', + details: 'Pick a quiet exhibit.', + suggestedAt: DateTime(2026, 5, 16), + confidence: 0.7, + status: AiSuggestionStatus.pending, + sourceRunId: 'run-1', + ), + ]); + await container + .read(localRepositoryProvider.notifier) + .dismissAiSuggestionDraft('ai-2'); + + final LocalDataState after = await container.read( + localRepositoryProvider.future, + ); + expect( + after.aiSuggestionDrafts.single.status, + AiSuggestionStatus.dismissed, + ); + expect(after.ideas.length, ideaCount); + }); +} + +ProviderContainer _createContainer() { + return ProviderContainer( + overrides: [ + localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()), + syncStateStoreProvider.overrideWithValue(InMemorySyncStateStore()), + reminderSchedulerProvider.overrideWithValue( + const NoopReminderScheduler(), + ), + ], + ); +} diff --git a/test/features/ai_digest/ai_digest_response_parser_test.dart b/test/features/ai_digest/ai_digest_response_parser_test.dart new file mode 100644 index 0000000..19961bb --- /dev/null +++ b/test/features/ai_digest/ai_digest_response_parser_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:relationship_saver/features/ai_digest/application/ai_digest_response_parser.dart'; +import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart'; + +void main() { + test('maps valid tokenized suggestions back to local person ids', () { + final AiDigestParseResult result = const AiDigestResponseParser().parse( + response: ''' + { + "suggestions": [ + { + "personToken": "person_001", + "kind": "giftIdea", + "title": "Coffee tasting set", + "details": "Pick a small local sampler.", + "suggestedTiming": "2026-05-20T18:00:00Z", + "confidence": 0.82, + "reason": "Matches known interests." + } + ] + } + ''', + tokenToPersonId: const {'person_001': 'p-1'}, + sourceRunId: 'run-1', + suggestedAt: DateTime(2026, 5, 16), + ); + + expect(result.success, isTrue); + expect(result.drafts, hasLength(1)); + expect(result.drafts.single.personId, 'p-1'); + expect(result.drafts.single.kind, AiSuggestionKind.giftIdea); + expect(result.drafts.single.status, AiSuggestionStatus.pending); + }); + + test('rejects unknown person tokens and malformed items', () { + final AiDigestParseResult result = const AiDigestResponseParser().parse( + response: ''' + [ + {"personToken": "person_404", "kind": "giftIdea", "title": "Bad"}, + {"personToken": "person_001", "kind": "unknown", "title": "Bad"}, + {"personToken": "person_001", "kind": "checkIn", "title": ""} + ] + ''', + tokenToPersonId: const {'person_001': 'p-1'}, + sourceRunId: 'run-1', + suggestedAt: DateTime(2026, 5, 16), + ); + + expect(result.success, isFalse); + expect(result.drafts, isEmpty); + }); +} diff --git a/test/features/ai_digest/anonymized_llm_context_builder_test.dart b/test/features/ai_digest/anonymized_llm_context_builder_test.dart new file mode 100644 index 0000000..b68e91a --- /dev/null +++ b/test/features/ai_digest/anonymized_llm_context_builder_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:relationship_saver/app/state/local_data_state.dart'; +import 'package:relationship_saver/features/ai_digest/application/anonymized_llm_context_builder.dart'; +import 'package:relationship_saver/features/dashboard/domain/dashboard_models.dart'; +import 'package:relationship_saver/features/ideas/domain/idea_models.dart'; +import 'package:relationship_saver/features/moments/domain/moment_models.dart'; +import 'package:relationship_saver/features/people/domain/person_models.dart'; +import 'package:relationship_saver/features/reminders/domain/reminder_models.dart'; +import 'package:relationship_saver/features/share_intake/domain/share_models.dart'; + +void main() { + test('builds pseudonymous payload without identifiable profile data', () { + final LocalDataState state = LocalDataState( + people: [ + PersonProfile( + id: 'p-1', + name: 'Alice Secret', + relationship: 'Sister', + affinityScore: 85, + nextMoment: DateTime(2026, 6, 1), + tags: const ['coffee', 'books'], + notes: 'Passport number and private address should stay local.', + aliases: const ['Ally'], + location: 'Zurich, Switzerland', + lastInteractedAt: DateTime(2026, 5, 1), + ), + ], + moments: const [], + ideas: const [], + reminders: const [], + tasks: const [], + sourceLinks: [ + SourceProfileLink( + id: 'sl-1', + sourceApp: 'whatsapp', + normalizedDisplayName: 'Alice Secret', + profileId: 'p-1', + firstSeenAt: DateTime(2026, 5, 1), + lastSeenAt: DateTime(2026, 5, 1), + sourceUserId: 'wa:alice', + sourceThreadId: 'thread-secret', + sourceFingerprint: 'whatsapp|u:wa:alice', + ), + ], + sharedMessages: [ + SharedMessageEntry( + id: 'sm-1', + profileId: 'p-1', + payload: SharedPayload( + sourceApp: 'whatsapp', + payloadType: SharedPayloadType.text, + rawText: 'Raw private chat text', + createdAt: DateTime(2026, 5, 1), + receivedAt: DateTime(2026, 5, 1), + platform: 'ios', + sourceDisplayName: 'Alice Secret', + sourceUserId: 'wa:alice', + sourceThreadId: 'thread-secret', + ), + importedAt: DateTime(2026, 5, 1), + resolvedAutomatically: true, + ), + ], + personFacts: [ + PersonFact( + id: 'pf-1', + personId: 'p-1', + type: CapturedFactType.note, + text: 'Secret medical detail', + sourceKind: CaptureSourceKind.manual, + createdAt: DateTime(2026, 5, 1), + updatedAt: DateTime(2026, 5, 1), + isSensitive: true, + ), + ], + ); + + final AnonymizedLlmDigestContext context = AnonymizedLlmContextBuilder( + now: () => DateTime(2026, 5, 16), + ).build(state); + final String prompt = context.toPromptJson(); + + expect(context.tokenToPersonId, {'person_001': 'p-1'}); + expect(prompt, contains('person_001')); + expect(prompt, isNot(contains('Alice'))); + expect(prompt, isNot(contains('Ally'))); + expect(prompt, isNot(contains('Zurich'))); + expect(prompt, isNot(contains('Passport'))); + expect(prompt, isNot(contains('wa:alice'))); + expect(prompt, isNot(contains('thread-secret'))); + expect(prompt, isNot(contains('Raw private chat text'))); + expect(prompt, isNot(contains('Secret medical detail'))); + }); +} diff --git a/test/features/ai_digest/llm_digest_orchestrator_test.dart b/test/features/ai_digest/llm_digest_orchestrator_test.dart new file mode 100644 index 0000000..75f88e9 --- /dev/null +++ b/test/features/ai_digest/llm_digest_orchestrator_test.dart @@ -0,0 +1,168 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:relationship_saver/app/data/relationship_repository.dart'; +import 'package:relationship_saver/app/data/storage/local_data_store_in_memory.dart'; +import 'package:relationship_saver/app/data/storage/local_data_store_provider.dart'; +import 'package:relationship_saver/app/state/local_data_state.dart'; +import 'package:relationship_saver/features/ai_digest/application/ai_digest_notifier.dart'; +import 'package:relationship_saver/features/ai_digest/application/llm_digest_environment.dart'; +import 'package:relationship_saver/features/ai_digest/application/llm_digest_orchestrator.dart'; +import 'package:relationship_saver/features/ai_digest/application/llm_digest_text_client.dart'; +import 'package:relationship_saver/features/ai_digest/data/llm_digest_config.dart'; +import 'package:relationship_saver/features/ai_digest/data/llm_digest_run_store.dart'; +import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart'; +import 'package:relationship_saver/features/reminders/application/reminder_scheduler.dart'; +import 'package:relationship_saver/features/reminders/application/reminder_scheduler_provider.dart'; +import 'package:relationship_saver/features/sync/data/storage/sync_state_store_in_memory.dart'; +import 'package:relationship_saver/features/sync/data/storage/sync_state_store_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test( + 'scheduled digest creates review drafts with one LLM call and notification', + () async { + final FakeTextClient textClient = FakeTextClient( + response: ''' + { + "suggestions": [ + { + "personToken": "person_001", + "kind": "checkIn", + "title": "Send a quick note", + "details": "Ask how the week is going.", + "confidence": 0.7, + "reason": "Contact has been quiet." + } + ] + } + ''', + ); + final FakeNotifier notifier = FakeNotifier(); + final ProviderContainer container = _createContainer( + textClient: textClient, + notifier: notifier, + ); + addTearDown(container.dispose); + + await container.read(llmDigestConfigProvider.notifier).setEnabled(true); + + final LlmDigestRunResult result = await container + .read(llmDigestOrchestratorProvider) + .runScheduledDigest(); + + final LocalDataState state = await container.read( + localRepositoryProvider.future, + ); + expect(result.completed, isTrue); + expect(result.createdDraftCount, 1); + expect(textClient.calls, 1); + expect(textClient.lastPrompt, isNot(contains(state.people.first.name))); + expect(state.aiSuggestionDrafts, hasLength(1)); + expect( + state.aiSuggestionDrafts.single.status, + AiSuggestionStatus.pending, + ); + expect(notifier.lastCount, 1); + }, + ); + + test('scheduled digest respects weekly cooldown', () async { + final LlmDigestRunStore runStore = const LlmDigestRunStore(); + await runStore.write(LlmDigestRunState(lastCompletedAt: DateTime.now())); + final FakeTextClient textClient = FakeTextClient(response: '[]'); + final ProviderContainer container = _createContainer( + textClient: textClient, + notifier: FakeNotifier(), + ); + addTearDown(container.dispose); + + await container.read(llmDigestConfigProvider.notifier).setEnabled(true); + + final LlmDigestRunResult result = await container + .read(llmDigestOrchestratorProvider) + .runScheduledDigest(); + + expect(result.started, isFalse); + expect(textClient.calls, 0); + }); + + test('scheduled digest skips when network policy is not satisfied', () async { + final FakeTextClient textClient = FakeTextClient(response: '[]'); + final ProviderContainer container = _createContainer( + textClient: textClient, + notifier: FakeNotifier(), + environment: const FakeEnvironment(canRunValue: false), + ); + addTearDown(container.dispose); + + await container.read(llmDigestConfigProvider.notifier).setEnabled(true); + + final LlmDigestRunResult result = await container + .read(llmDigestOrchestratorProvider) + .runScheduledDigest(); + + expect(result.started, isFalse); + expect(textClient.calls, 0); + }); +} + +ProviderContainer _createContainer({ + required FakeTextClient textClient, + required FakeNotifier notifier, + LlmDigestEnvironment environment = const FakeEnvironment(), +}) { + return ProviderContainer( + overrides: [ + localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()), + syncStateStoreProvider.overrideWithValue(InMemorySyncStateStore()), + reminderSchedulerProvider.overrideWithValue( + const NoopReminderScheduler(), + ), + llmDigestInitializerProvider.overrideWithValue(() async {}), + llmDigestApiKeyConfiguredProvider.overrideWithValue(true), + llmDigestTextClientProvider.overrideWithValue(textClient), + llmDigestEnvironmentProvider.overrideWithValue(environment), + aiDigestNotifierProvider.overrideWithValue(notifier), + ], + ); +} + +class FakeTextClient implements LlmDigestTextClient { + FakeTextClient({required this.response}); + + final String response; + int calls = 0; + String? lastPrompt; + + @override + Future complete({ + required String systemPrompt, + required String userPrompt, + }) async { + calls += 1; + lastPrompt = userPrompt; + return response; + } +} + +class FakeNotifier implements AiDigestNotifier { + int? lastCount; + + @override + Future showDigestReady(int count) async { + lastCount = count; + } +} + +class FakeEnvironment implements LlmDigestEnvironment { + const FakeEnvironment({this.canRunValue = true}); + + final bool canRunValue; + + @override + Future canRun(LlmDigestConfigState config) async => canRunValue; +} diff --git a/test/features/app/app_auth_flow_test.dart b/test/features/app/app_auth_flow_test.dart index af25f90..7e6d777 100644 --- a/test/features/app/app_auth_flow_test.dart +++ b/test/features/app/app_auth_flow_test.dart @@ -50,7 +50,7 @@ void main() { findsOneWidget, ); - await tester.tap(find.widgetWithText(FilledButton, 'Sign Out')); + await tester.tap(find.widgetWithText(OutlinedButton, 'Sign Out')); await tester.pumpAndSettle(const Duration(milliseconds: 450)); expect(find.text('Sign in to continue'), findsOneWidget); diff --git a/test/features/app/app_mobile_crud_sync_flow_test.dart b/test/features/app/app_mobile_crud_sync_flow_test.dart index 70d5d3f..3a3b548 100644 --- a/test/features/app/app_mobile_crud_sync_flow_test.dart +++ b/test/features/app/app_mobile_crud_sync_flow_test.dart @@ -59,7 +59,10 @@ void main() { expect(find.text('Add Person'), findsOneWidget); - await tester.enterText(find.widgetWithText(TextField, 'Name'), 'Taylor Quinn'); + await tester.enterText( + find.widgetWithText(TextField, 'Name'), + 'Taylor Quinn', + ); await tester.enterText( find.widgetWithText(TextField, 'Relationship'), 'Partner', diff --git a/test/features/local/local_repository_test.dart b/test/features/local/local_repository_test.dart index f82291d..b0b7c1a 100644 --- a/test/features/local/local_repository_test.dart +++ b/test/features/local/local_repository_test.dart @@ -6,6 +6,8 @@ import 'package:relationship_saver/features/local/storage/local_data_store_in_me import 'package:relationship_saver/features/local/storage/local_data_store_provider.dart'; import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler.dart'; import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_provider.dart'; +import 'package:relationship_saver/features/share_intake/share_capture_models.dart'; +import 'package:relationship_saver/features/share_intake/share_payload_parser.dart'; import 'package:relationship_saver/features/sync/storage/sync_state_store_in_memory.dart'; import 'package:relationship_saver/features/sync/storage/sync_state_store_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -277,6 +279,105 @@ void main() { }, ); + test( + 'saveSharedPayloadToInbox then resolve persists structured fact draft', + () async { + final ProviderContainer container = _createContainer(); + addTearDown(container.dispose); + + final LocalDataState state = await container.read( + localRepositoryProvider.future, + ); + final String personId = state.people.first.id; + final SharedPayload payload = SharePayloadParser.parse( + rawText: 'She loves vegan sushi and rooftop dinners.', + sourceApp: 'share_sheet', + platform: 'android', + ); + + final SharedMessageIngestResult queued = await container + .read(localRepositoryProvider.notifier) + .saveSharedPayloadToInbox( + payload: payload, + draft: const CapturedFactDraft( + type: CapturedFactType.like, + text: 'Vegan sushi and rooftop dinners', + ), + ); + + await container + .read(localRepositoryProvider.notifier) + .resolveSharedInboxToExistingProfile( + inboxEntryId: queued.inboxEntryId!, + profileId: personId, + draft: const CapturedFactDraft( + type: CapturedFactType.like, + text: 'Vegan sushi and rooftop dinners', + ), + ); + + final LocalDataState after = await container.read( + localRepositoryProvider.future, + ); + expect( + after.personFacts.any( + (PersonFact fact) => + fact.personId == personId && + fact.type == CapturedFactType.like && + fact.text.contains('Vegan sushi'), + ), + isTrue, + ); + }, + ); + + test( + 'captureSharedPayloadByCreatingProfile can persist important date', + () async { + final ProviderContainer container = _createContainer(); + addTearDown(container.dispose); + + await container.read(localRepositoryProvider.future); + final SharedPayload payload = SharePayloadParser.parse( + rawText: 'Anniversary dinner https://example.com', + sourceApp: 'share_sheet', + platform: 'ios', + ); + + final SharedMessageIngestResult result = await container + .read(localRepositoryProvider.notifier) + .captureSharedPayloadByCreatingProfile( + payload: payload, + name: 'Robin Vale', + relationship: 'Partner', + aliases: const ['RV'], + draft: CapturedFactDraft( + type: CapturedFactType.importantDate, + text: 'Anniversary', + label: 'Anniversary dinner', + dateValue: DateTime(2026, 9, 14), + ), + ); + + final LocalDataState after = await container.read( + localRepositoryProvider.future, + ); + final PersonProfile created = after.people.firstWhere( + (PersonProfile person) => person.id == result.profileId, + ); + + expect(created.aliases, contains('RV')); + expect( + after.importantDates.any( + (PersonImportantDate value) => + value.personId == created.id && + value.label == 'Anniversary dinner', + ), + isTrue, + ); + }, + ); + test( 'queues near-match conflict and then uses fingerprint mapping for follow-up shares', () async { @@ -684,6 +785,22 @@ void main() { expect(afterDelete.reminders.length, beforeCount); }); + test('dismissLocalSignal persists dismissed ids in local state', () async { + final ProviderContainer container = _createContainer(); + addTearDown(container.dispose); + + await container.read(localRepositoryProvider.future); + + await container + .read(localRepositoryProvider.notifier) + .dismissLocalSignal('local-checkin-p-ava-123'); + + final LocalDataState after = await container.read( + localRepositoryProvider.future, + ); + expect(after.dismissedSignalIds, contains('local-checkin-p-ava-123')); + }); + test('reconciles reminder schedule after state changes', () async { final RecordingReminderScheduler scheduler = RecordingReminderScheduler(); final ProviderContainer container = _createContainer(scheduler: scheduler); diff --git a/test/features/local/storage/local_repository_hive_migration_test.dart b/test/features/local/storage/local_repository_hive_migration_test.dart index 029f016..995fdf5 100644 --- a/test/features/local/storage/local_repository_hive_migration_test.dart +++ b/test/features/local/storage/local_repository_hive_migration_test.dart @@ -52,7 +52,7 @@ void main() { final LocalDataRecord? migrated = await hiveStore.read(); expect(migrated, isNotNull); - expect(migrated!.schemaVersion, 3); + expect(migrated!.schemaVersion, 5); final LocalDataRecord? legacyAfter = await legacyStore.read(); expect(legacyAfter, isNull); diff --git a/test/features/share_intake/share_capture_draft_suggester_test.dart b/test/features/share_intake/share_capture_draft_suggester_test.dart new file mode 100644 index 0000000..02bf882 --- /dev/null +++ b/test/features/share_intake/share_capture_draft_suggester_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:relationship_saver/features/local/local_models.dart'; +import 'package:relationship_saver/features/share_intake/domain/share_capture_draft_suggester.dart'; + +void main() { + test('suggests gift idea draft from gift-oriented text', () { + final CapturedFactDraft draft = + ShareCaptureDraftSuggester.suggestForPayload( + SharedPayload( + sourceApp: 'whatsapp', + payloadType: SharedPayloadType.text, + rawText: 'Gift idea: blue scarf she mentioned last week', + createdAt: DateTime(2026, 4, 17), + receivedAt: DateTime(2026, 4, 17), + platform: 'android', + ), + ); + + expect(draft.type, CapturedFactType.giftIdea); + expect(draft.label, 'Gift idea'); + expect(draft.needsReview, isTrue); + }); + + test( + 'suggests important date draft when birthday and explicit date are present', + () { + final CapturedFactDraft draft = + ShareCaptureDraftSuggester.suggestForPayload( + SharedPayload( + sourceApp: 'notes', + payloadType: SharedPayloadType.text, + rawText: 'Her birthday is 2026-09-14', + createdAt: DateTime(2026, 4, 17), + receivedAt: DateTime(2026, 4, 17), + platform: 'ios', + ), + ); + + expect(draft.type, CapturedFactType.importantDate); + expect(draft.label, 'Birthday'); + expect(draft.dateValue, DateTime(2026, 9, 14)); + }, + ); + + test('suggests place idea for shared maps links', () { + final CapturedFactDraft draft = + ShareCaptureDraftSuggester.suggestForPayload( + SharedPayload( + sourceApp: 'share_sheet', + payloadType: SharedPayloadType.url, + rawText: '', + url: 'https://maps.apple.com/?address=1%20Infinite%20Loop', + createdAt: DateTime(2026, 4, 17), + receivedAt: DateTime(2026, 4, 17), + platform: 'ios', + ), + ); + + expect(draft.type, CapturedFactType.placeIdea); + expect(draft.label, 'Place idea'); + }); + + test( + 'suggests dislike when text contains strong negative preference signal', + () { + final CapturedFactDraft draft = + ShareCaptureDraftSuggester.suggestForPayload( + SharedPayload( + sourceApp: 'signal', + payloadType: SharedPayloadType.text, + rawText: "She doesn't like crowded bars", + createdAt: DateTime(2026, 4, 17), + receivedAt: DateTime(2026, 4, 17), + platform: 'android', + ), + ); + + expect(draft.type, CapturedFactType.dislike); + expect(draft.label, 'Preference'); + }, + ); +} diff --git a/test/features/share_intake/share_inbox_view_test.dart b/test/features/share_intake/share_inbox_view_test.dart index 4820eb5..e86371c 100644 --- a/test/features/share_intake/share_inbox_view_test.dart +++ b/test/features/share_intake/share_inbox_view_test.dart @@ -7,6 +7,7 @@ import 'package:relationship_saver/features/local/storage/local_data_store_in_me import 'package:relationship_saver/features/local/storage/local_data_store_provider.dart'; import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler.dart'; import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_provider.dart'; +import 'package:relationship_saver/features/share_intake/share_capture_models.dart'; import 'package:relationship_saver/features/share_intake/share_inbox_view.dart'; import 'package:relationship_saver/features/sync/storage/sync_state_store_in_memory.dart'; import 'package:relationship_saver/features/sync/storage/sync_state_store_provider.dart'; @@ -66,7 +67,7 @@ void main() { expect(find.text('Share Inbox'), findsOneWidget); expect(find.text('Ambiguous match'), findsOneWidget); - expect(find.widgetWithText(FilledButton, 'Review & Match'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'Review & Save'), findsOneWidget); await tester.tap(find.widgetWithText(TextButton, 'Dismiss Item').first); await tester.pumpAndSettle(); @@ -116,7 +117,12 @@ void main() { expect(find.text('Near match conflict'), findsOneWidget); - await tester.tap(find.widgetWithText(FilledButton, 'Review & Match').first); + await tester.tap(find.widgetWithText(FilledButton, 'Review & Save').first); + await tester.pumpAndSettle(); + + expect(find.text('Review Capture Mapping'), findsOneWidget); + + await tester.tap(find.widgetWithText(FilledButton, 'Use Mapping')); await tester.pumpAndSettle(); expect(find.text('Review Match'), findsOneWidget); diff --git a/test/features/share_intake/share_payload_parser_test.dart b/test/features/share_intake/share_payload_parser_test.dart new file mode 100644 index 0000000..042ef30 --- /dev/null +++ b/test/features/share_intake/share_payload_parser_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:relationship_saver/features/local/local_models.dart'; +import 'package:relationship_saver/features/share_intake/share_payload_parser.dart'; + +void main() { + test('parses url-only payload', () { + final SharedPayload payload = SharePayloadParser.parse( + rawText: 'https://example.com/date-night', + sourceApp: 'share_sheet', + platform: 'android', + ); + + expect(payload.payloadType, SharedPayloadType.url); + expect(payload.url, 'https://example.com/date-night'); + expect(payload.rawText, isEmpty); + }); + + test('parses text plus url payload', () { + final SharedPayload payload = SharePayloadParser.parse( + rawText: 'Could be a good anniversary place https://example.com', + sourceApp: 'share_sheet', + platform: 'android', + ); + + expect(payload.payloadType, SharedPayloadType.textWithUrl); + expect(payload.url, 'https://example.com'); + expect(payload.rawText, 'Could be a good anniversary place'); + }); + + test('preserves WhatsApp sender extraction when pattern matches', () { + final SharedPayload payload = SharePayloadParser.parse( + rawText: 'Ava Hart: loves cozy Italian restaurants', + sourceApp: 'whatsapp', + platform: 'ios', + ); + + expect(payload.sourceDisplayName, 'Ava Hart'); + expect(payload.sourceUserId, 'wa:ava hart'); + expect(payload.rawText, 'loves cozy Italian restaurants'); + }); + + test('uses explicit signal source app hint and extracts sender', () { + final SharedPayload payload = SharePayloadParser.parse( + rawText: 'Marek: this concert looks promising', + sourceApp: 'signal', + platform: 'android', + ); + + expect(payload.sourceApp, 'signal'); + expect(payload.sourceDisplayName, 'Marek'); + expect(payload.sourceUserId, 'signal:marek'); + expect(payload.rawText, 'this concert looks promising'); + }); + + test('normalizes notes hint without trying to invent sender metadata', () { + final SharedPayload payload = SharePayloadParser.parse( + rawText: 'Dinner idea from Notes https://example.com', + sourceApp: 'notes', + platform: 'ios', + ); + + expect(payload.sourceApp, 'notes'); + expect(payload.sourceDisplayName, isNull); + expect(payload.payloadType, SharedPayloadType.textWithUrl); + expect(payload.rawText, 'Dinner idea from Notes'); + }); +} diff --git a/test/features/signals/signals_feed_synthesizer_test.dart b/test/features/signals/signals_feed_synthesizer_test.dart new file mode 100644 index 0000000..5ec3ef9 --- /dev/null +++ b/test/features/signals/signals_feed_synthesizer_test.dart @@ -0,0 +1,139 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:relationship_saver/app/state/local_data_state.dart'; +import 'package:relationship_saver/features/ideas/domain/idea_models.dart'; +import 'package:relationship_saver/features/people/domain/person_models.dart'; +import 'package:relationship_saver/features/reminders/domain/reminder_models.dart'; +import 'package:relationship_saver/features/share_intake/domain/share_models.dart'; +import 'package:relationship_saver/features/signals/domain/signals_feed_synthesizer.dart'; + +void main() { + test( + 'creates a check-in signal when interaction is stale and no reminder exists', + () { + final LocalDataState state = LocalDataState( + people: [ + PersonProfile( + id: 'p-1', + name: 'Alex', + relationship: 'Friend', + affinityScore: 80, + nextMoment: DateTime(2026, 4, 30), + tags: const [], + notes: '', + lastInteractedAt: DateTime(2026, 4, 1), + ), + ], + moments: const [], + ideas: const [], + reminders: const [], + tasks: const [], + ); + + final signals = SignalsFeedSynthesizer.synthesize( + state: state, + now: DateTime(2026, 4, 17), + ); + + expect(signals, isNotEmpty); + expect(signals.first.title, 'Check in with Alex'); + expect( + signals.first.metadata?[signalMetadataActionKey], + createReminderSignalAction, + ); + }, + ); + + test( + 'suppresses stale check-in signal when a nearby reminder already exists', + () { + final LocalDataState state = LocalDataState( + people: [ + PersonProfile( + id: 'p-1', + name: 'Alex', + relationship: 'Friend', + affinityScore: 80, + nextMoment: DateTime(2026, 4, 30), + tags: const [], + notes: '', + lastInteractedAt: DateTime(2026, 4, 1), + ), + ], + moments: const [], + ideas: const [], + reminders: [ + ReminderRule( + id: 'r-1', + personId: 'p-1', + title: 'Weekly check in', + cadence: ReminderCadence.weekly, + nextAt: DateTime(2026, 4, 18), + ), + ], + tasks: const [], + ); + + final signals = SignalsFeedSynthesizer.synthesize( + state: state, + now: DateTime(2026, 4, 17), + ); + + expect( + signals.where((signal) => signal.title.contains('Check in')), + isEmpty, + ); + }, + ); + + test('creates an upcoming date signal with idea count context', () { + final DateTime now = DateTime(2026, 4, 17); + final LocalDataState state = LocalDataState( + people: [ + PersonProfile( + id: 'p-1', + name: 'Mila', + relationship: 'Sister', + affinityScore: 88, + nextMoment: DateTime(2026, 4, 25), + tags: const [], + notes: '', + lastInteractedAt: DateTime(2026, 4, 16), + ), + ], + moments: const [], + ideas: [ + RelationshipIdea( + id: 'i-1', + personId: 'p-1', + type: IdeaType.gift, + title: 'Plant stand', + details: 'Oak finish', + createdAt: DateTime(2026, 4, 10), + ), + ], + reminders: const [], + tasks: const [], + importantDates: [ + PersonImportantDate( + id: 'd-1', + personId: 'p-1', + label: 'Birthday', + date: now.add(const Duration(days: 3)), + classification: 'birthday', + sourceKind: CaptureSourceKind.manual, + createdAt: now.subtract(const Duration(days: 1)), + updatedAt: now, + ), + ], + ); + + final signals = SignalsFeedSynthesizer.synthesize(state: state, now: now); + + expect( + signals.any( + (signal) => signal.title.contains('Birthday for Mila is in 3 days'), + ), + isTrue, + ); + }); +} diff --git a/test/features/sync/sync_background_runner_test.dart b/test/features/sync/sync_background_runner_test.dart index 65e7ae2..2f5e11a 100644 --- a/test/features/sync/sync_background_runner_test.dart +++ b/test/features/sync/sync_background_runner_test.dart @@ -9,45 +9,48 @@ import 'package:relationship_saver/features/sync/sync_background_runner.dart'; import 'package:relationship_saver/features/sync/sync_coordinator.dart'; void main() { - testWidgets('triggers sync when reachability transitions from offline to online', ( - WidgetTester tester, - ) async { - final _TestReachability reachability = _TestReachability(initial: false); - addTearDown(reachability.dispose); + testWidgets( + 'triggers sync when reachability transitions from offline to online', + (WidgetTester tester) async { + final _TestReachability reachability = _TestReachability(initial: false); + addTearDown(reachability.dispose); - int syncCalls = 0; + int syncCalls = 0; - await tester.pumpWidget( - ProviderScope( - overrides: [ - networkReachabilityProvider.overrideWithValue(reachability), - syncCoordinatorProvider.overrideWith( - (Ref ref) => - _TestSyncCoordinator(ref, onSyncNow: () async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + networkReachabilityProvider.overrideWithValue(reachability), + syncCoordinatorProvider.overrideWith( + (Ref ref) => _TestSyncCoordinator( + ref, + onSyncNow: () async { syncCalls += 1; return _okResult(); - }), + }, + ), + ), + ], + child: const Directionality( + textDirection: TextDirection.ltr, + child: SyncBackgroundRunner(child: SizedBox()), ), - ], - child: const Directionality( - textDirection: TextDirection.ltr, - child: SyncBackgroundRunner(child: SizedBox()), ), - ), - ); - await tester.pumpAndSettle(const Duration(milliseconds: 60)); + ); + await tester.pumpAndSettle(const Duration(milliseconds: 60)); - // Startup trigger always runs once when background sync is enabled. - expect(syncCalls, 1); + // Startup trigger always runs once when background sync is enabled. + expect(syncCalls, 1); - reachability.emit(false); - await tester.pumpAndSettle(const Duration(milliseconds: 40)); - expect(syncCalls, 1); + reachability.emit(false); + await tester.pumpAndSettle(const Duration(milliseconds: 40)); + expect(syncCalls, 1); - reachability.emit(true); - await tester.pumpAndSettle(const Duration(milliseconds: 60)); - expect(syncCalls, 2); - }); + reachability.emit(true); + await tester.pumpAndSettle(const Duration(milliseconds: 60)); + expect(syncCalls, 2); + }, + ); testWidgets('does not trigger extra sync while connectivity stays online', ( WidgetTester tester, @@ -62,11 +65,13 @@ void main() { overrides: [ networkReachabilityProvider.overrideWithValue(reachability), syncCoordinatorProvider.overrideWith( - (Ref ref) => - _TestSyncCoordinator(ref, onSyncNow: () async { - syncCalls += 1; - return _okResult(); - }), + (Ref ref) => _TestSyncCoordinator( + ref, + onSyncNow: () async { + syncCalls += 1; + return _okResult(); + }, + ), ), ], child: const Directionality( @@ -102,7 +107,9 @@ class _TestReachability implements NetworkReachability { bool _current; @override - Future isReachable({Duration timeout = const Duration(seconds: 2)}) async { + Future isReachable({ + Duration timeout = const Duration(seconds: 2), + }) async { return _current; } diff --git a/test/features/sync/sync_rejection_repair_flow_test.dart b/test/features/sync/sync_rejection_repair_flow_test.dart index 1d3a9ae..b226442 100644 --- a/test/features/sync/sync_rejection_repair_flow_test.dart +++ b/test/features/sync/sync_rejection_repair_flow_test.dart @@ -55,7 +55,9 @@ void main() { ], ), ); - final syncState = await container.read(syncQueueRepositoryProvider.future); + final syncState = await container.read( + syncQueueRepositoryProvider.future, + ); expect(syncState.lastRejected.length, 1); await tester.pumpWidget( diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index af1f996..3806141 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,12 +6,18 @@ #include "generated_plugin_registrant.h" +#include #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + BatteryPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("BatteryPlusWindowsPlugin")); ConnectivityPlusWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + SentryFlutterPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SentryFlutterPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 71d65ea..0b5f8d8 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,8 +3,10 @@ # list(APPEND FLUTTER_PLUGIN_LIST + battery_plus connectivity_plus flutter_secure_storage_windows + sentry_flutter ) list(APPEND FLUTTER_FFI_PLUGIN_LIST