Refine backendless share intake
This commit is contained in:
@@ -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,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 }
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>[],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user