Refine backendless share intake

This commit is contained in:
Rijad Zuzo
2026-05-18 20:33:54 +02:00
parent f655adfbea
commit 42a59e959f
37 changed files with 1467 additions and 824 deletions
+13 -53
View File
@@ -96,41 +96,15 @@ extension LocalRepositoryShareOperations on LocalRepository {
);
}
bool createdProfile = false;
final List<PersonProfile> nextPeople = current.people.toList(
growable: true,
);
if (resolvedPerson == null) {
final String? inferredName = _deriveAutoProfileName(
sourceDisplayName: sourceDisplayName,
sourceUserId: sourceUserId,
sourceThreadId: sourceThreadId,
return _queueSharedInbox(
current: current,
payload: payload,
sourceFingerprint: sourceFingerprint,
normalizedDisplayName: normalizedDisplayName,
reason: SharedInboxReason.missingIdentity,
candidateProfileIds: const <String>[],
);
if (inferredName == null) {
return _queueSharedInbox(
current: current,
payload: payload,
sourceFingerprint: sourceFingerprint,
normalizedDisplayName: normalizedDisplayName,
reason: SharedInboxReason.missingIdentity,
candidateProfileIds: const <String>[],
);
}
createdProfile = true;
resolvedPerson = PersonProfile(
id: 'p-${_uuid.v4()}',
name: inferredName,
relationship: 'WhatsApp Contact',
affinityScore: 70,
nextMoment: now.add(const Duration(days: 2)),
tags: const <String>['whatsapp'],
notes: 'Auto-created from shared message.',
aliases: sourceDisplayName == null || sourceDisplayName == inferredName
? const <String>[]
: <String>[sourceDisplayName],
lastUpdatedAt: now,
);
nextPeople.insert(0, resolvedPerson);
}
return _ingestIntoResolvedProfile(
@@ -141,8 +115,8 @@ extension LocalRepositoryShareOperations on LocalRepository {
normalizedDisplayName: normalizedDisplayName,
resolvedPerson: resolvedPerson,
matchedLink: matchedLink,
createdProfile: createdProfile,
people: nextPeople,
createdProfile: false,
people: current.people,
resolvedAutomatically: true,
draft: _defaultDraftForPayload(payload),
);
@@ -298,14 +272,10 @@ extension LocalRepositoryShareOperations on LocalRepository {
}) async {
final LocalDataState current = _requireState();
final DateTime now = DateTime.now();
final String profileName =
_trimToNull(name) ??
_deriveAutoProfileName(
sourceDisplayName: payload.sourceDisplayName,
sourceUserId: payload.sourceUserId,
sourceThreadId: payload.sourceThreadId,
) ??
'New Contact';
final String? profileName = _trimToNull(name);
if (profileName == null) {
throw ArgumentError('A person name is required to create from a share');
}
final String normalizedRelationship = relationship.trim().isEmpty
? 'Contact'
: relationship.trim();
@@ -842,16 +812,6 @@ SharedInboxEntry _requireSharedInboxEntry(
throw ArgumentError('Shared inbox entry not found');
}
String? _deriveAutoProfileName({
required String? sourceDisplayName,
required String? sourceUserId,
required String? sourceThreadId,
}) {
return _trimToNull(sourceDisplayName) ??
_trimToNull(sourceUserId) ??
_trimToNull(sourceThreadId);
}
String? _buildStableSourceFingerprint({
required String sourceApp,
required String? sourceUserId,
+2 -10
View File
@@ -2,14 +2,9 @@ import 'package:flutter/material.dart';
/// Typed shell destinations used by the app navigation shell.
enum AppDestination {
dashboard('Dashboard', Icons.dashboard_rounded),
shareInbox('Capture', Icons.inbox_rounded),
people('People', Icons.people_alt_rounded),
moments('Moments', Icons.auto_awesome_rounded),
signals('Signals', Icons.tips_and_updates_rounded),
aiReview('AI Review', Icons.rate_review_rounded),
ideas('Ideas', Icons.lightbulb_outline_rounded),
reminders('Reminders', Icons.notifications_active_outlined),
sync('Sync', Icons.sync_rounded),
aiReview('Digest', Icons.rate_review_rounded),
settings('Settings', Icons.settings_rounded);
const AppDestination(this.label, this.icon);
@@ -17,6 +12,3 @@ enum AppDestination {
final String label;
final IconData icon;
}
/// Secondary destinations exposed from the compact shell overflow menu.
enum CompactSecondaryDestination { aiReview, ideas, reminders, sync, settings }
+16 -115
View File
@@ -3,14 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/app/navigation/app_destination.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/ai_digest/presentation/ai_digest_review_view.dart';
import 'package:relationship_saver/features/dashboard/presentation/dashboard_view.dart';
import 'package:relationship_saver/features/ideas/presentation/ideas_view.dart';
import 'package:relationship_saver/features/moments/presentation/moments_view.dart';
import 'package:relationship_saver/features/people/presentation/people_view.dart';
import 'package:relationship_saver/features/reminders/presentation/reminders_view.dart';
import 'package:relationship_saver/features/settings/presentation/settings_view.dart';
import 'package:relationship_saver/features/signals/presentation/signals_view.dart';
import 'package:relationship_saver/features/sync/presentation/sync_view.dart';
import 'package:relationship_saver/features/share_intake/presentation/share_inbox_view.dart';
/// Responsive shell that hosts the main feature slices.
class AppShell extends ConsumerStatefulWidget {
@@ -21,16 +16,11 @@ class AppShell extends ConsumerStatefulWidget {
}
class _AppShellState extends ConsumerState<AppShell> {
AppDestination _destination = AppDestination.dashboard;
AppDestination _destination = AppDestination.shareInbox;
static const List<AppDestination> _destinations = AppDestination.values;
static const List<AppDestination> _compactPrimaryDestinations =
<AppDestination>[
AppDestination.dashboard,
AppDestination.people,
AppDestination.moments,
AppDestination.signals,
];
AppDestination.values;
@override
Widget build(BuildContext context) {
@@ -65,6 +55,7 @@ class _AppShellState extends ConsumerState<AppShell> {
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
layoutBuilder: _expandedSwitcherLayout,
child: KeyedSubtree(
key: ValueKey<AppDestination>(_destination),
child: _screenFor(_destination),
@@ -84,31 +75,10 @@ class _AppShellState extends ConsumerState<AppShell> {
final int compactIndex = _compactSelectedIndex(_destination);
final AppDestination active = _compactPrimaryDestinations[compactIndex];
return _CompactShell(
title: active.label,
selected: active,
body: _screenFor(active),
destinations: _compactPrimaryDestinations,
onChange: _selectDestination,
onOpenSecondary: (CompactSecondaryDestination destination) {
final AppDestination screenDestination = switch (destination) {
CompactSecondaryDestination.ideas => AppDestination.ideas,
CompactSecondaryDestination.aiReview => AppDestination.aiReview,
CompactSecondaryDestination.reminders => AppDestination.reminders,
CompactSecondaryDestination.sync => AppDestination.sync,
CompactSecondaryDestination.settings => AppDestination.settings,
};
Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (BuildContext context) {
return _CompactSecondaryScreen(
title: screenDestination.label,
child: _screenFor(screenDestination),
);
},
),
);
},
);
}
@@ -129,22 +99,12 @@ class _AppShellState extends ConsumerState<AppShell> {
Widget _screenFor(AppDestination destination) {
switch (destination) {
case AppDestination.dashboard:
return const DashboardView();
case AppDestination.shareInbox:
return const ShareInboxView();
case AppDestination.people:
return const PeopleView();
case AppDestination.moments:
return const MomentsView();
case AppDestination.signals:
return const SignalsView();
case AppDestination.aiReview:
return const AiDigestReviewView();
case AppDestination.ideas:
return const IdeasView();
case AppDestination.reminders:
return const RemindersView();
case AppDestination.sync:
return const SyncView();
case AppDestination.settings:
return const SettingsView();
}
@@ -255,7 +215,7 @@ class _Sidebar extends StatelessWidget {
borderRadius: BorderRadius.circular(16),
),
child: Text(
'Tip: Use Signals daily and convert at least one into a concrete plan.',
'Share messages or paste text into Capture. Nothing is assigned until you choose a person.',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
@@ -269,69 +229,25 @@ class _Sidebar extends StatelessWidget {
class _CompactShell extends StatelessWidget {
const _CompactShell({
required this.title,
required this.selected,
required this.body,
required this.destinations,
required this.onChange,
required this.onOpenSecondary,
});
final String title;
final AppDestination selected;
final Widget body;
final List<AppDestination> destinations;
final ValueChanged<AppDestination> onChange;
final ValueChanged<CompactSecondaryDestination> onOpenSecondary;
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
children: <Widget>[
Expanded(
child: Text(
title,
style: Theme.of(context).textTheme.titleLarge,
),
),
PopupMenuButton<CompactSecondaryDestination>(
tooltip: 'More',
icon: const Icon(Icons.more_horiz_rounded),
onSelected: onOpenSecondary,
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<CompactSecondaryDestination>>[
const PopupMenuItem<CompactSecondaryDestination>(
value: CompactSecondaryDestination.aiReview,
child: Text('AI Review'),
),
const PopupMenuItem<CompactSecondaryDestination>(
value: CompactSecondaryDestination.ideas,
child: Text('Ideas'),
),
const PopupMenuItem<CompactSecondaryDestination>(
value: CompactSecondaryDestination.reminders,
child: Text('Reminders'),
),
const PopupMenuItem<CompactSecondaryDestination>(
value: CompactSecondaryDestination.sync,
child: Text('Sync'),
),
const PopupMenuItem<CompactSecondaryDestination>(
value: CompactSecondaryDestination.settings,
child: Text('Settings'),
),
],
),
],
),
),
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
layoutBuilder: _expandedSwitcherLayout,
child: KeyedSubtree(
key: ValueKey<AppDestination>(selected),
child: body,
@@ -355,27 +271,12 @@ class _CompactShell extends StatelessWidget {
}
}
class _CompactSecondaryScreen extends StatelessWidget {
const _CompactSecondaryScreen({required this.title, required this.child});
final String title;
final Widget child;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: <Color>[Color(0xFFF3F8FB), Color(0xFFEAF1F8)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(title: Text(title)),
body: child,
),
);
}
Widget _expandedSwitcherLayout(
Widget? currentChild,
List<Widget> previousChildren,
) {
return Stack(
fit: StackFit.expand,
children: <Widget>[...previousChildren, ?currentChild],
);
}
+9 -156
View File
@@ -219,162 +219,15 @@ class LocalDataState {
);
}
static LocalDataState seed() {
return LocalDataState(
people: <PersonProfile>[
PersonProfile(
id: 'p-ava',
name: 'Ava Hart',
relationship: 'Partner',
affinityScore: 92,
nextMoment: DateTime(2026, 2, 16, 19, 30),
tags: <String>['coffee', 'books', 'slow mornings'],
notes: 'Prefers thoughtful plans over expensive plans.',
aliases: <String>['Aves'],
location: 'Austin, TX',
lastUpdatedAt: DateTime(2026, 2, 14, 9, 0),
lastInteractedAt: DateTime(2026, 2, 12, 18, 30),
),
PersonProfile(
id: 'p-jordan',
name: 'Jordan Lee',
relationship: 'Close Friend',
affinityScore: 78,
nextMoment: DateTime(2026, 2, 18, 18, 0),
tags: <String>['running', 'street food'],
notes: 'Has a race on Sunday; ask how training is going.',
location: 'Chicago, IL',
lastUpdatedAt: DateTime(2026, 2, 13, 8, 30),
lastInteractedAt: DateTime(2026, 2, 11, 19, 0),
),
PersonProfile(
id: 'p-mila',
name: 'Mila Stone',
relationship: 'Sister',
affinityScore: 84,
nextMoment: DateTime(2026, 2, 20, 12, 0),
tags: <String>['plants', 'retro music'],
notes: 'Birthday prep should start this week.',
aliases: <String>['Millie'],
location: 'Seattle, WA',
lastUpdatedAt: DateTime(2026, 2, 13, 14, 20),
lastInteractedAt: DateTime(2026, 2, 12, 14, 20),
),
],
moments: <RelationshipMoment>[
RelationshipMoment(
id: 'm-1',
personId: 'p-ava',
title: 'Sunday Walk Tradition',
summary: 'Short walk + no-phone hour worked really well.',
at: DateTime(2026, 2, 9, 9, 30),
type: 'ritual',
),
RelationshipMoment(
id: 'm-2',
personId: 'p-jordan',
title: 'Post-workout Check-in',
summary: 'Shared training playlist and grabbed smoothies.',
at: DateTime(2026, 2, 11, 19, 0),
type: 'support',
),
RelationshipMoment(
id: 'm-3',
personId: 'p-mila',
title: 'Gift Idea Captured',
summary: 'Found a vintage lamp from her saved style board.',
at: DateTime(2026, 2, 12, 14, 20),
type: 'gift',
),
],
ideas: <RelationshipIdea>[
RelationshipIdea(
id: 'i-1',
personId: 'p-ava',
type: IdeaType.gift,
title: 'Handwritten recipe journal',
details: 'Collect 10 memories and recipes from this year.',
createdAt: DateTime(2026, 2, 12, 9),
),
RelationshipIdea(
id: 'i-2',
personId: 'p-mila',
type: IdeaType.event,
title: 'Plant market + brunch date',
details: 'Saturday morning slot; book nearby cafe in advance.',
createdAt: DateTime(2026, 2, 13, 11),
),
],
reminders: <ReminderRule>[
ReminderRule(
id: 'r-1',
personId: 'p-jordan',
title: 'Weekly check-in message',
cadence: ReminderCadence.weekly,
nextAt: DateTime(2026, 2, 18, 18),
),
ReminderRule(
id: 'r-2',
personId: 'p-ava',
title: 'Sunday ritual planning',
cadence: ReminderCadence.weekly,
nextAt: DateTime(2026, 2, 16, 10),
),
],
tasks: <DashboardTask>[
DashboardTask(
id: 't-1',
title: 'Plan Friday dinner with Ava',
description: 'Keep it low-key: ramen + bookstore stop.',
when: DateTime(2026, 2, 16, 17, 0),
),
DashboardTask(
id: 't-2',
title: 'Send race-day encouragement to Jordan',
description: 'Voice note before 8:00 AM.',
when: DateTime(2026, 2, 17, 7, 30),
),
DashboardTask(
id: 't-3',
title: 'Finalize Mila birthday shortlist',
description: 'Pick 1 meaningful gift and 1 backup.',
when: DateTime(2026, 2, 18, 20, 0),
),
],
personFacts: <PersonFact>[
PersonFact(
id: 'pf-1',
personId: 'p-ava',
type: CapturedFactType.like,
text: 'Quiet brunch spots and recipe-book stores',
label: 'Weekend preference',
sourceKind: CaptureSourceKind.manual,
createdAt: DateTime(2026, 2, 10, 8, 0),
updatedAt: DateTime(2026, 2, 10, 8, 0),
),
PersonFact(
id: 'pf-2',
personId: 'p-mila',
type: CapturedFactType.giftIdea,
text: 'Vintage ceramic planter in muted green',
label: 'Gift lead',
sourceKind: CaptureSourceKind.manual,
createdAt: DateTime(2026, 2, 12, 14, 25),
updatedAt: DateTime(2026, 2, 12, 14, 25),
),
],
importantDates: <PersonImportantDate>[
PersonImportantDate(
id: 'pd-1',
personId: 'p-mila',
label: 'Birthday',
date: DateTime(2026, 3, 3),
classification: 'birthday',
sourceKind: CaptureSourceKind.manual,
createdAt: DateTime(2026, 2, 12, 14, 30),
updatedAt: DateTime(2026, 2, 12, 14, 30),
),
],
static LocalDataState empty() {
return const LocalDataState(
people: <PersonProfile>[],
moments: <RelationshipMoment>[],
ideas: <RelationshipIdea>[],
reminders: <ReminderRule>[],
tasks: <DashboardTask>[],
);
}
static LocalDataState seed() => empty();
}
+1 -1
View File
@@ -33,7 +33,7 @@ class AppConfig {
/// Enables periodic startup/resume sync triggers while authenticated.
static bool get enableBackgroundSync =>
const bool.fromEnvironment('ENABLE_BACKGROUND_SYNC', defaultValue: true);
const bool.fromEnvironment('ENABLE_BACKGROUND_SYNC', defaultValue: false);
/// Enables local notifications runtime for reminder delivery.
static bool get enableLocalNotifications => const bool.fromEnvironment(
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:math' as math;
import 'package:relationship_saver/app/state/local_data_state.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:relationship_saver/features/people/domain/person_models.dart';
class AnonymizedLlmDigestContext {
@@ -22,11 +23,13 @@ class AnonymizedLlmContextBuilder {
const AnonymizedLlmContextBuilder({
this.maxPeople = 20,
this.maxSignalsPerPerson = 8,
this.maxPriorSuggestionsPerPerson = 6,
DateTime Function()? now,
}) : _now = now ?? DateTime.now;
final int maxPeople;
final int maxSignalsPerPerson;
final int maxPriorSuggestionsPerPerson;
final DateTime Function() _now;
AnonymizedLlmDigestContext build(LocalDataState state) {
@@ -66,6 +69,7 @@ class AnonymizedLlmContextBuilder {
'Do not infer or ask for names.',
'Return JSON only.',
'Prefer practical suggestions that can be reviewed before saving.',
'Do not repeat priorSuggestions for the same personToken.',
],
'limits': <String, dynamic>{
'maxItems': 10,
@@ -184,9 +188,27 @@ class AnonymizedLlmContextBuilder {
.map((PersonPreferenceSignal signal) => 'avoid ${signal.label}')
.toList(growable: false),
).take(maxSignalsPerPerson).toList(growable: false),
'priorSuggestions': _priorSuggestions(state, person),
};
}
List<Map<String, String>> _priorSuggestions(
LocalDataState state,
PersonProfile person,
) {
return state.aiSuggestionDrafts
.where((AiSuggestionDraft draft) => draft.personId == person.id)
.take(maxPriorSuggestionsPerPerson)
.map(
(AiSuggestionDraft draft) => <String, String>{
'kind': draft.kind.name,
'status': draft.status.name,
'title': _safeCategory(draft.title),
},
)
.toList(growable: false);
}
String _relationshipCategory(String value) {
final String normalized = value.toLowerCase();
if (_containsAny(normalized, <String>[
@@ -391,11 +391,11 @@ class _PersonDetail extends ConsumerWidget {
]
: sharedMessages
.take(compact ? 3 : 5)
.map(
.map<Widget>(
(SharedMessageEntry entry) => _PeopleInsightRow(
title:
entry.sourceDisplayName?.trim().isNotEmpty == true
? entry.sourceDisplayName!.trim()
? 'Detected in text: ${entry.sourceDisplayName!.trim()}'
: 'Shared from ${entry.sourceApp}',
subtitle: entry.messageText.trim().isEmpty
? 'Imported message content is empty'
@@ -404,6 +404,23 @@ class _PersonDetail extends ConsumerWidget {
'${entry.resolvedAutomatically ? 'AUTO-LINKED' : 'MANUAL-LINK'}${_shortDateTimeLabel(entry.sharedAt)}',
),
)
.followedBy(<Widget>[
if (sharedMessages.length > (compact ? 3 : 5))
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () => _showSharedMessageHistory(
context,
person: person,
sharedMessages: sharedMessages,
),
icon: const Icon(Icons.list_alt_rounded),
label: Text(
'View all ${sharedMessages.length} shared texts',
),
),
),
])
.toList(growable: false),
),
],
@@ -411,6 +428,70 @@ class _PersonDetail extends ConsumerWidget {
}
}
Future<void> _showSharedMessageHistory(
BuildContext context, {
required PersonProfile person,
required List<SharedMessageEntry> sharedMessages,
}) {
return showModalBottomSheet<void>(
context: context,
useSafeArea: true,
isScrollControlled: true,
showDragHandle: true,
builder: (BuildContext context) {
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.82,
minChildSize: 0.45,
maxChildSize: 0.95,
builder: (BuildContext context, ScrollController scrollController) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Shared texts',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
'${person.name}${sharedMessages.length} item${sharedMessages.length == 1 ? '' : 's'}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 12),
Expanded(
child: ListView.separated(
controller: scrollController,
itemCount: sharedMessages.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (BuildContext context, int index) {
final SharedMessageEntry entry = sharedMessages[index];
return _PeopleInsightRow(
title:
entry.sourceDisplayName?.trim().isNotEmpty == true
? 'Detected in text: ${entry.sourceDisplayName!.trim()}'
: 'Shared from ${entry.sourceApp}',
subtitle: entry.messageText.trim().isEmpty
? entry.url ?? 'Imported message content is empty'
: entry.messageText.trim(),
meta:
'${entry.sourceApp.toUpperCase()}${entry.resolvedAutomatically ? 'AUTO-LINKED' : 'MANUAL-LINK'}${_shortDateTimeLabel(entry.sharedAt)}',
);
},
),
),
],
),
);
},
);
},
);
}
Future<void> _showPeoplePreferenceEvidence(
BuildContext context, {
required PersonPreferenceSignal signal,
@@ -10,6 +10,7 @@ import 'package:relationship_saver/features/ai_digest/presentation/ai_digest_rev
import 'package:relationship_saver/features/auth/session_controller.dart';
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_provider.dart';
import 'package:relationship_saver/features/share_intake/application/share_intake_debug_log.dart';
import 'package:relationship_saver/features/share_intake/share_capture_flow.dart';
import 'package:relationship_saver/features/share_intake/share_capture_models.dart';
import 'package:relationship_saver/features/share_intake/share_inbox_view.dart';
@@ -210,6 +211,11 @@ class SettingsView extends ConsumerWidget {
icon: const Icon(Icons.inbox_rounded),
label: const Text('Open Share Inbox'),
),
OutlinedButton.icon(
onPressed: () => _showShareDiagnostics(context),
icon: const Icon(Icons.bug_report_rounded),
label: const Text('Share Diagnostics'),
),
],
),
],
@@ -411,6 +417,26 @@ class SettingsView extends ConsumerWidget {
),
);
}
Future<void> _showShareDiagnostics(BuildContext context) async {
final ShareIntakeDebugLog debugLog = const ShareIntakeDebugLog();
final List<ShareIntakeDebugEvent> events = await debugLog.read();
if (!context.mounted) {
return;
}
await showDialog<void>(
context: context,
builder: (BuildContext context) => _ShareDiagnosticsDialog(
events: events,
onClear: () async {
await debugLog.clear();
if (context.mounted) {
Navigator.of(context).pop();
}
},
),
);
}
}
class _SettingRow extends StatelessWidget {
@@ -462,6 +488,57 @@ class _SettingRow extends StatelessWidget {
}
}
class _ShareDiagnosticsDialog extends StatelessWidget {
const _ShareDiagnosticsDialog({required this.events, required this.onClear});
final List<ShareIntakeDebugEvent> events;
final Future<void> Function() onClear;
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Share Diagnostics'),
content: SizedBox(
width: 520,
child: events.isEmpty
? const Text(
'No Flutter share events recorded yet. If you tried sharing, the native share extension likely did not open the app or the plugin did not receive the URL callback.',
)
: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: events
.map(
(ShareIntakeDebugEvent event) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: SelectableText(
'${_debugTimeLabel(event.at)} ${event.stage}\n${event.message}',
),
),
)
.toList(growable: false),
),
),
),
actions: <Widget>[
TextButton(
onPressed: events.isEmpty ? null : onClear,
child: const Text('Clear'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'),
),
],
);
}
}
String _debugTimeLabel(DateTime value) {
return '${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}:${value.second.toString().padLeft(2, '0')}';
}
class _LlmConfigDraft {
const _LlmConfigDraft({
required this.provider,
@@ -39,12 +39,7 @@ Future<SharedMessageIngestResult?> startShareCaptureReview(
required WidgetRef ref,
required SharedPayload payload,
}) {
return showShareCaptureReviewSheet(
context,
ref: ref,
payload: payload,
initialPersonId: ref.read(selectedPersonIdProvider),
);
return showShareCaptureReviewSheet(context, ref: ref, payload: payload);
}
void openShareCaptureDestination(
@@ -73,10 +68,10 @@ void openShareCaptureDestination(
String shareCaptureResultMessage(SharedMessageIngestResult result) {
if (result.isQueuedForResolution) {
return 'Saved shared capture to inbox.';
return 'Saved shared text to Capture inbox.';
}
if (result.createdProfile) {
return 'Saved shared capture and created ${result.profileName}.';
return 'Saved shared text and created ${result.profileName}.';
}
return 'Saved shared capture to ${result.profileName}.';
return 'Saved shared text to ${result.profileName}.';
}
@@ -0,0 +1,79 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
const String _shareIntakeDebugLogKey = 'share_intake_debug_log_v1';
const int _maxShareIntakeDebugEvents = 40;
class ShareIntakeDebugEvent {
const ShareIntakeDebugEvent({
required this.at,
required this.stage,
required this.message,
});
factory ShareIntakeDebugEvent.fromJson(Map<String, dynamic> json) {
return ShareIntakeDebugEvent(
at: DateTime.parse(
json['at'] as String? ?? DateTime.now().toUtc().toIso8601String(),
).toLocal(),
stage: json['stage'] as String? ?? 'unknown',
message: json['message'] as String? ?? '',
);
}
final DateTime at;
final String stage;
final String message;
Map<String, dynamic> toJson() {
return <String, dynamic>{
'at': at.toUtc().toIso8601String(),
'stage': stage,
'message': message,
};
}
}
class ShareIntakeDebugLog {
const ShareIntakeDebugLog();
Future<List<ShareIntakeDebugEvent>> read() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
final String? raw = prefs.getString(_shareIntakeDebugLogKey);
if (raw == null || raw.isEmpty) {
return const <ShareIntakeDebugEvent>[];
}
try {
final List<dynamic> items = jsonDecode(raw) as List<dynamic>;
return items
.whereType<Map<String, dynamic>>()
.map(ShareIntakeDebugEvent.fromJson)
.toList(growable: false);
} on FormatException {
return const <ShareIntakeDebugEvent>[];
}
}
Future<void> record(String stage, String message) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
final List<ShareIntakeDebugEvent> current = await read();
final List<ShareIntakeDebugEvent> next = <ShareIntakeDebugEvent>[
ShareIntakeDebugEvent(at: DateTime.now(), stage: stage, message: message),
...current,
].take(_maxShareIntakeDebugEvents).toList(growable: false);
await prefs.setString(
_shareIntakeDebugLogKey,
jsonEncode(
next
.map((ShareIntakeDebugEvent event) => event.toJson())
.toList(growable: false),
),
);
}
Future<void> clear() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.remove(_shareIntakeDebugLogKey);
}
}
@@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:relationship_saver/core/config/app_config.dart';
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/share_intake/application/share_intake_debug_log.dart';
import 'package:relationship_saver/features/share_intake/share_capture_flow.dart';
import 'package:relationship_saver/features/share_intake/share_capture_models.dart';
@@ -23,6 +24,7 @@ class IncomingShareListener extends ConsumerStatefulWidget {
class _IncomingShareListenerState extends ConsumerState<IncomingShareListener> {
StreamSubscription<List<SharedMediaFile>>? _mediaSubscription;
String? _lastToken;
final ShareIntakeDebugLog _debugLog = const ShareIntakeDebugLog();
@override
void initState() {
@@ -41,25 +43,44 @@ class _IncomingShareListenerState extends ConsumerState<IncomingShareListener> {
Future<void> _start() async {
if (!AppConfig.enableWhatsAppShareIntake || kIsWeb) {
await _debugLog.record(
'listener_skipped',
'enabled=${AppConfig.enableWhatsAppShareIntake}, kIsWeb=$kIsWeb',
);
return;
}
try {
await _debugLog.record('listener_start', 'Subscribing to share intents.');
_mediaSubscription = ReceiveSharingIntent.instance
.getMediaStream()
.listen(
(List<SharedMediaFile> files) =>
_handleMediaList(files, autoOpenDestination: false),
onError: (_) {},
(List<SharedMediaFile> files) {
unawaited(
_debugLog.record(
'media_stream',
'Received ${files.length} shared item(s).',
),
);
unawaited(_handleMediaList(files, autoOpenDestination: false));
},
onError: (Object error) {
unawaited(_debugLog.record('media_stream_error', '$error'));
},
);
final List<SharedMediaFile> initialMedia = await ReceiveSharingIntent
.instance
.getInitialMedia();
await _debugLog.record(
'initial_media',
'Received ${initialMedia.length} initial shared item(s).',
);
if (initialMedia.isNotEmpty) {
await _handleMediaList(initialMedia, autoOpenDestination: true);
}
} catch (_) {
} catch (error) {
await _debugLog.record('listener_error', '$error');
// Unsupported platform/plugin state should not break app usage.
}
}
@@ -69,8 +90,16 @@ class _IncomingShareListenerState extends ConsumerState<IncomingShareListener> {
required bool autoOpenDestination,
}) async {
for (final SharedMediaFile file in files) {
await _debugLog.record(
'media_item',
'type=${file.type.name}, mime=${file.mimeType ?? 'none'}, pathLength=${file.path.length}, messageLength=${file.message?.length ?? 0}',
);
final String? rawText = _extractText(file);
if (rawText == null) {
await _debugLog.record(
'extract_skip',
'No usable text for type=${file.type.name}.',
);
continue;
}
await _handleRawText(
@@ -129,12 +158,21 @@ class _IncomingShareListenerState extends ConsumerState<IncomingShareListener> {
sourceAppHint: sourceAppHint,
);
if (payload == null) {
await _debugLog.record(
'payload_skip',
'Parser returned null for rawLength=${trimmed.length}.',
);
return;
}
await _debugLog.record(
'payload_parsed',
'source=${payload.sourceApp}, type=${payload.payloadType.name}, rawLength=${payload.rawText.length}, hasUrl=${payload.url != null}, detectedSender=${payload.sourceDisplayName != null}',
);
final String token =
'${payload.sourceApp}:${payload.sourceUserId ?? payload.sourceDisplayName ?? ''}:${payload.rawText}:${payload.url ?? ''}';
if (_lastToken == token) {
await _debugLog.record('payload_duplicate', 'Ignored duplicate share.');
return;
}
_lastToken = token;
@@ -149,8 +187,17 @@ class _IncomingShareListenerState extends ConsumerState<IncomingShareListener> {
);
if (!mounted || result == null) {
await _debugLog.record('review_cancelled', 'Share review closed.');
return;
}
unawaited(
_debugLog.record(
'review_result',
result.isQueuedForResolution
? 'Queued for inbox.'
: 'Saved to profile; createdProfile=${result.createdProfile}.',
),
);
if (autoOpenDestination) {
openShareCaptureDestination(context, ref: ref, result: result);
@@ -88,12 +88,12 @@ class _ShareCaptureReviewSheetState
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Save Shared Capture',
'Save Shared Text',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 6),
Text(
'Review the incoming text or link, then assign it safely.',
'Choose who this belongs to, or keep it in the inbox for later review.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTheme.textSecondary,
),
@@ -101,10 +101,7 @@ class _ShareCaptureReviewSheetState
const SizedBox(height: 14),
_PayloadPreview(payload: widget.payload),
const SizedBox(height: 14),
Text(
'What kind of info is this?',
style: Theme.of(context).textTheme.titleMedium,
),
Text('Type', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Wrap(
spacing: 8,
@@ -344,9 +341,7 @@ class _ShareCaptureReviewSheetState
await showDialog<_CreatePersonFromShareDraft>(
context: context,
builder: (BuildContext context) => _CreatePersonFromShareDialog(
initialName:
widget.payload.sourceDisplayName ??
widget.payload.rawText.split(RegExp(r'\s+')).take(2).join(' '),
detectedName: widget.payload.sourceDisplayName,
),
);
if (draft == null) {
@@ -445,7 +440,7 @@ class _PayloadPreview extends StatelessWidget {
if (payload.sourceDisplayName != null) ...<Widget>[
const SizedBox(height: 4),
Text(
'Sender: ${payload.sourceDisplayName}',
'Detected in text: ${payload.sourceDisplayName}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
@@ -484,9 +479,9 @@ class _CreatePersonFromShareDraft {
}
class _CreatePersonFromShareDialog extends StatefulWidget {
const _CreatePersonFromShareDialog({required this.initialName});
const _CreatePersonFromShareDialog({this.detectedName});
final String initialName;
final String? detectedName;
@override
State<_CreatePersonFromShareDialog> createState() =>
@@ -504,7 +499,7 @@ class _CreatePersonFromShareDialogState
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.initialName);
_nameController = TextEditingController();
}
@override
@@ -523,9 +518,21 @@ class _CreatePersonFromShareDialogState
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (widget.detectedName?.trim().isNotEmpty == true) ...<Widget>[
Text(
'Detected message author: ${widget.detectedName!.trim()}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 10),
],
TextField(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Name'),
decoration: const InputDecoration(
labelText: 'Person name',
hintText: 'Enter the real contact',
),
),
const SizedBox(height: 10),
TextField(
@@ -4,6 +4,8 @@ import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/local/local_repository.dart';
import 'package:relationship_saver/features/share_intake/share_capture_models.dart';
import 'package:relationship_saver/features/share_intake/share_capture_review_sheet.dart';
import 'package:relationship_saver/features/share_intake/share_payload_parser.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart';
/// Review unresolved shared messages and map them to a person profile.
@@ -45,13 +47,25 @@ class _ShareInboxViewState extends ConsumerState<ShareInboxView> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Share Inbox',
style: Theme.of(context).textTheme.headlineMedium,
Wrap(
spacing: 12,
runSpacing: 10,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
Text(
'Capture',
style: Theme.of(context).textTheme.headlineMedium,
),
FilledButton.icon(
onPressed: _submitting ? null : _addTextCapture,
icon: const Icon(Icons.edit_note_rounded),
label: const Text('Add Text'),
),
],
),
const SizedBox(height: 6),
Text(
'Resolve shared messages that could not be matched confidently.',
'Share messages here, paste copied text, then choose or create the right person.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: AppTheme.textSecondary,
),
@@ -59,7 +73,9 @@ class _ShareInboxViewState extends ConsumerState<ShareInboxView> {
const SizedBox(height: 14),
Expanded(
child: localState.sharedInbox.isEmpty
? _EmptyInbox(compact: compact)
? SingleChildScrollView(
child: _EmptyInbox(compact: compact),
)
: ListView.separated(
itemCount: localState.sharedInbox.length,
separatorBuilder: (_, _) =>
@@ -178,13 +194,8 @@ class _ShareInboxViewState extends ConsumerState<ShareInboxView> {
}
final _CreateProfileDraft? draft = await showDialog<_CreateProfileDraft>(
context: context,
builder: (BuildContext context) => _CreateProfileDialog(
initialName:
entry.sourceDisplayName ??
entry.sourceUserId ??
entry.sourceThreadId ??
'',
),
builder: (BuildContext context) =>
_CreateProfileDialog(detectedName: entry.sourceDisplayName),
);
if (draft == null || !mounted) {
return;
@@ -232,6 +243,38 @@ class _ShareInboxViewState extends ConsumerState<ShareInboxView> {
});
}
Future<void> _addTextCapture() async {
if (_submitting) {
return;
}
final String? rawText = await showDialog<String>(
context: context,
builder: (BuildContext context) => const _AddTextDialog(),
);
if (rawText == null || rawText.trim().isEmpty || !mounted) {
return;
}
final SharedPayload payload = SharePayloadParser.parse(
rawText: rawText,
sourceApp: 'manual',
platform: 'manual',
createdAt: DateTime.now(),
receivedAt: DateTime.now(),
);
final SharedMessageIngestResult? result = await showShareCaptureReviewSheet(
context,
ref: ref,
payload: payload,
);
if (result == null || !mounted) {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(_captureResultMessage(result))));
}
Future<void> _runRepositoryAction(Future<void> Function() action) async {
setState(() {
_submitting = true;
@@ -453,6 +496,60 @@ class _ShareInboxViewState extends ConsumerState<ShareInboxView> {
}
}
class _AddTextDialog extends StatefulWidget {
const _AddTextDialog();
@override
State<_AddTextDialog> createState() => _AddTextDialogState();
}
class _AddTextDialogState extends State<_AddTextDialog> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Add Text'),
content: SizedBox(
width: 520,
child: TextField(
controller: _controller,
autofocus: true,
minLines: 5,
maxLines: 10,
textInputAction: TextInputAction.newline,
decoration: const InputDecoration(
labelText: 'Shared or copied text',
hintText: 'Paste a message, note, link, preference, or date...',
),
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(_controller.text),
child: const Text('Review'),
),
],
);
}
}
class _CandidateReview {
const _CandidateReview({
required this.person,
@@ -1073,6 +1170,16 @@ String _factTypeLabel(CapturedFactType type) {
};
}
String _captureResultMessage(SharedMessageIngestResult result) {
if (result.isQueuedForResolution) {
return 'Saved to Capture inbox.';
}
if (result.createdProfile) {
return 'Saved and created ${result.profileName}.';
}
return 'Saved to ${result.profileName}.';
}
class _EmptyInbox extends StatelessWidget {
const _EmptyInbox({required this.compact});
@@ -1103,7 +1210,7 @@ class _EmptyInbox extends StatelessWidget {
),
const SizedBox(height: 6),
Text(
'Incoming shared captures that need manual person mapping or fact triage will appear here.',
'Share a message into the app or use Add Text to start building your private relationship notes.',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
@@ -1132,9 +1239,9 @@ class _CreateProfileDraft {
}
class _CreateProfileDialog extends StatefulWidget {
const _CreateProfileDialog({required this.initialName});
const _CreateProfileDialog({this.detectedName});
final String initialName;
final String? detectedName;
@override
State<_CreateProfileDialog> createState() => _CreateProfileDialogState();
@@ -1149,7 +1256,7 @@ class _CreateProfileDialogState extends State<_CreateProfileDialog> {
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.initialName);
_nameController = TextEditingController();
_relationshipController = TextEditingController(text: 'Contact');
_locationController = TextEditingController();
_aliasesController = TextEditingController();
@@ -1172,9 +1279,21 @@ class _CreateProfileDialogState extends State<_CreateProfileDialog> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (widget.detectedName?.trim().isNotEmpty == true) ...<Widget>[
Text(
'Detected message author: ${widget.detectedName!.trim()}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 8),
],
TextField(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Name'),
decoration: const InputDecoration(
labelText: 'Person name',
hintText: 'Enter the real contact',
),
),
const SizedBox(height: 8),
TextField(
@@ -97,21 +97,9 @@ class BackendGatewayFake implements BackendGateway {
@override
Future<SyncPullResult> pullChanges({String? cursor, int limit = 200}) async {
final List<ChangeEnvelope> changes = <ChangeEnvelope>[
ChangeEnvelope(
schemaVersion: 1,
entityType: 'person',
entityId: 'person-1',
op: ChangeOperation.upsert,
modifiedAt: _seedTime,
clientMutationId: 'cm-1',
payload: const <String, dynamic>{'name': 'Alex', 'importance': 5},
),
];
return SyncPullResult(
cursor: 'fake-pull-cursor-1',
changes: changes.take(limit).toList(),
cursor: cursor ?? 'fake-pull-cursor-empty',
changes: const <ChangeEnvelope>[],
);
}