diff --git a/docs/SETUP.md b/docs/SETUP.md index db7b850..5b87806 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -75,6 +75,32 @@ Behavior notes: permission prompts - tapping a reminder notification opens reminder management UI in-app +## WhatsApp Share Intake (MVP) + +Inbound WhatsApp share-intake is now wired behind a listener in the authenticated +app shell. + +Control flag: + +```bash +flutter run --dart-define=ENABLE_WHATSAPP_SHARE_INTAKE=true +``` + +Disable explicitly: + +```bash +flutter run --dart-define=ENABLE_WHATSAPP_SHARE_INTAKE=false +``` + +Behavior notes: + +- iOS/Android: integrates with share-intent plugin (`receive_sharing_intent`) +- shared text is parsed and ingested into: + - person profile (auto-create when unresolved) + - source->profile link map for follow-up matching + - moment history (`type=whatsapp`) +- Settings includes `Simulate WhatsApp Share` action for local/dev testing. + ## Local Persistence Backend Default local store: diff --git a/docs/progress.md b/docs/progress.md index dc4baaa..19cdbd8 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -2,6 +2,55 @@ Updated: 2026-02-17 +## Latest Milestone (2026-02-17): WhatsApp Share Intake + Profile Quick Actions + +Implemented the next major usage-flow slice focused on capture-to-action speed: + +- WhatsApp share intake pipeline (MVP): + - `lib/features/share_intake/whatsapp_share_listener.dart` + - `lib/features/share_intake/whatsapp_share_parser.dart` + - listener now ingests shared WhatsApp text into local state while + authenticated + - parser resolves sender-prefixed message shapes (including timestamped + export line format) +- Source identity linking for repeat routing: + - `lib/features/local/local_models.dart` + - `lib/features/local/local_repository.dart` + - added persisted source->profile link records and shared-message history + - repeated shares with same source identity resolve to the same profile +- Auto profile creation on first unresolved share: + - `LocalRepository.ingestSharedMessage(...)` + - creates profile (when needed), writes/updates source link, and appends + WhatsApp moment capture to that profile history +- Profile model enhancement: + - added optional `location` field on `PersonProfile` + - wired into serialization, repository payload mapping, and People editor UI +- People context quick-add: + - `lib/features/people/people_view.dart` + - profile detail now includes `+` quick actions: + - Add Capture + - Add Note + - Add Idea + - Add Reminder + - each action is person-scoped and writes directly to local state +- Settings support for dev/testing: + - `lib/features/settings/settings_view.dart` + - added `Simulate WhatsApp Share` flow to test ingestion without OS share UI +- App bootstrap wiring: + - `lib/main.dart` wraps authenticated shell with `WhatsAppShareListener` + - runtime flag in `lib/core/config/app_config.dart`: + `ENABLE_WHATSAPP_SHARE_INTAKE` +- Added tests: + - `test/features/share_intake/whatsapp_share_parser_test.dart` + - `test/features/local/local_repository_test.dart` share-ingest/link reuse case + - updated migration expectation in + `test/features/local/storage/local_repository_hive_migration_test.dart` + +Validation for this milestone: + +- `flutter analyze` -> pass +- `flutter test` -> pass + ## Latest Milestone (2026-02-17): Interactive Relationship Graph + Person Insights Drilldown Upgraded the dashboard graph from static visualization to interactive diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index c7bbe77..2675bff 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -36,6 +36,12 @@ class AppConfig { defaultValue: true, ); + /// Enables inbound share-intake pipeline for WhatsApp text shares. + static bool get enableWhatsAppShareIntake => const bool.fromEnvironment( + 'ENABLE_WHATSAPP_SHARE_INTAKE', + defaultValue: true, + ); + /// Interval used for periodic background sync ticks. static int get backgroundSyncIntervalSeconds => const int.fromEnvironment( 'BACKGROUND_SYNC_INTERVAL_SECONDS', diff --git a/lib/features/local/local_models.dart b/lib/features/local/local_models.dart index 1bf6ef2..3c1f686 100644 --- a/lib/features/local/local_models.dart +++ b/lib/features/local/local_models.dart @@ -16,6 +16,7 @@ class PersonProfile { required this.nextMoment, required this.tags, required this.notes, + this.location, }); final String id; @@ -25,6 +26,7 @@ class PersonProfile { final DateTime nextMoment; final List tags; final String notes; + final String? location; PersonProfile copyWith({ String? id, @@ -34,6 +36,7 @@ class PersonProfile { DateTime? nextMoment, List? tags, String? notes, + String? location, }) { return PersonProfile( id: id ?? this.id, @@ -43,6 +46,7 @@ class PersonProfile { nextMoment: nextMoment ?? this.nextMoment, tags: tags ?? this.tags, notes: notes ?? this.notes, + location: location ?? this.location, ); } @@ -55,6 +59,7 @@ class PersonProfile { 'nextMoment': nextMoment.toUtc().toIso8601String(), 'tags': tags, 'notes': notes, + 'location': location, }; } @@ -69,6 +74,7 @@ class PersonProfile { .map((dynamic tag) => '$tag') .toList(growable: false), notes: json['notes'] as String? ?? '', + location: json['location'] as String?, ); } } @@ -318,6 +324,162 @@ class DashboardTask { } } +@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, + }); + + final String id; + final String sourceApp; + final String? sourceUserId; + final String? sourceThreadId; + final String normalizedDisplayName; + final String profileId; + final DateTime firstSeenAt; + final DateTime lastSeenAt; + + SourceProfileLink copyWith({ + String? id, + String? sourceApp, + String? sourceUserId, + String? sourceThreadId, + 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, + 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, + '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?, + 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?, + ); + } +} + @immutable class DashboardSummary { const DashboardSummary({ @@ -341,6 +503,8 @@ class LocalDataState { required this.ideas, required this.reminders, required this.tasks, + this.sourceLinks = const [], + this.sharedMessages = const [], }); final List people; @@ -348,6 +512,8 @@ class LocalDataState { final List ideas; final List reminders; final List tasks; + final List sourceLinks; + final List sharedMessages; LocalDataState copyWith({ List? people, @@ -355,6 +521,8 @@ class LocalDataState { List? ideas, List? reminders, List? tasks, + List? sourceLinks, + List? sharedMessages, }) { return LocalDataState( people: people ?? this.people, @@ -362,6 +530,8 @@ class LocalDataState { ideas: ideas ?? this.ideas, reminders: reminders ?? this.reminders, tasks: tasks ?? this.tasks, + sourceLinks: sourceLinks ?? this.sourceLinks, + sharedMessages: sharedMessages ?? this.sharedMessages, ); } @@ -398,6 +568,12 @@ class LocalDataState { '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), }; } @@ -433,6 +609,18 @@ class LocalDataState { 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), ); } @@ -447,6 +635,7 @@ class LocalDataState { 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', @@ -456,6 +645,7 @@ class LocalDataState { 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', @@ -465,6 +655,7 @@ class LocalDataState { nextMoment: DateTime(2026, 2, 20, 12, 0), tags: ['plants', 'retro music'], notes: 'Birthday prep should start this week.', + location: 'Seattle, WA', ), ], moments: [ diff --git a/lib/features/local/local_repository.dart b/lib/features/local/local_repository.dart index d32daed..0f221a6 100644 --- a/lib/features/local/local_repository.dart +++ b/lib/features/local/local_repository.dart @@ -12,9 +12,45 @@ 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. +class SharedMessageIngestResult { + const SharedMessageIngestResult({ + required this.profileId, + required this.profileName, + required this.createdProfile, + required this.createdSourceLink, + required this.momentId, + }); + + final String profileId; + final String profileName; + final bool createdProfile; + final bool createdSourceLink; + final String momentId; +} + /// Persisted local data source for offline-first product state. class LocalRepository extends AsyncNotifier { - static const int _schemaVersion = 2; + static const int _schemaVersion = 3; static const int _syncSchemaVersion = 1; static const Uuid _uuid = Uuid(); @@ -67,6 +103,7 @@ class LocalRepository extends AsyncNotifier { required String notes, required List tags, DateTime? nextMoment, + String? location, }) async { final String normalizedName = name.trim(); final String normalizedRelationship = relationship.trim(); @@ -83,6 +120,7 @@ class LocalRepository extends AsyncNotifier { 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 = [ @@ -100,6 +138,165 @@ class LocalRepository extends AsyncNotifier { ); } + 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 SourceProfileLink? matchedLink = _findExistingSourceLink( + links: current.sourceLinks, + sourceApp: sourceApp, + sourceUserId: sourceUserId, + sourceThreadId: sourceThreadId, + normalizedDisplayName: normalizedDisplayName, + ); + + PersonProfile? resolvedPerson; + if (matchedLink != null) { + resolvedPerson = _findPersonById(current.people, matchedLink.profileId); + } + + if (resolvedPerson == null && normalizedDisplayName.isNotEmpty) { + resolvedPerson = _findPersonByNormalizedName( + current.people, + normalizedDisplayName, + ); + } + + bool createdProfile = false; + final List nextPeople = current.people.toList( + growable: true, + ); + if (resolvedPerson == null) { + createdProfile = true; + resolvedPerson = PersonProfile( + id: 'p-${_uuid.v4()}', + name: sourceDisplayName ?? 'WhatsApp Contact', + 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); + } + + 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, + 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, + normalizedDisplayName: normalizedDisplayName.isEmpty + ? matchedLink.normalizedDisplayName + : normalizedDisplayName, + profileId: resolvedPerson.id, + lastSeenAt: sharedAt, + ); + } + } + + final String summary = trimmedMessage.length > 1800 + ? trimmedMessage.substring(0, 1800) + : trimmedMessage; + 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 List nextMessages = [ + SharedMessageEntry( + id: 'sm-${_uuid.v4()}', + sourceApp: sourceApp, + profileId: resolvedPerson.id, + messageText: summary, + sharedAt: sharedAt, + importedAt: now, + resolvedAutomatically: true, + sourceDisplayName: sourceDisplayName, + sourceUserId: sourceUserId, + sourceThreadId: sourceThreadId, + ), + ...current.sharedMessages, + ]; + + await _setState( + current.copyWith( + people: nextPeople, + moments: nextMoments, + sourceLinks: nextLinks, + sharedMessages: nextMessages, + ), + ); + + 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); + + return SharedMessageIngestResult( + profileId: resolvedPerson.id, + profileName: resolvedPerson.name, + createdProfile: createdProfile, + createdSourceLink: createdSourceLink, + momentId: moment.id, + ); + } + Future updatePerson(PersonProfile person) async { if (person.name.trim().isEmpty || person.relationship.trim().isEmpty) { throw ArgumentError('Name and relationship are required'); @@ -546,6 +743,7 @@ class LocalRepository extends AsyncNotifier { ), 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, @@ -729,6 +927,7 @@ class LocalRepository extends AsyncNotifier { 'nextMoment': person.nextMoment.toUtc().toIso8601String(), 'tags': person.tags, 'notes': person.notes, + 'location': person.location, }; } @@ -844,6 +1043,22 @@ class LocalRepository extends AsyncNotifier { 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) { @@ -952,6 +1167,82 @@ class LocalRepository extends AsyncNotifier { final int take = words.length < 5 ? words.length : 5; return words.take(take).join(' '); } + + SourceProfileLink? _findExistingSourceLink({ + required List links, + required String sourceApp, + required String? sourceUserId, + required String? sourceThreadId, + required String normalizedDisplayName, + }) { + 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; + } + + PersonProfile? _findPersonByNormalizedName( + List people, + String normalizedDisplayName, + ) { + for (final PersonProfile person in people) { + if (_normalizeDisplayName(person.name) == normalizedDisplayName) { + return person; + } + } + return null; + } + + 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 diff --git a/lib/features/people/people_view.dart b/lib/features/people/people_view.dart index 3c43f90..ddb1b08 100644 --- a/lib/features/people/people_view.dart +++ b/lib/features/people/people_view.dart @@ -23,6 +23,8 @@ selectedPersonIdProvider = NotifierProvider( SelectedPersonIdNotifier.new, ); +enum _PersonQuickAction { capture, note, idea, reminder } + class PeopleView extends ConsumerWidget { const PeopleView({super.key}); @@ -81,6 +83,7 @@ class PeopleView extends ConsumerWidget { relationship: draft.relationship, notes: draft.notes, tags: draft.tags, + location: draft.location, ); } @@ -98,6 +101,7 @@ class PeopleView extends ConsumerWidget { relationship: person.relationship, notes: person.notes, tags: person.tags, + location: person.location, ), ), ); @@ -114,6 +118,7 @@ class PeopleView extends ConsumerWidget { relationship: draft.relationship, notes: draft.notes, tags: draft.tags, + location: draft.location, ), ); } @@ -202,6 +207,8 @@ class PeopleView extends ConsumerWidget { person: selected, onEdit: () => _handleEditPerson(context, ref, selected), onDelete: () => _handleDeletePerson(context, ref, selected.id), + onQuickAction: (_PersonQuickAction action) => + _handleQuickAction(context, ref, selected, action), ), ), ), @@ -244,12 +251,125 @@ class PeopleView extends ConsumerWidget { compact: true, onEdit: () => _handleEditPerson(context, ref, selected), onDelete: () => _handleDeletePerson(context, ref, selected.id), + onQuickAction: (_PersonQuickAction action) => + _handleQuickAction(context, ref, selected, action), ), ), ], ), ); } + + 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 _showQuickTextCaptureDialog( + BuildContext context, { + required String title, + required String hintText, + }) async { + final TextEditingController controller = TextEditingController(); + final String? result = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text(title), + content: TextField( + controller: controller, + minLines: 2, + maxLines: 4, + decoration: InputDecoration(hintText: hintText), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final String value = controller.text.trim(); + if (value.isEmpty) { + return; + } + Navigator.of(context).pop(value); + }, + child: const Text('Save'), + ), + ], + ); + }, + ); + controller.dispose(); + return result; + } } class _EmptyPeopleView extends StatelessWidget { @@ -294,12 +414,14 @@ class _PersonDetail extends StatelessWidget { required this.person, required this.onEdit, required this.onDelete, + required this.onQuickAction, this.compact = false, }); final PersonProfile person; final VoidCallback onEdit; final VoidCallback onDelete; + final ValueChanged<_PersonQuickAction> onQuickAction; final bool compact; @override @@ -341,6 +463,30 @@ class _PersonDetail extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.end, children: [ + PopupMenuButton<_PersonQuickAction>( + tooltip: 'Quick add', + 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'), + ), + ], + icon: const Icon(Icons.add_circle_outline_rounded), + ), IconButton( onPressed: onEdit, icon: const Icon(Icons.edit_rounded), @@ -382,6 +528,30 @@ class _PersonDetail extends StatelessWidget { ], ), ), + PopupMenuButton<_PersonQuickAction>( + tooltip: 'Quick add', + 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'), + ), + ], + icon: const Icon(Icons.add_circle_outline_rounded), + ), IconButton( onPressed: onEdit, icon: const Icon(Icons.edit_rounded), @@ -394,6 +564,12 @@ class _PersonDetail extends StatelessWidget { ), ], ), + if ((person.location ?? '').trim().isNotEmpty) ...[ + SizedBox(height: compact ? 10 : 14), + Text('Location', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + _DetailTag(label: person.location!.trim()), + ], SizedBox(height: compact ? 14 : 22), Text('Next moment', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), @@ -555,6 +731,7 @@ class _PersonEditorDialog extends StatefulWidget { class _PersonEditorDialogState extends State<_PersonEditorDialog> { late final TextEditingController _nameController; late final TextEditingController _relationshipController; + late final TextEditingController _locationController; late final TextEditingController _tagsController; late final TextEditingController _notesController; @@ -565,6 +742,9 @@ class _PersonEditorDialogState extends State<_PersonEditorDialog> { _relationshipController = TextEditingController( text: widget.initial?.relationship ?? '', ); + _locationController = TextEditingController( + text: widget.initial?.location ?? '', + ); _tagsController = TextEditingController( text: widget.initial?.tags.join(', ') ?? '', ); @@ -575,6 +755,7 @@ class _PersonEditorDialogState extends State<_PersonEditorDialog> { void dispose() { _nameController.dispose(); _relationshipController.dispose(); + _locationController.dispose(); _tagsController.dispose(); _notesController.dispose(); super.dispose(); @@ -598,6 +779,12 @@ class _PersonEditorDialogState extends State<_PersonEditorDialog> { controller: _relationshipController, decoration: const InputDecoration(labelText: 'Relationship'), ), + TextField( + controller: _locationController, + decoration: const InputDecoration( + labelText: 'Location (optional)', + ), + ), TextField( controller: _tagsController, decoration: const InputDecoration( @@ -629,6 +816,9 @@ class _PersonEditorDialogState extends State<_PersonEditorDialog> { final _PersonDraft draft = _PersonDraft( name: name, relationship: relationship, + location: _locationController.text.trim().isEmpty + ? null + : _locationController.text.trim(), notes: _notesController.text.trim(), tags: _tagsController.text .split(',') @@ -651,12 +841,250 @@ class _PersonDraft { 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; +} + +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 _DetailTag extends StatelessWidget { diff --git a/lib/features/settings/settings_view.dart b/lib/features/settings/settings_view.dart index 7f9ebd1..dd7102f 100644 --- a/lib/features/settings/settings_view.dart +++ b/lib/features/settings/settings_view.dart @@ -3,7 +3,9 @@ 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/reminders/scheduling/reminder_scheduler_provider.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'; @@ -120,6 +122,12 @@ class SettingsView extends ConsumerWidget { ), label: const Text('Request Notification Access'), ), + OutlinedButton.icon( + onPressed: () => + _simulateWhatsAppShare(context, ref), + icon: const Icon(Icons.message_rounded), + label: const Text('Simulate WhatsApp Share'), + ), ], ), ], @@ -169,6 +177,59 @@ class SettingsView extends ConsumerWidget { ), ); } + + 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.createdProfile + ? 'Imported and created ${result.profileName}.' + : 'Imported to ${result.profileName}.', + ), + ), + ); + } } class _SettingRow extends StatelessWidget { @@ -219,3 +280,65 @@ class _SettingRow extends StatelessWidget { ); } } + +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'), + ), + ], + ); + } +} diff --git a/lib/features/share_intake/whatsapp_share_listener.dart b/lib/features/share_intake/whatsapp_share_listener.dart new file mode 100644 index 0000000..83d9a31 --- /dev/null +++ b/lib/features/share_intake/whatsapp_share_listener.dart @@ -0,0 +1,158 @@ +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/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(_handleMediaList, onError: (_) {}); + + final List initialMedia = await ReceiveSharingIntent + .instance + .getInitialMedia(); + if (initialMedia.isNotEmpty) { + await _handleMediaList(initialMedia); + } + } catch (_) { + // Unsupported platform/plugin state should not break app usage. + } + } + + Future _handleMediaList(List files) async { + for (final SharedMediaFile file in files) { + final String? rawText = _extractText(file); + if (rawText == null) { + continue; + } + await _handleRawText(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; + } + + Future _handleRawText(String rawText) 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; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.createdProfile + ? 'Imported from WhatsApp and created profile ${result.profileName}.' + : 'Imported from WhatsApp to ${result.profileName}.', + ), + action: SnackBarAction( + label: 'Open People', + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) => const PeopleView(), + ), + ); + }, + ), + ), + ); + + try { + await ReceiveSharingIntent.instance.reset(); + } catch (_) { + // Best effort. + } + } +} diff --git a/lib/features/share_intake/whatsapp_share_parser.dart b/lib/features/share_intake/whatsapp_share_parser.dart new file mode 100644 index 0000000..dbb7752 --- /dev/null +++ b/lib/features/share_intake/whatsapp_share_parser.dart @@ -0,0 +1,78 @@ +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'; + } +} diff --git a/lib/main.dart b/lib/main.dart index 1d9bbcb..45d4f60 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,7 @@ 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'; @@ -29,8 +30,10 @@ class RelationshipSaverApp extends ConsumerWidget { body: sessionState.when( data: (session) => session == null ? const SignInView() - : const ReminderNotificationListener( - child: SyncBackgroundRunner(child: AppShell()), + : const WhatsAppShareListener( + child: ReminderNotificationListener( + child: SyncBackgroundRunner(child: AppShell()), + ), ), loading: () => const Center(child: CircularProgressIndicator()), error: (Object error, StackTrace stackTrace) => const SignInView(), diff --git a/pubspec.lock b/pubspec.lock index 71ed454..92bedfe 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -720,6 +720,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.2" + receive_sharing_intent: + dependency: "direct main" + description: + name: receive_sharing_intent + sha256: ec76056e4d258ad708e76d85591d933678625318e411564dcb9059048ca3a593 + url: "https://pub.dev" + source: hosted + version: "1.8.1" rfc_6901: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 548dd88..d044a75 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,7 @@ dependencies: shared_preferences: ^2.5.4 hive_flutter: ^1.1.0 connectivity_plus: ^7.0.0 + receive_sharing_intent: ^1.8.1 timezone: ^0.10.1 flutter_local_notifications: ^20.1.0 diff --git a/test/features/local/local_repository_test.dart b/test/features/local/local_repository_test.dart index 8deb968..09f0b33 100644 --- a/test/features/local/local_repository_test.dart +++ b/test/features/local/local_repository_test.dart @@ -40,10 +40,12 @@ void main() { relationship: 'Friend', notes: 'test', tags: ['alpha'], + location: 'Berlin', ); final afterAdd = await container.read(localRepositoryProvider.future); expect(afterAdd.people.length, initialCount + 1); + expect(afterAdd.people.first.location, 'Berlin'); final String insertedId = afterAdd.people.first.id; await container @@ -78,6 +80,68 @@ void main() { expect(afterDelete.moments.length, beforeCount); }); + test( + 'ingestSharedMessage auto-creates profile and reuses source link for follow-up messages', + () async { + final ProviderContainer container = _createContainer(); + addTearDown(container.dispose); + + final LocalDataState before = await container.read( + localRepositoryProvider.future, + ); + + final SharedMessageIngestResult first = await container + .read(localRepositoryProvider.notifier) + .ingestSharedMessage( + const SharedMessageIngestInput( + sourceApp: 'whatsapp', + sourceDisplayName: 'Taylor Quinn', + sourceUserId: 'wa:taylor quinn', + messageText: 'Can we do coffee this week?', + ), + ); + + final LocalDataState afterFirst = await container.read( + localRepositoryProvider.future, + ); + expect(first.createdProfile, isTrue); + expect(afterFirst.people.length, before.people.length + 1); + expect( + afterFirst.sourceLinks.any((link) => link.profileId == first.profileId), + isTrue, + ); + expect( + afterFirst.moments.any( + (RelationshipMoment moment) => + moment.personId == first.profileId && moment.type == 'whatsapp', + ), + isTrue, + ); + + final SharedMessageIngestResult second = await container + .read(localRepositoryProvider.notifier) + .ingestSharedMessage( + const SharedMessageIngestInput( + sourceApp: 'whatsapp', + sourceDisplayName: 'Taylor Quinn', + sourceUserId: 'wa:taylor quinn', + messageText: 'Lets plan Sunday brunch too.', + ), + ); + + final LocalDataState afterSecond = await container.read( + localRepositoryProvider.future, + ); + expect(second.createdProfile, isFalse); + expect(second.profileId, first.profileId); + expect(afterSecond.people.length, afterFirst.people.length); + expect( + afterSecond.sharedMessages.length, + afterFirst.sharedMessages.length + 1, + ); + }, + ); + test('adds, archives, edits, and deletes ideas', () async { final ProviderContainer container = _createContainer(); addTearDown(container.dispose); 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 500caf0..029f016 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, 2); + expect(migrated!.schemaVersion, 3); final LocalDataRecord? legacyAfter = await legacyStore.read(); expect(legacyAfter, isNull); diff --git a/test/features/share_intake/whatsapp_share_parser_test.dart b/test/features/share_intake/whatsapp_share_parser_test.dart new file mode 100644 index 0000000..0914555 --- /dev/null +++ b/test/features/share_intake/whatsapp_share_parser_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:relationship_saver/features/share_intake/whatsapp_share_parser.dart'; + +void main() { + test('parses sender-prefixed WhatsApp text', () { + final WhatsAppSharePayload payload = WhatsAppShareParser.parse( + 'Ava Hart: Lets do coffee on Saturday?', + ); + + expect(payload.sourceDisplayName, 'Ava Hart'); + expect(payload.sourceUserId, 'wa:ava hart'); + expect(payload.messageText, 'Lets do coffee on Saturday?'); + }); + + test('parses bracket timestamp style WhatsApp export line', () { + final WhatsAppSharePayload payload = WhatsAppShareParser.parse( + '[2/17/26, 22:41] Jordan Lee: Finished 5k today!', + ); + + expect(payload.sourceDisplayName, 'Jordan Lee'); + expect(payload.sourceUserId, 'wa:jordan lee'); + expect(payload.messageText, 'Finished 5k today!'); + }); + + test('falls back to raw message when sender cannot be detected', () { + const String raw = 'Remember to ask about her interview tomorrow.'; + final WhatsAppSharePayload payload = WhatsAppShareParser.parse(raw); + + expect(payload.sourceDisplayName, isNull); + expect(payload.sourceUserId, isNull); + expect(payload.messageText, raw); + }); +}