Implement WhatsApp share intake and person quick actions
This commit is contained in:
@@ -75,6 +75,32 @@ Behavior notes:
|
|||||||
permission prompts
|
permission prompts
|
||||||
- tapping a reminder notification opens reminder management UI in-app
|
- 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
|
## Local Persistence Backend
|
||||||
|
|
||||||
Default local store:
|
Default local store:
|
||||||
|
|||||||
@@ -2,6 +2,55 @@
|
|||||||
|
|
||||||
Updated: 2026-02-17
|
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
|
## Latest Milestone (2026-02-17): Interactive Relationship Graph + Person Insights Drilldown
|
||||||
|
|
||||||
Upgraded the dashboard graph from static visualization to interactive
|
Upgraded the dashboard graph from static visualization to interactive
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ class AppConfig {
|
|||||||
defaultValue: true,
|
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.
|
/// Interval used for periodic background sync ticks.
|
||||||
static int get backgroundSyncIntervalSeconds => const int.fromEnvironment(
|
static int get backgroundSyncIntervalSeconds => const int.fromEnvironment(
|
||||||
'BACKGROUND_SYNC_INTERVAL_SECONDS',
|
'BACKGROUND_SYNC_INTERVAL_SECONDS',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class PersonProfile {
|
|||||||
required this.nextMoment,
|
required this.nextMoment,
|
||||||
required this.tags,
|
required this.tags,
|
||||||
required this.notes,
|
required this.notes,
|
||||||
|
this.location,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
@@ -25,6 +26,7 @@ class PersonProfile {
|
|||||||
final DateTime nextMoment;
|
final DateTime nextMoment;
|
||||||
final List<String> tags;
|
final List<String> tags;
|
||||||
final String notes;
|
final String notes;
|
||||||
|
final String? location;
|
||||||
|
|
||||||
PersonProfile copyWith({
|
PersonProfile copyWith({
|
||||||
String? id,
|
String? id,
|
||||||
@@ -34,6 +36,7 @@ class PersonProfile {
|
|||||||
DateTime? nextMoment,
|
DateTime? nextMoment,
|
||||||
List<String>? tags,
|
List<String>? tags,
|
||||||
String? notes,
|
String? notes,
|
||||||
|
String? location,
|
||||||
}) {
|
}) {
|
||||||
return PersonProfile(
|
return PersonProfile(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -43,6 +46,7 @@ class PersonProfile {
|
|||||||
nextMoment: nextMoment ?? this.nextMoment,
|
nextMoment: nextMoment ?? this.nextMoment,
|
||||||
tags: tags ?? this.tags,
|
tags: tags ?? this.tags,
|
||||||
notes: notes ?? this.notes,
|
notes: notes ?? this.notes,
|
||||||
|
location: location ?? this.location,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +59,7 @@ class PersonProfile {
|
|||||||
'nextMoment': nextMoment.toUtc().toIso8601String(),
|
'nextMoment': nextMoment.toUtc().toIso8601String(),
|
||||||
'tags': tags,
|
'tags': tags,
|
||||||
'notes': notes,
|
'notes': notes,
|
||||||
|
'location': location,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +74,7 @@ class PersonProfile {
|
|||||||
.map((dynamic tag) => '$tag')
|
.map((dynamic tag) => '$tag')
|
||||||
.toList(growable: false),
|
.toList(growable: false),
|
||||||
notes: json['notes'] as String? ?? '',
|
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
|
@immutable
|
||||||
class DashboardSummary {
|
class DashboardSummary {
|
||||||
const DashboardSummary({
|
const DashboardSummary({
|
||||||
@@ -341,6 +503,8 @@ class LocalDataState {
|
|||||||
required this.ideas,
|
required this.ideas,
|
||||||
required this.reminders,
|
required this.reminders,
|
||||||
required this.tasks,
|
required this.tasks,
|
||||||
|
this.sourceLinks = const <SourceProfileLink>[],
|
||||||
|
this.sharedMessages = const <SharedMessageEntry>[],
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<PersonProfile> people;
|
final List<PersonProfile> people;
|
||||||
@@ -348,6 +512,8 @@ class LocalDataState {
|
|||||||
final List<RelationshipIdea> ideas;
|
final List<RelationshipIdea> ideas;
|
||||||
final List<ReminderRule> reminders;
|
final List<ReminderRule> reminders;
|
||||||
final List<DashboardTask> tasks;
|
final List<DashboardTask> tasks;
|
||||||
|
final List<SourceProfileLink> sourceLinks;
|
||||||
|
final List<SharedMessageEntry> sharedMessages;
|
||||||
|
|
||||||
LocalDataState copyWith({
|
LocalDataState copyWith({
|
||||||
List<PersonProfile>? people,
|
List<PersonProfile>? people,
|
||||||
@@ -355,6 +521,8 @@ class LocalDataState {
|
|||||||
List<RelationshipIdea>? ideas,
|
List<RelationshipIdea>? ideas,
|
||||||
List<ReminderRule>? reminders,
|
List<ReminderRule>? reminders,
|
||||||
List<DashboardTask>? tasks,
|
List<DashboardTask>? tasks,
|
||||||
|
List<SourceProfileLink>? sourceLinks,
|
||||||
|
List<SharedMessageEntry>? sharedMessages,
|
||||||
}) {
|
}) {
|
||||||
return LocalDataState(
|
return LocalDataState(
|
||||||
people: people ?? this.people,
|
people: people ?? this.people,
|
||||||
@@ -362,6 +530,8 @@ class LocalDataState {
|
|||||||
ideas: ideas ?? this.ideas,
|
ideas: ideas ?? this.ideas,
|
||||||
reminders: reminders ?? this.reminders,
|
reminders: reminders ?? this.reminders,
|
||||||
tasks: tasks ?? this.tasks,
|
tasks: tasks ?? this.tasks,
|
||||||
|
sourceLinks: sourceLinks ?? this.sourceLinks,
|
||||||
|
sharedMessages: sharedMessages ?? this.sharedMessages,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,6 +568,12 @@ class LocalDataState {
|
|||||||
'tasks': tasks
|
'tasks': tasks
|
||||||
.map((DashboardTask task) => task.toJson())
|
.map((DashboardTask task) => task.toJson())
|
||||||
.toList(growable: false),
|
.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>),
|
DashboardTask.fromJson(task as Map<String, dynamic>),
|
||||||
)
|
)
|
||||||
.toList(growable: false),
|
.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),
|
nextMoment: DateTime(2026, 2, 16, 19, 30),
|
||||||
tags: <String>['coffee', 'books', 'slow mornings'],
|
tags: <String>['coffee', 'books', 'slow mornings'],
|
||||||
notes: 'Prefers thoughtful plans over expensive plans.',
|
notes: 'Prefers thoughtful plans over expensive plans.',
|
||||||
|
location: 'Austin, TX',
|
||||||
),
|
),
|
||||||
PersonProfile(
|
PersonProfile(
|
||||||
id: 'p-jordan',
|
id: 'p-jordan',
|
||||||
@@ -456,6 +645,7 @@ class LocalDataState {
|
|||||||
nextMoment: DateTime(2026, 2, 18, 18, 0),
|
nextMoment: DateTime(2026, 2, 18, 18, 0),
|
||||||
tags: <String>['running', 'street food'],
|
tags: <String>['running', 'street food'],
|
||||||
notes: 'Has a race on Sunday; ask how training is going.',
|
notes: 'Has a race on Sunday; ask how training is going.',
|
||||||
|
location: 'Chicago, IL',
|
||||||
),
|
),
|
||||||
PersonProfile(
|
PersonProfile(
|
||||||
id: 'p-mila',
|
id: 'p-mila',
|
||||||
@@ -465,6 +655,7 @@ class LocalDataState {
|
|||||||
nextMoment: DateTime(2026, 2, 20, 12, 0),
|
nextMoment: DateTime(2026, 2, 20, 12, 0),
|
||||||
tags: <String>['plants', 'retro music'],
|
tags: <String>['plants', 'retro music'],
|
||||||
notes: 'Birthday prep should start this week.',
|
notes: 'Birthday prep should start this week.',
|
||||||
|
location: 'Seattle, WA',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
moments: <RelationshipMoment>[
|
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:relationship_saver/integrations/backend/models/backend_models.dart';
|
||||||
import 'package:uuid/uuid.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.
|
/// Persisted local data source for offline-first product state.
|
||||||
class LocalRepository extends AsyncNotifier<LocalDataState> {
|
class LocalRepository extends AsyncNotifier<LocalDataState> {
|
||||||
static const int _schemaVersion = 2;
|
static const int _schemaVersion = 3;
|
||||||
static const int _syncSchemaVersion = 1;
|
static const int _syncSchemaVersion = 1;
|
||||||
|
|
||||||
static const Uuid _uuid = Uuid();
|
static const Uuid _uuid = Uuid();
|
||||||
@@ -67,6 +103,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
|||||||
required String notes,
|
required String notes,
|
||||||
required List<String> tags,
|
required List<String> tags,
|
||||||
DateTime? nextMoment,
|
DateTime? nextMoment,
|
||||||
|
String? location,
|
||||||
}) async {
|
}) async {
|
||||||
final String normalizedName = name.trim();
|
final String normalizedName = name.trim();
|
||||||
final String normalizedRelationship = relationship.trim();
|
final String normalizedRelationship = relationship.trim();
|
||||||
@@ -83,6 +120,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
|||||||
nextMoment: nextMoment ?? DateTime.now().add(const Duration(days: 3)),
|
nextMoment: nextMoment ?? DateTime.now().add(const Duration(days: 3)),
|
||||||
tags: tags,
|
tags: tags,
|
||||||
notes: notes.trim(),
|
notes: notes.trim(),
|
||||||
|
location: location?.trim().isEmpty == true ? null : location?.trim(),
|
||||||
);
|
);
|
||||||
|
|
||||||
final List<PersonProfile> people = <PersonProfile>[
|
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 {
|
Future<void> updatePerson(PersonProfile person) async {
|
||||||
if (person.name.trim().isEmpty || person.relationship.trim().isEmpty) {
|
if (person.name.trim().isEmpty || person.relationship.trim().isEmpty) {
|
||||||
throw ArgumentError('Name and relationship are required');
|
throw ArgumentError('Name and relationship are required');
|
||||||
@@ -546,6 +743,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
|||||||
),
|
),
|
||||||
tags: _stringListField(payload, 'tags', existing?.tags ?? <String>[]),
|
tags: _stringListField(payload, 'tags', existing?.tags ?? <String>[]),
|
||||||
notes: _stringField(payload, 'notes', existing?.notes ?? ''),
|
notes: _stringField(payload, 'notes', existing?.notes ?? ''),
|
||||||
|
location: _stringOrNullField(payload, 'location', existing?.location),
|
||||||
);
|
);
|
||||||
final List<PersonProfile> people = _upsertItem(
|
final List<PersonProfile> people = _upsertItem(
|
||||||
items: current.people,
|
items: current.people,
|
||||||
@@ -729,6 +927,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
|||||||
'nextMoment': person.nextMoment.toUtc().toIso8601String(),
|
'nextMoment': person.nextMoment.toUtc().toIso8601String(),
|
||||||
'tags': person.tags,
|
'tags': person.tags,
|
||||||
'notes': person.notes,
|
'notes': person.notes,
|
||||||
|
'location': person.location,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -844,6 +1043,22 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
|||||||
return fallback;
|
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) {
|
int _intField(Map<String, dynamic> payload, String key, int fallback) {
|
||||||
final dynamic value = payload[key];
|
final dynamic value = payload[key];
|
||||||
if (value is int) {
|
if (value is int) {
|
||||||
@@ -952,6 +1167,82 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
|||||||
final int take = words.length < 5 ? words.length : 5;
|
final int take = words.length < 5 ? words.length : 5;
|
||||||
return words.take(take).join(' ');
|
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>
|
final AsyncNotifierProvider<LocalRepository, LocalDataState>
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ selectedPersonIdProvider = NotifierProvider<SelectedPersonIdNotifier, String?>(
|
|||||||
SelectedPersonIdNotifier.new,
|
SelectedPersonIdNotifier.new,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
enum _PersonQuickAction { capture, note, idea, reminder }
|
||||||
|
|
||||||
class PeopleView extends ConsumerWidget {
|
class PeopleView extends ConsumerWidget {
|
||||||
const PeopleView({super.key});
|
const PeopleView({super.key});
|
||||||
|
|
||||||
@@ -81,6 +83,7 @@ class PeopleView extends ConsumerWidget {
|
|||||||
relationship: draft.relationship,
|
relationship: draft.relationship,
|
||||||
notes: draft.notes,
|
notes: draft.notes,
|
||||||
tags: draft.tags,
|
tags: draft.tags,
|
||||||
|
location: draft.location,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +101,7 @@ class PeopleView extends ConsumerWidget {
|
|||||||
relationship: person.relationship,
|
relationship: person.relationship,
|
||||||
notes: person.notes,
|
notes: person.notes,
|
||||||
tags: person.tags,
|
tags: person.tags,
|
||||||
|
location: person.location,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -114,6 +118,7 @@ class PeopleView extends ConsumerWidget {
|
|||||||
relationship: draft.relationship,
|
relationship: draft.relationship,
|
||||||
notes: draft.notes,
|
notes: draft.notes,
|
||||||
tags: draft.tags,
|
tags: draft.tags,
|
||||||
|
location: draft.location,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -202,6 +207,8 @@ class PeopleView extends ConsumerWidget {
|
|||||||
person: selected,
|
person: selected,
|
||||||
onEdit: () => _handleEditPerson(context, ref, selected),
|
onEdit: () => _handleEditPerson(context, ref, selected),
|
||||||
onDelete: () => _handleDeletePerson(context, ref, selected.id),
|
onDelete: () => _handleDeletePerson(context, ref, selected.id),
|
||||||
|
onQuickAction: (_PersonQuickAction action) =>
|
||||||
|
_handleQuickAction(context, ref, selected, action),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -244,12 +251,125 @@ class PeopleView extends ConsumerWidget {
|
|||||||
compact: true,
|
compact: true,
|
||||||
onEdit: () => _handleEditPerson(context, ref, selected),
|
onEdit: () => _handleEditPerson(context, ref, selected),
|
||||||
onDelete: () => _handleDeletePerson(context, ref, selected.id),
|
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 {
|
class _EmptyPeopleView extends StatelessWidget {
|
||||||
@@ -294,12 +414,14 @@ class _PersonDetail extends StatelessWidget {
|
|||||||
required this.person,
|
required this.person,
|
||||||
required this.onEdit,
|
required this.onEdit,
|
||||||
required this.onDelete,
|
required this.onDelete,
|
||||||
|
required this.onQuickAction,
|
||||||
this.compact = false,
|
this.compact = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final PersonProfile person;
|
final PersonProfile person;
|
||||||
final VoidCallback onEdit;
|
final VoidCallback onEdit;
|
||||||
final VoidCallback onDelete;
|
final VoidCallback onDelete;
|
||||||
|
final ValueChanged<_PersonQuickAction> onQuickAction;
|
||||||
final bool compact;
|
final bool compact;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -341,6 +463,30 @@ class _PersonDetail extends StatelessWidget {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
children: <Widget>[
|
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(
|
IconButton(
|
||||||
onPressed: onEdit,
|
onPressed: onEdit,
|
||||||
icon: const Icon(Icons.edit_rounded),
|
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(
|
IconButton(
|
||||||
onPressed: onEdit,
|
onPressed: onEdit,
|
||||||
icon: const Icon(Icons.edit_rounded),
|
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),
|
SizedBox(height: compact ? 14 : 22),
|
||||||
Text('Next moment', style: Theme.of(context).textTheme.titleMedium),
|
Text('Next moment', style: Theme.of(context).textTheme.titleMedium),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -555,6 +731,7 @@ class _PersonEditorDialog extends StatefulWidget {
|
|||||||
class _PersonEditorDialogState extends State<_PersonEditorDialog> {
|
class _PersonEditorDialogState extends State<_PersonEditorDialog> {
|
||||||
late final TextEditingController _nameController;
|
late final TextEditingController _nameController;
|
||||||
late final TextEditingController _relationshipController;
|
late final TextEditingController _relationshipController;
|
||||||
|
late final TextEditingController _locationController;
|
||||||
late final TextEditingController _tagsController;
|
late final TextEditingController _tagsController;
|
||||||
late final TextEditingController _notesController;
|
late final TextEditingController _notesController;
|
||||||
|
|
||||||
@@ -565,6 +742,9 @@ class _PersonEditorDialogState extends State<_PersonEditorDialog> {
|
|||||||
_relationshipController = TextEditingController(
|
_relationshipController = TextEditingController(
|
||||||
text: widget.initial?.relationship ?? '',
|
text: widget.initial?.relationship ?? '',
|
||||||
);
|
);
|
||||||
|
_locationController = TextEditingController(
|
||||||
|
text: widget.initial?.location ?? '',
|
||||||
|
);
|
||||||
_tagsController = TextEditingController(
|
_tagsController = TextEditingController(
|
||||||
text: widget.initial?.tags.join(', ') ?? '',
|
text: widget.initial?.tags.join(', ') ?? '',
|
||||||
);
|
);
|
||||||
@@ -575,6 +755,7 @@ class _PersonEditorDialogState extends State<_PersonEditorDialog> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_nameController.dispose();
|
_nameController.dispose();
|
||||||
_relationshipController.dispose();
|
_relationshipController.dispose();
|
||||||
|
_locationController.dispose();
|
||||||
_tagsController.dispose();
|
_tagsController.dispose();
|
||||||
_notesController.dispose();
|
_notesController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -598,6 +779,12 @@ class _PersonEditorDialogState extends State<_PersonEditorDialog> {
|
|||||||
controller: _relationshipController,
|
controller: _relationshipController,
|
||||||
decoration: const InputDecoration(labelText: 'Relationship'),
|
decoration: const InputDecoration(labelText: 'Relationship'),
|
||||||
),
|
),
|
||||||
|
TextField(
|
||||||
|
controller: _locationController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Location (optional)',
|
||||||
|
),
|
||||||
|
),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _tagsController,
|
controller: _tagsController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
@@ -629,6 +816,9 @@ class _PersonEditorDialogState extends State<_PersonEditorDialog> {
|
|||||||
final _PersonDraft draft = _PersonDraft(
|
final _PersonDraft draft = _PersonDraft(
|
||||||
name: name,
|
name: name,
|
||||||
relationship: relationship,
|
relationship: relationship,
|
||||||
|
location: _locationController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: _locationController.text.trim(),
|
||||||
notes: _notesController.text.trim(),
|
notes: _notesController.text.trim(),
|
||||||
tags: _tagsController.text
|
tags: _tagsController.text
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -651,12 +841,250 @@ class _PersonDraft {
|
|||||||
required this.relationship,
|
required this.relationship,
|
||||||
required this.notes,
|
required this.notes,
|
||||||
required this.tags,
|
required this.tags,
|
||||||
|
this.location,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String name;
|
final String name;
|
||||||
final String relationship;
|
final String relationship;
|
||||||
final String notes;
|
final String notes;
|
||||||
final List<String> tags;
|
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 {
|
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_config.dart';
|
||||||
import 'package:relationship_saver/core/config/app_theme.dart';
|
import 'package:relationship_saver/core/config/app_theme.dart';
|
||||||
import 'package:relationship_saver/features/auth/session_controller.dart';
|
import 'package:relationship_saver/features/auth/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/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/features/shared/frosted_card.dart';
|
||||||
import 'package:relationship_saver/integrations/backend/models/backend_models.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'),
|
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 {
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-2
@@ -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/auth/sign_in_view.dart';
|
||||||
import 'package:relationship_saver/features/home/app_shell.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/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/features/sync/sync_background_runner.dart';
|
||||||
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
||||||
|
|
||||||
@@ -29,8 +30,10 @@ class RelationshipSaverApp extends ConsumerWidget {
|
|||||||
body: sessionState.when(
|
body: sessionState.when(
|
||||||
data: (session) => session == null
|
data: (session) => session == null
|
||||||
? const SignInView()
|
? const SignInView()
|
||||||
: const ReminderNotificationListener(
|
: const WhatsAppShareListener(
|
||||||
child: SyncBackgroundRunner(child: AppShell()),
|
child: ReminderNotificationListener(
|
||||||
|
child: SyncBackgroundRunner(child: AppShell()),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (Object error, StackTrace stackTrace) => const SignInView(),
|
error: (Object error, StackTrace stackTrace) => const SignInView(),
|
||||||
|
|||||||
@@ -720,6 +720,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.2"
|
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:
|
rfc_6901:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ dependencies:
|
|||||||
shared_preferences: ^2.5.4
|
shared_preferences: ^2.5.4
|
||||||
hive_flutter: ^1.1.0
|
hive_flutter: ^1.1.0
|
||||||
connectivity_plus: ^7.0.0
|
connectivity_plus: ^7.0.0
|
||||||
|
receive_sharing_intent: ^1.8.1
|
||||||
timezone: ^0.10.1
|
timezone: ^0.10.1
|
||||||
flutter_local_notifications: ^20.1.0
|
flutter_local_notifications: ^20.1.0
|
||||||
|
|
||||||
|
|||||||
@@ -40,10 +40,12 @@ void main() {
|
|||||||
relationship: 'Friend',
|
relationship: 'Friend',
|
||||||
notes: 'test',
|
notes: 'test',
|
||||||
tags: <String>['alpha'],
|
tags: <String>['alpha'],
|
||||||
|
location: 'Berlin',
|
||||||
);
|
);
|
||||||
|
|
||||||
final afterAdd = await container.read(localRepositoryProvider.future);
|
final afterAdd = await container.read(localRepositoryProvider.future);
|
||||||
expect(afterAdd.people.length, initialCount + 1);
|
expect(afterAdd.people.length, initialCount + 1);
|
||||||
|
expect(afterAdd.people.first.location, 'Berlin');
|
||||||
|
|
||||||
final String insertedId = afterAdd.people.first.id;
|
final String insertedId = afterAdd.people.first.id;
|
||||||
await container
|
await container
|
||||||
@@ -78,6 +80,68 @@ void main() {
|
|||||||
expect(afterDelete.moments.length, beforeCount);
|
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 {
|
test('adds, archives, edits, and deletes ideas', () async {
|
||||||
final ProviderContainer container = _createContainer();
|
final ProviderContainer container = _createContainer();
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ void main() {
|
|||||||
|
|
||||||
final LocalDataRecord? migrated = await hiveStore.read();
|
final LocalDataRecord? migrated = await hiveStore.read();
|
||||||
expect(migrated, isNotNull);
|
expect(migrated, isNotNull);
|
||||||
expect(migrated!.schemaVersion, 2);
|
expect(migrated!.schemaVersion, 3);
|
||||||
|
|
||||||
final LocalDataRecord? legacyAfter = await legacyStore.read();
|
final LocalDataRecord? legacyAfter = await legacyStore.read();
|
||||||
expect(legacyAfter, isNull);
|
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