Implement WhatsApp share intake and person quick actions
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>[
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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,9 +30,11 @@ class RelationshipSaverApp extends ConsumerWidget {
|
||||
body: sessionState.when(
|
||||
data: (session) => session == null
|
||||
? const SignInView()
|
||||
: const ReminderNotificationListener(
|
||||
: const WhatsAppShareListener(
|
||||
child: ReminderNotificationListener(
|
||||
child: SyncBackgroundRunner(child: AppShell()),
|
||||
),
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (Object error, StackTrace stackTrace) => const SignInView(),
|
||||
),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -40,10 +40,12 @@ void main() {
|
||||
relationship: 'Friend',
|
||||
notes: 'test',
|
||||
tags: <String>['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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user