Implement WhatsApp share intake and person quick actions

This commit is contained in:
Rijad Zuzo
2026-02-19 00:09:28 +01:00
parent 818c1046f3
commit d2205bd3d9
15 changed files with 1463 additions and 4 deletions
+191
View File
@@ -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<String> tags;
final String notes;
final String? location;
PersonProfile copyWith({
String? id,
@@ -34,6 +36,7 @@ class PersonProfile {
DateTime? nextMoment,
List<String>? 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<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'sourceApp': sourceApp,
'sourceUserId': sourceUserId,
'sourceThreadId': sourceThreadId,
'normalizedDisplayName': normalizedDisplayName,
'profileId': profileId,
'firstSeenAt': firstSeenAt.toUtc().toIso8601String(),
'lastSeenAt': lastSeenAt.toUtc().toIso8601String(),
};
}
factory SourceProfileLink.fromJson(Map<String, dynamic> 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<String, dynamic> toJson() {
return <String, dynamic>{
'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<String, dynamic> 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 <SourceProfileLink>[],
this.sharedMessages = const <SharedMessageEntry>[],
});
final List<PersonProfile> people;
@@ -348,6 +512,8 @@ class LocalDataState {
final List<RelationshipIdea> ideas;
final List<ReminderRule> reminders;
final List<DashboardTask> tasks;
final List<SourceProfileLink> sourceLinks;
final List<SharedMessageEntry> sharedMessages;
LocalDataState copyWith({
List<PersonProfile>? people,
@@ -355,6 +521,8 @@ class LocalDataState {
List<RelationshipIdea>? ideas,
List<ReminderRule>? reminders,
List<DashboardTask>? tasks,
List<SourceProfileLink>? sourceLinks,
List<SharedMessageEntry>? 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<String, dynamic>),
)
.toList(growable: false),
sourceLinks: (json['sourceLinks'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic link) =>
SourceProfileLink.fromJson(link as Map<String, dynamic>),
)
.toList(growable: false),
sharedMessages: (json['sharedMessages'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic entry) =>
SharedMessageEntry.fromJson(entry as Map<String, dynamic>),
)
.toList(growable: false),
);
}
@@ -447,6 +635,7 @@ class LocalDataState {
nextMoment: DateTime(2026, 2, 16, 19, 30),
tags: <String>['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: <String>['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: <String>['plants', 'retro music'],
notes: 'Birthday prep should start this week.',
location: 'Seattle, WA',
),
],
moments: <RelationshipMoment>[
+292 -1
View File
@@ -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<LocalDataState> {
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<LocalDataState> {
required String notes,
required List<String> tags,
DateTime? nextMoment,
String? location,
}) async {
final String normalizedName = name.trim();
final String normalizedRelationship = relationship.trim();
@@ -83,6 +120,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
nextMoment: nextMoment ?? DateTime.now().add(const Duration(days: 3)),
tags: tags,
notes: notes.trim(),
location: location?.trim().isEmpty == true ? null : location?.trim(),
);
final List<PersonProfile> people = <PersonProfile>[
@@ -100,6 +138,165 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
);
}
Future<SharedMessageIngestResult> 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<PersonProfile> 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 <String>['whatsapp'],
notes: 'Auto-created from shared message.',
);
nextPeople.insert(0, resolvedPerson);
}
final bool createdSourceLink = matchedLink == null;
final List<SourceProfileLink> 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<RelationshipMoment> nextMoments = <RelationshipMoment>[
moment,
...current.moments,
];
final List<SharedMessageEntry> nextMessages = <SharedMessageEntry>[
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<ChangeEnvelope> outbound = <ChangeEnvelope>[
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<void> 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<LocalDataState> {
),
tags: _stringListField(payload, 'tags', existing?.tags ?? <String>[]),
notes: _stringField(payload, 'notes', existing?.notes ?? ''),
location: _stringOrNullField(payload, 'location', existing?.location),
);
final List<PersonProfile> people = _upsertItem(
items: current.people,
@@ -729,6 +927,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
'nextMoment': person.nextMoment.toUtc().toIso8601String(),
'tags': person.tags,
'notes': person.notes,
'location': person.location,
};
}
@@ -844,6 +1043,22 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
return fallback;
}
String? _stringOrNullField(
Map<String, dynamic> 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<String, dynamic> payload, String key, int fallback) {
final dynamic value = payload[key];
if (value is int) {
@@ -952,6 +1167,82 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
final int take = words.length < 5 ? words.length : 5;
return words.take(take).join(' ');
}
SourceProfileLink? _findExistingSourceLink({
required List<SourceProfileLink> 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<PersonProfile> people, String id) {
for (final PersonProfile person in people) {
if (person.id == id) {
return person;
}
}
return null;
}
PersonProfile? _findPersonByNormalizedName(
List<PersonProfile> 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<LocalRepository, LocalDataState>
+428
View File
@@ -23,6 +23,8 @@ selectedPersonIdProvider = NotifierProvider<SelectedPersonIdNotifier, String?>(
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<void> _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<String?> _showQuickTextCaptureDialog(
BuildContext context, {
required String title,
required String hintText,
}) async {
final TextEditingController controller = TextEditingController();
final String? result = await showDialog<String>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(title),
content: TextField(
controller: controller,
minLines: 2,
maxLines: 4,
decoration: InputDecoration(hintText: hintText),
),
actions: <Widget>[
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: <Widget>[
PopupMenuButton<_PersonQuickAction>(
tooltip: 'Quick add',
onSelected: onQuickAction,
itemBuilder: (BuildContext context) =>
const <PopupMenuEntry<_PersonQuickAction>>[
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 <PopupMenuEntry<_PersonQuickAction>>[
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) ...<Widget>[
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<String> 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: <Widget>[
SegmentedButton<IdeaType>(
segments: const <ButtonSegment<IdeaType>>[
ButtonSegment<IdeaType>(
value: IdeaType.gift,
label: Text('Gift'),
),
ButtonSegment<IdeaType>(
value: IdeaType.event,
label: Text('Event'),
),
],
selected: <IdeaType>{_type},
onSelectionChanged: (Set<IdeaType> 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: <Widget>[
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: <Widget>[
TextField(
controller: _titleController,
decoration: const InputDecoration(labelText: 'Title'),
),
const SizedBox(height: 10),
DropdownButtonFormField<ReminderCadence>(
initialValue: _cadence,
decoration: const InputDecoration(labelText: 'Cadence'),
items: ReminderCadence.values
.map(
(ReminderCadence cadence) =>
DropdownMenuItem<ReminderCadence>(
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: <Widget>[
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: <Widget>[
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 {
+123
View File
@@ -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<void> _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: <Widget>[
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'),
),
],
);
}
}
@@ -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<WhatsAppShareListener> createState() =>
_WhatsAppShareListenerState();
}
class _WhatsAppShareListenerState extends ConsumerState<WhatsAppShareListener> {
StreamSubscription<List<SharedMediaFile>>? _mediaSubscription;
String? _lastToken;
@override
void initState() {
super.initState();
_start();
}
@override
void dispose() {
_mediaSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
Future<void> _start() async {
if (!AppConfig.enableWhatsAppShareIntake || kIsWeb) {
return;
}
try {
_mediaSubscription = ReceiveSharingIntent.instance
.getMediaStream()
.listen(_handleMediaList, onError: (_) {});
final List<SharedMediaFile> initialMedia = await ReceiveSharingIntent
.instance
.getInitialMedia();
if (initialMedia.isNotEmpty) {
await _handleMediaList(initialMedia);
}
} catch (_) {
// Unsupported platform/plugin state should not break app usage.
}
}
Future<void> _handleMediaList(List<SharedMediaFile> 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<void> _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<void>(
MaterialPageRoute<void>(
builder: (BuildContext context) => const PeopleView(),
),
);
},
),
),
);
try {
await ReceiveSharingIntent.instance.reset();
} catch (_) {
// Best effort.
}
}
}
@@ -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';
}
}