feat: add local-first private AI digest workflow

Migrate app code into canonical feature slices, add phone-only AI digest scheduling and review, wire local notification/background task support, and cover the flow with tests.
This commit is contained in:
Rijad Zuzo
2026-05-17 00:17:20 +02:00
parent dab50abf0e
commit f655adfbea
212 changed files with 24178 additions and 15895 deletions
+11
View File
@@ -0,0 +1,11 @@
# Lib Layout
This app now uses a prototype-friendly vertical-slice structure.
Use `lib/app/` for bootstrap, shell navigation, shared app-state wiring, and
local persistence. Use `lib/features/<slice>/` for product work inside a
single slice. Use `lib/core/` only for cross-cutting primitives that are not
owned by one feature.
If you are starting work, open the slice README first. Most old top-level
files are now compatibility exports that point to the new canonical location.
+12
View File
@@ -0,0 +1,12 @@
# App Layer
`lib/app/` holds app-level code that is not one product slice:
- `relationship_saver_app.dart`: root widget and global listeners.
- `navigation/`: typed shell destinations.
- `presentation/`: responsive app shell.
- `data/`: aggregate prototype repository and persistence plumbing.
- `state/`: shared aggregate snapshot used by the current local-first store.
This is the place to change boot flow, shell behavior, or prototype-wide state
composition.
+15
View File
@@ -0,0 +1,15 @@
# App Data
This folder contains the prototype app store and persistence adapters.
`relationship_repository.dart` is the single local-first write boundary for the
prototype. It now delegates behavior into part files so the code is grouped by
slice instead of one giant file:
- `relationship_repository_people.dart`: people CRUD and profile merging.
- `relationship_repository_share.dart`: share intake, inbox, and person resolution.
- `relationship_repository_activity.dart`: captures, facts, ideas, reminders, and task toggles.
- `relationship_repository_sync.dart`: sync envelopes and remote-change application.
If you are changing persistence behavior, start with the matching part file and
only touch the root repository file when the shared lifecycle changes.
+176
View File
@@ -0,0 +1,176 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/app/data/storage/local_data_store.dart';
import 'package:relationship_saver/app/data/storage/local_data_store_hive.dart';
import 'package:relationship_saver/app/data/storage/local_data_store_provider.dart';
import 'package:relationship_saver/app/data/storage/local_data_store_shared_prefs.dart';
import 'package:relationship_saver/app/state/local_data_state.dart';
import 'package:relationship_saver/core/config/app_config.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:relationship_saver/features/dashboard/domain/dashboard_models.dart';
import 'package:relationship_saver/features/ideas/domain/idea_models.dart';
import 'package:relationship_saver/features/local/chat_preference_extractor.dart';
import 'package:relationship_saver/features/moments/domain/moment_models.dart';
import 'package:relationship_saver/features/people/domain/person_models.dart';
import 'package:relationship_saver/features/reminders/application/reminder_scheduler_provider.dart';
import 'package:relationship_saver/features/reminders/domain/reminder_models.dart';
import 'package:relationship_saver/features/share_intake/application/share_capture_models.dart';
import 'package:relationship_saver/features/share_intake/domain/share_capture_draft_suggester.dart';
import 'package:relationship_saver/features/share_intake/domain/share_models.dart';
import 'package:relationship_saver/features/sync/data/sync_queue_repository.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
import 'package:uuid/uuid.dart';
part 'relationship_repository_activity.dart';
part 'relationship_repository_ai_digest.dart';
part 'relationship_repository_people.dart';
part 'relationship_repository_share.dart';
part 'relationship_repository_sync.dart';
const int _schemaVersion = 5;
const int _syncSchemaVersion = 1;
const Uuid _uuid = Uuid();
const ChatPreferenceExtractor _chatPreferenceExtractor =
ChatPreferenceExtractor();
/// Persisted local-first app store used by the current prototype.
///
/// The storage backend is still a single local record because that keeps the
/// prototype stable, but the repository implementation is now split by feature
/// behavior so it reads like vertical slices instead of one monolithic file.
class LocalRepository extends AsyncNotifier<LocalDataState> {
@override
Future<LocalDataState> build() async {
final LocalDataStore store = ref.read(localDataStoreProvider);
LocalDataRecord? record = await store.read();
if ((record == null || record.rawState.isEmpty) &&
AppConfig.useHiveLocalDb &&
store is HiveLocalDataStore) {
final SharedPrefsLocalDataStore legacyStore = SharedPrefsLocalDataStore();
final LocalDataRecord? legacy = await legacyStore.read();
if (legacy != null && legacy.rawState.isNotEmpty) {
await store.write(
rawState: legacy.rawState,
schemaVersion: legacy.schemaVersion,
);
await legacyStore.clear();
record = await store.read();
}
}
if (record == null || record.rawState.isEmpty) {
final LocalDataState seeded = LocalDataState.seed();
await _persist(seeded);
await _reconcileReminderSchedule(seeded);
return seeded;
}
final LocalDataRecord migrated = await _migrateIfNeeded(record);
try {
final Map<String, dynamic> json =
jsonDecode(migrated.rawState) as Map<String, dynamic>;
final LocalDataState loaded = LocalDataState.fromJson(json);
await _reconcileReminderSchedule(loaded);
return loaded;
} on FormatException {
final LocalDataState fallback = LocalDataState.seed();
await _persist(fallback);
await _reconcileReminderSchedule(fallback);
return fallback;
}
}
Future<void> _setState(LocalDataState next) async {
state = AsyncData<LocalDataState>(next);
await _persist(next);
await _reconcileReminderSchedule(next);
}
LocalDataState _requireState() {
final LocalDataState? value = state.asData?.value;
if (value == null) {
throw StateError('Local data state is not ready yet');
}
return value;
}
Future<void> _persist(LocalDataState state) async {
await ref
.read(localDataStoreProvider)
.write(
rawState: jsonEncode(state.toJson()),
schemaVersion: _schemaVersion,
);
}
Future<void> _reconcileReminderSchedule(LocalDataState state) async {
try {
await ref.read(reminderSchedulerProvider).reconcile(state.reminders);
} catch (_) {
// Scheduling failures must not block local-first data updates.
}
}
Future<LocalDataRecord> _migrateIfNeeded(LocalDataRecord record) async {
final int currentVersion = record.schemaVersion;
if (currentVersion == _schemaVersion) {
return record;
}
final LocalDataStore store = ref.read(localDataStoreProvider);
if (currentVersion <= 0) {
final LocalDataState seeded = LocalDataState.seed();
await store.clear();
await _persist(seeded);
return LocalDataRecord(
rawState: jsonEncode(seeded.toJson()),
schemaVersion: _schemaVersion,
);
}
try {
final Map<String, dynamic> json =
jsonDecode(record.rawState) as Map<String, dynamic>;
final LocalDataState migratedState = LocalDataState.fromJson(json);
final String migratedRawState = jsonEncode(migratedState.toJson());
await store.write(
rawState: migratedRawState,
schemaVersion: _schemaVersion,
);
return LocalDataRecord(
rawState: migratedRawState,
schemaVersion: _schemaVersion,
);
} on FormatException {
final LocalDataState seeded = LocalDataState.seed();
await store.clear();
await _persist(seeded);
return LocalDataRecord(
rawState: jsonEncode(seeded.toJson()),
schemaVersion: _schemaVersion,
);
}
}
Future<void> _enqueueChange(ChangeEnvelope change) async {
await ref.read(syncQueueRepositoryProvider.notifier).enqueue(change);
}
Future<void> _enqueueChanges(List<ChangeEnvelope> changes) async {
await ref.read(syncQueueRepositoryProvider.notifier).enqueueAll(changes);
}
}
String? _trimToNull(String? value) {
if (value == null) {
return null;
}
final String trimmed = value.trim();
return trimmed.isEmpty ? null : trimmed;
}
final AsyncNotifierProvider<LocalRepository, LocalDataState>
localRepositoryProvider =
AsyncNotifierProvider<LocalRepository, LocalDataState>(LocalRepository.new);
@@ -0,0 +1,667 @@
part of 'relationship_repository.dart';
/// Capture, idea, reminder, and lightweight dashboard state persistence.
extension LocalRepositoryActivityOperations on LocalRepository {
Future<void> addMoment({
required String personId,
required String summary,
String type = 'capture',
}) async {
final String normalized = summary.trim();
if (normalized.isEmpty) {
throw ArgumentError('Capture summary cannot be empty');
}
final LocalDataState current = _requireState();
final String payload = _truncate(normalized, 280);
final RelationshipMoment moment = RelationshipMoment(
id: 'm-${_uuid.v4()}',
personId: personId,
title: _titleFromSummary(payload),
summary: payload,
at: DateTime.now(),
type: type,
);
await _setState(
current.copyWith(
moments: <RelationshipMoment>[moment, ...current.moments],
),
);
await _enqueueChange(
_buildEnvelope(
entityType: 'capture',
entityId: moment.id,
op: ChangeOperation.upsert,
payload: _momentPayload(moment),
),
);
}
Future<void> addPersonFact({
required String personId,
required CapturedFactType type,
required String text,
String? label,
CaptureSourceKind sourceKind = CaptureSourceKind.manual,
String? sourceApp,
String? sourceUrl,
String? sharedMessageId,
String? inboxEntryId,
double? confidence,
bool needsReview = false,
bool isSensitive = false,
DateTime? createdAt,
}) async {
final LocalDataState current = _requireState();
final String normalizedText = text.trim();
if (normalizedText.isEmpty) {
throw ArgumentError('Fact text is required');
}
final DateTime now = createdAt ?? DateTime.now();
final PersonFact fact = PersonFact(
id: 'pf-${_uuid.v4()}',
personId: personId,
type: type,
text: normalizedText,
label: _trimToNull(label),
sourceKind: sourceKind,
sourceApp: _trimToNull(sourceApp),
sourceUrl: _trimToNull(sourceUrl),
sharedMessageId: sharedMessageId,
inboxEntryId: inboxEntryId,
confidence: confidence,
needsReview: needsReview,
isSensitive: isSensitive,
createdAt: now,
updatedAt: now,
);
await _setState(
current.copyWith(
personFacts: <PersonFact>[fact, ...current.personFacts],
people: _touchPeople(
current.people,
personId: personId,
updatedAt: now,
interactedAt: now,
),
),
);
}
Future<void> updatePersonFact(PersonFact fact) async {
final LocalDataState current = _requireState();
final DateTime now = DateTime.now();
final List<PersonFact> updated = current.personFacts
.map(
(PersonFact item) =>
item.id == fact.id ? fact.copyWith(updatedAt: now) : item,
)
.toList(growable: false);
await _setState(
current.copyWith(
personFacts: updated,
people: _touchPeople(
current.people,
personId: fact.personId,
updatedAt: now,
),
),
);
}
Future<void> deletePersonFact(String factId) async {
final LocalDataState current = _requireState();
final List<PersonFact> updated = current.personFacts
.where((PersonFact item) => item.id != factId)
.toList(growable: false);
await _setState(current.copyWith(personFacts: updated));
}
Future<void> addImportantDate({
required String personId,
required String label,
required DateTime date,
String classification = 'important',
CaptureSourceKind sourceKind = CaptureSourceKind.manual,
String? sourceApp,
String? inboxEntryId,
bool isSensitive = false,
DateTime? createdAt,
}) async {
final LocalDataState current = _requireState();
final String normalizedLabel = label.trim();
if (normalizedLabel.isEmpty) {
throw ArgumentError('Date label is required');
}
final DateTime now = createdAt ?? DateTime.now();
final PersonImportantDate importantDate = PersonImportantDate(
id: 'pd-${_uuid.v4()}',
personId: personId,
label: normalizedLabel,
date: date,
classification: classification.trim().isEmpty
? 'important'
: classification.trim(),
sourceKind: sourceKind,
sourceApp: _trimToNull(sourceApp),
inboxEntryId: inboxEntryId,
isSensitive: isSensitive,
createdAt: now,
updatedAt: now,
);
await _setState(
current.copyWith(
importantDates: <PersonImportantDate>[
importantDate,
...current.importantDates,
],
people: _touchPeople(
current.people,
personId: personId,
updatedAt: now,
interactedAt: now,
),
),
);
}
Future<void> updateImportantDate(PersonImportantDate value) async {
final LocalDataState current = _requireState();
final DateTime now = DateTime.now();
final List<PersonImportantDate> updated = current.importantDates
.map(
(PersonImportantDate item) =>
item.id == value.id ? value.copyWith(updatedAt: now) : item,
)
.toList(growable: false);
await _setState(
current.copyWith(
importantDates: updated,
people: _touchPeople(
current.people,
personId: value.personId,
updatedAt: now,
),
),
);
}
Future<void> deleteImportantDate(String dateId) async {
final LocalDataState current = _requireState();
final List<PersonImportantDate> updated = current.importantDates
.where((PersonImportantDate item) => item.id != dateId)
.toList(growable: false);
await _setState(current.copyWith(importantDates: updated));
}
Future<void> updateMoment(RelationshipMoment moment) async {
if (moment.summary.trim().isEmpty) {
throw ArgumentError('Capture summary cannot be empty');
}
final LocalDataState current = _requireState();
final List<RelationshipMoment> updated = current.moments
.map((RelationshipMoment item) => item.id == moment.id ? moment : item)
.toList(growable: false);
await _setState(current.copyWith(moments: updated));
await _enqueueChange(
_buildEnvelope(
entityType: 'capture',
entityId: moment.id,
op: ChangeOperation.upsert,
payload: _momentPayload(moment),
),
);
}
Future<void> deleteMoment(String momentId) async {
final LocalDataState current = _requireState();
final List<RelationshipMoment> moments = current.moments
.where((RelationshipMoment moment) => moment.id != momentId)
.toList(growable: false);
await _setState(current.copyWith(moments: moments));
await _enqueueChange(
_buildEnvelope(
entityType: 'capture',
entityId: momentId,
op: ChangeOperation.delete,
),
);
}
Future<void> addIdea({
required IdeaType type,
required String title,
required String details,
String? personId,
}) async {
final String normalizedTitle = title.trim();
if (normalizedTitle.isEmpty) {
throw ArgumentError('Idea title is required');
}
final LocalDataState current = _requireState();
final RelationshipIdea idea = RelationshipIdea(
id: 'i-${_uuid.v4()}',
personId: personId,
type: type,
title: normalizedTitle,
details: details.trim(),
createdAt: DateTime.now(),
);
await _setState(
current.copyWith(ideas: <RelationshipIdea>[idea, ...current.ideas]),
);
await _enqueueChange(
_buildEnvelope(
entityType: _ideaEntityType(idea.type),
entityId: idea.id,
op: ChangeOperation.upsert,
payload: _ideaPayload(idea),
),
);
}
Future<void> updateIdea(RelationshipIdea idea) async {
if (idea.title.trim().isEmpty) {
throw ArgumentError('Idea title is required');
}
final LocalDataState current = _requireState();
final List<RelationshipIdea> ideas = current.ideas
.map((RelationshipIdea item) => item.id == idea.id ? idea : item)
.toList(growable: false);
await _setState(current.copyWith(ideas: ideas));
await _enqueueChange(
_buildEnvelope(
entityType: _ideaEntityType(idea.type),
entityId: idea.id,
op: ChangeOperation.upsert,
payload: _ideaPayload(idea),
),
);
}
Future<void> toggleIdeaArchived(String ideaId) async {
final LocalDataState current = _requireState();
RelationshipIdea? updatedIdea;
final List<RelationshipIdea> ideas = current.ideas
.map((RelationshipIdea item) {
if (item.id != ideaId) {
return item;
}
final RelationshipIdea toggled = item.copyWith(
isArchived: !item.isArchived,
);
updatedIdea = toggled;
return toggled;
})
.toList(growable: false);
await _setState(current.copyWith(ideas: ideas));
if (updatedIdea != null) {
await _enqueueChange(
_buildEnvelope(
entityType: _ideaEntityType(updatedIdea!.type),
entityId: updatedIdea!.id,
op: ChangeOperation.upsert,
payload: _ideaPayload(updatedIdea!),
),
);
}
}
Future<void> deleteIdea(String ideaId) async {
final LocalDataState current = _requireState();
final List<RelationshipIdea> ideas = current.ideas
.where((RelationshipIdea idea) => idea.id != ideaId)
.toList(growable: false);
await _setState(current.copyWith(ideas: ideas));
await _enqueueChange(
_buildEnvelope(
entityType: _ideaEntityTypeFromId(current.ideas, ideaId),
entityId: ideaId,
op: ChangeOperation.delete,
),
);
}
Future<void> addReminder({
required String title,
required ReminderCadence cadence,
required DateTime nextAt,
String? personId,
}) async {
final String normalizedTitle = title.trim();
if (normalizedTitle.isEmpty) {
throw ArgumentError('Reminder title is required');
}
final LocalDataState current = _requireState();
final ReminderRule reminder = ReminderRule(
id: 'r-${_uuid.v4()}',
personId: personId,
title: normalizedTitle,
cadence: cadence,
nextAt: nextAt,
enabled: true,
);
await _setState(
current.copyWith(
reminders: <ReminderRule>[reminder, ...current.reminders],
),
);
await _enqueueChange(
_buildEnvelope(
entityType: 'reminderRule',
entityId: reminder.id,
op: ChangeOperation.upsert,
payload: _reminderPayload(reminder),
),
);
}
Future<void> updateReminder(ReminderRule reminder) async {
if (reminder.title.trim().isEmpty) {
throw ArgumentError('Reminder title is required');
}
final LocalDataState current = _requireState();
final List<ReminderRule> reminders = current.reminders
.map((ReminderRule item) => item.id == reminder.id ? reminder : item)
.toList(growable: false);
await _setState(current.copyWith(reminders: reminders));
await _enqueueChange(
_buildEnvelope(
entityType: 'reminderRule',
entityId: reminder.id,
op: ChangeOperation.upsert,
payload: _reminderPayload(reminder),
),
);
}
Future<void> toggleReminderEnabled(String reminderId) async {
final LocalDataState current = _requireState();
ReminderRule? updatedReminder;
final List<ReminderRule> reminders = current.reminders
.map((ReminderRule item) {
if (item.id != reminderId) {
return item;
}
final ReminderRule toggled = item.copyWith(enabled: !item.enabled);
updatedReminder = toggled;
return toggled;
})
.toList(growable: false);
await _setState(current.copyWith(reminders: reminders));
if (updatedReminder != null) {
await _enqueueChange(
_buildEnvelope(
entityType: 'reminderRule',
entityId: updatedReminder!.id,
op: ChangeOperation.upsert,
payload: _reminderPayload(updatedReminder!),
),
);
}
}
Future<void> deleteReminder(String reminderId) async {
final LocalDataState current = _requireState();
final List<ReminderRule> reminders = current.reminders
.where((ReminderRule reminder) => reminder.id != reminderId)
.toList(growable: false);
await _setState(current.copyWith(reminders: reminders));
await _enqueueChange(
_buildEnvelope(
entityType: 'reminderRule',
entityId: reminderId,
op: ChangeOperation.delete,
),
);
}
Future<PersonPreferenceSignal> upsertInferredPreferenceSignalObservation({
required String personId,
required String key,
required String label,
required String category,
required PreferenceSignalPolarity polarity,
required double confidence,
String? sourceApp,
String? evidenceMessageId,
String? evidenceSnippet,
DateTime? observedAt,
}) async {
final LocalDataState current = _requireState();
final DateTime observed = observedAt ?? DateTime.now();
final String normalizedKey = _normalizePreferenceKey(key);
if (normalizedKey.isEmpty) {
throw ArgumentError('Preference signal key cannot be empty');
}
if (!_personExists(current.people, personId)) {
throw ArgumentError('Profile does not exist for preference signal');
}
final int existingIndex = current.preferenceSignals.indexWhere(
(PersonPreferenceSignal signal) =>
signal.personId == personId &&
signal.key == normalizedKey &&
signal.polarity == polarity,
);
final double normalizedConfidence = _clampUnit(confidence);
final String normalizedLabel = label.trim().isEmpty
? normalizedKey
: label.trim();
final String normalizedCategory = category.trim().isEmpty
? 'general'
: category.trim().toLowerCase();
final String? snippet = _trimToNull(evidenceSnippet);
final String? messageId = _trimToNull(evidenceMessageId);
final String? source = _trimToNull(sourceApp)?.toLowerCase();
late final PersonPreferenceSignal nextSignal;
final List<PersonPreferenceSignal> nextSignals = current.preferenceSignals
.toList(growable: true);
if (existingIndex < 0) {
nextSignal = PersonPreferenceSignal(
id: 'ps-${_uuid.v4()}',
personId: personId,
key: normalizedKey,
label: normalizedLabel,
category: normalizedCategory,
polarity: polarity,
confidence: normalizedConfidence,
status: PreferenceSignalStatus.inferred,
firstSeenAt: observed,
lastSeenAt: observed,
occurrenceCount: 1,
sourceApps: source == null ? const <String>[] : <String>[source],
evidenceMessageIds: messageId == null
? const <String>[]
: <String>[messageId],
evidenceSnippets: snippet == null
? const <String>[]
: <String>[snippet],
);
nextSignals.insert(0, nextSignal);
} else {
final PersonPreferenceSignal existing = nextSignals[existingIndex];
final int currentCount = existing.occurrenceCount < 1
? 1
: existing.occurrenceCount;
final int nextCount = currentCount + 1;
final double mergedConfidence =
((existing.confidence * currentCount) + normalizedConfidence) /
nextCount;
nextSignal = existing.copyWith(
label: normalizedLabel,
category: normalizedCategory,
confidence: _clampUnit(mergedConfidence),
lastSeenAt: observed,
occurrenceCount: nextCount,
sourceApps: _mergeUniqueStrings(existing.sourceApps, source),
evidenceMessageIds: _mergeUniqueStrings(
existing.evidenceMessageIds,
messageId,
maxItems: 8,
),
evidenceSnippets: _mergeUniqueStrings(
existing.evidenceSnippets,
snippet == null ? null : _truncate(snippet, 220),
maxItems: 6,
),
);
nextSignals[existingIndex] = nextSignal;
}
await _setState(current.copyWith(preferenceSignals: nextSignals));
return nextSignal;
}
Future<void> upsertPreferenceSignal(PersonPreferenceSignal signal) async {
final LocalDataState current = _requireState();
if (!_personExists(current.people, signal.personId)) {
throw ArgumentError('Profile does not exist for preference signal');
}
final List<PersonPreferenceSignal> nextSignals = _upsertItem(
items: current.preferenceSignals,
item: signal,
idOf: (PersonPreferenceSignal value) => value.id,
);
await _setState(current.copyWith(preferenceSignals: nextSignals));
}
Future<void> setPreferenceSignalStatus({
required String signalId,
required PreferenceSignalStatus status,
}) async {
final LocalDataState current = _requireState();
final List<PersonPreferenceSignal> nextSignals = current.preferenceSignals
.map((PersonPreferenceSignal signal) {
if (signal.id != signalId) {
return signal;
}
return signal.copyWith(status: status);
})
.toList(growable: false);
await _setState(current.copyWith(preferenceSignals: nextSignals));
}
Future<void> confirmPreferenceSignal(String signalId) {
return setPreferenceSignalStatus(
signalId: signalId,
status: PreferenceSignalStatus.confirmed,
);
}
Future<void> dismissPreferenceSignal(String signalId) {
return setPreferenceSignalStatus(
signalId: signalId,
status: PreferenceSignalStatus.dismissed,
);
}
Future<void> dismissLocalSignal(String signalId) async {
final String normalizedId = signalId.trim();
if (normalizedId.isEmpty) {
return;
}
final LocalDataState current = _requireState();
if (current.dismissedSignalIds.contains(normalizedId)) {
return;
}
await _setState(
current.copyWith(
dismissedSignalIds: <String>[
normalizedId,
...current.dismissedSignalIds,
],
),
);
}
Future<void> toggleTaskDone(String taskId) async {
final LocalDataState current = _requireState();
final List<DashboardTask> tasks = current.tasks
.map(
(DashboardTask task) =>
task.id == taskId ? task.copyWith(done: !task.done) : task,
)
.toList(growable: false);
await _setState(current.copyWith(tasks: tasks));
}
}
bool _personExists(List<PersonProfile> people, String personId) {
return people.any((PersonProfile person) => person.id == personId);
}
String _normalizePreferenceKey(String value) {
return value
.trim()
.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9:_ -]+'), '')
.replaceAll(RegExp(r'\s+'), ' ');
}
double _clampUnit(double value) {
if (value.isNaN) {
return 0;
}
return value.clamp(0.0, 1.0).toDouble();
}
List<String> _mergeUniqueStrings(
List<String> existing,
String? incoming, {
int maxItems = 12,
}) {
final List<String> values = <String>[];
final Set<String> seen = <String>{};
for (final String raw in existing) {
final String value = raw.trim();
if (value.isEmpty) {
continue;
}
final String key = value.toLowerCase();
if (seen.add(key)) {
values.add(value);
}
}
final String? newValue = _trimToNull(incoming);
if (newValue != null) {
final String key = newValue.toLowerCase();
if (!seen.contains(key)) {
values.insert(0, newValue);
}
}
if (values.length <= maxItems) {
return values;
}
return values.take(maxItems).toList(growable: false);
}
String _truncate(String value, int maxChars) {
if (value.length <= maxChars) {
return value;
}
return value.substring(0, maxChars);
}
String _titleFromSummary(String summary) {
final List<String> words = summary.split(RegExp(r'\s+'));
final int take = words.length < 5 ? words.length : 5;
return words.take(take).join(' ');
}
@@ -0,0 +1,161 @@
part of 'relationship_repository.dart';
extension LocalRepositoryAiDigestOperations on LocalRepository {
Future<void> saveAiSuggestionDrafts(List<AiSuggestionDraft> drafts) async {
if (drafts.isEmpty) {
return;
}
final LocalDataState current = _requireState();
final Set<String> existingKeys = current.aiSuggestionDrafts
.map(_aiDraftDedupeKey)
.toSet();
final List<AiSuggestionDraft> uniqueDrafts = drafts
.where((AiSuggestionDraft draft) {
if (!_personExists(current.people, draft.personId)) {
return false;
}
return existingKeys.add(_aiDraftDedupeKey(draft));
})
.toList(growable: false);
if (uniqueDrafts.isEmpty) {
return;
}
await _setState(
current.copyWith(
aiSuggestionDrafts: <AiSuggestionDraft>[
...uniqueDrafts,
...current.aiSuggestionDrafts,
],
),
);
}
Future<void> acceptAiSuggestionDraft(String draftId) async {
final LocalDataState current = _requireState();
final AiSuggestionDraft? draft = current.aiSuggestionDrafts
.where((AiSuggestionDraft item) => item.id == draftId)
.cast<AiSuggestionDraft?>()
.firstOrNull;
if (draft == null || draft.status != AiSuggestionStatus.pending) {
return;
}
if (!_personExists(current.people, draft.personId)) {
await _setAiSuggestionStatus(draftId, AiSuggestionStatus.dismissed);
return;
}
final DateTime now = DateTime.now();
final List<AiSuggestionDraft> drafts = current.aiSuggestionDrafts
.map(
(AiSuggestionDraft item) => item.id == draftId
? item.copyWith(status: AiSuggestionStatus.accepted)
: item,
)
.toList(growable: false);
switch (draft.kind) {
case AiSuggestionKind.giftIdea:
case AiSuggestionKind.eventIdea:
final RelationshipIdea idea = RelationshipIdea(
id: 'i-${_uuid.v4()}',
personId: draft.personId,
type: draft.kind == AiSuggestionKind.giftIdea
? IdeaType.gift
: IdeaType.event,
title: draft.title,
details: _aiDraftDetails(draft),
createdAt: now,
);
await _setState(
current.copyWith(
aiSuggestionDrafts: drafts,
ideas: <RelationshipIdea>[idea, ...current.ideas],
),
);
await _enqueueChange(
_buildEnvelope(
entityType: _ideaEntityType(idea.type),
entityId: idea.id,
op: ChangeOperation.upsert,
payload: _ideaPayload(idea),
),
);
case AiSuggestionKind.checkIn:
final DashboardTask task = DashboardTask(
id: 't-${_uuid.v4()}',
title: draft.title,
description: _aiDraftDetails(draft),
when: draft.suggestedFor ?? now.add(const Duration(days: 1)),
);
await _setState(
current.copyWith(
aiSuggestionDrafts: drafts,
tasks: <DashboardTask>[task, ...current.tasks],
),
);
case AiSuggestionKind.reminder:
final ReminderRule reminder = ReminderRule(
id: 'r-${_uuid.v4()}',
personId: draft.personId,
title: draft.title,
cadence: ReminderCadence.weekly,
nextAt: draft.suggestedFor ?? now.add(const Duration(days: 7)),
);
await _setState(
current.copyWith(
aiSuggestionDrafts: drafts,
reminders: <ReminderRule>[reminder, ...current.reminders],
),
);
await _enqueueChange(
_buildEnvelope(
entityType: 'reminderRule',
entityId: reminder.id,
op: ChangeOperation.upsert,
payload: _reminderPayload(reminder),
),
);
}
}
Future<void> dismissAiSuggestionDraft(String draftId) {
return _setAiSuggestionStatus(draftId, AiSuggestionStatus.dismissed);
}
Future<void> _setAiSuggestionStatus(
String draftId,
AiSuggestionStatus status,
) async {
final LocalDataState current = _requireState();
final List<AiSuggestionDraft> drafts = current.aiSuggestionDrafts
.map(
(AiSuggestionDraft item) =>
item.id == draftId ? item.copyWith(status: status) : item,
)
.toList(growable: false);
await _setState(current.copyWith(aiSuggestionDrafts: drafts));
}
}
String _aiDraftDedupeKey(AiSuggestionDraft draft) {
return <String>[
draft.personId,
draft.kind.name,
draft.title.trim().toLowerCase(),
draft.details.trim().toLowerCase(),
].join('|');
}
String _aiDraftDetails(AiSuggestionDraft draft) {
final String reason = draft.reason == null || draft.reason!.trim().isEmpty
? ''
: '\n\nWhy: ${draft.reason!.trim()}';
return '${draft.details.trim()}$reason'.trim();
}
extension<T> on Iterable<T> {
T? get firstOrNull => isEmpty ? null : first;
}
@@ -0,0 +1,523 @@
part of 'relationship_repository.dart';
/// Person-profile persistence and merge operations.
extension LocalRepositoryPeopleOperations on LocalRepository {
Future<void> addPerson({
required String name,
required String relationship,
required String notes,
required List<String> tags,
DateTime? nextMoment,
String? location,
List<String> aliases = const <String>[],
}) async {
final String normalizedName = name.trim();
final String normalizedRelationship = relationship.trim();
if (normalizedName.isEmpty || normalizedRelationship.isEmpty) {
throw ArgumentError('Name and relationship are required');
}
final DateTime now = DateTime.now();
final LocalDataState current = _requireState();
final PersonProfile person = PersonProfile(
id: 'p-${_uuid.v4()}',
name: normalizedName,
relationship: normalizedRelationship,
affinityScore: 75,
nextMoment: nextMoment ?? now.add(const Duration(days: 3)),
tags: _mergeTags(tags, const <String>[]),
notes: notes.trim(),
aliases: _normalizeAliases(aliases),
location: _trimToNull(location),
lastUpdatedAt: now,
);
await _setState(
current.copyWith(people: <PersonProfile>[person, ...current.people]),
);
await _enqueueChange(
_buildEnvelope(
entityType: 'person',
entityId: person.id,
op: ChangeOperation.upsert,
payload: _personPayload(person),
),
);
}
Future<void> updatePerson(PersonProfile person) async {
final String normalizedName = person.name.trim();
final String normalizedRelationship = person.relationship.trim();
if (normalizedName.isEmpty || normalizedRelationship.isEmpty) {
throw ArgumentError('Name and relationship are required');
}
final DateTime now = DateTime.now();
final PersonProfile updatedPerson = person.copyWith(
name: normalizedName,
relationship: normalizedRelationship,
notes: person.notes.trim(),
tags: _mergeTags(person.tags, const <String>[]),
aliases: _normalizeAliases(person.aliases),
location: _trimToNull(person.location),
lastUpdatedAt: now,
);
final LocalDataState current = _requireState();
final List<PersonProfile> updated = current.people
.map(
(PersonProfile item) =>
item.id == updatedPerson.id ? updatedPerson : item,
)
.toList(growable: false);
await _setState(current.copyWith(people: updated));
await _enqueueChange(
_buildEnvelope(
entityType: 'person',
entityId: updatedPerson.id,
op: ChangeOperation.upsert,
payload: _personPayload(updatedPerson),
),
);
}
Future<void> deletePerson(String personId) async {
final LocalDataState current = _requireState();
final List<RelationshipMoment> removedMoments = current.moments
.where((RelationshipMoment moment) => moment.personId == personId)
.toList(growable: false);
final List<RelationshipIdea> removedIdeas = current.ideas
.where((RelationshipIdea idea) => idea.personId == personId)
.toList(growable: false);
final List<ReminderRule> removedReminders = current.reminders
.where((ReminderRule reminder) => reminder.personId == personId)
.toList(growable: false);
final List<PersonFact> removedFacts = current.personFacts
.where((PersonFact fact) => fact.personId == personId)
.toList(growable: false);
final List<PersonImportantDate> removedDates = current.importantDates
.where((PersonImportantDate value) => value.personId == personId)
.toList(growable: false);
await _setState(
current.copyWith(
people: current.people
.where((PersonProfile person) => person.id != personId)
.toList(growable: false),
moments: current.moments
.where((RelationshipMoment moment) => moment.personId != personId)
.toList(growable: false),
ideas: current.ideas
.where((RelationshipIdea idea) => idea.personId != personId)
.toList(growable: false),
reminders: current.reminders
.where((ReminderRule reminder) => reminder.personId != personId)
.toList(growable: false),
preferenceSignals: current.preferenceSignals
.where(
(PersonPreferenceSignal signal) => signal.personId != personId,
)
.toList(growable: false),
personFacts: current.personFacts
.where((PersonFact fact) => fact.personId != personId)
.toList(growable: false),
aiSuggestionDrafts: current.aiSuggestionDrafts
.where((AiSuggestionDraft draft) => draft.personId != personId)
.toList(growable: false),
importantDates: current.importantDates
.where((PersonImportantDate value) => value.personId != personId)
.toList(growable: false),
sourceLinks: current.sourceLinks
.where((SourceProfileLink link) => link.profileId != personId)
.toList(growable: false),
sharedMessages: current.sharedMessages
.where((SharedMessageEntry entry) => entry.profileId != personId)
.toList(growable: false),
sharedInbox: current.sharedInbox
.map((SharedInboxEntry entry) {
final List<String> nextCandidates = entry.candidateProfileIds
.where((String candidateId) => candidateId != personId)
.toList(growable: false);
return entry.copyWith(
candidateProfileIds: nextCandidates,
targetPersonId: entry.targetPersonId == personId
? null
: entry.targetPersonId,
);
})
.toList(growable: false),
),
);
await _enqueueChanges(<ChangeEnvelope>[
_buildEnvelope(
entityType: 'person',
entityId: personId,
op: ChangeOperation.delete,
),
...removedMoments.map(
(RelationshipMoment moment) => _buildEnvelope(
entityType: 'capture',
entityId: moment.id,
op: ChangeOperation.delete,
),
),
...removedIdeas.map(
(RelationshipIdea idea) => _buildEnvelope(
entityType: _ideaEntityType(idea.type),
entityId: idea.id,
op: ChangeOperation.delete,
),
),
...removedReminders.map(
(ReminderRule reminder) => _buildEnvelope(
entityType: 'reminderRule',
entityId: reminder.id,
op: ChangeOperation.delete,
),
),
...removedFacts.map(
(PersonFact fact) => _buildEnvelope(
entityType: 'personFact',
entityId: fact.id,
op: ChangeOperation.delete,
),
),
...removedDates.map(
(PersonImportantDate value) => _buildEnvelope(
entityType: 'personDate',
entityId: value.id,
op: ChangeOperation.delete,
),
),
]);
}
Future<void> mergePersonProfiles({
required String sourcePersonId,
required String targetPersonId,
}) async {
if (sourcePersonId == targetPersonId) {
throw ArgumentError('Source and target profile must be different');
}
final LocalDataState current = _requireState();
final PersonProfile? source = _findPersonById(
current.people,
sourcePersonId,
);
final PersonProfile? target = _findPersonById(
current.people,
targetPersonId,
);
if (source == null || target == null) {
throw ArgumentError('Both source and target profiles must exist');
}
final DateTime now = DateTime.now();
final PersonProfile mergedTarget = target.copyWith(
affinityScore: target.affinityScore > source.affinityScore
? target.affinityScore
: source.affinityScore,
nextMoment: source.nextMoment.isBefore(target.nextMoment)
? source.nextMoment
: target.nextMoment,
tags: _mergeTags(target.tags, source.tags),
notes: _mergeNotes(
targetNotes: target.notes,
sourceName: source.name,
sourceNotes: source.notes,
),
aliases: _normalizeAliases(<String>[
...target.aliases,
...source.aliases,
]),
location: _effectiveLocation(target.location, source.location),
lastUpdatedAt: now,
lastInteractedAt: _latestDate(
target.lastInteractedAt,
source.lastInteractedAt,
),
);
final List<PersonProfile> nextPeople = current.people
.where((PersonProfile person) => person.id != sourcePersonId)
.map(
(PersonProfile person) =>
person.id == targetPersonId ? mergedTarget : person,
)
.toList(growable: false);
final List<RelationshipMoment> updatedMoments = <RelationshipMoment>[];
final List<RelationshipMoment> nextMoments = current.moments
.map((RelationshipMoment moment) {
if (moment.personId != sourcePersonId) {
return moment;
}
final RelationshipMoment merged = moment.copyWith(
personId: targetPersonId,
);
updatedMoments.add(merged);
return merged;
})
.toList(growable: false);
final List<RelationshipIdea> updatedIdeas = <RelationshipIdea>[];
final List<RelationshipIdea> nextIdeas = current.ideas
.map((RelationshipIdea idea) {
if (idea.personId != sourcePersonId) {
return idea;
}
final RelationshipIdea merged = idea.copyWith(
personId: targetPersonId,
);
updatedIdeas.add(merged);
return merged;
})
.toList(growable: false);
final List<ReminderRule> updatedReminders = <ReminderRule>[];
final List<ReminderRule> nextReminders = current.reminders
.map((ReminderRule reminder) {
if (reminder.personId != sourcePersonId) {
return reminder;
}
final ReminderRule merged = reminder.copyWith(
personId: targetPersonId,
);
updatedReminders.add(merged);
return merged;
})
.toList(growable: false);
final List<SourceProfileLink> nextLinks = current.sourceLinks
.map((SourceProfileLink link) {
if (link.profileId != sourcePersonId) {
return link;
}
return link.copyWith(profileId: targetPersonId);
})
.toList(growable: false);
final List<SharedMessageEntry> nextMessages = current.sharedMessages
.map((SharedMessageEntry entry) {
if (entry.profileId != sourcePersonId) {
return entry;
}
return entry.copyWith(profileId: targetPersonId);
})
.toList(growable: false);
final List<SharedInboxEntry> nextInbox = current.sharedInbox
.map((SharedInboxEntry entry) {
if (!entry.candidateProfileIds.contains(sourcePersonId) &&
entry.targetPersonId != sourcePersonId) {
return entry;
}
final List<String> candidates = entry.candidateProfileIds
.map(
(String profileId) =>
profileId == sourcePersonId ? targetPersonId : profileId,
)
.toSet()
.toList(growable: false);
return entry.copyWith(
candidateProfileIds: candidates,
targetPersonId: entry.targetPersonId == sourcePersonId
? targetPersonId
: entry.targetPersonId,
);
})
.toList(growable: false);
final List<PersonPreferenceSignal> nextPreferenceSignals = current
.preferenceSignals
.map((PersonPreferenceSignal signal) {
if (signal.personId != sourcePersonId) {
return signal;
}
return signal.copyWith(personId: targetPersonId);
})
.toList(growable: false);
final List<PersonFact> nextFacts = current.personFacts
.map((PersonFact fact) {
if (fact.personId != sourcePersonId) {
return fact;
}
return fact.copyWith(personId: targetPersonId, updatedAt: now);
})
.toList(growable: false);
final List<PersonImportantDate> nextImportantDates = current.importantDates
.map((PersonImportantDate value) {
if (value.personId != sourcePersonId) {
return value;
}
return value.copyWith(personId: targetPersonId, updatedAt: now);
})
.toList(growable: false);
final List<AiSuggestionDraft> nextAiSuggestionDrafts = current
.aiSuggestionDrafts
.map((AiSuggestionDraft draft) {
if (draft.personId != sourcePersonId) {
return draft;
}
return draft.copyWith(personId: targetPersonId);
})
.toList(growable: false);
await _setState(
current.copyWith(
people: nextPeople,
moments: nextMoments,
ideas: nextIdeas,
reminders: nextReminders,
sourceLinks: nextLinks,
sharedMessages: nextMessages,
sharedInbox: nextInbox,
personFacts: nextFacts,
importantDates: nextImportantDates,
preferenceSignals: nextPreferenceSignals,
aiSuggestionDrafts: nextAiSuggestionDrafts,
),
);
await _enqueueChanges(<ChangeEnvelope>[
_buildEnvelope(
entityType: 'person',
entityId: mergedTarget.id,
op: ChangeOperation.upsert,
payload: _personPayload(mergedTarget),
),
_buildEnvelope(
entityType: 'person',
entityId: sourcePersonId,
op: ChangeOperation.delete,
),
...updatedMoments.map(
(RelationshipMoment moment) => _buildEnvelope(
entityType: 'capture',
entityId: moment.id,
op: ChangeOperation.upsert,
payload: _momentPayload(moment),
),
),
...updatedIdeas.map(
(RelationshipIdea idea) => _buildEnvelope(
entityType: _ideaEntityType(idea.type),
entityId: idea.id,
op: ChangeOperation.upsert,
payload: _ideaPayload(idea),
),
),
...updatedReminders.map(
(ReminderRule reminder) => _buildEnvelope(
entityType: 'reminderRule',
entityId: reminder.id,
op: ChangeOperation.upsert,
payload: _reminderPayload(reminder),
),
),
]);
}
}
List<String> _mergeTags(List<String> primary, List<String> secondary) {
final List<String> merged = <String>[];
final Set<String> seen = <String>{};
for (final String raw in <String>[...primary, ...secondary]) {
final String tag = raw.trim();
if (tag.isEmpty) {
continue;
}
final String normalized = tag.toLowerCase();
if (seen.add(normalized)) {
merged.add(tag);
}
}
return merged;
}
String _mergeNotes({
required String targetNotes,
required String sourceName,
required String sourceNotes,
}) {
final String target = targetNotes.trim();
final String source = sourceNotes.trim();
if (target.isEmpty) {
return source;
}
if (source.isEmpty) {
return target;
}
if (target == source) {
return target;
}
return '$target\n\nMerged from $sourceName:\n$source';
}
String? _effectiveLocation(String? target, String? source) {
final String? targetValue = _trimToNull(target);
if (targetValue != null) {
return targetValue;
}
return _trimToNull(source);
}
List<String> _normalizeAliases(List<String> aliases) {
final List<String> values = <String>[];
final Set<String> seen = <String>{};
for (final String raw in aliases) {
final String value = raw.trim();
if (value.isEmpty) {
continue;
}
final String key = value.toLowerCase();
if (seen.add(key)) {
values.add(value);
}
}
return values;
}
DateTime? _latestDate(DateTime? left, DateTime? right) {
if (left == null) {
return right;
}
if (right == null) {
return left;
}
return left.isAfter(right) ? left : right;
}
List<PersonProfile> _touchPeople(
List<PersonProfile> people, {
required String personId,
DateTime? updatedAt,
DateTime? interactedAt,
}) {
return people
.map((PersonProfile person) {
if (person.id != personId) {
return person;
}
return person.copyWith(
lastUpdatedAt: updatedAt ?? person.lastUpdatedAt ?? DateTime.now(),
lastInteractedAt:
interactedAt ?? person.lastInteractedAt ?? DateTime.now(),
);
})
.toList(growable: false);
}
PersonProfile? _findPersonById(List<PersonProfile> people, String id) {
for (final PersonProfile person in people) {
if (person.id == id) {
return person;
}
}
return null;
}
@@ -0,0 +1,883 @@
part of 'relationship_repository.dart';
/// Share capture intake, person resolution, and inbox triage.
extension LocalRepositoryShareOperations on LocalRepository {
Future<SharedMessageIngestResult> ingestSharedMessage(
SharedMessageIngestInput input,
) async {
final DateTime now = DateTime.now();
final SharedPayload payload = SharedPayload(
sourceApp: input.sourceApp.trim().toLowerCase(),
payloadType: SharedPayloadType.text,
rawText: input.messageText.trim(),
createdAt: input.sharedAt ?? now,
receivedAt: now,
platform: 'legacy',
sourceDisplayName: _trimToNull(input.sourceDisplayName),
sourceUserId: _trimToNull(input.sourceUserId),
sourceThreadId: _trimToNull(input.sourceThreadId),
dedupeKey: _dedupeKeyForShare(
sourceApp: input.sourceApp,
rawText: input.messageText,
url: null,
sourceUserId: input.sourceUserId,
sourceThreadId: input.sourceThreadId,
),
);
if (payload.rawText.isEmpty) {
throw ArgumentError('Shared message text cannot be empty');
}
final LocalDataState current = _requireState();
final String sourceApp = payload.sourceApp;
final String? sourceDisplayName = payload.sourceDisplayName;
final String normalizedDisplayName = _normalizeDisplayName(
sourceDisplayName,
);
final String? sourceUserId = payload.sourceUserId;
final String? sourceThreadId = payload.sourceThreadId;
final String? sourceFingerprint = _buildStableSourceFingerprint(
sourceApp: sourceApp,
sourceUserId: sourceUserId,
sourceThreadId: sourceThreadId,
);
final SourceProfileLink? matchedLink = _findExistingSourceLink(
links: current.sourceLinks,
sourceApp: sourceApp,
sourceFingerprint: sourceFingerprint,
sourceUserId: sourceUserId,
sourceThreadId: sourceThreadId,
normalizedDisplayName: normalizedDisplayName,
);
PersonProfile? resolvedPerson;
if (matchedLink != null) {
resolvedPerson = _findPersonById(current.people, matchedLink.profileId);
}
final List<PersonProfile> matchingPeople = normalizedDisplayName.isEmpty
? const <PersonProfile>[]
: _findPeopleByNormalizedName(current.people, normalizedDisplayName);
if (resolvedPerson == null && matchingPeople.length == 1) {
resolvedPerson = matchingPeople.first;
}
if (resolvedPerson == null && matchingPeople.length > 1) {
return _queueSharedInbox(
current: current,
payload: payload,
sourceFingerprint: sourceFingerprint,
normalizedDisplayName: normalizedDisplayName,
reason: SharedInboxReason.ambiguousProfileMatch,
candidateProfileIds: matchingPeople
.map((PersonProfile person) => person.id)
.toList(growable: false),
);
}
final List<PersonProfile> nearMatchPeople = resolvedPerson == null
? _findNearMatchPeople(
current.people,
normalizedDisplayName: normalizedDisplayName,
)
: const <PersonProfile>[];
if (resolvedPerson == null && nearMatchPeople.isNotEmpty) {
return _queueSharedInbox(
current: current,
payload: payload,
sourceFingerprint: sourceFingerprint,
normalizedDisplayName: normalizedDisplayName,
reason: SharedInboxReason.nearProfileConflict,
candidateProfileIds: nearMatchPeople
.map((PersonProfile person) => person.id)
.toList(growable: false),
);
}
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,
);
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(
current: current,
importedAt: now,
payload: payload,
sourceFingerprint: sourceFingerprint,
normalizedDisplayName: normalizedDisplayName,
resolvedPerson: resolvedPerson,
matchedLink: matchedLink,
createdProfile: createdProfile,
people: nextPeople,
resolvedAutomatically: true,
draft: _defaultDraftForPayload(payload),
);
}
Future<SharedMessageIngestResult> resolveSharedInboxToExistingProfile({
required String inboxEntryId,
required String profileId,
CapturedFactDraft? draft,
}) async {
final LocalDataState current = _requireState();
final SharedInboxEntry entry = _requireSharedInboxEntry(
current,
inboxEntryId,
);
final PersonProfile? resolvedPerson = _findPersonById(
current.people,
profileId,
);
if (resolvedPerson == null) {
throw ArgumentError('Profile not found for inbox entry');
}
return captureSharedPayloadToExistingProfile(
payload: entry.payload,
profileId: profileId,
draft: draft ?? entry.draft,
consumedInboxEntryId: entry.id,
normalizedDisplayName: entry.normalizedDisplayName,
sourceFingerprint: entry.sourceFingerprint,
resolvedAutomatically: false,
);
}
Future<SharedMessageIngestResult> resolveSharedInboxByCreatingProfile({
required String inboxEntryId,
String? name,
String relationship = 'WhatsApp Contact',
String? location,
List<String> aliases = const <String>[],
CapturedFactDraft? draft,
}) async {
final LocalDataState current = _requireState();
final SharedInboxEntry entry = _requireSharedInboxEntry(
current,
inboxEntryId,
);
return captureSharedPayloadByCreatingProfile(
payload: entry.payload,
name: name,
relationship: relationship,
location: location,
aliases: aliases,
draft: draft ?? entry.draft,
consumedInboxEntryId: entry.id,
normalizedDisplayName: entry.normalizedDisplayName,
sourceFingerprint: entry.sourceFingerprint,
resolvedAutomatically: false,
);
}
Future<SharedMessageIngestResult> saveSharedPayloadToInbox({
required SharedPayload payload,
SharedInboxReason reason = SharedInboxReason.missingIdentity,
List<String> candidateProfileIds = const <String>[],
String? normalizedDisplayName,
String? targetPersonId,
String? sourceFingerprint,
CapturedFactDraft? draft,
}) async {
final LocalDataState current = _requireState();
return _queueSharedInbox(
current: current,
payload: payload,
sourceFingerprint:
sourceFingerprint ??
_buildStableSourceFingerprint(
sourceApp: payload.sourceApp,
sourceUserId: payload.sourceUserId,
sourceThreadId: payload.sourceThreadId,
),
normalizedDisplayName:
normalizedDisplayName ??
_normalizeDisplayName(payload.sourceDisplayName),
reason: reason,
candidateProfileIds: candidateProfileIds,
targetPersonId: targetPersonId,
draft: draft,
);
}
Future<SharedMessageIngestResult> captureSharedPayloadToExistingProfile({
required SharedPayload payload,
required String profileId,
CapturedFactDraft? draft,
bool resolvedAutomatically = false,
String? consumedInboxEntryId,
String? normalizedDisplayName,
String? sourceFingerprint,
}) async {
final LocalDataState current = _requireState();
final PersonProfile? resolvedPerson = _findPersonById(
current.people,
profileId,
);
if (resolvedPerson == null) {
throw ArgumentError('Profile not found');
}
final String effectiveNormalizedDisplayName =
normalizedDisplayName ??
_normalizeDisplayName(payload.sourceDisplayName);
final String? effectiveSourceFingerprint =
sourceFingerprint ??
_buildStableSourceFingerprint(
sourceApp: payload.sourceApp,
sourceUserId: payload.sourceUserId,
sourceThreadId: payload.sourceThreadId,
);
final SourceProfileLink? matchedLink = _findExistingSourceLink(
links: current.sourceLinks,
sourceApp: payload.sourceApp,
sourceFingerprint: effectiveSourceFingerprint,
sourceUserId: payload.sourceUserId,
sourceThreadId: payload.sourceThreadId,
normalizedDisplayName: effectiveNormalizedDisplayName,
);
return _ingestIntoResolvedProfile(
current: current,
importedAt: DateTime.now(),
payload: payload,
sourceFingerprint: effectiveSourceFingerprint,
normalizedDisplayName: effectiveNormalizedDisplayName,
resolvedPerson: resolvedPerson,
matchedLink: matchedLink,
createdProfile: false,
people: current.people,
resolvedAutomatically: resolvedAutomatically,
consumedInboxEntryId: consumedInboxEntryId,
draft: draft ?? _defaultDraftForPayload(payload),
);
}
Future<SharedMessageIngestResult> captureSharedPayloadByCreatingProfile({
required SharedPayload payload,
String? name,
String relationship = 'Contact',
String? location,
List<String> aliases = const <String>[],
CapturedFactDraft? draft,
bool resolvedAutomatically = false,
String? consumedInboxEntryId,
String? normalizedDisplayName,
String? sourceFingerprint,
}) 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 normalizedRelationship = relationship.trim().isEmpty
? 'Contact'
: relationship.trim();
final PersonProfile createdPerson = PersonProfile(
id: 'p-${_uuid.v4()}',
name: profileName,
relationship: normalizedRelationship,
affinityScore: 70,
nextMoment: now.add(const Duration(days: 2)),
tags: <String>[
if (payload.sourceApp.trim().isNotEmpty) payload.sourceApp.trim(),
],
notes: 'Created from shared capture.',
aliases: _normalizeAliases(<String>[
...aliases,
if (payload.sourceDisplayName != null) payload.sourceDisplayName!,
]),
location: _trimToNull(location),
lastUpdatedAt: now,
lastInteractedAt: now,
);
return _ingestIntoResolvedProfile(
current: current,
importedAt: now,
payload: payload,
sourceFingerprint:
sourceFingerprint ??
_buildStableSourceFingerprint(
sourceApp: payload.sourceApp,
sourceUserId: payload.sourceUserId,
sourceThreadId: payload.sourceThreadId,
),
normalizedDisplayName:
normalizedDisplayName ??
_normalizeDisplayName(payload.sourceDisplayName),
resolvedPerson: createdPerson,
matchedLink: null,
createdProfile: true,
people: <PersonProfile>[createdPerson, ...current.people],
resolvedAutomatically: resolvedAutomatically,
consumedInboxEntryId: consumedInboxEntryId,
draft: draft ?? _defaultDraftForPayload(payload),
);
}
Future<void> dismissSharedInboxEntry(String inboxEntryId) async {
final LocalDataState current = _requireState();
final List<SharedInboxEntry> nextInbox = current.sharedInbox
.where((SharedInboxEntry entry) => entry.id != inboxEntryId)
.toList(growable: false);
if (nextInbox.length == current.sharedInbox.length) {
return;
}
await _setState(current.copyWith(sharedInbox: nextInbox));
}
Future<SharedMessageIngestResult> _queueSharedInbox({
required LocalDataState current,
required SharedPayload payload,
required SharedInboxReason reason,
required List<String> candidateProfileIds,
required String normalizedDisplayName,
String? sourceFingerprint,
String? targetPersonId,
CapturedFactDraft? draft,
}) async {
final SharedPayload storedPayload = payload.copyWith(
rawText: _truncate(payload.rawText, 1800),
dedupeKey:
payload.dedupeKey ??
_dedupeKeyForShare(
sourceApp: payload.sourceApp,
rawText: payload.rawText,
url: payload.url,
sourceUserId: payload.sourceUserId,
sourceThreadId: payload.sourceThreadId,
),
);
final SharedInboxEntry entry = SharedInboxEntry(
id: 'si-${_uuid.v4()}',
payload: storedPayload,
reason: reason,
candidateProfileIds: candidateProfileIds,
normalizedDisplayName: normalizedDisplayName,
assignmentStatus: ShareAssignmentStatus.savedToInbox,
targetPersonId: targetPersonId,
sourceFingerprint: sourceFingerprint,
draft: draft ?? _defaultDraftForPayload(storedPayload),
);
await _setState(
current.copyWith(
sharedInbox: <SharedInboxEntry>[entry, ...current.sharedInbox],
),
);
return SharedMessageIngestResult.queuedForResolution(
inboxEntryId: entry.id,
);
}
Future<SharedMessageIngestResult> _ingestIntoResolvedProfile({
required LocalDataState current,
required SharedPayload payload,
required DateTime importedAt,
required String normalizedDisplayName,
required String? sourceFingerprint,
required PersonProfile resolvedPerson,
required bool createdProfile,
required List<PersonProfile> people,
required bool resolvedAutomatically,
required CapturedFactDraft draft,
SourceProfileLink? matchedLink,
String? consumedInboxEntryId,
}) async {
final String sourceApp = payload.sourceApp;
final DateTime sharedAt = payload.createdAt;
final String? sourceUserId = payload.sourceUserId;
final String? sourceThreadId = payload.sourceThreadId;
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,
sourceFingerprint: sourceFingerprint,
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,
sourceFingerprint: sourceFingerprint ?? matchedLink.sourceFingerprint,
normalizedDisplayName: normalizedDisplayName.isEmpty
? matchedLink.normalizedDisplayName
: normalizedDisplayName,
profileId: resolvedPerson.id,
lastSeenAt: sharedAt,
);
}
}
final String summary = _truncate(payload.rawText, 1800);
final RelationshipMoment moment = RelationshipMoment(
id: 'm-${_uuid.v4()}',
personId: resolvedPerson.id,
title: _titleFromSummary(summary),
summary: summary,
at: sharedAt,
type: sourceApp == 'whatsapp' ? 'whatsapp' : 'shared',
);
final SharedMessageEntry sharedEntry = SharedMessageEntry(
id: 'sm-${_uuid.v4()}',
profileId: resolvedPerson.id,
payload: payload.copyWith(
rawText: summary,
dedupeKey:
payload.dedupeKey ??
_dedupeKeyForShare(
sourceApp: payload.sourceApp,
rawText: payload.rawText,
url: payload.url,
sourceUserId: payload.sourceUserId,
sourceThreadId: payload.sourceThreadId,
),
),
importedAt: importedAt,
resolvedAutomatically: resolvedAutomatically,
assignmentStatus: ShareAssignmentStatus.assignedDirectly,
draft: draft,
);
final List<SharedInboxEntry> nextInbox = consumedInboxEntryId == null
? current.sharedInbox
: current.sharedInbox
.where(
(SharedInboxEntry entry) => entry.id != consumedInboxEntryId,
)
.toList(growable: false);
await _setState(
current.copyWith(
people: _touchPeople(
people,
personId: resolvedPerson.id,
updatedAt: importedAt,
interactedAt: sharedAt,
),
moments: <RelationshipMoment>[moment, ...current.moments],
sourceLinks: nextLinks,
sharedMessages: <SharedMessageEntry>[
sharedEntry,
...current.sharedMessages,
],
sharedInbox: nextInbox,
personFacts: _applyFactDraftToFacts(
current.personFacts,
personId: resolvedPerson.id,
sharedEntry: sharedEntry,
draft: draft,
consumedInboxEntryId: consumedInboxEntryId,
observedAt: sharedAt,
),
importantDates: _applyFactDraftToDates(
current.importantDates,
personId: resolvedPerson.id,
draft: draft,
sourceApp: payload.sourceApp,
consumedInboxEntryId: consumedInboxEntryId,
observedAt: sharedAt,
),
),
);
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);
final List<ExtractedPreferenceSignalCandidate> extractedSignals =
_chatPreferenceExtractor.extract(summary);
for (final ExtractedPreferenceSignalCandidate candidate
in extractedSignals) {
await upsertInferredPreferenceSignalObservation(
personId: resolvedPerson.id,
key: candidate.key,
label: candidate.label,
category: candidate.category,
polarity: candidate.polarity,
confidence: candidate.confidence,
sourceApp: sourceApp,
evidenceMessageId: sharedEntry.id,
evidenceSnippet: candidate.evidenceSnippet,
observedAt: sharedAt,
);
}
return SharedMessageIngestResult.imported(
profileId: resolvedPerson.id,
profileName: resolvedPerson.name,
createdProfile: createdProfile,
createdSourceLink: createdSourceLink,
momentId: moment.id,
);
}
}
CapturedFactDraft _defaultDraftForPayload(SharedPayload payload) {
return ShareCaptureDraftSuggester.suggestForPayload(
payload,
).copyWith(needsReview: true);
}
String _dedupeKeyForShare({
required String sourceApp,
required String rawText,
required String? url,
required String? sourceUserId,
required String? sourceThreadId,
}) {
final String text = rawText.trim().toLowerCase().replaceAll(
RegExp(r'\s+'),
' ',
);
return <String>[
sourceApp.trim().toLowerCase(),
sourceUserId?.trim().toLowerCase() ?? '',
sourceThreadId?.trim().toLowerCase() ?? '',
url?.trim().toLowerCase() ?? '',
text,
].join('|');
}
List<PersonFact> _applyFactDraftToFacts(
List<PersonFact> currentFacts, {
required String personId,
required SharedMessageEntry sharedEntry,
required CapturedFactDraft draft,
required String? consumedInboxEntryId,
required DateTime observedAt,
}) {
if (draft.type == CapturedFactType.importantDate ||
draft.text.trim().isEmpty) {
return currentFacts;
}
final PersonFact fact = PersonFact(
id: 'pf-${_uuid.v4()}',
personId: personId,
type: draft.type,
text: draft.text.trim(),
label: _trimToNull(draft.label),
sourceKind: consumedInboxEntryId == null
? CaptureSourceKind.shareSheet
: CaptureSourceKind.shareInbox,
sourceApp: sharedEntry.sourceApp,
sourceUrl: sharedEntry.url,
sharedMessageId: sharedEntry.id,
inboxEntryId: consumedInboxEntryId,
confidence: draft.confidence,
needsReview: draft.needsReview,
isSensitive: draft.isSensitive,
createdAt: observedAt,
updatedAt: observedAt,
);
return <PersonFact>[fact, ...currentFacts];
}
List<PersonImportantDate> _applyFactDraftToDates(
List<PersonImportantDate> currentDates, {
required String personId,
required CapturedFactDraft draft,
required String sourceApp,
required String? consumedInboxEntryId,
required DateTime observedAt,
}) {
if (draft.type != CapturedFactType.importantDate || draft.dateValue == null) {
return currentDates;
}
final PersonImportantDate value = PersonImportantDate(
id: 'pd-${_uuid.v4()}',
personId: personId,
label: _trimToNull(draft.label) ?? draft.text.trim(),
date: draft.dateValue!,
classification: 'important',
sourceKind: consumedInboxEntryId == null
? CaptureSourceKind.shareSheet
: CaptureSourceKind.shareInbox,
sourceApp: sourceApp,
inboxEntryId: consumedInboxEntryId,
isSensitive: draft.isSensitive,
createdAt: observedAt,
updatedAt: observedAt,
);
return <PersonImportantDate>[value, ...currentDates];
}
SourceProfileLink? _findExistingSourceLink({
required List<SourceProfileLink> links,
required String sourceApp,
required String? sourceFingerprint,
required String? sourceUserId,
required String? sourceThreadId,
required String normalizedDisplayName,
}) {
if (sourceFingerprint != null && sourceFingerprint.isNotEmpty) {
for (final SourceProfileLink link in links) {
final String? linkFingerprint =
link.sourceFingerprint ??
_buildStableSourceFingerprint(
sourceApp: link.sourceApp,
sourceUserId: link.sourceUserId,
sourceThreadId: link.sourceThreadId,
);
if (link.sourceApp == sourceApp && linkFingerprint == sourceFingerprint) {
return link;
}
}
}
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;
}
List<PersonProfile> _findPeopleByNormalizedName(
List<PersonProfile> people,
String normalizedDisplayName,
) {
return people
.where((PersonProfile person) {
if (_normalizeDisplayName(person.name) == normalizedDisplayName) {
return true;
}
for (final String alias in person.aliases) {
if (_normalizeDisplayName(alias) == normalizedDisplayName) {
return true;
}
}
return false;
})
.toList(growable: false);
}
List<PersonProfile> _findNearMatchPeople(
List<PersonProfile> people, {
required String normalizedDisplayName,
}) {
if (normalizedDisplayName.isEmpty) {
return const <PersonProfile>[];
}
final Map<String, int> distanceById = <String, int>{};
final List<PersonProfile> candidates = <PersonProfile>[];
for (final PersonProfile person in people) {
final List<String> probes = <String>[person.name, ...person.aliases];
for (final String probe in probes) {
final String personName = _normalizeDisplayName(probe);
if (personName.isEmpty || personName == normalizedDisplayName) {
continue;
}
final int distance = _levenshteinDistance(
normalizedDisplayName,
personName,
);
if (_isNearDisplayName(
normalizedDisplayName,
personName,
distance: distance,
)) {
distanceById[person.id] = distance;
if (!candidates.any((PersonProfile item) => item.id == person.id)) {
candidates.add(person);
}
}
}
}
candidates.sort((PersonProfile a, PersonProfile b) {
final int left = distanceById[a.id] ?? 999;
final int right = distanceById[b.id] ?? 999;
if (left != right) {
return left.compareTo(right);
}
return a.name.length.compareTo(b.name.length);
});
if (candidates.length <= 5) {
return candidates;
}
return candidates.take(5).toList(growable: false);
}
bool _isNearDisplayName(String left, String right, {required int distance}) {
final int maxLength = left.length > right.length ? left.length : right.length;
final int minLength = left.length < right.length ? left.length : right.length;
if (minLength >= 4 && (left.contains(right) || right.contains(left))) {
return true;
}
final int threshold = maxLength >= 14 ? 3 : 2;
if (distance <= threshold) {
return true;
}
return maxLength >= 8 && (distance / maxLength) <= 0.22;
}
int _levenshteinDistance(String left, String right) {
if (left == right) {
return 0;
}
if (left.isEmpty) {
return right.length;
}
if (right.isEmpty) {
return left.length;
}
List<int> previous = List<int>.generate(
right.length + 1,
(int index) => index,
growable: false,
);
for (int i = 1; i <= left.length; i += 1) {
final List<int> current = List<int>.filled(right.length + 1, 0);
current[0] = i;
for (int j = 1; j <= right.length; j += 1) {
final int substitutionCost = left[i - 1] == right[j - 1] ? 0 : 1;
final int deletion = previous[j] + 1;
final int insertion = current[j - 1] + 1;
final int substitution = previous[j - 1] + substitutionCost;
final int value = deletion < insertion ? deletion : insertion;
current[j] = value < substitution ? value : substitution;
}
previous = current;
}
return previous[right.length];
}
SharedInboxEntry _requireSharedInboxEntry(
LocalDataState current,
String inboxEntryId,
) {
for (final SharedInboxEntry entry in current.sharedInbox) {
if (entry.id == inboxEntryId) {
return entry;
}
}
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,
required String? sourceThreadId,
}) {
final String? userId = _trimToNull(sourceUserId);
final String? threadId = _trimToNull(sourceThreadId);
if (userId == null && threadId == null) {
return null;
}
final List<String> parts = <String>[
sourceApp.trim().toLowerCase(),
if (userId != null) 'u:${userId.toLowerCase()}',
if (threadId != null) 't:${threadId.toLowerCase()}',
];
return parts.join('|');
}
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+'), ' ');
}
@@ -0,0 +1,486 @@
part of 'relationship_repository.dart';
/// Sync queue integration and change-envelope serialization helpers.
extension LocalRepositorySyncOperations on LocalRepository {
Future<void> applyRemoteChanges(List<ChangeEnvelope> changes) async {
if (changes.isEmpty) {
return;
}
LocalDataState next = _requireState();
for (final ChangeEnvelope change in changes) {
next = _applyRemoteChange(next, change);
}
await _setState(next);
}
LocalDataState _applyRemoteChange(
LocalDataState current,
ChangeEnvelope change,
) {
switch (change.entityType) {
case 'person':
return _applyPersonChange(current, change);
case 'capture':
return _applyMomentChange(current, change);
case 'giftIdea':
case 'eventIdea':
return _applyIdeaChange(current, change);
case 'reminderRule':
return _applyReminderChange(current, change);
default:
return current;
}
}
LocalDataState _applyPersonChange(
LocalDataState current,
ChangeEnvelope change,
) {
if (change.op == ChangeOperation.delete) {
final String personId = change.entityId;
return current.copyWith(
people: current.people
.where((PersonProfile person) => person.id != personId)
.toList(growable: false),
moments: current.moments
.where((RelationshipMoment moment) => moment.personId != personId)
.toList(growable: false),
ideas: current.ideas
.where((RelationshipIdea idea) => idea.personId != personId)
.toList(growable: false),
reminders: current.reminders
.where((ReminderRule reminder) => reminder.personId != personId)
.toList(growable: false),
preferenceSignals: current.preferenceSignals
.where(
(PersonPreferenceSignal signal) => signal.personId != personId,
)
.toList(growable: false),
personFacts: current.personFacts
.where((PersonFact fact) => fact.personId != personId)
.toList(growable: false),
aiSuggestionDrafts: current.aiSuggestionDrafts
.where((AiSuggestionDraft draft) => draft.personId != personId)
.toList(growable: false),
importantDates: current.importantDates
.where((PersonImportantDate value) => value.personId != personId)
.toList(growable: false),
sourceLinks: current.sourceLinks
.where((SourceProfileLink link) => link.profileId != personId)
.toList(growable: false),
sharedMessages: current.sharedMessages
.where((SharedMessageEntry entry) => entry.profileId != personId)
.toList(growable: false),
);
}
final int existingIndex = current.people.indexWhere(
(PersonProfile person) => person.id == change.entityId,
);
final PersonProfile? existing = existingIndex >= 0
? current.people[existingIndex]
: null;
final Map<String, dynamic> payload = change.payload ?? <String, dynamic>{};
final PersonProfile next = PersonProfile(
id: change.entityId,
name: _stringField(payload, 'name', existing?.name ?? 'Unknown'),
relationship: _stringField(
payload,
'relationship',
existing?.relationship ?? 'Relationship',
),
affinityScore: _intField(
payload,
'affinityScore',
existing?.affinityScore ?? 70,
),
nextMoment: _dateField(
payload,
'nextMoment',
existing?.nextMoment ?? DateTime.now().add(const Duration(days: 3)),
),
tags: _stringListField(payload, 'tags', existing?.tags ?? <String>[]),
notes: _stringField(payload, 'notes', existing?.notes ?? ''),
aliases: _stringListField(
payload,
'aliases',
existing?.aliases ?? <String>[],
),
location: _stringOrNullField(payload, 'location', existing?.location),
lastUpdatedAt: _dateOrNullField(
payload,
'lastUpdatedAt',
existing?.lastUpdatedAt,
),
lastInteractedAt: _dateOrNullField(
payload,
'lastInteractedAt',
existing?.lastInteractedAt,
),
);
final List<PersonProfile> people = _upsertItem(
items: current.people,
item: next,
idOf: (PersonProfile person) => person.id,
);
return current.copyWith(people: people);
}
LocalDataState _applyMomentChange(
LocalDataState current,
ChangeEnvelope change,
) {
if (change.op == ChangeOperation.delete) {
return current.copyWith(
moments: current.moments
.where((RelationshipMoment moment) => moment.id != change.entityId)
.toList(growable: false),
);
}
final int existingIndex = current.moments.indexWhere(
(RelationshipMoment moment) => moment.id == change.entityId,
);
final RelationshipMoment? existing = existingIndex >= 0
? current.moments[existingIndex]
: null;
final Map<String, dynamic> payload = change.payload ?? <String, dynamic>{};
final String summary = _stringField(
payload,
'summary',
existing?.summary ?? '',
);
final RelationshipMoment next = RelationshipMoment(
id: change.entityId,
personId: _stringField(
payload,
'personId',
existing?.personId ??
(current.people.isEmpty
? 'person-unknown'
: current.people.first.id),
),
title: _stringField(
payload,
'title',
existing?.title ?? _titleFromSummary(summary),
),
summary: summary,
at: _dateField(payload, 'at', existing?.at ?? DateTime.now()),
type: _stringField(payload, 'type', existing?.type ?? 'capture'),
);
final List<RelationshipMoment> moments = _upsertItem(
items: current.moments,
item: next,
idOf: (RelationshipMoment moment) => moment.id,
);
return current.copyWith(moments: moments);
}
LocalDataState _applyIdeaChange(
LocalDataState current,
ChangeEnvelope change,
) {
if (change.op == ChangeOperation.delete) {
return current.copyWith(
ideas: current.ideas
.where((RelationshipIdea idea) => idea.id != change.entityId)
.toList(growable: false),
);
}
final int existingIndex = current.ideas.indexWhere(
(RelationshipIdea idea) => idea.id == change.entityId,
);
final RelationshipIdea? existing = existingIndex >= 0
? current.ideas[existingIndex]
: null;
final Map<String, dynamic> payload = change.payload ?? <String, dynamic>{};
final IdeaType fallbackType = _ideaTypeFromEntityType(
change.entityType,
fallback: existing?.type ?? IdeaType.gift,
);
final IdeaType type = _ideaTypeFromRaw(
payload['type'],
fallback: fallbackType,
);
final RelationshipIdea next = RelationshipIdea(
id: change.entityId,
personId: payload['personId'] as String? ?? existing?.personId,
type: type,
title: _stringField(payload, 'title', existing?.title ?? 'Untitled idea'),
details: _stringField(payload, 'details', existing?.details ?? ''),
createdAt: _dateField(
payload,
'createdAt',
existing?.createdAt ?? DateTime.now(),
),
isArchived: _boolField(
payload,
'isArchived',
existing?.isArchived ?? false,
),
);
final List<RelationshipIdea> ideas = _upsertItem(
items: current.ideas,
item: next,
idOf: (RelationshipIdea idea) => idea.id,
);
return current.copyWith(ideas: ideas);
}
LocalDataState _applyReminderChange(
LocalDataState current,
ChangeEnvelope change,
) {
if (change.op == ChangeOperation.delete) {
return current.copyWith(
reminders: current.reminders
.where((ReminderRule reminder) => reminder.id != change.entityId)
.toList(growable: false),
);
}
final int existingIndex = current.reminders.indexWhere(
(ReminderRule reminder) => reminder.id == change.entityId,
);
final ReminderRule? existing = existingIndex >= 0
? current.reminders[existingIndex]
: null;
final Map<String, dynamic> payload = change.payload ?? <String, dynamic>{};
final ReminderRule next = ReminderRule(
id: change.entityId,
personId: payload['personId'] as String? ?? existing?.personId,
title: _stringField(payload, 'title', existing?.title ?? 'Reminder'),
cadence: _cadenceFromRaw(
payload['cadence'],
fallback: existing?.cadence ?? ReminderCadence.weekly,
),
nextAt: _dateField(payload, 'nextAt', existing?.nextAt ?? DateTime.now()),
enabled: _boolField(payload, 'enabled', existing?.enabled ?? true),
);
final List<ReminderRule> reminders = _upsertItem(
items: current.reminders,
item: next,
idOf: (ReminderRule reminder) => reminder.id,
);
return current.copyWith(reminders: reminders);
}
ChangeEnvelope _buildEnvelope({
required String entityType,
required String entityId,
required ChangeOperation op,
Map<String, dynamic>? payload,
}) {
return ChangeEnvelope(
schemaVersion: _syncSchemaVersion,
entityType: entityType,
entityId: entityId,
op: op,
modifiedAt: DateTime.now().toUtc(),
clientMutationId: _uuid.v4(),
payload: payload,
);
}
Map<String, dynamic> _personPayload(PersonProfile person) {
return <String, dynamic>{
'name': person.name,
'relationship': person.relationship,
'affinityScore': person.affinityScore,
'nextMoment': person.nextMoment.toUtc().toIso8601String(),
'tags': person.tags,
'notes': person.notes,
'aliases': person.aliases,
'location': person.location,
'lastUpdatedAt': person.lastUpdatedAt?.toUtc().toIso8601String(),
'lastInteractedAt': person.lastInteractedAt?.toUtc().toIso8601String(),
};
}
Map<String, dynamic> _momentPayload(RelationshipMoment moment) {
return <String, dynamic>{
'personId': moment.personId,
'title': moment.title,
'summary': moment.summary,
'at': moment.at.toUtc().toIso8601String(),
'type': moment.type,
};
}
Map<String, dynamic> _ideaPayload(RelationshipIdea idea) {
return <String, dynamic>{
'personId': idea.personId,
'type': idea.type.name,
'title': idea.title,
'details': idea.details,
'createdAt': idea.createdAt.toUtc().toIso8601String(),
'isArchived': idea.isArchived,
};
}
Map<String, dynamic> _reminderPayload(ReminderRule reminder) {
return <String, dynamic>{
'personId': reminder.personId,
'title': reminder.title,
'cadence': reminder.cadence.name,
'nextAt': reminder.nextAt.toUtc().toIso8601String(),
'enabled': reminder.enabled,
};
}
}
String _ideaEntityType(IdeaType type) {
return type == IdeaType.event ? 'eventIdea' : 'giftIdea';
}
String _ideaEntityTypeFromId(List<RelationshipIdea> ideas, String ideaId) {
final int index = ideas.indexWhere(
(RelationshipIdea idea) => idea.id == ideaId,
);
final RelationshipIdea? existing = index >= 0 ? ideas[index] : null;
if (existing == null) {
return 'giftIdea';
}
return _ideaEntityType(existing.type);
}
IdeaType _ideaTypeFromEntityType(
String entityType, {
required IdeaType fallback,
}) {
switch (entityType) {
case 'eventIdea':
return IdeaType.event;
case 'giftIdea':
return IdeaType.gift;
default:
return fallback;
}
}
IdeaType _ideaTypeFromRaw(dynamic value, {required IdeaType fallback}) {
if (value is! String || value.isEmpty) {
return fallback;
}
return IdeaType.values.firstWhere(
(IdeaType type) => type.name == value,
orElse: () => fallback,
);
}
ReminderCadence _cadenceFromRaw(
dynamic value, {
required ReminderCadence fallback,
}) {
if (value is! String || value.isEmpty) {
return fallback;
}
return ReminderCadence.values.firstWhere(
(ReminderCadence cadence) => cadence.name == value,
orElse: () => fallback,
);
}
List<T> _upsertItem<T>({
required List<T> items,
required T item,
required String Function(T value) idOf,
}) {
final int index = items.indexWhere((T value) => idOf(value) == idOf(item));
if (index < 0) {
return <T>[item, ...items];
}
final List<T> copy = items.toList();
copy[index] = item;
return copy;
}
String _stringField(Map<String, dynamic> payload, String key, String fallback) {
final dynamic value = payload[key];
if (value is String && value.trim().isNotEmpty) {
return value.trim();
}
return fallback;
}
String? _stringOrNullField(
Map<String, dynamic> payload,
String key,
String? fallback,
) {
final dynamic value = payload[key];
if (value is String) {
final String trimmed = value.trim();
if (trimmed.isNotEmpty) {
return trimmed;
}
return null;
}
return fallback;
}
int _intField(Map<String, dynamic> payload, String key, int fallback) {
final dynamic value = payload[key];
if (value is int) {
return value;
}
if (value is num) {
return value.toInt();
}
return fallback;
}
bool _boolField(Map<String, dynamic> payload, String key, bool fallback) {
final dynamic value = payload[key];
if (value is bool) {
return value;
}
return fallback;
}
List<String> _stringListField(
Map<String, dynamic> payload,
String key,
List<String> fallback,
) {
final dynamic value = payload[key];
if (value is! List<dynamic>) {
return fallback;
}
return value.map((dynamic item) => '$item').toList(growable: false);
}
DateTime _dateField(
Map<String, dynamic> payload,
String key,
DateTime fallback,
) {
final dynamic value = payload[key];
if (value is String) {
final DateTime? parsed = DateTime.tryParse(value);
if (parsed != null) {
return parsed.toLocal();
}
}
return fallback;
}
DateTime? _dateOrNullField(
Map<String, dynamic> payload,
String key,
DateTime? fallback,
) {
final dynamic value = payload[key];
if (value is String) {
final DateTime? parsed = DateTime.tryParse(value);
return parsed?.toLocal();
}
return fallback;
}
+7
View File
@@ -0,0 +1,7 @@
# App Storage
This folder owns local persistence adapters for the aggregate app store.
Use these files when you need to change how the local snapshot is stored. The
contract is `local_data_store.dart`; Hive and shared-preferences adapters are
swap-in implementations behind the provider.
@@ -0,0 +1,19 @@
/// Raw local state payload and associated schema version.
class LocalDataRecord {
const LocalDataRecord({required this.rawState, required this.schemaVersion});
final String rawState;
final int schemaVersion;
}
/// Persistence boundary for local app state.
abstract interface class LocalDataStore {
/// Reads latest persisted state payload.
Future<LocalDataRecord?> read();
/// Writes state payload and schema version.
Future<void> write({required String rawState, required int schemaVersion});
/// Clears persisted state payload.
Future<void> clear();
}
@@ -0,0 +1,54 @@
import 'package:hive_flutter/hive_flutter.dart';
import 'package:relationship_saver/app/data/storage/local_data_store.dart';
/// Hive-backed local store for future DB migration path.
class HiveLocalDataStore implements LocalDataStore {
static const String _boxName = 'relationship_saver_local';
static const String _stateKey = 'state_json';
static const String _schemaVersionKey = 'schema_version';
static bool _initialized = false;
@override
Future<void> clear() async {
final Box<dynamic> box = await _openBox();
await box.delete(_stateKey);
await box.delete(_schemaVersionKey);
}
@override
Future<LocalDataRecord?> read() async {
final Box<dynamic> box = await _openBox();
final String? rawState = box.get(_stateKey) as String?;
if (rawState == null || rawState.isEmpty) {
return null;
}
final int schemaVersion =
(box.get(_schemaVersionKey) as num?)?.toInt() ?? 0;
return LocalDataRecord(rawState: rawState, schemaVersion: schemaVersion);
}
@override
Future<void> write({
required String rawState,
required int schemaVersion,
}) async {
final Box<dynamic> box = await _openBox();
await box.put(_stateKey, rawState);
await box.put(_schemaVersionKey, schemaVersion);
}
Future<Box<dynamic>> _openBox() async {
if (!_initialized) {
await Hive.initFlutter();
_initialized = true;
}
if (!Hive.isBoxOpen(_boxName)) {
return Hive.openBox<dynamic>(_boxName);
}
return Hive.box<dynamic>(_boxName);
}
}
@@ -0,0 +1,22 @@
import 'package:relationship_saver/app/data/storage/local_data_store.dart';
/// In-memory local store for deterministic tests.
class InMemoryLocalDataStore implements LocalDataStore {
LocalDataRecord? _record;
@override
Future<void> clear() async {
_record = null;
}
@override
Future<LocalDataRecord?> read() async => _record;
@override
Future<void> write({
required String rawState,
required int schemaVersion,
}) async {
_record = LocalDataRecord(rawState: rawState, schemaVersion: schemaVersion);
}
}
@@ -0,0 +1,14 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/app/data/storage/local_data_store.dart';
import 'package:relationship_saver/app/data/storage/local_data_store_hive.dart';
import 'package:relationship_saver/app/data/storage/local_data_store_shared_prefs.dart';
import 'package:relationship_saver/core/config/app_config.dart';
/// Chooses local persistence backend for app state.
final Provider<LocalDataStore> localDataStoreProvider =
Provider<LocalDataStore>((Ref ref) {
if (AppConfig.useHiveLocalDb) {
return HiveLocalDataStore();
}
return SharedPrefsLocalDataStore();
});
@@ -0,0 +1,44 @@
import 'package:relationship_saver/app/data/storage/local_data_store.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// shared_preferences-backed local store used as stable default.
class SharedPrefsLocalDataStore implements LocalDataStore {
SharedPrefsLocalDataStore({
this.stateKey = 'local_data_state_v1',
this.schemaVersionKey = 'local_data_schema_version',
});
final String stateKey;
final String schemaVersionKey;
@override
Future<void> clear() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.remove(stateKey);
await prefs.remove(schemaVersionKey);
}
@override
Future<LocalDataRecord?> read() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
final String? rawState = prefs.getString(stateKey);
if (rawState == null || rawState.isEmpty) {
return null;
}
return LocalDataRecord(
rawState: rawState,
schemaVersion: prefs.getInt(schemaVersionKey) ?? 0,
);
}
@override
Future<void> write({
required String rawState,
required int schemaVersion,
}) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setString(stateKey, rawState);
await prefs.setInt(schemaVersionKey, schemaVersion);
}
}
+6
View File
@@ -0,0 +1,6 @@
# App Navigation
This folder contains typed shell destinations used by the main app shell.
Change `app_destination.dart` when you add, remove, or rename a top-level tab.
The shell in `../presentation/` maps these destinations to screens.
+22
View File
@@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
/// Typed shell destinations used by the app navigation shell.
enum AppDestination {
dashboard('Dashboard', Icons.dashboard_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),
settings('Settings', Icons.settings_rounded);
const AppDestination(this.label, this.icon);
final String label;
final IconData icon;
}
/// Secondary destinations exposed from the compact shell overflow menu.
enum CompactSecondaryDestination { aiReview, ideas, reminders, sync, settings }
+7
View File
@@ -0,0 +1,7 @@
# App Presentation
This folder owns the shared app shell UI.
`app_shell.dart` is intentionally the only place that knows how wide-layout and
compact-layout navigation work. Feature screens should stay unaware of shell
details and just render their slice.
+381
View File
@@ -0,0 +1,381 @@
import 'package:flutter/material.dart';
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';
/// Responsive shell that hosts the main feature slices.
class AppShell extends ConsumerStatefulWidget {
const AppShell({super.key});
@override
ConsumerState<AppShell> createState() => _AppShellState();
}
class _AppShellState extends ConsumerState<AppShell> {
AppDestination _destination = AppDestination.dashboard;
static const List<AppDestination> _destinations = AppDestination.values;
static const List<AppDestination> _compactPrimaryDestinations =
<AppDestination>[
AppDestination.dashboard,
AppDestination.people,
AppDestination.moments,
AppDestination.signals,
];
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: <Color>[
Color(0xFFF3F8FB),
Color(0xFFEAF1F8),
Color(0xFFF8FAFC),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
if (constraints.maxWidth < 1000) {
return _buildCompactLayout(context);
}
return Row(
children: <Widget>[
_Sidebar(
selected: _destination,
destinations: _destinations,
onChange: _selectDestination,
),
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: KeyedSubtree(
key: ValueKey<AppDestination>(_destination),
child: _screenFor(_destination),
),
),
),
],
);
},
),
),
),
);
}
Widget _buildCompactLayout(BuildContext context) {
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),
);
},
),
);
},
);
}
void _selectDestination(AppDestination destination) {
setState(() {
_destination = destination;
});
}
int _compactSelectedIndex(AppDestination destination) {
for (int i = 0; i < _compactPrimaryDestinations.length; i += 1) {
if (_compactPrimaryDestinations[i] == destination) {
return i;
}
}
return 0;
}
Widget _screenFor(AppDestination destination) {
switch (destination) {
case AppDestination.dashboard:
return const DashboardView();
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();
}
}
}
class _Sidebar extends StatelessWidget {
const _Sidebar({
required this.selected,
required this.destinations,
required this.onChange,
});
final AppDestination selected;
final List<AppDestination> destinations;
final ValueChanged<AppDestination> onChange;
@override
Widget build(BuildContext context) {
return Container(
width: 290,
margin: const EdgeInsets.fromLTRB(20, 20, 8, 20),
padding: const EdgeInsets.fromLTRB(18, 20, 18, 18),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.78),
borderRadius: BorderRadius.circular(28),
border: Border.all(color: Colors.white.withValues(alpha: 0.7)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
gradient: const LinearGradient(
colors: <Color>[Color(0xFF1AB6C8), Color(0xFF286DE0)],
),
),
child: const Icon(
Icons.favorite_rounded,
color: Colors.white,
size: 20,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Relationship Saver',
style: Theme.of(context).textTheme.titleMedium,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 22),
...destinations.map((AppDestination destination) {
final bool isSelected = destination == selected;
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: InkWell(
onTap: () => onChange(destination),
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
decoration: BoxDecoration(
color: isSelected
? const Color(0xFFEAF7FB)
: Colors.transparent,
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: <Widget>[
Icon(
destination.icon,
size: 20,
color: isSelected
? AppTheme.primary
: AppTheme.textSecondary,
),
const SizedBox(width: 10),
Text(
destination.label,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(
color: isSelected
? AppTheme.primary
: AppTheme.textSecondary,
),
),
],
),
),
),
);
}),
const Spacer(),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF2F8FB),
borderRadius: BorderRadius.circular(16),
),
child: Text(
'Tip: Use Signals daily and convert at least one into a concrete plan.',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
),
),
],
),
);
}
}
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),
child: KeyedSubtree(
key: ValueKey<AppDestination>(selected),
child: body,
),
),
),
NavigationBar(
selectedIndex: destinations.indexOf(selected),
onDestinationSelected: (int index) => onChange(destinations[index]),
destinations: destinations
.map(
(AppDestination destination) => NavigationDestination(
icon: Icon(destination.icon),
label: destination.label,
),
)
.toList(growable: false),
),
],
);
}
}
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,
),
);
}
}
+63
View File
@@ -0,0 +1,63 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/app/presentation/app_shell.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/core/llm/llm_config.dart';
import 'package:relationship_saver/features/ai_digest/application/llm_digest_background_scheduler.dart';
import 'package:relationship_saver/features/ai_digest/presentation/ai_digest_notification_listener.dart';
import 'package:relationship_saver/features/auth/application/session_controller.dart';
import 'package:relationship_saver/features/auth/presentation/sign_in_view.dart';
import 'package:relationship_saver/features/reminders/application/reminder_notification_listener.dart';
import 'package:relationship_saver/features/share_intake/presentation/incoming_share_listener.dart';
import 'package:relationship_saver/features/sync/application/sync_background_runner.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
/// Root app widget that wires auth, shell entry, and global listeners.
class RelationshipSaverApp extends ConsumerStatefulWidget {
const RelationshipSaverApp({super.key});
@override
ConsumerState<RelationshipSaverApp> createState() =>
_RelationshipSaverAppState();
}
class _RelationshipSaverAppState extends ConsumerState<RelationshipSaverApp> {
@override
void initState() {
super.initState();
unawaited(ref.read(llmConfigProvider.notifier).initialize());
}
@override
Widget build(BuildContext context) {
final WidgetRef ref = this.ref;
final AsyncValue<AuthSession?> sessionState = ref.watch(
sessionControllerProvider,
);
return MaterialApp(
title: 'Relationship Saver',
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
home: Scaffold(
body: sessionState.when(
data: (AuthSession? session) => session == null
? const SignInView()
: const IncomingShareListener(
child: ReminderNotificationListener(
child: AiDigestNotificationListener(
child: LlmDigestBackgroundRunner(
child: SyncBackgroundRunner(child: AppShell()),
),
),
),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (Object error, StackTrace stackTrace) => const SignInView(),
),
),
);
}
}
+7
View File
@@ -0,0 +1,7 @@
# App State
`local_data_state.dart` is the aggregate snapshot that the prototype persists.
Treat it as composition, not as a domain home. New models should usually be
added in a feature slice first, then referenced from the aggregate state if the
prototype store needs them persisted.
+380
View File
@@ -0,0 +1,380 @@
// ignore_for_file: sort_constructors_first
import 'package:flutter/foundation.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:relationship_saver/features/dashboard/domain/dashboard_models.dart';
import 'package:relationship_saver/features/ideas/domain/idea_models.dart';
import 'package:relationship_saver/features/moments/domain/moment_models.dart';
import 'package:relationship_saver/features/people/domain/person_models.dart';
import 'package:relationship_saver/features/reminders/domain/reminder_models.dart';
import 'package:relationship_saver/features/share_intake/domain/share_models.dart';
/// Aggregate local snapshot used by the prototype app store.
///
/// During this prototyping phase the app still uses one persisted snapshot for
/// most product state. The types inside it are now feature-owned, so each slice
/// can evolve without hiding its models in a horizontal "local" module.
@immutable
class LocalDataState {
const LocalDataState({
required this.people,
required this.moments,
required this.ideas,
required this.reminders,
required this.tasks,
this.dismissedSignalIds = const <String>[],
this.sourceLinks = const <SourceProfileLink>[],
this.sharedMessages = const <SharedMessageEntry>[],
this.sharedInbox = const <SharedInboxEntry>[],
this.personFacts = const <PersonFact>[],
this.importantDates = const <PersonImportantDate>[],
this.preferenceSignals = const <PersonPreferenceSignal>[],
this.aiSuggestionDrafts = const <AiSuggestionDraft>[],
});
final List<PersonProfile> people;
final List<RelationshipMoment> moments;
final List<RelationshipIdea> ideas;
final List<ReminderRule> reminders;
final List<DashboardTask> tasks;
final List<String> dismissedSignalIds;
final List<SourceProfileLink> sourceLinks;
final List<SharedMessageEntry> sharedMessages;
final List<SharedInboxEntry> sharedInbox;
final List<PersonFact> personFacts;
final List<PersonImportantDate> importantDates;
final List<PersonPreferenceSignal> preferenceSignals;
final List<AiSuggestionDraft> aiSuggestionDrafts;
LocalDataState copyWith({
List<PersonProfile>? people,
List<RelationshipMoment>? moments,
List<RelationshipIdea>? ideas,
List<ReminderRule>? reminders,
List<DashboardTask>? tasks,
List<String>? dismissedSignalIds,
List<SourceProfileLink>? sourceLinks,
List<SharedMessageEntry>? sharedMessages,
List<SharedInboxEntry>? sharedInbox,
List<PersonFact>? personFacts,
List<PersonImportantDate>? importantDates,
List<PersonPreferenceSignal>? preferenceSignals,
List<AiSuggestionDraft>? aiSuggestionDrafts,
}) {
return LocalDataState(
people: people ?? this.people,
moments: moments ?? this.moments,
ideas: ideas ?? this.ideas,
reminders: reminders ?? this.reminders,
tasks: tasks ?? this.tasks,
dismissedSignalIds: dismissedSignalIds ?? this.dismissedSignalIds,
sourceLinks: sourceLinks ?? this.sourceLinks,
sharedMessages: sharedMessages ?? this.sharedMessages,
sharedInbox: sharedInbox ?? this.sharedInbox,
personFacts: personFacts ?? this.personFacts,
importantDates: importantDates ?? this.importantDates,
preferenceSignals: preferenceSignals ?? this.preferenceSignals,
aiSuggestionDrafts: aiSuggestionDrafts ?? this.aiSuggestionDrafts,
);
}
DashboardSummary summary({DateTime? now}) {
final DateTime baseline = now ?? DateTime.now();
return DashboardSummary(
activePeople: people.length,
upcomingPlans: people
.where((PersonProfile p) => p.nextMoment.isAfter(baseline))
.length,
pendingIdeas: ideas
.where((RelationshipIdea idea) => !idea.isArchived)
.length,
weeklyConsistency: moments.isEmpty
? 0
: (70 + moments.length * 4).clamp(0, 100),
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'people': people
.map((PersonProfile person) => person.toJson())
.toList(growable: false),
'moments': moments
.map((RelationshipMoment moment) => moment.toJson())
.toList(growable: false),
'ideas': ideas
.map((RelationshipIdea idea) => idea.toJson())
.toList(growable: false),
'reminders': reminders
.map((ReminderRule reminder) => reminder.toJson())
.toList(growable: false),
'tasks': tasks
.map((DashboardTask task) => task.toJson())
.toList(growable: false),
'dismissedSignalIds': dismissedSignalIds,
'sourceLinks': sourceLinks
.map((SourceProfileLink link) => link.toJson())
.toList(growable: false),
'sharedMessages': sharedMessages
.map((SharedMessageEntry entry) => entry.toJson())
.toList(growable: false),
'sharedInbox': sharedInbox
.map((SharedInboxEntry entry) => entry.toJson())
.toList(growable: false),
'personFacts': personFacts
.map((PersonFact fact) => fact.toJson())
.toList(growable: false),
'importantDates': importantDates
.map((PersonImportantDate value) => value.toJson())
.toList(growable: false),
'preferenceSignals': preferenceSignals
.map((PersonPreferenceSignal signal) => signal.toJson())
.toList(growable: false),
'aiSuggestionDrafts': aiSuggestionDrafts
.map((AiSuggestionDraft draft) => draft.toJson())
.toList(growable: false),
};
}
factory LocalDataState.fromJson(Map<String, dynamic> json) {
return LocalDataState(
people: (json['people'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic person) =>
PersonProfile.fromJson(person as Map<String, dynamic>),
)
.toList(growable: false),
moments: (json['moments'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic moment) =>
RelationshipMoment.fromJson(moment as Map<String, dynamic>),
)
.toList(growable: false),
ideas: (json['ideas'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic idea) =>
RelationshipIdea.fromJson(idea as Map<String, dynamic>),
)
.toList(growable: false),
reminders: (json['reminders'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic reminder) =>
ReminderRule.fromJson(reminder as Map<String, dynamic>),
)
.toList(growable: false),
tasks: (json['tasks'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic task) =>
DashboardTask.fromJson(task as Map<String, dynamic>),
)
.toList(growable: false),
dismissedSignalIds:
(json['dismissedSignalIds'] as List<dynamic>? ?? <dynamic>[])
.map((dynamic value) => '$value')
.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),
sharedInbox: (json['sharedInbox'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic entry) =>
SharedInboxEntry.fromJson(entry as Map<String, dynamic>),
)
.toList(growable: false),
personFacts: (json['personFacts'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic fact) => PersonFact.fromJson(fact as Map<String, dynamic>),
)
.toList(growable: false),
importantDates: (json['importantDates'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic value) =>
PersonImportantDate.fromJson(value as Map<String, dynamic>),
)
.toList(growable: false),
preferenceSignals:
(json['preferenceSignals'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic signal) => PersonPreferenceSignal.fromJson(
signal as Map<String, dynamic>,
),
)
.toList(growable: false),
aiSuggestionDrafts:
(json['aiSuggestionDrafts'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic draft) =>
AiSuggestionDraft.fromJson(draft as Map<String, dynamic>),
)
.toList(growable: false),
);
}
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),
),
],
);
}
}
+11
View File
@@ -0,0 +1,11 @@
# Core
`lib/core/` is for truly cross-cutting code:
- config and theme
- auth primitives
- network clients and interceptors
- small shared utilities
- shared presentation primitives
If a file mainly exists for one slice, keep it in that slice instead.
+4
View File
@@ -0,0 +1,4 @@
# Core Auth
Cross-cutting auth primitives live here. Keep this folder limited to reusable
infrastructure rather than slice-specific sign-in UI or session flows.
+4
View File
@@ -0,0 +1,4 @@
# Core Config
App-wide configuration, feature flags, and theme-level constants live here.
Use this folder for global configuration only, not feature-owned defaults.
+8
View File
@@ -9,6 +9,11 @@ class AppConfig {
static String? _baseUrlOverride;
static const String _defaultSentryDsn = String.fromEnvironment(
'SENTRY_DSN',
defaultValue: '',
);
/// Returns the configured backend base URL.
static String get backendBaseUrl {
final String? override = _baseUrlOverride;
@@ -48,6 +53,9 @@ class AppConfig {
defaultValue: 180,
);
/// Optional Sentry DSN for release crash/error reporting.
static String get sentryDsn => _defaultSentryDsn.trim();
/// Runtime override for backend URL (e.g. local settings screen).
static void overrideBackendBaseUrl(String? baseUrl) {
_baseUrlOverride = baseUrl;
@@ -0,0 +1,73 @@
import 'dart:convert';
import 'package:relationship_saver/features/share_intake/domain/share_models.dart';
CapturedFactDraft? parseCapturedFactDraftSuggestion(
String response, {
required SharedPayload fallbackPayload,
}) {
try {
final int jsonStart = response.indexOf('{');
final int jsonEnd = response.lastIndexOf('}');
if (jsonStart == -1 || jsonEnd == -1 || jsonEnd <= jsonStart) {
return null;
}
final Map<String, dynamic> json =
jsonDecode(response.substring(jsonStart, jsonEnd + 1))
as Map<String, dynamic>;
final String typeName = '${json['type'] ?? CapturedFactType.note.name}';
final DateTime? parsedDate = _parseDate(json['dateValue']);
final double? confidence = _parseConfidence(json['confidence']);
return CapturedFactDraft(
type: CapturedFactType.values.firstWhere(
(CapturedFactType type) => type.name == typeName,
orElse: () => CapturedFactType.note,
),
text: '${json['text'] ?? fallbackPayload.rawText}'.trim(),
label: _trimToNull(json['label'] as String?),
dateValue: parsedDate,
confidence: confidence,
isSensitive: json['isSensitive'] as bool? ?? false,
needsReview: true,
);
} catch (_) {
return null;
}
}
String? _trimToNull(String? value) {
if (value == null) {
return null;
}
final String trimmed = value.trim();
return trimmed.isEmpty ? null : trimmed;
}
DateTime? _parseDate(Object? raw) {
if (raw == null) {
return null;
}
final String value = '$raw'.trim();
if (value.isEmpty) {
return null;
}
return DateTime.tryParse(value)?.toLocal();
}
double? _parseConfidence(Object? raw) {
if (raw == null) {
return null;
}
final double? value = switch (raw) {
final num number => number.toDouble(),
final String text => double.tryParse(text),
_ => null,
};
if (value == null) {
return null;
}
final num clamped = value.clamp(0, 1);
return clamped.toDouble();
}
+107
View File
@@ -0,0 +1,107 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
const String _llmApiKeyStorageKey = 'llm_api_key';
const String _llmProviderStorageKey = 'llm_provider';
enum LlmProvider { openai, anthropic, google }
class LlmConfigState {
const LlmConfigState({
this.apiKeyConfigured = false,
this.provider = LlmProvider.openai,
this.isConfigured = false,
});
final bool apiKeyConfigured;
final LlmProvider provider;
final bool isConfigured;
LlmConfigState copyWith({
bool? apiKeyConfigured,
LlmProvider? provider,
bool? isConfigured,
}) {
return LlmConfigState(
apiKeyConfigured: apiKeyConfigured ?? this.apiKeyConfigured,
provider: provider ?? this.provider,
isConfigured: isConfigured ?? this.isConfigured,
);
}
}
class LlmConfigNotifier extends Notifier<LlmConfigState> {
Future<void>? _initializeFuture;
@override
LlmConfigState build() {
return const LlmConfigState();
}
Future<void> initialize() async {
final Future<void>? inFlight = _initializeFuture;
if (inFlight != null) {
return inFlight;
}
_initializeFuture = _initializeFromStorage();
return _initializeFuture;
}
Future<void> _initializeFromStorage() async {
const FlutterSecureStorage secureStorage = FlutterSecureStorage();
final String? storedKey = await secureStorage.read(
key: _llmApiKeyStorageKey,
);
final String? storedProvider = await secureStorage.read(
key: _llmProviderStorageKey,
);
state = LlmConfigState(
apiKeyConfigured: storedKey != null && storedKey.isNotEmpty,
provider: storedProvider != null
? LlmProvider.values.firstWhere(
(LlmProvider p) => p.name == storedProvider,
orElse: () => LlmProvider.openai,
)
: LlmProvider.openai,
isConfigured: storedKey != null && storedKey.isNotEmpty,
);
}
Future<void> setApiKey(String apiKey) async {
const FlutterSecureStorage secureStorage = FlutterSecureStorage();
if (apiKey.isEmpty) {
await secureStorage.delete(key: _llmApiKeyStorageKey);
} else {
await secureStorage.write(key: _llmApiKeyStorageKey, value: apiKey);
}
state = state.copyWith(
apiKeyConfigured: apiKey.isNotEmpty,
isConfigured: apiKey.isNotEmpty,
);
}
Future<void> setProvider(LlmProvider provider) async {
const FlutterSecureStorage secureStorage = FlutterSecureStorage();
await secureStorage.write(
key: _llmProviderStorageKey,
value: provider.name,
);
state = state.copyWith(provider: provider);
}
Future<String?> getApiKey() async {
const FlutterSecureStorage secureStorage = FlutterSecureStorage();
return secureStorage.read(key: _llmApiKeyStorageKey);
}
Future<void> clearApiKey() async {
const FlutterSecureStorage secureStorage = FlutterSecureStorage();
await secureStorage.delete(key: _llmApiKeyStorageKey);
state = state.copyWith(apiKeyConfigured: false, isConfigured: false);
}
}
final llmConfigProvider = NotifierProvider<LlmConfigNotifier, LlmConfigState>(
LlmConfigNotifier.new,
);
+328
View File
@@ -0,0 +1,328 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/llm/captured_fact_draft_parser.dart';
import 'package:relationship_saver/core/llm/llm_config.dart';
import 'package:relationship_saver/features/people/domain/person_models.dart';
import 'package:relationship_saver/features/share_intake/domain/share_models.dart';
class LlmService {
LlmService(this._ref);
final Ref _ref;
Future<LlmConfigState> get _config async {
final state = _ref.read(llmConfigProvider);
if (!state.isConfigured) {
throw Exception('LLM not configured');
}
return state;
}
Future<List<GeneratedSignal>> generateSignals(
List<PersonProfile> people,
) async {
final config = await _config;
final apiKey = await _ref.read(llmConfigProvider.notifier).getApiKey();
if (apiKey == null || apiKey.isEmpty) {
throw Exception('No API key available');
}
final systemPrompt =
'''You are a relationship assistant that helps users maintain meaningful connections with their friends and family.
Based on the person's profile data, generate helpful signals such as:
- Check-in reminders
- Gift ideas
- Follow-up suggestions
- Relationship maintenance tips
Return your response as a JSON array of signals, each with:
- title: short actionable title
- description: brief explanation
- type: "recommendation", "gift", "followup", or "reminder"
- personId: the person's ID (use a valid ID from the input if applicable)
Only return the JSON array, no other text.''';
final peopleContext = people
.map(
(p) =>
'- ${p.name} (${p.relationship}): tags=${p.tags.join(", ")}, notes=${p.notes.isEmpty ? "none" : p.notes}, affinity=${p.affinityScore}',
)
.join('\n');
final userPrompt =
'''Here are the people in my life:
$peopleContext
Generate 3-5 helpful relationship signals for these people.''';
try {
final response = await _callLlm(
provider: config.provider,
apiKey: apiKey,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
);
return _parseSignalsResponse(response, people);
} catch (e) {
throw Exception('Failed to generate signals: $e');
}
}
Future<String> completeText({
required String systemPrompt,
required String userPrompt,
}) async {
final config = await _config;
final String? apiKey = await _ref
.read(llmConfigProvider.notifier)
.getApiKey();
if (apiKey == null || apiKey.isEmpty) {
throw Exception('No API key available');
}
return _callLlm(
provider: config.provider,
apiKey: apiKey,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
);
}
Future<CapturedFactDraft?> suggestCaptureDraft(SharedPayload payload) async {
final config = await _config;
final String? apiKey = await _ref
.read(llmConfigProvider.notifier)
.getApiKey();
if (apiKey == null || apiKey.isEmpty) {
throw Exception('No API key available');
}
final String systemPrompt =
'''You analyze shared messages and links for a relationship-tracking app.
Return exactly one JSON object with:
- type: one of "note", "like", "dislike", "importantDate", "giftIdea", "placeIdea", "activityIdea", "misc"
- text: cleaned user-facing summary
- label: short optional label
- dateValue: ISO-8601 date if the content clearly describes an important date, otherwise null
- confidence: number from 0 to 1
- isSensitive: true only if the content looks private or sensitive
Be conservative. If the content is ambiguous, prefer "note". Return JSON only.''';
final String userPrompt =
'''Source app: ${payload.sourceApp}
Sender: ${payload.sourceDisplayName ?? "unknown"}
Platform: ${payload.platform}
URL: ${payload.url ?? "none"}
Raw text:
${payload.rawText}''';
try {
final String response = await _callLlm(
provider: config.provider,
apiKey: apiKey,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
);
return parseCapturedFactDraftSuggestion(
response,
fallbackPayload: payload,
);
} catch (e) {
throw Exception('Failed to suggest share capture draft: $e');
}
}
Future<String> _callLlm({
required LlmProvider provider,
required String apiKey,
required String systemPrompt,
required String userPrompt,
}) async {
final dio = Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 20),
receiveTimeout: const Duration(seconds: 20),
sendTimeout: const Duration(seconds: 20),
),
);
switch (provider) {
case LlmProvider.openai:
return _callOpenAI(dio, apiKey, systemPrompt, userPrompt);
case LlmProvider.anthropic:
return _callAnthropic(dio, apiKey, systemPrompt, userPrompt);
case LlmProvider.google:
return _callGoogleAI(dio, apiKey, systemPrompt, userPrompt);
}
}
Future<String> _callOpenAI(
Dio dio,
String apiKey,
String systemPrompt,
String userPrompt,
) async {
final Response<Map<String, dynamic>> response = await dio
.post<Map<String, dynamic>>(
'https://api.openai.com/v1/chat/completions',
options: Options(
headers: {
'Authorization': 'Bearer $apiKey',
'Content-Type': 'application/json',
},
),
data: jsonEncode({
'model': 'gpt-4o-mini',
'messages': [
{'role': 'system', 'content': systemPrompt},
{'role': 'user', 'content': userPrompt},
],
'max_tokens': 1000,
}),
);
final Map<String, dynamic> responseData = response.data!;
final List<dynamic> choices = responseData['choices'] as List<dynamic>;
final Map<String, dynamic> firstChoice = choices[0] as Map<String, dynamic>;
final Map<String, dynamic> message =
firstChoice['message'] as Map<String, dynamic>;
final String content = message['content'] as String;
return content;
}
Future<String> _callAnthropic(
Dio dio,
String apiKey,
String systemPrompt,
String userPrompt,
) async {
final Response<Map<String, dynamic>> response = await dio
.post<Map<String, dynamic>>(
'https://api.anthropic.com/v1/messages',
options: Options(
headers: {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'Content-Type': 'application/json',
},
),
data: jsonEncode({
'model': 'claude-3-haiku-20240307',
'system': systemPrompt,
'messages': [
{'role': 'user', 'content': userPrompt},
],
'max_tokens': 1000,
}),
);
final Map<String, dynamic> responseData = response.data!;
final List<dynamic> contentList = responseData['content'] as List<dynamic>;
final Map<String, dynamic> firstContent =
contentList[0] as Map<String, dynamic>;
final String content = firstContent['text'] as String;
return content;
}
Future<String> _callGoogleAI(
Dio dio,
String apiKey,
String systemPrompt,
String userPrompt,
) async {
final Response<Map<String, dynamic>>
response = await dio.post<Map<String, dynamic>>(
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent',
options: Options(headers: {'Content-Type': 'application/json'}),
queryParameters: {'key': apiKey},
data: jsonEncode({
'systemInstruction': {
'parts': [
{'text': systemPrompt},
],
},
'contents': [
{
'parts': [
{'text': userPrompt},
],
},
],
'generationConfig': {'temperature': 0.7, 'maxOutputTokens': 1000},
}),
);
final Map<String, dynamic> responseData = response.data!;
final List<dynamic> candidates =
responseData['candidates'] as List<dynamic>;
final Map<String, dynamic> firstCandidate =
candidates[0] as Map<String, dynamic>;
final Map<String, dynamic> content =
firstCandidate['content'] as Map<String, dynamic>;
final List<dynamic> parts = content['parts'] as List<dynamic>;
final Map<String, dynamic> firstPart = parts[0] as Map<String, dynamic>;
final String contentText = firstPart['text'] as String;
return contentText;
}
List<GeneratedSignal> _parseSignalsResponse(
String response,
List<PersonProfile> people,
) {
try {
final jsonStart = response.indexOf('[');
final jsonEnd = response.lastIndexOf(']');
if (jsonStart == -1 || jsonEnd == -1) {
return [];
}
final jsonStr = response.substring(jsonStart, jsonEnd + 1);
final List<dynamic> items = jsonDecode(jsonStr) as List<dynamic>;
return items.map((item) {
final Map<String, dynamic> itemMap = item as Map<String, dynamic>;
final String? personId = itemMap['personId'] as String?;
final validPersonId =
personId != null && people.any((p) => p.id == personId)
? personId
: (people.isNotEmpty ? people.first.id : null);
return GeneratedSignal(
title: itemMap['title'] as String? ?? 'Check in',
description: itemMap['description'] as String? ?? '',
type: itemMap['type'] as String? ?? 'recommendation',
personId: validPersonId,
);
}).toList();
} catch (e) {
return [];
}
}
}
class GeneratedSignal {
const GeneratedSignal({
required this.title,
required this.description,
required this.type,
this.personId,
});
final String title;
final String description;
final String type;
final String? personId;
}
final llmServiceProvider = Provider<LlmService>((Ref ref) {
return LlmService(ref);
});
+4
View File
@@ -0,0 +1,4 @@
# Core Network
This folder owns shared networking infrastructure. It is the right place for
transport concerns like reachability and interceptors, not feature business logic.
+4
View File
@@ -0,0 +1,4 @@
# Network Interceptors
Request and response interception belongs here. Keep these classes stateless and
transport-focused so features do not need to know about client plumbing.
+4
View File
@@ -0,0 +1,4 @@
# Reachability
Network availability checks and platform-specific reachability code live here.
This folder should answer "can we talk to the network?" and nothing more.
@@ -30,19 +30,17 @@ class IoNetworkReachability implements NetworkReachability {
Stream<bool> watch({Duration timeout = const Duration(seconds: 2)}) {
final Stream<dynamic> changes =
_connectivity.onConnectivityChanged as Stream<dynamic>;
return changes
.asyncMap((dynamic event) async {
final Iterable<ConnectivityResult> results =
_normalizeConnectivityResults(event);
final bool hasTransport = results.any(
(ConnectivityResult result) => result != ConnectivityResult.none,
);
if (!hasTransport) {
return false;
}
return isReachable(timeout: timeout);
})
.distinct();
return changes.asyncMap((dynamic event) async {
final Iterable<ConnectivityResult> results =
_normalizeConnectivityResults(event);
final bool hasTransport = results.any(
(ConnectivityResult result) => result != ConnectivityResult.none,
);
if (!hasTransport) {
return false;
}
return isReachable(timeout: timeout);
}).distinct();
}
Iterable<ConnectivityResult> _normalizeConnectivityResults(dynamic event) {
+63
View File
@@ -0,0 +1,63 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_config.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
class SentryInit {
SentryInit();
Future<void>? _initializeFuture;
bool _initialized = false;
Future<void> initialize({FutureOr<void> Function()? appRunner}) async {
final Future<void>? inFlight = _initializeFuture;
if (inFlight != null) {
await inFlight;
if (appRunner != null && !_initialized) {
await Future<void>.sync(appRunner);
}
return;
}
_initializeFuture = _initializeInternal(appRunner: appRunner);
return _initializeFuture;
}
Future<void> _initializeInternal({
FutureOr<void> Function()? appRunner,
}) async {
if (!kReleaseMode || AppConfig.sentryDsn.isEmpty) {
if (appRunner != null) {
await Future<void>.sync(appRunner);
}
return;
}
await SentryFlutter.init((options) {
options.dsn = AppConfig.sentryDsn;
options.environment = AppConfig.useFakeBackend
? 'development'
: 'production';
options.tracesSampleRate = 0.1;
}, appRunner: appRunner);
_initialized = true;
}
void captureException(Object error, {StackTrace? stackTrace}) {
if (_initialized) {
Sentry.captureException(error, stackTrace: stackTrace);
}
}
void captureMessage(String message, {SentryLevel level = SentryLevel.info}) {
if (_initialized) {
Sentry.captureMessage(message, level: level);
}
}
}
final sentryInitProvider = Provider<SentryInit>((Ref ref) {
return SentryInit();
});
+7
View File
@@ -0,0 +1,7 @@
# Core Presentation
This folder holds shared UI primitives that are not owned by one product
feature. Keep widgets here small and reusable.
`frosted_card.dart` is the canonical shared card surface used across several
screens.
+40
View File
@@ -0,0 +1,40 @@
import 'dart:ui';
import 'package:flutter/material.dart';
class FrostedCard extends StatelessWidget {
const FrostedCard({
required this.child,
super.key,
this.padding = const EdgeInsets.all(20),
});
final Widget child;
final EdgeInsetsGeometry padding;
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(24),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: Container(
padding: padding,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: Colors.white.withValues(alpha: 0.6)),
boxShadow: const <BoxShadow>[
BoxShadow(
color: Color(0x14000000),
blurRadius: 24,
offset: Offset(0, 12),
),
],
),
child: child,
),
),
);
}
}
+4
View File
@@ -0,0 +1,4 @@
# Core Utils
Use this folder sparingly for tiny reusable helpers that are truly cross-cutting.
If a helper is owned by one slice, keep it in that slice instead.
+13
View File
@@ -0,0 +1,13 @@
# Feature Slices
Each feature folder is the main place to work on product behavior.
Typical subfolders:
- `domain/`: models or invariants owned by the slice
- `application/`: orchestration, providers, controllers, side effects
- `presentation/`: screens, widgets, dialogs, UI state
- `data/`: persistence or repositories owned by the slice
Some older files at the slice root still exist as compatibility exports so
tests and untouched code paths keep working while the structure settles.
@@ -0,0 +1,105 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
const String aiDigestNotificationPayload = 'ai_digest_review_inbox';
final Provider<AiDigestNotificationIntentBus>
aiDigestNotificationIntentBusProvider = Provider<AiDigestNotificationIntentBus>(
(Ref ref) {
final AiDigestNotificationIntentBus bus = AiDigestNotificationIntentBus();
ref.onDispose(() {
unawaited(bus.close());
});
return bus;
},
);
class AiDigestNotificationIntentBus {
final StreamController<String> _controller =
StreamController<String>.broadcast();
Stream<String> get intents => _controller.stream;
void emitTap(String payload) {
if (!_controller.isClosed) {
_controller.add(payload);
}
}
Future<void> close() => _controller.close();
}
abstract interface class AiDigestNotifier {
Future<void> showDigestReady(int count);
}
class LocalAiDigestNotifier implements AiDigestNotifier {
LocalAiDigestNotifier({
FlutterLocalNotificationsPlugin? plugin,
void Function(String payload)? onTapPayload,
}) : _plugin = plugin ?? FlutterLocalNotificationsPlugin(),
_onTapPayload = onTapPayload;
final FlutterLocalNotificationsPlugin _plugin;
final void Function(String payload)? _onTapPayload;
Future<void>? _initializeFuture;
@override
Future<void> showDigestReady(int count) async {
if (!_supportsNotifications()) {
return;
}
await _ensureInitialized();
await _plugin.show(
id: 8124,
title: 'Relationship digest ready',
body:
'$count private ${count == 1 ? 'suggestion is' : 'suggestions are'} ready to review.',
notificationDetails: const NotificationDetails(
android: AndroidNotificationDetails(
'relationship_saver_ai_digest',
'Relationship Digest',
channelDescription: 'Private AI digest suggestions ready for review',
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
),
iOS: DarwinNotificationDetails(),
macOS: DarwinNotificationDetails(),
),
payload: aiDigestNotificationPayload,
);
}
Future<void> _ensureInitialized() {
return _initializeFuture ??= _plugin.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
iOS: DarwinInitializationSettings(),
macOS: DarwinInitializationSettings(),
),
onDidReceiveNotificationResponse: (NotificationResponse response) {
final String? payload = response.payload;
if (payload != null && payload.isNotEmpty) {
_onTapPayload?.call(payload);
}
},
);
}
bool _supportsNotifications() {
if (kIsWeb) {
return false;
}
return defaultTargetPlatform != TargetPlatform.linux;
}
}
final Provider<AiDigestNotifier> aiDigestNotifierProvider =
Provider<AiDigestNotifier>((Ref ref) {
return LocalAiDigestNotifier(
onTapPayload: ref.watch(aiDigestNotificationIntentBusProvider).emitTap,
);
});
@@ -0,0 +1,175 @@
import 'dart:convert';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:uuid/uuid.dart';
const Uuid _uuid = Uuid();
class AiDigestParseResult {
const AiDigestParseResult({required this.drafts, this.error});
final List<AiSuggestionDraft> drafts;
final String? error;
bool get success => error == null;
}
class AiDigestResponseParser {
const AiDigestResponseParser({
this.maxItems = 10,
this.maxTitleChars = 90,
this.maxDetailsChars = 420,
});
final int maxItems;
final int maxTitleChars;
final int maxDetailsChars;
AiDigestParseResult parse({
required String response,
required Map<String, String> tokenToPersonId,
required String sourceRunId,
required DateTime suggestedAt,
}) {
try {
final Object? decoded = jsonDecode(_extractJson(response));
final List<dynamic> items = _extractItems(decoded);
final List<AiSuggestionDraft> drafts = <AiSuggestionDraft>[];
for (final dynamic item in items.take(maxItems)) {
if (item is! Map<String, dynamic>) {
continue;
}
final String? token = _string(item['personToken']);
final String? personId = token == null ? null : tokenToPersonId[token];
if (personId == null) {
continue;
}
final AiSuggestionKind? kind = _kind(item['kind']);
final String title = _trimAndCap(
_string(item['title']) ?? '',
maxTitleChars,
);
if (kind == null || title.isEmpty) {
continue;
}
drafts.add(
AiSuggestionDraft(
id: 'ai-${_uuid.v4()}',
personId: personId,
kind: kind,
title: title,
details: _trimAndCap(
_string(item['details']) ?? '',
maxDetailsChars,
),
suggestedAt: suggestedAt,
suggestedFor:
_dateOrNull(item['suggestedFor']) ??
_dateOrNull(item['suggestedTiming']),
confidence: _confidence(item['confidence']),
status: AiSuggestionStatus.pending,
sourceRunId: sourceRunId,
reason: _trimAndCap(_string(item['reason']) ?? '', 240),
),
);
}
if (drafts.isEmpty) {
return const AiDigestParseResult(
drafts: <AiSuggestionDraft>[],
error: 'No valid digest suggestions returned.',
);
}
return AiDigestParseResult(drafts: drafts);
} catch (error) {
return AiDigestParseResult(
drafts: const <AiSuggestionDraft>[],
error: 'Unable to parse digest response: $error',
);
}
}
String _extractJson(String response) {
final String trimmed = response.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
return trimmed;
}
final int objectStart = trimmed.indexOf('{');
final int arrayStart = trimmed.indexOf('[');
final int start = objectStart == -1
? arrayStart
: arrayStart == -1
? objectStart
: objectStart < arrayStart
? objectStart
: arrayStart;
final int objectEnd = trimmed.lastIndexOf('}');
final int arrayEnd = trimmed.lastIndexOf(']');
final int end = objectEnd > arrayEnd ? objectEnd : arrayEnd;
if (start < 0 || end < start) {
throw const FormatException('No JSON object or array found.');
}
return trimmed.substring(start, end + 1);
}
List<dynamic> _extractItems(Object? decoded) {
if (decoded is List<dynamic>) {
return decoded;
}
if (decoded is Map<String, dynamic>) {
final Object? suggestions = decoded['suggestions'] ?? decoded['items'];
if (suggestions is List<dynamic>) {
return suggestions;
}
}
throw const FormatException('Digest response must be a JSON array.');
}
AiSuggestionKind? _kind(Object? raw) {
final String? value = _string(raw);
if (value == null) {
return null;
}
for (final AiSuggestionKind kind in AiSuggestionKind.values) {
if (kind.name == value) {
return kind;
}
}
return null;
}
String? _string(Object? raw) {
if (raw == null) {
return null;
}
final String value = '$raw'.trim();
return value.isEmpty ? null : value;
}
DateTime? _dateOrNull(Object? raw) {
final String? value = _string(raw);
if (value == null) {
return null;
}
return DateTime.tryParse(value)?.toLocal();
}
double _confidence(Object? raw) {
if (raw is num) {
return raw.toDouble().clamp(0.0, 1.0).toDouble();
}
return 0.5;
}
String _trimAndCap(String value, int maxChars) {
final String trimmed = value.trim();
if (trimmed.length <= maxChars) {
return trimmed;
}
return trimmed.substring(0, maxChars).trim();
}
}
@@ -0,0 +1,285 @@
import 'dart:convert';
import 'dart:math' as math;
import 'package:relationship_saver/app/state/local_data_state.dart';
import 'package:relationship_saver/features/people/domain/person_models.dart';
class AnonymizedLlmDigestContext {
const AnonymizedLlmDigestContext({
required this.payload,
required this.tokenToPersonId,
});
final Map<String, dynamic> payload;
final Map<String, String> tokenToPersonId;
String toPromptJson() {
return const JsonEncoder.withIndent(' ').convert(payload);
}
}
class AnonymizedLlmContextBuilder {
const AnonymizedLlmContextBuilder({
this.maxPeople = 20,
this.maxSignalsPerPerson = 8,
DateTime Function()? now,
}) : _now = now ?? DateTime.now;
final int maxPeople;
final int maxSignalsPerPerson;
final DateTime Function() _now;
AnonymizedLlmDigestContext build(LocalDataState state) {
final DateTime now = _now();
final List<PersonProfile> selectedPeople =
state.people
.where(
(PersonProfile person) => _isDigestRelevant(state, person, now),
)
.toList(growable: false)
..sort(
(PersonProfile a, PersonProfile b) => _urgencyScore(
state,
b,
now,
).compareTo(_urgencyScore(state, a, now)),
);
final List<Map<String, dynamic>> peoplePayload = <Map<String, dynamic>>[];
final Map<String, String> tokenToPersonId = <String, String>{};
for (int i = 0; i < math.min(selectedPeople.length, maxPeople); i += 1) {
final PersonProfile person = selectedPeople[i];
final String token = 'person_${(i + 1).toString().padLeft(3, '0')}';
tokenToPersonId[token] = person.id;
peoplePayload.add(_personPayload(state, person, token, now));
}
return AnonymizedLlmDigestContext(
tokenToPersonId: tokenToPersonId,
payload: <String, dynamic>{
'schema': 'relationship_saver_private_digest_v1',
'task':
'Create a balanced private weekly digest with gift ideas, event ideas, reminders, and check-ins.',
'rules': <String>[
'Use only personToken values from the input.',
'Do not infer or ask for names.',
'Return JSON only.',
'Prefer practical suggestions that can be reviewed before saving.',
],
'limits': <String, dynamic>{
'maxItems': 10,
'allowedKinds': <String>[
'giftIdea',
'eventIdea',
'checkIn',
'reminder',
],
},
'people': peoplePayload,
},
);
}
bool _isDigestRelevant(
LocalDataState state,
PersonProfile person,
DateTime now,
) {
if (_daysUntil(person.nextMoment, now).abs() <= 60) {
return true;
}
final DateTime? last = person.lastInteractedAt;
if (last == null || now.difference(last).inDays >= 14) {
return true;
}
return state.importantDates.any(
(PersonImportantDate value) =>
value.personId == person.id &&
!value.isSensitive &&
_daysUntil(value.date, now).abs() <= 60,
) ||
state.preferenceSignals.any(
(PersonPreferenceSignal signal) =>
signal.personId == person.id &&
signal.status != PreferenceSignalStatus.dismissed,
);
}
int _urgencyScore(LocalDataState state, PersonProfile person, DateTime now) {
final int nextMomentDays = _daysUntil(person.nextMoment, now).abs();
int score = math.max(0, 80 - nextMomentDays);
final DateTime? last = person.lastInteractedAt;
if (last == null) {
score += 30;
} else {
score += math.min(40, now.difference(last).inDays);
}
score +=
state.importantDates
.where(
(PersonImportantDate value) =>
value.personId == person.id &&
!value.isSensitive &&
_daysUntil(value.date, now).abs() <= 60,
)
.length *
10;
return score;
}
Map<String, dynamic> _personPayload(
LocalDataState state,
PersonProfile person,
String token,
DateTime now,
) {
final List<PersonPreferenceSignal> signals = state.preferenceSignals
.where(
(PersonPreferenceSignal signal) =>
signal.personId == person.id &&
signal.status != PreferenceSignalStatus.dismissed,
)
.take(maxSignalsPerPerson)
.toList(growable: false);
final List<PersonImportantDate> dates = state.importantDates
.where(
(PersonImportantDate value) =>
value.personId == person.id && !value.isSensitive,
)
.take(maxSignalsPerPerson)
.toList(growable: false);
return <String, dynamic>{
'personToken': token,
'relationshipCategory': _relationshipCategory(person.relationship),
'affinityBand': _affinityBand(person.affinityScore),
'upcoming': <String>[
_relativeWindow('next planned moment', person.nextMoment, now),
...dates.map(
(PersonImportantDate value) => _relativeWindow(
_safeCategory(value.classification),
value.date,
now,
),
),
],
'recency': _recency(person.lastInteractedAt, now),
'interests': _safeList(<String>[
...person.tags,
...signals
.where(
(PersonPreferenceSignal signal) =>
signal.polarity == PreferenceSignalPolarity.like,
)
.map((PersonPreferenceSignal signal) => signal.label),
]).take(maxSignalsPerPerson).toList(growable: false),
'constraints': _safeList(
signals
.where(
(PersonPreferenceSignal signal) =>
signal.polarity == PreferenceSignalPolarity.dislike,
)
.map((PersonPreferenceSignal signal) => 'avoid ${signal.label}')
.toList(growable: false),
).take(maxSignalsPerPerson).toList(growable: false),
};
}
String _relationshipCategory(String value) {
final String normalized = value.toLowerCase();
if (_containsAny(normalized, <String>[
'partner',
'spouse',
'wife',
'husband',
])) {
return 'partner';
}
if (_containsAny(normalized, <String>[
'family',
'sister',
'brother',
'mother',
'father',
'parent',
'cousin',
'aunt',
'uncle',
])) {
return 'family';
}
if (normalized.contains('friend')) {
return 'friend';
}
if (_containsAny(normalized, <String>['work', 'colleague', 'coworker'])) {
return 'colleague';
}
return 'relationship';
}
String _affinityBand(int score) {
if (score >= 85) {
return 'very close';
}
if (score >= 65) {
return 'close';
}
return 'light';
}
String _relativeWindow(String label, DateTime date, DateTime now) {
final int days = _daysUntil(date, now);
final String when = days == 0
? 'today'
: days > 0
? 'in about $days days'
: 'about ${days.abs()} days ago';
return '${_safeCategory(label)} $when';
}
String _recency(DateTime? lastInteractedAt, DateTime now) {
if (lastInteractedAt == null) {
return 'no recent interaction recorded';
}
final int days = now.difference(lastInteractedAt).inDays;
if (days <= 1) {
return 'contacted recently';
}
return 'last contact about $days days ago';
}
List<String> _safeList(Iterable<String> values) {
final List<String> out = <String>[];
final Set<String> seen = <String>{};
for (final String raw in values) {
final String value = _safeCategory(raw);
if (value.isEmpty) {
continue;
}
if (seen.add(value.toLowerCase())) {
out.add(value);
}
}
return out;
}
String _safeCategory(String value) {
return value
.trim()
.replaceAll(RegExp(r'https?://\S+'), '')
.replaceAll(RegExp(r'[^a-zA-Z0-9 +&/-]+'), '')
.replaceAll(RegExp(r'\s+'), ' ')
.trim()
.toLowerCase();
}
int _daysUntil(DateTime date, DateTime now) {
return date.difference(DateTime(now.year, now.month, now.day)).inDays;
}
bool _containsAny(String value, List<String> probes) {
return probes.any(value.contains);
}
}
@@ -0,0 +1,125 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/features/ai_digest/data/llm_digest_config.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:workmanager/workmanager.dart';
const String llmDigestTaskUniqueName = 'relationship_saver_llm_digest_weekly';
const String llmDigestTaskName = 'com.relationshipsaver.llm.digest';
abstract interface class LlmDigestBackgroundScheduler {
Future<void> configure(LlmDigestConfigState config);
Future<void> cancel();
}
class WorkmanagerLlmDigestBackgroundScheduler
implements LlmDigestBackgroundScheduler {
const WorkmanagerLlmDigestBackgroundScheduler({Workmanager? workmanager})
: _workmanager = workmanager;
final Workmanager? _workmanager;
Workmanager get _driver => _workmanager ?? Workmanager();
@override
Future<void> configure(LlmDigestConfigState config) async {
if (!config.enabled) {
await cancel();
return;
}
try {
await _driver.registerPeriodicTask(
llmDigestTaskUniqueName,
llmDigestTaskName,
frequency: const Duration(days: 7),
initialDelay: _initialDelay(config, DateTime.now()),
constraints: Constraints(
networkType: config.requireWifi
? NetworkType.unmetered
: NetworkType.connected,
requiresCharging: config.requireCharging,
),
existingWorkPolicy: ExistingPeriodicWorkPolicy.update,
);
} on UnimplementedError {
// Flutter tests and unsupported platforms have no Workmanager backend.
}
}
@override
Future<void> cancel() async {
try {
await _driver.cancelByUniqueName(llmDigestTaskUniqueName);
} on UnimplementedError {
// Flutter tests and unsupported platforms have no Workmanager backend.
}
}
Duration _initialDelay(LlmDigestConfigState config, DateTime now) {
DateTime target = DateTime(
now.year,
now.month,
now.day,
config.preferredHour,
);
final int dayDelta = (config.preferredWeekday - now.weekday) % 7;
target = target.add(Duration(days: dayDelta));
if (!target.isAfter(now)) {
target = target.add(const Duration(days: 7));
}
return target.difference(now);
}
}
final Provider<LlmDigestBackgroundScheduler>
llmDigestBackgroundSchedulerProvider = Provider<LlmDigestBackgroundScheduler>(
(Ref ref) => const WorkmanagerLlmDigestBackgroundScheduler(),
);
class LlmDigestBackgroundRunner extends ConsumerStatefulWidget {
const LlmDigestBackgroundRunner({required this.child, super.key});
final Widget child;
@override
ConsumerState<LlmDigestBackgroundRunner> createState() =>
_LlmDigestBackgroundRunnerState();
}
class _LlmDigestBackgroundRunnerState
extends ConsumerState<LlmDigestBackgroundRunner> {
ProviderSubscription<LlmDigestConfigState>? _subscription;
@override
void initState() {
super.initState();
unawaited(_initializeAndConfigure());
_subscription = ref.listenManual<LlmDigestConfigState>(
llmDigestConfigProvider,
(LlmDigestConfigState? previous, LlmDigestConfigState next) {
unawaited(
ref.read(llmDigestBackgroundSchedulerProvider).configure(next),
);
},
);
}
Future<void> _initializeAndConfigure() async {
await ref.read(llmDigestConfigProvider.notifier).initialize();
await ref
.read(llmDigestBackgroundSchedulerProvider)
.configure(ref.read(llmDigestConfigProvider));
}
@override
void dispose() {
_subscription?.close();
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
}
@@ -0,0 +1,40 @@
import 'package:battery_plus/battery_plus.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
abstract interface class LlmDigestEnvironment {
Future<bool> canRun(LlmDigestConfigState config);
}
class DeviceLlmDigestEnvironment implements LlmDigestEnvironment {
DeviceLlmDigestEnvironment({Connectivity? connectivity, Battery? battery})
: _connectivity = connectivity ?? Connectivity(),
_battery = battery ?? Battery();
final Connectivity _connectivity;
final Battery _battery;
@override
Future<bool> canRun(LlmDigestConfigState config) async {
if (config.requireWifi) {
final List<ConnectivityResult> results = await _connectivity
.checkConnectivity();
if (!results.contains(ConnectivityResult.wifi)) {
return false;
}
}
if (config.requireCharging) {
final BatteryState state = await _battery.batteryState;
if (state != BatteryState.charging && state != BatteryState.full) {
return false;
}
}
return true;
}
}
final Provider<LlmDigestEnvironment> llmDigestEnvironmentProvider =
Provider<LlmDigestEnvironment>((Ref ref) => DeviceLlmDigestEnvironment());
@@ -0,0 +1,256 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/app/data/relationship_repository.dart';
import 'package:relationship_saver/app/state/local_data_state.dart';
import 'package:relationship_saver/core/llm/llm_config.dart';
import 'package:relationship_saver/features/ai_digest/application/ai_digest_notifier.dart';
import 'package:relationship_saver/features/ai_digest/application/ai_digest_response_parser.dart';
import 'package:relationship_saver/features/ai_digest/application/anonymized_llm_context_builder.dart';
import 'package:relationship_saver/features/ai_digest/application/llm_digest_environment.dart';
import 'package:relationship_saver/features/ai_digest/application/llm_digest_text_client.dart';
import 'package:relationship_saver/features/ai_digest/data/llm_digest_config.dart';
import 'package:relationship_saver/features/ai_digest/data/llm_digest_run_store.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:uuid/uuid.dart';
const Duration _scheduledCooldown = Duration(days: 7);
const Duration _staleRunningAfter = Duration(minutes: 30);
const Uuid _uuid = Uuid();
final Provider<bool> llmDigestApiKeyConfiguredProvider = Provider<bool>(
(Ref ref) => ref.watch(llmConfigProvider).isConfigured,
);
final Provider<Future<void> Function()> llmDigestInitializerProvider =
Provider<Future<void> Function()>((Ref ref) {
return () async {
await ref.read(llmConfigProvider.notifier).initialize();
await ref.read(llmDigestConfigProvider.notifier).initialize();
};
});
class LlmDigestRunResult {
const LlmDigestRunResult({
required this.started,
required this.completed,
this.createdDraftCount = 0,
this.reason,
});
final bool started;
final bool completed;
final int createdDraftCount;
final String? reason;
}
class LlmDigestOrchestrator {
LlmDigestOrchestrator(this._ref, {DateTime Function()? now})
: _now = now ?? DateTime.now;
final Ref _ref;
final DateTime Function() _now;
Future<LlmDigestRunResult> runScheduledDigest() {
return _run(bypassCooldown: false, notify: true);
}
Future<LlmDigestRunResult> runManualDigest() {
return _run(bypassCooldown: true, notify: true);
}
Future<LlmDigestRunResult> _run({
required bool bypassCooldown,
required bool notify,
}) async {
await _ref.read(llmDigestInitializerProvider)();
final LlmDigestConfigState config = _ref.read(llmDigestConfigProvider);
if (!config.enabled && !bypassCooldown) {
return const LlmDigestRunResult(
started: false,
completed: false,
reason: 'Digest is disabled.',
);
}
if (!_ref.read(llmDigestApiKeyConfiguredProvider)) {
await _markFailure('No LLM API key configured.');
return const LlmDigestRunResult(
started: false,
completed: false,
reason: 'No LLM API key configured.',
);
}
final LlmDigestRunStore store = _ref.read(llmDigestRunStoreProvider);
final LlmDigestRunState before = await store.read();
final DateTime now = _now();
if (_isAlreadyRunning(before, now)) {
return const LlmDigestRunResult(
started: false,
completed: false,
reason: 'Digest is already running.',
);
}
if (!bypassCooldown && !_cooldownAllowsRun(before, now)) {
return const LlmDigestRunResult(
started: false,
completed: false,
reason: 'Weekly digest cooldown is still active.',
);
}
if (!bypassCooldown && !_failureBackoffAllowsRun(before, now)) {
return const LlmDigestRunResult(
started: false,
completed: false,
reason: 'Failure backoff is still active.',
);
}
if (!await _ref.read(llmDigestEnvironmentProvider).canRun(config)) {
return const LlmDigestRunResult(
started: false,
completed: false,
reason: 'Network or charging policy is not satisfied.',
);
}
await store.write(
before.copyWith(lastStartedAt: now, running: true, lastFailure: null),
);
try {
final LocalDataState state = await _ref.read(
localRepositoryProvider.future,
);
final AnonymizedLlmDigestContext context =
const AnonymizedLlmContextBuilder().build(state);
if (context.tokenToPersonId.isEmpty) {
await store.write(
before.copyWith(
lastStartedAt: now,
lastCompletedAt: now,
running: false,
lastFailure: null,
consecutiveFailures: 0,
),
);
return const LlmDigestRunResult(
started: true,
completed: true,
reason: 'No eligible people for digest.',
);
}
final String sourceRunId = 'digest-${_uuid.v4()}';
final String response = await _ref
.read(llmDigestTextClientProvider)
.complete(
systemPrompt: _systemPrompt,
userPrompt: context.toPromptJson(),
);
final AiDigestParseResult parsed = const AiDigestResponseParser().parse(
response: response,
tokenToPersonId: context.tokenToPersonId,
sourceRunId: sourceRunId,
suggestedAt: now,
);
if (!parsed.success) {
await _markFailure(parsed.error ?? 'Invalid digest response.');
return LlmDigestRunResult(
started: true,
completed: false,
reason: parsed.error,
);
}
await _ref
.read(localRepositoryProvider.notifier)
.saveAiSuggestionDrafts(parsed.drafts);
await store.write(
LlmDigestRunState(
lastStartedAt: now,
lastCompletedAt: _now(),
consecutiveFailures: 0,
),
);
if (notify && parsed.drafts.isNotEmpty) {
await _ref
.read(aiDigestNotifierProvider)
.showDigestReady(parsed.drafts.length);
}
return LlmDigestRunResult(
started: true,
completed: true,
createdDraftCount: parsed.drafts.length,
);
} catch (error) {
await _markFailure('$error');
return LlmDigestRunResult(
started: true,
completed: false,
reason: '$error',
);
}
}
Future<void> _markFailure(String reason) async {
final LlmDigestRunStore store = _ref.read(llmDigestRunStoreProvider);
final LlmDigestRunState current = await store.read();
await store.write(
LlmDigestRunState(
lastStartedAt: current.lastStartedAt,
lastCompletedAt: current.lastCompletedAt,
lastFailure: reason,
consecutiveFailures: current.consecutiveFailures + 1,
),
);
}
bool _isAlreadyRunning(LlmDigestRunState state, DateTime now) {
final DateTime? started = state.lastStartedAt;
return state.running &&
started != null &&
now.difference(started) < _staleRunningAfter;
}
bool _cooldownAllowsRun(LlmDigestRunState state, DateTime now) {
final DateTime? completed = state.lastCompletedAt;
return completed == null || now.difference(completed) >= _scheduledCooldown;
}
bool _failureBackoffAllowsRun(LlmDigestRunState state, DateTime now) {
if (state.consecutiveFailures <= 0) {
return true;
}
final DateTime? started = state.lastStartedAt;
if (started == null) {
return true;
}
final int multiplier = 1 << state.consecutiveFailures.clamp(0, 5).toInt();
return now.difference(started) >= Duration(hours: multiplier);
}
}
const String _systemPrompt = '''
You create private relationship-maintenance digest suggestions.
The input contains pseudonymous people only. Never ask for or invent names.
Return exactly one JSON object:
{
"suggestions": [
{
"personToken": "person_001",
"kind": "giftIdea" | "eventIdea" | "checkIn" | "reminder",
"title": "short action title",
"details": "practical details for the user to review",
"suggestedTiming": "optional ISO-8601 date or null",
"confidence": 0.0,
"reason": "brief non-sensitive rationale"
}
]
}
Return at most 10 suggestions. Use only personToken values present in input.
''';
final Provider<LlmDigestOrchestrator> llmDigestOrchestratorProvider =
Provider<LlmDigestOrchestrator>((Ref ref) => LlmDigestOrchestrator(ref));
@@ -0,0 +1,31 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/llm/llm_service.dart';
abstract interface class LlmDigestTextClient {
Future<String> complete({
required String systemPrompt,
required String userPrompt,
});
}
class ConfiguredLlmDigestTextClient implements LlmDigestTextClient {
const ConfiguredLlmDigestTextClient(this._service);
final LlmService _service;
@override
Future<String> complete({
required String systemPrompt,
required String userPrompt,
}) {
return _service.completeText(
systemPrompt: systemPrompt,
userPrompt: userPrompt,
);
}
}
final Provider<LlmDigestTextClient> llmDigestTextClientProvider =
Provider<LlmDigestTextClient>((Ref ref) {
return ConfiguredLlmDigestTextClient(ref.watch(llmServiceProvider));
});
@@ -0,0 +1,77 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:shared_preferences/shared_preferences.dart';
const String _enabledKey = 'llm_digest_enabled';
const String _preferredWeekdayKey = 'llm_digest_preferred_weekday';
const String _preferredHourKey = 'llm_digest_preferred_hour';
const String _requireWifiKey = 'llm_digest_require_wifi';
const String _requireChargingKey = 'llm_digest_require_charging';
class LlmDigestConfigNotifier extends Notifier<LlmDigestConfigState> {
Future<void>? _initializeFuture;
@override
LlmDigestConfigState build() {
return const LlmDigestConfigState();
}
Future<void> initialize() {
_initializeFuture ??= _initializeFromStorage();
return _initializeFuture!;
}
Future<void> _initializeFromStorage() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
state = LlmDigestConfigState(
enabled: prefs.getBool(_enabledKey) ?? false,
preferredWeekday: prefs.getInt(_preferredWeekdayKey) ?? DateTime.sunday,
preferredHour: prefs.getInt(_preferredHourKey) ?? 21,
requireWifi: prefs.getBool(_requireWifiKey) ?? true,
requireCharging: prefs.getBool(_requireChargingKey) ?? true,
);
}
Future<void> setEnabled(bool enabled) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setBool(_enabledKey, enabled);
state = state.copyWith(enabled: enabled);
}
Future<void> setPreferredRunWindow({
required int weekday,
required int hour,
}) async {
final int normalizedWeekday = weekday.clamp(
DateTime.monday,
DateTime.sunday,
);
final int normalizedHour = hour.clamp(0, 23);
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setInt(_preferredWeekdayKey, normalizedWeekday);
await prefs.setInt(_preferredHourKey, normalizedHour);
state = state.copyWith(
preferredWeekday: normalizedWeekday,
preferredHour: normalizedHour,
);
}
Future<void> setNetworkPolicy({
required bool requireWifi,
required bool requireCharging,
}) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setBool(_requireWifiKey, requireWifi);
await prefs.setBool(_requireChargingKey, requireCharging);
state = state.copyWith(
requireWifi: requireWifi,
requireCharging: requireCharging,
);
}
}
final NotifierProvider<LlmDigestConfigNotifier, LlmDigestConfigState>
llmDigestConfigProvider =
NotifierProvider<LlmDigestConfigNotifier, LlmDigestConfigState>(
LlmDigestConfigNotifier.new,
);
@@ -0,0 +1,34 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:shared_preferences/shared_preferences.dart';
const String _runStateKey = 'llm_digest_run_state';
class LlmDigestRunStore {
const LlmDigestRunStore();
Future<LlmDigestRunState> read() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
final String? raw = prefs.getString(_runStateKey);
if (raw == null || raw.isEmpty) {
return const LlmDigestRunState();
}
try {
return LlmDigestRunState.fromJson(
jsonDecode(raw) as Map<String, dynamic>,
);
} on FormatException {
return const LlmDigestRunState();
}
}
Future<void> write(LlmDigestRunState state) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setString(_runStateKey, jsonEncode(state.toJson()));
}
}
final Provider<LlmDigestRunStore> llmDigestRunStoreProvider =
Provider<LlmDigestRunStore>((Ref ref) => const LlmDigestRunStore());
@@ -0,0 +1,207 @@
// ignore_for_file: sort_constructors_first
import 'package:flutter/foundation.dart';
enum AiSuggestionKind { giftIdea, eventIdea, checkIn, reminder }
enum AiSuggestionStatus { pending, accepted, dismissed }
enum LlmDigestCadence { weekly }
@immutable
class AiSuggestionDraft {
const AiSuggestionDraft({
required this.id,
required this.personId,
required this.kind,
required this.title,
required this.details,
required this.suggestedAt,
required this.confidence,
required this.status,
required this.sourceRunId,
this.suggestedFor,
this.reason,
});
final String id;
final String personId;
final AiSuggestionKind kind;
final String title;
final String details;
final DateTime suggestedAt;
final DateTime? suggestedFor;
final double confidence;
final AiSuggestionStatus status;
final String sourceRunId;
final String? reason;
AiSuggestionDraft copyWith({
String? id,
String? personId,
AiSuggestionKind? kind,
String? title,
String? details,
DateTime? suggestedAt,
DateTime? suggestedFor,
double? confidence,
AiSuggestionStatus? status,
String? sourceRunId,
String? reason,
}) {
return AiSuggestionDraft(
id: id ?? this.id,
personId: personId ?? this.personId,
kind: kind ?? this.kind,
title: title ?? this.title,
details: details ?? this.details,
suggestedAt: suggestedAt ?? this.suggestedAt,
suggestedFor: suggestedFor ?? this.suggestedFor,
confidence: confidence ?? this.confidence,
status: status ?? this.status,
sourceRunId: sourceRunId ?? this.sourceRunId,
reason: reason ?? this.reason,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'personId': personId,
'kind': kind.name,
'title': title,
'details': details,
'suggestedAt': suggestedAt.toUtc().toIso8601String(),
'suggestedFor': suggestedFor?.toUtc().toIso8601String(),
'confidence': confidence,
'status': status.name,
'sourceRunId': sourceRunId,
'reason': reason,
};
}
factory AiSuggestionDraft.fromJson(Map<String, dynamic> json) {
final String kindName =
json['kind'] as String? ?? AiSuggestionKind.checkIn.name;
final String statusName =
json['status'] as String? ?? AiSuggestionStatus.pending.name;
return AiSuggestionDraft(
id: json['id'] as String,
personId: json['personId'] as String,
kind: AiSuggestionKind.values.firstWhere(
(AiSuggestionKind value) => value.name == kindName,
orElse: () => AiSuggestionKind.checkIn,
),
title: json['title'] as String? ?? 'Suggestion',
details: json['details'] as String? ?? '',
suggestedAt: DateTime.parse(
json['suggestedAt'] as String? ??
DateTime.now().toUtc().toIso8601String(),
).toLocal(),
suggestedFor: json['suggestedFor'] == null
? null
: DateTime.parse(json['suggestedFor'] as String).toLocal(),
confidence: (json['confidence'] as num?)?.toDouble() ?? 0,
status: AiSuggestionStatus.values.firstWhere(
(AiSuggestionStatus value) => value.name == statusName,
orElse: () => AiSuggestionStatus.pending,
),
sourceRunId: json['sourceRunId'] as String? ?? 'unknown-run',
reason: json['reason'] as String?,
);
}
}
@immutable
class LlmDigestRunState {
const LlmDigestRunState({
this.lastStartedAt,
this.lastCompletedAt,
this.lastFailure,
this.consecutiveFailures = 0,
this.running = false,
});
final DateTime? lastStartedAt;
final DateTime? lastCompletedAt;
final String? lastFailure;
final int consecutiveFailures;
final bool running;
LlmDigestRunState copyWith({
DateTime? lastStartedAt,
DateTime? lastCompletedAt,
String? lastFailure,
int? consecutiveFailures,
bool? running,
}) {
return LlmDigestRunState(
lastStartedAt: lastStartedAt ?? this.lastStartedAt,
lastCompletedAt: lastCompletedAt ?? this.lastCompletedAt,
lastFailure: lastFailure,
consecutiveFailures: consecutiveFailures ?? this.consecutiveFailures,
running: running ?? this.running,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'lastStartedAt': lastStartedAt?.toUtc().toIso8601String(),
'lastCompletedAt': lastCompletedAt?.toUtc().toIso8601String(),
'lastFailure': lastFailure,
'consecutiveFailures': consecutiveFailures,
'running': running,
};
}
factory LlmDigestRunState.fromJson(Map<String, dynamic> json) {
return LlmDigestRunState(
lastStartedAt: json['lastStartedAt'] == null
? null
: DateTime.parse(json['lastStartedAt'] as String).toLocal(),
lastCompletedAt: json['lastCompletedAt'] == null
? null
: DateTime.parse(json['lastCompletedAt'] as String).toLocal(),
lastFailure: json['lastFailure'] as String?,
consecutiveFailures: (json['consecutiveFailures'] as num?)?.toInt() ?? 0,
running: json['running'] as bool? ?? false,
);
}
}
@immutable
class LlmDigestConfigState {
const LlmDigestConfigState({
this.enabled = false,
this.cadence = LlmDigestCadence.weekly,
this.preferredWeekday = DateTime.sunday,
this.preferredHour = 21,
this.requireWifi = true,
this.requireCharging = true,
});
final bool enabled;
final LlmDigestCadence cadence;
final int preferredWeekday;
final int preferredHour;
final bool requireWifi;
final bool requireCharging;
LlmDigestConfigState copyWith({
bool? enabled,
LlmDigestCadence? cadence,
int? preferredWeekday,
int? preferredHour,
bool? requireWifi,
bool? requireCharging,
}) {
return LlmDigestConfigState(
enabled: enabled ?? this.enabled,
cadence: cadence ?? this.cadence,
preferredWeekday: preferredWeekday ?? this.preferredWeekday,
preferredHour: preferredHour ?? this.preferredHour,
requireWifi: requireWifi ?? this.requireWifi,
requireCharging: requireCharging ?? this.requireCharging,
);
}
}
@@ -0,0 +1,50 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/features/ai_digest/application/ai_digest_notifier.dart';
import 'package:relationship_saver/features/ai_digest/presentation/ai_digest_review_view.dart';
class AiDigestNotificationListener extends ConsumerStatefulWidget {
const AiDigestNotificationListener({required this.child, super.key});
final Widget child;
@override
ConsumerState<AiDigestNotificationListener> createState() =>
_AiDigestNotificationListenerState();
}
class _AiDigestNotificationListenerState
extends ConsumerState<AiDigestNotificationListener> {
StreamSubscription<String>? _subscription;
@override
void initState() {
super.initState();
_subscription = ref
.read(aiDigestNotificationIntentBusProvider)
.intents
.listen(_handleIntent);
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
Future<void> _handleIntent(String payload) async {
if (!mounted || payload != aiDigestNotificationPayload) {
return;
}
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (BuildContext context) => const AiDigestReviewView(),
),
);
}
}
@@ -0,0 +1,197 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/app/data/relationship_repository.dart';
import 'package:relationship_saver/app/state/local_data_state.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:relationship_saver/features/people/domain/person_models.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart';
class AiDigestReviewView extends ConsumerWidget {
const AiDigestReviewView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final AsyncValue<LocalDataState> localData = ref.watch(
localRepositoryProvider,
);
return Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(title: const Text('AI Review Inbox')),
body: localData.when(
data: (LocalDataState data) => _AiDigestReviewContent(data: data),
loading: () => const Center(child: CircularProgressIndicator()),
error: (Object error, StackTrace stackTrace) =>
Center(child: Text('Unable to load AI suggestions. $error')),
),
);
}
}
class _AiDigestReviewContent extends ConsumerWidget {
const _AiDigestReviewContent({required this.data});
final LocalDataState data;
@override
Widget build(BuildContext context, WidgetRef ref) {
final List<AiSuggestionDraft> pending = data.aiSuggestionDrafts
.where(
(AiSuggestionDraft draft) =>
draft.status == AiSuggestionStatus.pending,
)
.toList(growable: false);
final Map<String, PersonProfile> peopleById = <String, PersonProfile>{
for (final PersonProfile person in data.people) person.id: person,
};
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 760;
return SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
compact ? 16 : 28,
compact ? 16 : 24,
compact ? 16 : 28,
28,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Private suggestions',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Review each suggestion before it changes local data.',
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 16),
if (pending.isEmpty)
const FrostedCard(child: Text('No pending AI suggestions.'))
else
...pending.map(
(AiSuggestionDraft draft) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _AiSuggestionCard(
draft: draft,
person: peopleById[draft.personId],
onAccept: () => ref
.read(localRepositoryProvider.notifier)
.acceptAiSuggestionDraft(draft.id),
onDismiss: () => ref
.read(localRepositoryProvider.notifier)
.dismissAiSuggestionDraft(draft.id),
),
),
),
],
),
);
},
);
}
}
class _AiSuggestionCard extends StatelessWidget {
const _AiSuggestionCard({
required this.draft,
required this.person,
required this.onAccept,
required this.onDismiss,
});
final AiSuggestionDraft draft;
final PersonProfile? person;
final VoidCallback onAccept;
final VoidCallback onDismiss;
@override
Widget build(BuildContext context) {
return FrostedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Icon(_iconForKind(draft.kind), color: AppTheme.primary),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
draft.title,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
'${_kindLabel(draft.kind)} · ${person?.name ?? 'Unknown person'}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
),
],
),
if (draft.details.isNotEmpty) ...<Widget>[
const SizedBox(height: 12),
Text(draft.details),
],
if (draft.reason != null &&
draft.reason!.trim().isNotEmpty) ...<Widget>[
const SizedBox(height: 8),
Text(
'Why: ${draft.reason}',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
),
],
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
FilledButton.icon(
onPressed: person == null ? null : onAccept,
icon: const Icon(Icons.check_rounded),
label: const Text('Accept'),
),
OutlinedButton.icon(
onPressed: onDismiss,
icon: const Icon(Icons.close_rounded),
label: const Text('Dismiss'),
),
],
),
],
),
);
}
IconData _iconForKind(AiSuggestionKind kind) {
return switch (kind) {
AiSuggestionKind.giftIdea => Icons.card_giftcard_rounded,
AiSuggestionKind.eventIdea => Icons.event_available_rounded,
AiSuggestionKind.checkIn => Icons.chat_bubble_outline_rounded,
AiSuggestionKind.reminder => Icons.notifications_active_outlined,
};
}
String _kindLabel(AiSuggestionKind kind) {
return switch (kind) {
AiSuggestionKind.giftIdea => 'Gift idea',
AiSuggestionKind.eventIdea => 'Event idea',
AiSuggestionKind.checkIn => 'Check-in',
AiSuggestionKind.reminder => 'Reminder',
};
}
}
+9
View File
@@ -0,0 +1,9 @@
# Auth Slice
This slice owns sign-in UI and session lifecycle.
- `application/session_controller.dart`: async auth session state.
- `presentation/sign_in_view.dart`: sign-in form and environment status UI.
Touch this slice when you change auth UX, local session behavior, or backend
sign-in integration.
+4
View File
@@ -0,0 +1,4 @@
# Auth Application
This layer coordinates auth state and session lifecycle. Start here when you
need to change how the app decides whether a user is signed in.
@@ -0,0 +1,76 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/auth/token_store.dart';
import 'package:relationship_saver/integrations/backend/backend_gateway.dart';
import 'package:relationship_saver/integrations/backend/backend_gateway_provider.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
/// Manages authenticated session lifecycle for app-level navigation.
class SessionController extends AsyncNotifier<AuthSession?> {
@override
Future<AuthSession?> build() async {
return ref.read(tokenStoreProvider).read();
}
Future<void> signIn({
required SignInMethod method,
String? email,
String? password,
String? idToken,
}) async {
state = const AsyncLoading<AuthSession?>();
try {
final BackendGateway gateway = ref.read(backendGatewayProvider);
final AuthSession session = await gateway.signIn(
SignInRequest(
method: method,
email: email,
password: password,
idToken: idToken,
),
);
// Ensure fake gateway sessions are also persisted.
await ref.read(tokenStoreProvider).write(session);
state = AsyncData<AuthSession?>(session);
} catch (error, stackTrace) {
state = AsyncError<AuthSession?>(error, stackTrace);
}
}
Future<void> signOut() async {
final BackendGateway gateway = ref.read(backendGatewayProvider);
final TokenStore tokenStore = ref.read(tokenStoreProvider);
try {
await gateway.signOut();
} catch (_) {
// Offline-first sign-out should always clear local session.
}
await tokenStore.clear();
state = const AsyncData<AuthSession?>(null);
}
Future<void> refreshProfile() async {
final AuthSession? current = state.asData?.value;
if (current == null) {
return;
}
try {
final UserProfile profile = await ref.read(backendGatewayProvider).me();
final AuthSession updated = current.copyWith(user: profile);
await ref.read(tokenStoreProvider).write(updated);
state = AsyncData<AuthSession?>(updated);
} catch (_) {
// Keep current state if profile refresh fails.
}
}
}
final AsyncNotifierProvider<SessionController, AuthSession?>
sessionControllerProvider =
AsyncNotifierProvider<SessionController, AuthSession?>(
SessionController.new,
);
+4
View File
@@ -0,0 +1,4 @@
# Auth Presentation
Auth UI belongs here. Keep sign-in screens, auth-specific widgets, and small
view helpers in this folder rather than mixing them into app bootstrap code.
@@ -0,0 +1,228 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_config.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/auth/session_controller.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
class SignInView extends ConsumerStatefulWidget {
const SignInView({super.key});
@override
ConsumerState<SignInView> createState() => _SignInViewState();
}
class _SignInViewState extends ConsumerState<SignInView> {
SignInMethod _method = SignInMethod.password;
final TextEditingController _emailController = TextEditingController(
text: 'demo@example.com',
);
final TextEditingController _passwordController = TextEditingController(
text: 'password123',
);
final TextEditingController _idTokenController = TextEditingController();
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
_idTokenController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final AsyncValue<AuthSession?> session = ref.watch(
sessionControllerProvider,
);
final bool busy = session.isLoading;
final Object? error = session.asError?.error;
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints viewport) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: viewport.maxHeight - 32),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 620),
child: FrostedCard(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 480;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Sign in to continue',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 8),
Text(
'Your local data remains available offline. Backend unlocks sync and signals.',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 18),
if (compact)
DropdownButtonFormField<SignInMethod>(
initialValue: _method,
items: SignInMethod.values
.map(
(SignInMethod method) =>
DropdownMenuItem<SignInMethod>(
value: method,
child: Text(_labelForMethod(method)),
),
)
.toList(growable: false),
onChanged: busy
? null
: (SignInMethod? value) {
if (value == null) {
return;
}
setState(() {
_method = value;
});
},
decoration: const InputDecoration(
labelText: 'Method',
),
)
else
SegmentedButton<SignInMethod>(
segments: const <ButtonSegment<SignInMethod>>[
ButtonSegment<SignInMethod>(
value: SignInMethod.password,
label: Text('Password'),
),
ButtonSegment<SignInMethod>(
value: SignInMethod.emailMagicLink,
label: Text('Magic Link'),
),
ButtonSegment<SignInMethod>(
value: SignInMethod.oidc,
label: Text('OIDC'),
),
],
selected: <SignInMethod>{_method},
onSelectionChanged: busy
? null
: (Set<SignInMethod> value) {
setState(() {
_method = value.first;
});
},
),
const SizedBox(height: 14),
if (_method == SignInMethod.password ||
_method == SignInMethod.emailMagicLink)
TextField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
enabled: !busy,
decoration: const InputDecoration(
labelText: 'Email',
),
),
if (_method == SignInMethod.password)
TextField(
controller: _passwordController,
obscureText: true,
enabled: !busy,
decoration: const InputDecoration(
labelText: 'Password',
),
),
if (_method == SignInMethod.oidc)
TextField(
controller: _idTokenController,
enabled: !busy,
decoration: const InputDecoration(
labelText: 'ID Token',
),
),
if (error != null)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(
'Sign-in failed: $error',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: const Color(0xFFC83030)),
),
),
const SizedBox(height: 16),
Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
FilledButton.icon(
onPressed: busy ? null : _submit,
icon: busy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.login_rounded),
label: const Text('Sign In'),
),
Text(
AppConfig.useFakeBackend
? 'Fake mode enabled'
: 'REST mode (${AppConfig.backendBaseUrl})',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: AppTheme.textSecondary),
),
],
),
],
);
},
),
),
),
),
),
);
},
);
}
Future<void> _submit() async {
final String? email = _emailController.text.trim().isEmpty
? null
: _emailController.text.trim();
final String? password = _passwordController.text.trim().isEmpty
? null
: _passwordController.text.trim();
final String? idToken = _idTokenController.text.trim().isEmpty
? null
: _idTokenController.text.trim();
await ref
.read(sessionControllerProvider.notifier)
.signIn(
method: _method,
email: email,
password: password,
idToken: idToken,
);
}
String _labelForMethod(SignInMethod method) {
return switch (method) {
SignInMethod.password => 'Password',
SignInMethod.emailMagicLink => 'Magic Link',
SignInMethod.oidc => 'OIDC',
};
}
}
+2 -76
View File
@@ -1,76 +1,2 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/auth/token_store.dart';
import 'package:relationship_saver/integrations/backend/backend_gateway.dart';
import 'package:relationship_saver/integrations/backend/backend_gateway_provider.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
/// Manages authenticated session lifecycle for app-level navigation.
class SessionController extends AsyncNotifier<AuthSession?> {
@override
Future<AuthSession?> build() async {
return ref.read(tokenStoreProvider).read();
}
Future<void> signIn({
required SignInMethod method,
String? email,
String? password,
String? idToken,
}) async {
state = const AsyncLoading<AuthSession?>();
try {
final BackendGateway gateway = ref.read(backendGatewayProvider);
final AuthSession session = await gateway.signIn(
SignInRequest(
method: method,
email: email,
password: password,
idToken: idToken,
),
);
// Ensure fake gateway sessions are also persisted.
await ref.read(tokenStoreProvider).write(session);
state = AsyncData<AuthSession?>(session);
} catch (error, stackTrace) {
state = AsyncError<AuthSession?>(error, stackTrace);
}
}
Future<void> signOut() async {
final BackendGateway gateway = ref.read(backendGatewayProvider);
final TokenStore tokenStore = ref.read(tokenStoreProvider);
try {
await gateway.signOut();
} catch (_) {
// Offline-first sign-out should always clear local session.
}
await tokenStore.clear();
state = const AsyncData<AuthSession?>(null);
}
Future<void> refreshProfile() async {
final AuthSession? current = state.asData?.value;
if (current == null) {
return;
}
try {
final UserProfile profile = await ref.read(backendGatewayProvider).me();
final AuthSession updated = current.copyWith(user: profile);
await ref.read(tokenStoreProvider).write(updated);
state = AsyncData<AuthSession?>(updated);
} catch (_) {
// Keep current state if profile refresh fails.
}
}
}
final AsyncNotifierProvider<SessionController, AuthSession?>
sessionControllerProvider =
AsyncNotifierProvider<SessionController, AuthSession?>(
SessionController.new,
);
// Legacy compatibility export. Prefer the `application` file in this slice.
export 'package:relationship_saver/features/auth/application/session_controller.dart';
+2 -228
View File
@@ -1,228 +1,2 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_config.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/auth/session_controller.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
class SignInView extends ConsumerStatefulWidget {
const SignInView({super.key});
@override
ConsumerState<SignInView> createState() => _SignInViewState();
}
class _SignInViewState extends ConsumerState<SignInView> {
SignInMethod _method = SignInMethod.password;
final TextEditingController _emailController = TextEditingController(
text: 'demo@example.com',
);
final TextEditingController _passwordController = TextEditingController(
text: 'password123',
);
final TextEditingController _idTokenController = TextEditingController();
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
_idTokenController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final AsyncValue<AuthSession?> session = ref.watch(
sessionControllerProvider,
);
final bool busy = session.isLoading;
final Object? error = session.asError?.error;
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints viewport) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: viewport.maxHeight - 32),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 620),
child: FrostedCard(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 480;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Sign in to continue',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 8),
Text(
'Your local data remains available offline. Backend unlocks sync and signals.',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 18),
if (compact)
DropdownButtonFormField<SignInMethod>(
initialValue: _method,
items: SignInMethod.values
.map(
(SignInMethod method) =>
DropdownMenuItem<SignInMethod>(
value: method,
child: Text(_labelForMethod(method)),
),
)
.toList(growable: false),
onChanged: busy
? null
: (SignInMethod? value) {
if (value == null) {
return;
}
setState(() {
_method = value;
});
},
decoration: const InputDecoration(
labelText: 'Method',
),
)
else
SegmentedButton<SignInMethod>(
segments: const <ButtonSegment<SignInMethod>>[
ButtonSegment<SignInMethod>(
value: SignInMethod.password,
label: Text('Password'),
),
ButtonSegment<SignInMethod>(
value: SignInMethod.emailMagicLink,
label: Text('Magic Link'),
),
ButtonSegment<SignInMethod>(
value: SignInMethod.oidc,
label: Text('OIDC'),
),
],
selected: <SignInMethod>{_method},
onSelectionChanged: busy
? null
: (Set<SignInMethod> value) {
setState(() {
_method = value.first;
});
},
),
const SizedBox(height: 14),
if (_method == SignInMethod.password ||
_method == SignInMethod.emailMagicLink)
TextField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
enabled: !busy,
decoration: const InputDecoration(
labelText: 'Email',
),
),
if (_method == SignInMethod.password)
TextField(
controller: _passwordController,
obscureText: true,
enabled: !busy,
decoration: const InputDecoration(
labelText: 'Password',
),
),
if (_method == SignInMethod.oidc)
TextField(
controller: _idTokenController,
enabled: !busy,
decoration: const InputDecoration(
labelText: 'ID Token',
),
),
if (error != null)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(
'Sign-in failed: $error',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: const Color(0xFFC83030)),
),
),
const SizedBox(height: 16),
Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
FilledButton.icon(
onPressed: busy ? null : _submit,
icon: busy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.login_rounded),
label: const Text('Sign In'),
),
Text(
AppConfig.useFakeBackend
? 'Fake mode enabled'
: 'REST mode (${AppConfig.backendBaseUrl})',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: AppTheme.textSecondary),
),
],
),
],
);
},
),
),
),
),
),
);
},
);
}
Future<void> _submit() async {
final String? email = _emailController.text.trim().isEmpty
? null
: _emailController.text.trim();
final String? password = _passwordController.text.trim().isEmpty
? null
: _passwordController.text.trim();
final String? idToken = _idTokenController.text.trim().isEmpty
? null
: _idTokenController.text.trim();
await ref
.read(sessionControllerProvider.notifier)
.signIn(
method: _method,
email: email,
password: password,
idToken: idToken,
);
}
String _labelForMethod(SignInMethod method) {
return switch (method) {
SignInMethod.password => 'Password',
SignInMethod.emailMagicLink => 'Magic Link',
SignInMethod.oidc => 'OIDC',
};
}
}
// Legacy compatibility export. Prefer the `presentation` file in this slice.
export 'package:relationship_saver/features/auth/presentation/sign_in_view.dart';
+9
View File
@@ -0,0 +1,9 @@
# Dashboard Slice
This slice owns the home overview experience.
- `domain/dashboard_models.dart`: lightweight dashboard task and summary types.
- `presentation/dashboard_view.dart`: dashboard screen and interactions.
If you want to change the founder daily workflow, this is usually the first
screen to inspect after the app shell.
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
# Dashboard Domain
Dashboard-owned value types live here. These models should stay dumb and stable
so the dashboard UI can evolve without changing persistence contracts everywhere.
@@ -0,0 +1,73 @@
// ignore_for_file: sort_constructors_first
import 'package:flutter/foundation.dart';
/// Lightweight task shown on the dashboard home surface.
@immutable
class DashboardTask {
const DashboardTask({
required this.id,
required this.title,
required this.description,
required this.when,
this.done = false,
});
final String id;
final String title;
final String description;
final DateTime when;
final bool done;
DashboardTask copyWith({
String? id,
String? title,
String? description,
DateTime? when,
bool? done,
}) {
return DashboardTask(
id: id ?? this.id,
title: title ?? this.title,
description: description ?? this.description,
when: when ?? this.when,
done: done ?? this.done,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'title': title,
'description': description,
'when': when.toUtc().toIso8601String(),
'done': done,
};
}
factory DashboardTask.fromJson(Map<String, dynamic> json) {
return DashboardTask(
id: json['id'] as String,
title: json['title'] as String,
description: json['description'] as String,
when: DateTime.parse(json['when'] as String).toLocal(),
done: json['done'] as bool? ?? false,
);
}
}
/// Summary numbers consumed by the dashboard UI.
@immutable
class DashboardSummary {
const DashboardSummary({
required this.activePeople,
required this.upcomingPlans,
required this.pendingIdeas,
required this.weeklyConsistency,
});
final int activePeople;
final int upcomingPlans;
final int pendingIdeas;
final int weeklyConsistency;
}
@@ -0,0 +1,4 @@
# Dashboard Presentation
This folder contains the overview/home UI for the relationship workspace.
Use it for founder-facing summary screens, not for deeper people-specific flows.
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
# Home Shell
This folder is a compatibility surface around the app shell. The canonical shell
now lives in `lib/app/presentation/`, so only keep thin exports or temporary glue here.
+2 -391
View File
@@ -1,391 +1,2 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/dashboard/dashboard_view.dart';
import 'package:relationship_saver/features/ideas/ideas_view.dart';
import 'package:relationship_saver/features/moments/moments_view.dart';
import 'package:relationship_saver/features/people/people_view.dart';
import 'package:relationship_saver/features/reminders/reminders_view.dart';
import 'package:relationship_saver/features/settings/settings_view.dart';
import 'package:relationship_saver/features/signals/signals_view.dart';
import 'package:relationship_saver/features/sync/sync_view.dart';
class AppShell extends ConsumerStatefulWidget {
const AppShell({super.key});
@override
ConsumerState<AppShell> createState() => _AppShellState();
}
class _AppShellState extends ConsumerState<AppShell> {
int _index = 0;
static const List<_ShellDestination> _destinations = <_ShellDestination>[
_ShellDestination('Dashboard', Icons.dashboard_rounded, 0),
_ShellDestination('People', Icons.people_alt_rounded, 1),
_ShellDestination('Moments', Icons.auto_awesome_rounded, 2),
_ShellDestination('Signals', Icons.tips_and_updates_rounded, 3),
_ShellDestination('Ideas', Icons.lightbulb_outline_rounded, 4),
_ShellDestination('Reminders', Icons.notifications_active_outlined, 5),
_ShellDestination('Sync', Icons.sync_rounded, 6),
_ShellDestination('Settings', Icons.settings_rounded, 7),
];
static const List<_ShellDestination> _compactPrimaryDestinations =
<_ShellDestination>[
_ShellDestination('Dashboard', Icons.dashboard_rounded, 0),
_ShellDestination('People', Icons.people_alt_rounded, 1),
_ShellDestination('Moments', Icons.auto_awesome_rounded, 2),
_ShellDestination('Signals', Icons.tips_and_updates_rounded, 3),
];
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: <Color>[
Color(0xFFF3F8FB),
Color(0xFFEAF1F8),
Color(0xFFF8FAFC),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
if (constraints.maxWidth < 1000) {
final int compactIndex = _compactSelectedIndex(_index);
final _ShellDestination active =
_compactPrimaryDestinations[compactIndex];
return _CompactShell(
title: active.label,
index: compactIndex,
body: _screenFor(active.screenIndex),
destinations: _compactPrimaryDestinations,
onChange: (int value) {
setState(() {
_index = _compactPrimaryDestinations[value].screenIndex;
});
},
onOpenSecondary: (_SecondaryDestination destination) {
final int screenIndex = switch (destination) {
_SecondaryDestination.ideas => 4,
_SecondaryDestination.reminders => 5,
_SecondaryDestination.sync => 6,
_SecondaryDestination.settings => 7,
};
Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (BuildContext context) {
return _CompactSecondaryScreen(
title: _destinations[screenIndex].label,
child: _screenFor(screenIndex),
);
},
),
);
},
);
}
return Row(
children: <Widget>[
_Sidebar(
index: _index,
destinations: _destinations,
onChange: (int value) {
setState(() {
_index = value;
});
},
),
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: KeyedSubtree(
key: ValueKey<int>(_index),
child: _screenFor(_index),
),
),
),
],
);
},
),
),
),
);
}
int _compactSelectedIndex(int fullIndex) {
for (int i = 0; i < _compactPrimaryDestinations.length; i += 1) {
if (_compactPrimaryDestinations[i].screenIndex == fullIndex) {
return i;
}
}
return 0;
}
Widget _screenFor(int index) {
switch (index) {
case 0:
return const DashboardView();
case 1:
return const PeopleView();
case 2:
return const MomentsView();
case 3:
return const SignalsView();
case 4:
return const IdeasView();
case 5:
return const RemindersView();
case 6:
return const SyncView();
case 7:
return const SettingsView();
default:
return const DashboardView();
}
}
}
class _Sidebar extends StatelessWidget {
const _Sidebar({
required this.index,
required this.destinations,
required this.onChange,
});
final int index;
final List<_ShellDestination> destinations;
final ValueChanged<int> onChange;
@override
Widget build(BuildContext context) {
return Container(
width: 290,
margin: const EdgeInsets.fromLTRB(20, 20, 8, 20),
padding: const EdgeInsets.fromLTRB(18, 20, 18, 18),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.78),
borderRadius: BorderRadius.circular(28),
border: Border.all(color: Colors.white.withValues(alpha: 0.7)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
gradient: const LinearGradient(
colors: <Color>[Color(0xFF1AB6C8), Color(0xFF286DE0)],
),
),
child: const Icon(
Icons.favorite_rounded,
color: Colors.white,
size: 20,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Relationship Saver',
style: Theme.of(context).textTheme.titleMedium,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 22),
...destinations.indexed.map(((int, _ShellDestination) tuple) {
final int destinationIndex = tuple.$1;
final _ShellDestination destination = tuple.$2;
final bool selected = destinationIndex == index;
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: InkWell(
onTap: () => onChange(destinationIndex),
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
decoration: BoxDecoration(
color: selected
? const Color(0xFFEAF7FB)
: Colors.transparent,
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: <Widget>[
Icon(
destination.icon,
size: 20,
color: selected
? AppTheme.primary
: AppTheme.textSecondary,
),
const SizedBox(width: 10),
Text(
destination.label,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(
color: selected
? AppTheme.primary
: AppTheme.textSecondary,
),
),
],
),
),
),
);
}),
const Spacer(),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF2F8FB),
borderRadius: BorderRadius.circular(16),
),
child: Text(
'Tip: Use Signals daily and convert at least one into a concrete plan.',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
),
),
],
),
);
}
}
class _CompactShell extends StatelessWidget {
const _CompactShell({
required this.title,
required this.index,
required this.body,
required this.destinations,
required this.onChange,
required this.onOpenSecondary,
});
final String title;
final int index;
final Widget body;
final List<_ShellDestination> destinations;
final ValueChanged<int> onChange;
final ValueChanged<_SecondaryDestination> 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<_SecondaryDestination>(
tooltip: 'More',
icon: const Icon(Icons.more_horiz_rounded),
onSelected: onOpenSecondary,
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<_SecondaryDestination>>[
const PopupMenuItem<_SecondaryDestination>(
value: _SecondaryDestination.ideas,
child: Text('Ideas'),
),
const PopupMenuItem<_SecondaryDestination>(
value: _SecondaryDestination.reminders,
child: Text('Reminders'),
),
const PopupMenuItem<_SecondaryDestination>(
value: _SecondaryDestination.sync,
child: Text('Sync'),
),
const PopupMenuItem<_SecondaryDestination>(
value: _SecondaryDestination.settings,
child: Text('Settings'),
),
],
),
],
),
),
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
child: KeyedSubtree(key: ValueKey<int>(index), child: body),
),
),
NavigationBar(
selectedIndex: index,
onDestinationSelected: onChange,
destinations: destinations
.map(
(_ShellDestination destination) => NavigationDestination(
icon: Icon(destination.icon),
label: destination.label,
),
)
.toList(growable: false),
),
],
);
}
}
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,
),
);
}
}
enum _SecondaryDestination { ideas, reminders, sync, settings }
class _ShellDestination {
const _ShellDestination(this.label, this.icon, this.screenIndex);
final String label;
final IconData icon;
final int screenIndex;
}
// Legacy compatibility export for the app shell.
export 'package:relationship_saver/app/presentation/app_shell.dart';
+9
View File
@@ -0,0 +1,9 @@
# Ideas Slice
This slice owns saved gift and activity ideas.
- `domain/idea_models.dart`: idea types and stored idea records.
- `presentation/ideas_view.dart`: ideas screen and CRUD UI.
Use this slice when you want to improve recommendation surfaces or idea capture
without touching the share pipeline yet.
+4
View File
@@ -0,0 +1,4 @@
# Ideas Domain
Gift ideas, event ideas, and related idea models live here. Keep the models
simple so future recommendation logic can consume them without UI coupling.
@@ -0,0 +1,76 @@
// ignore_for_file: sort_constructors_first
import 'package:flutter/foundation.dart';
/// Higher-level bucket for plan and gift suggestions.
enum IdeaType { gift, event }
/// Saved idea for a person or for the broader relationship backlog.
@immutable
class RelationshipIdea {
const RelationshipIdea({
required this.id,
required this.type,
required this.title,
required this.details,
required this.createdAt,
this.personId,
this.isArchived = false,
});
final String id;
final String? personId;
final IdeaType type;
final String title;
final String details;
final DateTime createdAt;
final bool isArchived;
RelationshipIdea copyWith({
String? id,
String? personId,
IdeaType? type,
String? title,
String? details,
DateTime? createdAt,
bool? isArchived,
}) {
return RelationshipIdea(
id: id ?? this.id,
personId: personId ?? this.personId,
type: type ?? this.type,
title: title ?? this.title,
details: details ?? this.details,
createdAt: createdAt ?? this.createdAt,
isArchived: isArchived ?? this.isArchived,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'personId': personId,
'type': type.name,
'title': title,
'details': details,
'createdAt': createdAt.toUtc().toIso8601String(),
'isArchived': isArchived,
};
}
factory RelationshipIdea.fromJson(Map<String, dynamic> json) {
final String typeName = json['type'] as String? ?? IdeaType.gift.name;
return RelationshipIdea(
id: json['id'] as String,
personId: json['personId'] as String?,
type: IdeaType.values.firstWhere(
(IdeaType type) => type.name == typeName,
orElse: () => IdeaType.gift,
),
title: json['title'] as String,
details: json['details'] as String? ?? '',
createdAt: DateTime.parse(json['createdAt'] as String).toLocal(),
isArchived: json['isArchived'] as bool? ?? false,
);
}
}
+2 -559
View File
@@ -1,559 +1,2 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
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/shared/frosted_card.dart';
class IdeasView extends ConsumerWidget {
const IdeasView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final AsyncValue<LocalDataState> localData = ref.watch(
localRepositoryProvider,
);
return localData.when(
data: (LocalDataState data) {
final List<RelationshipIdea> ideas = data.ideas;
final Map<String, PersonProfile> peopleById = <String, PersonProfile>{
for (final PersonProfile person in data.people) person.id: person,
};
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 760;
return Padding(
padding: EdgeInsets.fromLTRB(
compact ? 16 : 28,
compact ? 16 : 24,
compact ? 16 : 28,
compact ? 20 : 28,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (compact)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Ideas',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Capture gift and event ideas before they slip away.',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: () => _addIdea(context, ref, data.people),
icon: const Icon(Icons.lightbulb_outline_rounded),
label: const Text('Add Idea'),
),
],
)
else
Row(
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Ideas',
style: Theme.of(
context,
).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Capture gift and event ideas before they slip away.',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppTheme.textSecondary),
),
],
),
),
FilledButton.icon(
onPressed: () => _addIdea(context, ref, data.people),
icon: const Icon(Icons.lightbulb_outline_rounded),
label: const Text('Add Idea'),
),
],
),
const SizedBox(height: 16),
Expanded(
child: ListView.separated(
itemCount: ideas.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (BuildContext context, int index) {
final RelationshipIdea idea = ideas[index];
final PersonProfile? person = idea.personId == null
? null
: peopleById[idea.personId!];
return FrostedCard(
child: compact
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
_IdeaTypeTag(type: idea.type),
const SizedBox(width: 8),
Expanded(
child: Text(
idea.title,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
decoration: idea.isArchived
? TextDecoration
.lineThrough
: null,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
if (idea.details.trim().isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
idea.details,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: AppTheme.textSecondary,
),
),
),
const SizedBox(height: 8),
Text(
person == null
? 'Unassigned'
: person.name,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: <Widget>[
OutlinedButton.icon(
onPressed: () {
ref
.read(
localRepositoryProvider
.notifier,
)
.toggleIdeaArchived(idea.id);
},
icon: Icon(
idea.isArchived
? Icons.unarchive_outlined
: Icons.archive_outlined,
),
label: Text(
idea.isArchived
? 'Unarchive'
: 'Archive',
),
),
OutlinedButton.icon(
onPressed: () => _editIdea(
context,
ref,
data.people,
idea,
),
icon: const Icon(Icons.edit_rounded),
label: const Text('Edit'),
),
TextButton.icon(
onPressed: () {
ref
.read(
localRepositoryProvider
.notifier,
)
.deleteIdea(idea.id);
},
icon: const Icon(
Icons.delete_outline_rounded,
),
label: const Text('Delete'),
),
],
),
],
)
: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_IdeaTypeTag(type: idea.type),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
Text(
idea.title,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
decoration: idea.isArchived
? TextDecoration
.lineThrough
: null,
),
),
if (idea.details.trim().isNotEmpty)
Padding(
padding: const EdgeInsets.only(
top: 6,
),
child: Text(
idea.details,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: AppTheme
.textSecondary,
),
),
),
const SizedBox(height: 8),
Text(
person == null
? 'Unassigned'
: person.name,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
),
const SizedBox(width: 8),
Column(
children: <Widget>[
IconButton(
onPressed: () {
ref
.read(
localRepositoryProvider
.notifier,
)
.toggleIdeaArchived(idea.id);
},
icon: Icon(
idea.isArchived
? Icons.unarchive_outlined
: Icons.archive_outlined,
),
tooltip: idea.isArchived
? 'Unarchive'
: 'Archive',
),
IconButton(
onPressed: () => _editIdea(
context,
ref,
data.people,
idea,
),
icon: const Icon(Icons.edit_rounded),
tooltip: 'Edit',
),
IconButton(
onPressed: () {
ref
.read(
localRepositoryProvider
.notifier,
)
.deleteIdea(idea.id);
},
icon: const Icon(
Icons.delete_outline_rounded,
),
tooltip: 'Delete',
),
],
),
],
),
);
},
),
),
],
),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (Object error, StackTrace stackTrace) {
return const Center(child: Text('Unable to load ideas'));
},
);
}
Future<void> _addIdea(
BuildContext context,
WidgetRef ref,
List<PersonProfile> people,
) async {
final _IdeaDraft? draft = await showDialog<_IdeaDraft>(
context: context,
builder: (BuildContext context) =>
_IdeaEditorDialog(title: 'Add Idea', people: people),
);
if (draft == null) {
return;
}
await ref
.read(localRepositoryProvider.notifier)
.addIdea(
type: draft.type,
title: draft.title,
details: draft.details,
personId: draft.personId,
);
}
Future<void> _editIdea(
BuildContext context,
WidgetRef ref,
List<PersonProfile> people,
RelationshipIdea idea,
) async {
final _IdeaDraft? draft = await showDialog<_IdeaDraft>(
context: context,
builder: (BuildContext context) => _IdeaEditorDialog(
title: 'Edit Idea',
people: people,
initial: _IdeaDraft(
personId: idea.personId,
type: idea.type,
title: idea.title,
details: idea.details,
),
),
);
if (draft == null) {
return;
}
await ref
.read(localRepositoryProvider.notifier)
.updateIdea(
idea.copyWith(
personId: draft.personId,
type: draft.type,
title: draft.title,
details: draft.details,
),
);
}
}
class _IdeaTypeTag extends StatelessWidget {
const _IdeaTypeTag({required this.type});
final IdeaType type;
@override
Widget build(BuildContext context) {
final (Color bg, Color fg, String label) = switch (type) {
IdeaType.gift => (const Color(0xFFEAF7FB), AppTheme.primary, 'GIFT'),
IdeaType.event => (
const Color(0xFFEEF7F3),
const Color(0xFF1D9C66),
'EVENT',
),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(99),
),
child: Text(
label,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: fg,
fontWeight: FontWeight.w700,
),
),
);
}
}
class _IdeaEditorDialog extends StatefulWidget {
const _IdeaEditorDialog({
required this.title,
required this.people,
this.initial,
});
final String title;
final List<PersonProfile> people;
final _IdeaDraft? initial;
@override
State<_IdeaEditorDialog> createState() => _IdeaEditorDialogState();
}
class _IdeaEditorDialogState extends State<_IdeaEditorDialog> {
late final TextEditingController _titleController;
late final TextEditingController _detailsController;
late IdeaType _type;
String? _personId;
@override
void initState() {
super.initState();
_titleController = TextEditingController(text: widget.initial?.title ?? '');
_detailsController = TextEditingController(
text: widget.initial?.details ?? '',
);
_type = widget.initial?.type ?? IdeaType.gift;
_personId = widget.initial?.personId;
}
@override
void dispose() {
_titleController.dispose();
_detailsController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.title),
content: SingleChildScrollView(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 430),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
DropdownButtonFormField<IdeaType>(
initialValue: _type,
items: IdeaType.values
.map(
(IdeaType type) => DropdownMenuItem<IdeaType>(
value: type,
child: Text(type.name.toUpperCase()),
),
)
.toList(growable: false),
onChanged: (IdeaType? value) {
if (value == null) {
return;
}
setState(() {
_type = value;
});
},
decoration: const InputDecoration(labelText: 'Type'),
),
DropdownButtonFormField<String?>(
initialValue: _personId,
items: <DropdownMenuItem<String?>>[
const DropdownMenuItem<String?>(
value: null,
child: Text('Unassigned'),
),
...widget.people.map(
(PersonProfile person) => DropdownMenuItem<String?>(
value: person.id,
child: Text(person.name),
),
),
],
onChanged: (String? value) {
setState(() {
_personId = value;
});
},
decoration: const InputDecoration(labelText: 'Person'),
),
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(
_IdeaDraft(
personId: _personId,
type: _type,
title: title,
details: _detailsController.text.trim(),
),
);
},
child: const Text('Save'),
),
],
);
}
}
class _IdeaDraft {
const _IdeaDraft({
required this.personId,
required this.type,
required this.title,
required this.details,
});
final String? personId;
final IdeaType type;
final String title;
final String details;
}
// Legacy compatibility export for the ideas presentation entry.
export 'package:relationship_saver/features/ideas/presentation/ideas_view.dart';
@@ -0,0 +1,4 @@
# Ideas Presentation
This folder owns idea-specific screens and widgets. It should answer how ideas
are displayed and edited, not how they are stored or inferred.
@@ -0,0 +1,600 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
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/shared/frosted_card.dart';
class IdeasView extends ConsumerStatefulWidget {
const IdeasView({super.key});
@override
ConsumerState<IdeasView> createState() => _IdeasViewState();
}
class _IdeasViewState extends ConsumerState<IdeasView> {
final TextEditingController _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final AsyncValue<LocalDataState> localData = ref.watch(
localRepositoryProvider,
);
return localData.when(
data: (LocalDataState data) {
List<RelationshipIdea> ideas = data.ideas;
final Map<String, PersonProfile> peopleById = <String, PersonProfile>{
for (final PersonProfile person in data.people) person.id: person,
};
if (_searchController.text.isNotEmpty) {
final query = _searchController.text.toLowerCase();
ideas = ideas.where((i) {
final person = i.personId != null ? peopleById[i.personId] : null;
return i.title.toLowerCase().contains(query) ||
i.details.toLowerCase().contains(query) ||
(person?.name.toLowerCase().contains(query) ?? false);
}).toList();
}
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 760;
return Padding(
padding: EdgeInsets.fromLTRB(
compact ? 16 : 28,
compact ? 16 : 24,
compact ? 16 : 28,
compact ? 20 : 28,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (compact)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Ideas',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Capture gift and event ideas before they slip away.',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: () => _addIdea(context, ref, data.people),
icon: const Icon(Icons.lightbulb_outline_rounded),
label: const Text('Add Idea'),
),
],
)
else
Row(
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Ideas',
style: Theme.of(
context,
).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Capture gift and event ideas before they slip away.',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppTheme.textSecondary),
),
],
),
),
FilledButton.icon(
onPressed: () => _addIdea(context, ref, data.people),
icon: const Icon(Icons.lightbulb_outline_rounded),
label: const Text('Add Idea'),
),
],
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search ideas...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
onChanged: (_) => setState(() {}),
),
),
Expanded(
child: ListView.separated(
itemCount: ideas.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (BuildContext context, int index) {
final RelationshipIdea idea = ideas[index];
final PersonProfile? person = idea.personId == null
? null
: peopleById[idea.personId!];
return FrostedCard(
child: compact
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
_IdeaTypeTag(type: idea.type),
const SizedBox(width: 8),
Expanded(
child: Text(
idea.title,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
decoration: idea.isArchived
? TextDecoration
.lineThrough
: null,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
if (idea.details.trim().isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
idea.details,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: AppTheme.textSecondary,
),
),
),
const SizedBox(height: 8),
Text(
person == null
? 'Unassigned'
: person.name,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: <Widget>[
OutlinedButton.icon(
onPressed: () {
ref
.read(
localRepositoryProvider
.notifier,
)
.toggleIdeaArchived(idea.id);
},
icon: Icon(
idea.isArchived
? Icons.unarchive_outlined
: Icons.archive_outlined,
),
label: Text(
idea.isArchived
? 'Unarchive'
: 'Archive',
),
),
OutlinedButton.icon(
onPressed: () => _editIdea(
context,
ref,
data.people,
idea,
),
icon: const Icon(Icons.edit_rounded),
label: const Text('Edit'),
),
TextButton.icon(
onPressed: () {
ref
.read(
localRepositoryProvider
.notifier,
)
.deleteIdea(idea.id);
},
icon: const Icon(
Icons.delete_outline_rounded,
),
label: const Text('Delete'),
),
],
),
],
)
: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_IdeaTypeTag(type: idea.type),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
Text(
idea.title,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
decoration: idea.isArchived
? TextDecoration
.lineThrough
: null,
),
),
if (idea.details.trim().isNotEmpty)
Padding(
padding: const EdgeInsets.only(
top: 6,
),
child: Text(
idea.details,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: AppTheme
.textSecondary,
),
),
),
const SizedBox(height: 8),
Text(
person == null
? 'Unassigned'
: person.name,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
),
const SizedBox(width: 8),
Column(
children: <Widget>[
IconButton(
onPressed: () {
ref
.read(
localRepositoryProvider
.notifier,
)
.toggleIdeaArchived(idea.id);
},
icon: Icon(
idea.isArchived
? Icons.unarchive_outlined
: Icons.archive_outlined,
),
tooltip: idea.isArchived
? 'Unarchive'
: 'Archive',
),
IconButton(
onPressed: () => _editIdea(
context,
ref,
data.people,
idea,
),
icon: const Icon(Icons.edit_rounded),
tooltip: 'Edit',
),
IconButton(
onPressed: () {
ref
.read(
localRepositoryProvider
.notifier,
)
.deleteIdea(idea.id);
},
icon: const Icon(
Icons.delete_outline_rounded,
),
tooltip: 'Delete',
),
],
),
],
),
);
},
),
),
],
),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (Object error, StackTrace stackTrace) {
return const Center(child: Text('Unable to load ideas'));
},
);
}
Future<void> _addIdea(
BuildContext context,
WidgetRef ref,
List<PersonProfile> people,
) async {
final _IdeaDraft? draft = await showDialog<_IdeaDraft>(
context: context,
builder: (BuildContext context) =>
_IdeaEditorDialog(title: 'Add Idea', people: people),
);
if (draft == null) {
return;
}
await ref
.read(localRepositoryProvider.notifier)
.addIdea(
type: draft.type,
title: draft.title,
details: draft.details,
personId: draft.personId,
);
}
Future<void> _editIdea(
BuildContext context,
WidgetRef ref,
List<PersonProfile> people,
RelationshipIdea idea,
) async {
final _IdeaDraft? draft = await showDialog<_IdeaDraft>(
context: context,
builder: (BuildContext context) => _IdeaEditorDialog(
title: 'Edit Idea',
people: people,
initial: _IdeaDraft(
personId: idea.personId,
type: idea.type,
title: idea.title,
details: idea.details,
),
),
);
if (draft == null) {
return;
}
await ref
.read(localRepositoryProvider.notifier)
.updateIdea(
idea.copyWith(
personId: draft.personId,
type: draft.type,
title: draft.title,
details: draft.details,
),
);
}
}
class _IdeaTypeTag extends StatelessWidget {
const _IdeaTypeTag({required this.type});
final IdeaType type;
@override
Widget build(BuildContext context) {
final (Color bg, Color fg, String label) = switch (type) {
IdeaType.gift => (const Color(0xFFEAF7FB), AppTheme.primary, 'GIFT'),
IdeaType.event => (
const Color(0xFFEEF7F3),
const Color(0xFF1D9C66),
'EVENT',
),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(99),
),
child: Text(
label,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: fg,
fontWeight: FontWeight.w700,
),
),
);
}
}
class _IdeaEditorDialog extends StatefulWidget {
const _IdeaEditorDialog({
required this.title,
required this.people,
this.initial,
});
final String title;
final List<PersonProfile> people;
final _IdeaDraft? initial;
@override
State<_IdeaEditorDialog> createState() => _IdeaEditorDialogState();
}
class _IdeaEditorDialogState extends State<_IdeaEditorDialog> {
late final TextEditingController _titleController;
late final TextEditingController _detailsController;
late IdeaType _type;
String? _personId;
@override
void initState() {
super.initState();
_titleController = TextEditingController(text: widget.initial?.title ?? '');
_detailsController = TextEditingController(
text: widget.initial?.details ?? '',
);
_type = widget.initial?.type ?? IdeaType.gift;
_personId = widget.initial?.personId;
}
@override
void dispose() {
_titleController.dispose();
_detailsController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.title),
content: SingleChildScrollView(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 430),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
DropdownButtonFormField<IdeaType>(
initialValue: _type,
items: IdeaType.values
.map(
(IdeaType type) => DropdownMenuItem<IdeaType>(
value: type,
child: Text(type.name.toUpperCase()),
),
)
.toList(growable: false),
onChanged: (IdeaType? value) {
if (value == null) {
return;
}
setState(() {
_type = value;
});
},
decoration: const InputDecoration(labelText: 'Type'),
),
DropdownButtonFormField<String?>(
initialValue: _personId,
items: <DropdownMenuItem<String?>>[
const DropdownMenuItem<String?>(
value: null,
child: Text('Unassigned'),
),
...widget.people.map(
(PersonProfile person) => DropdownMenuItem<String?>(
value: person.id,
child: Text(person.name),
),
),
],
onChanged: (String? value) {
setState(() {
_personId = value;
});
},
decoration: const InputDecoration(labelText: 'Person'),
),
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(
_IdeaDraft(
personId: _personId,
type: _type,
title: title,
details: _detailsController.text.trim(),
),
);
},
child: const Text('Save'),
),
],
);
}
}
class _IdeaDraft {
const _IdeaDraft({
required this.personId,
required this.type,
required this.title,
required this.details,
});
final String? personId;
final IdeaType type;
final String title;
final String details;
}
+8
View File
@@ -0,0 +1,8 @@
# Legacy Local Layer
This folder is now a compatibility layer.
The old horizontal `local` module was the main architectural weak spot. Its
root files now re-export canonical code from `lib/app/` and feature-owned
`domain/` files so older imports keep working while the real implementation
lives in clearer locations.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
# Legacy Local Storage Exports
These files exist as compatibility exports while the canonical storage layer has
moved to `lib/app/data/storage/`. Prefer editing the app-layer storage files.
@@ -1,19 +1,2 @@
/// Raw local state payload and associated schema version.
class LocalDataRecord {
const LocalDataRecord({required this.rawState, required this.schemaVersion});
final String rawState;
final int schemaVersion;
}
/// Persistence boundary for local app state.
abstract interface class LocalDataStore {
/// Reads latest persisted state payload.
Future<LocalDataRecord?> read();
/// Writes state payload and schema version.
Future<void> write({required String rawState, required int schemaVersion});
/// Clears persisted state payload.
Future<void> clear();
}
// Legacy compatibility export for the app storage contract.
export 'package:relationship_saver/app/data/storage/local_data_store.dart';
@@ -1,54 +1,2 @@
import 'package:hive_flutter/hive_flutter.dart';
import 'package:relationship_saver/features/local/storage/local_data_store.dart';
/// Hive-backed local store for future DB migration path.
class HiveLocalDataStore implements LocalDataStore {
static const String _boxName = 'relationship_saver_local';
static const String _stateKey = 'state_json';
static const String _schemaVersionKey = 'schema_version';
static bool _initialized = false;
@override
Future<void> clear() async {
final Box<dynamic> box = await _openBox();
await box.delete(_stateKey);
await box.delete(_schemaVersionKey);
}
@override
Future<LocalDataRecord?> read() async {
final Box<dynamic> box = await _openBox();
final String? rawState = box.get(_stateKey) as String?;
if (rawState == null || rawState.isEmpty) {
return null;
}
final int schemaVersion =
(box.get(_schemaVersionKey) as num?)?.toInt() ?? 0;
return LocalDataRecord(rawState: rawState, schemaVersion: schemaVersion);
}
@override
Future<void> write({
required String rawState,
required int schemaVersion,
}) async {
final Box<dynamic> box = await _openBox();
await box.put(_stateKey, rawState);
await box.put(_schemaVersionKey, schemaVersion);
}
Future<Box<dynamic>> _openBox() async {
if (!_initialized) {
await Hive.initFlutter();
_initialized = true;
}
if (!Hive.isBoxOpen(_boxName)) {
return Hive.openBox<dynamic>(_boxName);
}
return Hive.box<dynamic>(_boxName);
}
}
// Legacy compatibility export for the Hive local data store.
export 'package:relationship_saver/app/data/storage/local_data_store_hive.dart';
@@ -1,22 +1,2 @@
import 'package:relationship_saver/features/local/storage/local_data_store.dart';
/// In-memory local store for deterministic tests.
class InMemoryLocalDataStore implements LocalDataStore {
LocalDataRecord? _record;
@override
Future<void> clear() async {
_record = null;
}
@override
Future<LocalDataRecord?> read() async => _record;
@override
Future<void> write({
required String rawState,
required int schemaVersion,
}) async {
_record = LocalDataRecord(rawState: rawState, schemaVersion: schemaVersion);
}
}
// Legacy compatibility export for the in-memory local data store.
export 'package:relationship_saver/app/data/storage/local_data_store_in_memory.dart';
@@ -1,14 +1,2 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_config.dart';
import 'package:relationship_saver/features/local/storage/local_data_store.dart';
import 'package:relationship_saver/features/local/storage/local_data_store_hive.dart';
import 'package:relationship_saver/features/local/storage/local_data_store_shared_prefs.dart';
/// Chooses local persistence backend for app state.
final Provider<LocalDataStore> localDataStoreProvider =
Provider<LocalDataStore>((Ref ref) {
if (AppConfig.useHiveLocalDb) {
return HiveLocalDataStore();
}
return SharedPrefsLocalDataStore();
});
// Legacy compatibility export for the local data store provider.
export 'package:relationship_saver/app/data/storage/local_data_store_provider.dart';
@@ -1,44 +1,2 @@
import 'package:relationship_saver/features/local/storage/local_data_store.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// shared_preferences-backed local store used as stable default.
class SharedPrefsLocalDataStore implements LocalDataStore {
SharedPrefsLocalDataStore({
this.stateKey = 'local_data_state_v1',
this.schemaVersionKey = 'local_data_schema_version',
});
final String stateKey;
final String schemaVersionKey;
@override
Future<void> clear() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.remove(stateKey);
await prefs.remove(schemaVersionKey);
}
@override
Future<LocalDataRecord?> read() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
final String? rawState = prefs.getString(stateKey);
if (rawState == null || rawState.isEmpty) {
return null;
}
return LocalDataRecord(
rawState: rawState,
schemaVersion: prefs.getInt(schemaVersionKey) ?? 0,
);
}
@override
Future<void> write({
required String rawState,
required int schemaVersion,
}) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setString(stateKey, rawState);
await prefs.setInt(schemaVersionKey, schemaVersion);
}
}
// Legacy compatibility export for the shared preferences local store.
export 'package:relationship_saver/app/data/storage/local_data_store_shared_prefs.dart';
+9
View File
@@ -0,0 +1,9 @@
# Moments Slice
This slice owns the lightweight relationship timeline.
- `domain/moment_models.dart`: stored moment model.
- `presentation/moments_view.dart`: moments UI and quick capture paths.
Moments are still simpler than structured facts. They are good for fast manual
logging and timeline history.
+4
View File
@@ -0,0 +1,4 @@
# Moments Domain
Relationship capture and timeline models live here. Keep this folder focused on
recorded events and notes about interactions.
@@ -0,0 +1,63 @@
// ignore_for_file: sort_constructors_first
import 'package:flutter/foundation.dart';
/// Timeline event stored for a specific relationship.
@immutable
class RelationshipMoment {
const RelationshipMoment({
required this.id,
required this.personId,
required this.title,
required this.summary,
required this.at,
required this.type,
});
final String id;
final String personId;
final String title;
final String summary;
final DateTime at;
final String type;
RelationshipMoment copyWith({
String? id,
String? personId,
String? title,
String? summary,
DateTime? at,
String? type,
}) {
return RelationshipMoment(
id: id ?? this.id,
personId: personId ?? this.personId,
title: title ?? this.title,
summary: summary ?? this.summary,
at: at ?? this.at,
type: type ?? this.type,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'personId': personId,
'title': title,
'summary': summary,
'at': at.toUtc().toIso8601String(),
'type': type,
};
}
factory RelationshipMoment.fromJson(Map<String, dynamic> json) {
return RelationshipMoment(
id: json['id'] as String,
personId: json['personId'] as String,
title: json['title'] as String,
summary: json['summary'] as String,
at: DateTime.parse(json['at'] as String).toLocal(),
type: json['type'] as String,
);
}
}
+2 -344
View File
@@ -1,344 +1,2 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
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/shared/frosted_card.dart';
class MomentsView extends ConsumerStatefulWidget {
const MomentsView({super.key});
@override
ConsumerState<MomentsView> createState() => _MomentsViewState();
}
class _MomentsViewState extends ConsumerState<MomentsView> {
final TextEditingController _captureController = TextEditingController();
String? _selectedPersonId;
@override
void dispose() {
_captureController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final AsyncValue<LocalDataState> localData = ref.watch(
localRepositoryProvider,
);
return localData.when(
data: (LocalDataState data) {
final List<PersonProfile> people = data.people;
final List<RelationshipMoment> moments = data.moments;
final Map<String, PersonProfile> peopleById = <String, PersonProfile>{
for (final PersonProfile person in people) person.id: person,
};
final String? fallbackPersonId = people.isEmpty
? null
: people.first.id;
final String? activePerson =
people.any((PersonProfile p) => p.id == _selectedPersonId)
? _selectedPersonId
: fallbackPersonId;
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 760;
return Padding(
padding: EdgeInsets.fromLTRB(
compact ? 16 : 28,
compact ? 16 : 24,
compact ? 16 : 28,
compact ? 20 : 28,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Moments',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Capture wins and signals from everyday interactions.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 16),
FrostedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
TextField(
controller: _captureController,
minLines: 1,
maxLines: compact ? 4 : 2,
decoration: InputDecoration(
hintText:
'Quick capture: what happened, how it felt, what to repeat...',
hintStyle: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: AppTheme.textSecondary),
border: InputBorder.none,
),
),
const SizedBox(height: 8),
if (compact)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
if (people.isNotEmpty)
DropdownButtonFormField<String>(
initialValue: activePerson,
items: people
.map(
(PersonProfile person) =>
DropdownMenuItem<String>(
value: person.id,
child: Text(person.name),
),
)
.toList(growable: false),
onChanged: (String? value) {
setState(() {
_selectedPersonId = value;
});
},
decoration: const InputDecoration(
labelText: 'Person',
),
),
const SizedBox(height: 10),
FilledButton.icon(
onPressed:
people.isEmpty || activePerson == null
? null
: () => _addCapture(activePerson),
icon: const Icon(Icons.add_rounded),
label: const Text('Add Capture'),
),
],
)
else
Row(
children: <Widget>[
if (people.isNotEmpty)
DropdownButton<String>(
value: activePerson,
hint: const Text('Person'),
items: people
.map(
(PersonProfile person) =>
DropdownMenuItem<String>(
value: person.id,
child: Text(person.name),
),
)
.toList(growable: false),
onChanged: (String? value) {
setState(() {
_selectedPersonId = value;
});
},
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed:
people.isEmpty || activePerson == null
? null
: () => _addCapture(activePerson),
icon: const Icon(Icons.add_rounded),
label: const Text('Add Capture'),
),
],
),
if (people.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(
'Add people first before capturing moments.',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: AppTheme.textSecondary),
),
),
],
),
),
const SizedBox(height: 16),
Expanded(
child: ListView.separated(
itemCount: moments.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (BuildContext context, int index) {
final RelationshipMoment moment = moments[index];
final PersonProfile? person =
peopleById[moment.personId];
return FrostedCard(
child: compact
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Container(
width: 10,
height: 10,
decoration: const BoxDecoration(
color: Color(0xFF1AB6C8),
shape: BoxShape.circle,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
moment.title,
style: Theme.of(
context,
).textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
onPressed: () {
ref
.read(
localRepositoryProvider
.notifier,
)
.deleteMoment(moment.id);
},
icon: const Icon(
Icons.delete_outline_rounded,
),
tooltip: 'Delete capture',
),
],
),
Text(
'${person?.name ?? 'Unknown'}${moment.type.toUpperCase()}${moment.at.month}/${moment.at.day}',
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
Text(
moment.summary,
style: Theme.of(
context,
).textTheme.bodyLarge,
),
],
)
: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Container(
width: 12,
height: 12,
margin: const EdgeInsets.only(top: 6),
decoration: const BoxDecoration(
color: Color(0xFF1AB6C8),
shape: BoxShape.circle,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
Text(
moment.title,
style: Theme.of(
context,
).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
'${person?.name ?? 'Unknown'}${moment.type.toUpperCase()}',
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
Text(
moment.summary,
style: Theme.of(
context,
).textTheme.bodyLarge,
),
],
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment:
CrossAxisAlignment.end,
children: <Widget>[
Text(
'${moment.at.month}/${moment.at.day}',
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
IconButton(
onPressed: () {
ref
.read(
localRepositoryProvider
.notifier,
)
.deleteMoment(moment.id);
},
icon: const Icon(
Icons.delete_outline_rounded,
),
tooltip: 'Delete capture',
),
],
),
],
),
);
},
),
),
],
),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (Object error, StackTrace stackTrace) {
return const Center(child: Text('Unable to load moments'));
},
);
}
Future<void> _addCapture(String personId) async {
final String summary = _captureController.text.trim();
if (summary.isEmpty) {
return;
}
await ref
.read(localRepositoryProvider.notifier)
.addMoment(personId: personId, summary: summary);
_captureController.clear();
}
}
// Legacy compatibility export for the moments presentation entry.
export 'package:relationship_saver/features/moments/presentation/moments_view.dart';
@@ -0,0 +1,4 @@
# Moments Presentation
Moment timeline UI belongs here. If you are improving how captures are browsed
or edited, this is the slice entry point.
@@ -0,0 +1,372 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
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/shared/frosted_card.dart';
class MomentsView extends ConsumerStatefulWidget {
const MomentsView({super.key});
@override
ConsumerState<MomentsView> createState() => _MomentsViewState();
}
class _MomentsViewState extends ConsumerState<MomentsView> {
final TextEditingController _captureController = TextEditingController();
final TextEditingController _searchController = TextEditingController();
String? _selectedPersonId;
@override
void dispose() {
_captureController.dispose();
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final AsyncValue<LocalDataState> localData = ref.watch(
localRepositoryProvider,
);
return localData.when(
data: (LocalDataState data) {
final List<PersonProfile> people = data.people;
List<RelationshipMoment> moments = data.moments;
final Map<String, PersonProfile> peopleById = <String, PersonProfile>{
for (final PersonProfile person in people) person.id: person,
};
if (_searchController.text.isNotEmpty) {
final query = _searchController.text.toLowerCase();
moments = moments.where((m) {
final person = peopleById[m.personId];
return m.title.toLowerCase().contains(query) ||
m.summary.toLowerCase().contains(query) ||
(person?.name.toLowerCase().contains(query) ?? false);
}).toList();
}
final String? fallbackPersonId = people.isEmpty
? null
: people.first.id;
final String? activePerson =
people.any((PersonProfile p) => p.id == _selectedPersonId)
? _selectedPersonId
: fallbackPersonId;
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 760;
return Padding(
padding: EdgeInsets.fromLTRB(
compact ? 16 : 28,
compact ? 16 : 24,
compact ? 16 : 28,
compact ? 20 : 28,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Moments',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Capture wins and signals from everyday interactions.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 16),
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search moments...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 16),
FrostedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
TextField(
controller: _captureController,
minLines: 1,
maxLines: compact ? 4 : 2,
decoration: InputDecoration(
hintText:
'Quick capture: what happened, how it felt, what to repeat...',
hintStyle: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: AppTheme.textSecondary),
border: InputBorder.none,
),
),
const SizedBox(height: 8),
if (compact)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
if (people.isNotEmpty)
DropdownButtonFormField<String>(
initialValue: activePerson,
items: people
.map(
(PersonProfile person) =>
DropdownMenuItem<String>(
value: person.id,
child: Text(person.name),
),
)
.toList(growable: false),
onChanged: (String? value) {
setState(() {
_selectedPersonId = value;
});
},
decoration: const InputDecoration(
labelText: 'Person',
),
),
const SizedBox(height: 10),
FilledButton.icon(
onPressed:
people.isEmpty || activePerson == null
? null
: () => _addCapture(activePerson),
icon: const Icon(Icons.add_rounded),
label: const Text('Add Capture'),
),
],
)
else
Row(
children: <Widget>[
if (people.isNotEmpty)
DropdownButton<String>(
value: activePerson,
hint: const Text('Person'),
items: people
.map(
(PersonProfile person) =>
DropdownMenuItem<String>(
value: person.id,
child: Text(person.name),
),
)
.toList(growable: false),
onChanged: (String? value) {
setState(() {
_selectedPersonId = value;
});
},
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed:
people.isEmpty || activePerson == null
? null
: () => _addCapture(activePerson),
icon: const Icon(Icons.add_rounded),
label: const Text('Add Capture'),
),
],
),
if (people.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(
'Add people first before capturing moments.',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: AppTheme.textSecondary),
),
),
],
),
),
const SizedBox(height: 16),
Expanded(
child: ListView.separated(
itemCount: moments.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (BuildContext context, int index) {
final RelationshipMoment moment = moments[index];
final PersonProfile? person =
peopleById[moment.personId];
return FrostedCard(
child: compact
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Container(
width: 10,
height: 10,
decoration: const BoxDecoration(
color: Color(0xFF1AB6C8),
shape: BoxShape.circle,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
moment.title,
style: Theme.of(
context,
).textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
onPressed: () {
ref
.read(
localRepositoryProvider
.notifier,
)
.deleteMoment(moment.id);
},
icon: const Icon(
Icons.delete_outline_rounded,
),
tooltip: 'Delete capture',
),
],
),
Text(
'${person?.name ?? 'Unknown'}${moment.type.toUpperCase()}${moment.at.month}/${moment.at.day}',
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
Text(
moment.summary,
style: Theme.of(
context,
).textTheme.bodyLarge,
),
],
)
: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Container(
width: 12,
height: 12,
margin: const EdgeInsets.only(top: 6),
decoration: const BoxDecoration(
color: Color(0xFF1AB6C8),
shape: BoxShape.circle,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
Text(
moment.title,
style: Theme.of(
context,
).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
'${person?.name ?? 'Unknown'}${moment.type.toUpperCase()}',
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
Text(
moment.summary,
style: Theme.of(
context,
).textTheme.bodyLarge,
),
],
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment:
CrossAxisAlignment.end,
children: <Widget>[
Text(
'${moment.at.month}/${moment.at.day}',
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
IconButton(
onPressed: () {
ref
.read(
localRepositoryProvider
.notifier,
)
.deleteMoment(moment.id);
},
icon: const Icon(
Icons.delete_outline_rounded,
),
tooltip: 'Delete capture',
),
],
),
],
),
);
},
),
),
],
),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (Object error, StackTrace stackTrace) {
return const Center(child: Text('Unable to load moments'));
},
);
}
Future<void> _addCapture(String personId) async {
final String summary = _captureController.text.trim();
if (summary.isEmpty) {
return;
}
await ref
.read(localRepositoryProvider.notifier)
.addMoment(personId: personId, summary: summary);
_captureController.clear();
}
}
+12
View File
@@ -0,0 +1,12 @@
# People Slice
This is the richest slice in the app and the main relationship workspace.
- `domain/person_models.dart`: person profile, facts, dates, preference signals,
and source-link models.
- `presentation/people_view.dart`: main people workspace screen.
- `presentation/providers/people_ui_state.dart`: list/search/filter selection
state for the people workspace.
If you want to extend person intelligence, start in `domain/` for data shape
and then wire the UI through `presentation/`.
+4
View File
@@ -0,0 +1,4 @@
# People Domain
Person profiles, structured facts, dates, source links, and preference signals
live here. These are core app entities, so keep them framework-free and explicit.
@@ -0,0 +1,538 @@
// ignore_for_file: sort_constructors_first
import 'package:flutter/foundation.dart';
import 'package:relationship_saver/features/share_intake/domain/share_models.dart';
/// Polarity for inferred or confirmed preference signals.
enum PreferenceSignalPolarity { like, dislike, neutral }
/// Lifecycle state for machine-assisted preference signals.
enum PreferenceSignalStatus { inferred, confirmed, dismissed }
/// Primary profile object used across the relationship app.
@immutable
class PersonProfile {
const PersonProfile({
required this.id,
required this.name,
required this.relationship,
required this.affinityScore,
required this.nextMoment,
required this.tags,
required this.notes,
this.aliases = const <String>[],
this.location,
this.lastUpdatedAt,
this.lastInteractedAt,
});
final String id;
final String name;
final String relationship;
final int affinityScore;
final DateTime nextMoment;
final List<String> tags;
final String notes;
final List<String> aliases;
final String? location;
final DateTime? lastUpdatedAt;
final DateTime? lastInteractedAt;
PersonProfile copyWith({
String? id,
String? name,
String? relationship,
int? affinityScore,
DateTime? nextMoment,
List<String>? tags,
String? notes,
List<String>? aliases,
String? location,
DateTime? lastUpdatedAt,
DateTime? lastInteractedAt,
}) {
return PersonProfile(
id: id ?? this.id,
name: name ?? this.name,
relationship: relationship ?? this.relationship,
affinityScore: affinityScore ?? this.affinityScore,
nextMoment: nextMoment ?? this.nextMoment,
tags: tags ?? this.tags,
notes: notes ?? this.notes,
aliases: aliases ?? this.aliases,
location: location ?? this.location,
lastUpdatedAt: lastUpdatedAt ?? this.lastUpdatedAt,
lastInteractedAt: lastInteractedAt ?? this.lastInteractedAt,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'name': name,
'relationship': relationship,
'affinityScore': affinityScore,
'nextMoment': nextMoment.toUtc().toIso8601String(),
'tags': tags,
'notes': notes,
'aliases': aliases,
'location': location,
'lastUpdatedAt': lastUpdatedAt?.toUtc().toIso8601String(),
'lastInteractedAt': lastInteractedAt?.toUtc().toIso8601String(),
};
}
factory PersonProfile.fromJson(Map<String, dynamic> json) {
return PersonProfile(
id: json['id'] as String,
name: json['name'] as String,
relationship: json['relationship'] as String,
affinityScore: (json['affinityScore'] as num).toInt(),
nextMoment: DateTime.parse(json['nextMoment'] as String).toLocal(),
tags: (json['tags'] as List<dynamic>? ?? <dynamic>[])
.map((dynamic tag) => '$tag')
.toList(growable: false),
notes: json['notes'] as String? ?? '',
aliases: (json['aliases'] as List<dynamic>? ?? <dynamic>[])
.map((dynamic value) => '$value')
.toList(growable: false),
location: json['location'] as String?,
lastUpdatedAt: json['lastUpdatedAt'] == null
? null
: DateTime.parse(json['lastUpdatedAt'] as String).toLocal(),
lastInteractedAt: json['lastInteractedAt'] == null
? null
: DateTime.parse(json['lastInteractedAt'] as String).toLocal(),
);
}
}
/// Stable mapping between an external sender/thread identity and a person.
@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,
this.sourceFingerprint,
});
final String id;
final String sourceApp;
final String? sourceUserId;
final String? sourceThreadId;
final String? sourceFingerprint;
final String normalizedDisplayName;
final String profileId;
final DateTime firstSeenAt;
final DateTime lastSeenAt;
SourceProfileLink copyWith({
String? id,
String? sourceApp,
String? sourceUserId,
String? sourceThreadId,
String? sourceFingerprint,
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,
sourceFingerprint: sourceFingerprint ?? this.sourceFingerprint,
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,
'sourceFingerprint': sourceFingerprint,
'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?,
sourceFingerprint: json['sourceFingerprint'] 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(),
);
}
}
/// Structured fact captured about a person.
@immutable
class PersonFact {
const PersonFact({
required this.id,
required this.personId,
required this.type,
required this.text,
required this.sourceKind,
required this.createdAt,
required this.updatedAt,
this.label,
this.sourceApp,
this.sourceUrl,
this.sharedMessageId,
this.inboxEntryId,
this.confidence,
this.needsReview = false,
this.isSensitive = false,
});
final String id;
final String personId;
final CapturedFactType type;
final String text;
final String? label;
final CaptureSourceKind sourceKind;
final String? sourceApp;
final String? sourceUrl;
final String? sharedMessageId;
final String? inboxEntryId;
final double? confidence;
final bool needsReview;
final bool isSensitive;
final DateTime createdAt;
final DateTime updatedAt;
PersonFact copyWith({
String? id,
String? personId,
CapturedFactType? type,
String? text,
String? label,
CaptureSourceKind? sourceKind,
String? sourceApp,
String? sourceUrl,
String? sharedMessageId,
String? inboxEntryId,
double? confidence,
bool? needsReview,
bool? isSensitive,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return PersonFact(
id: id ?? this.id,
personId: personId ?? this.personId,
type: type ?? this.type,
text: text ?? this.text,
label: label ?? this.label,
sourceKind: sourceKind ?? this.sourceKind,
sourceApp: sourceApp ?? this.sourceApp,
sourceUrl: sourceUrl ?? this.sourceUrl,
sharedMessageId: sharedMessageId ?? this.sharedMessageId,
inboxEntryId: inboxEntryId ?? this.inboxEntryId,
confidence: confidence ?? this.confidence,
needsReview: needsReview ?? this.needsReview,
isSensitive: isSensitive ?? this.isSensitive,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'personId': personId,
'type': type.name,
'text': text,
'label': label,
'sourceKind': sourceKind.name,
'sourceApp': sourceApp,
'sourceUrl': sourceUrl,
'sharedMessageId': sharedMessageId,
'inboxEntryId': inboxEntryId,
'confidence': confidence,
'needsReview': needsReview,
'isSensitive': isSensitive,
'createdAt': createdAt.toUtc().toIso8601String(),
'updatedAt': updatedAt.toUtc().toIso8601String(),
};
}
factory PersonFact.fromJson(Map<String, dynamic> json) {
final String typeName =
json['type'] as String? ?? CapturedFactType.note.name;
final String sourceKindName =
json['sourceKind'] as String? ?? CaptureSourceKind.manual.name;
return PersonFact(
id: json['id'] as String,
personId: json['personId'] as String,
type: CapturedFactType.values.firstWhere(
(CapturedFactType value) => value.name == typeName,
orElse: () => CapturedFactType.note,
),
text: json['text'] as String? ?? '',
label: json['label'] as String?,
sourceKind: CaptureSourceKind.values.firstWhere(
(CaptureSourceKind value) => value.name == sourceKindName,
orElse: () => CaptureSourceKind.manual,
),
sourceApp: json['sourceApp'] as String?,
sourceUrl: json['sourceUrl'] as String?,
sharedMessageId: json['sharedMessageId'] as String?,
inboxEntryId: json['inboxEntryId'] as String?,
confidence: (json['confidence'] as num?)?.toDouble(),
needsReview: json['needsReview'] as bool? ?? false,
isSensitive: json['isSensitive'] as bool? ?? false,
createdAt: DateTime.parse(json['createdAt'] as String).toLocal(),
updatedAt: DateTime.parse(json['updatedAt'] as String).toLocal(),
);
}
}
/// Important date owned by a person profile.
@immutable
class PersonImportantDate {
const PersonImportantDate({
required this.id,
required this.personId,
required this.label,
required this.date,
required this.classification,
required this.sourceKind,
required this.createdAt,
required this.updatedAt,
this.sourceApp,
this.inboxEntryId,
this.isSensitive = false,
});
final String id;
final String personId;
final String label;
final DateTime date;
final String classification;
final CaptureSourceKind sourceKind;
final String? sourceApp;
final String? inboxEntryId;
final bool isSensitive;
final DateTime createdAt;
final DateTime updatedAt;
PersonImportantDate copyWith({
String? id,
String? personId,
String? label,
DateTime? date,
String? classification,
CaptureSourceKind? sourceKind,
String? sourceApp,
String? inboxEntryId,
bool? isSensitive,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return PersonImportantDate(
id: id ?? this.id,
personId: personId ?? this.personId,
label: label ?? this.label,
date: date ?? this.date,
classification: classification ?? this.classification,
sourceKind: sourceKind ?? this.sourceKind,
sourceApp: sourceApp ?? this.sourceApp,
inboxEntryId: inboxEntryId ?? this.inboxEntryId,
isSensitive: isSensitive ?? this.isSensitive,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'personId': personId,
'label': label,
'date': date.toUtc().toIso8601String(),
'classification': classification,
'sourceKind': sourceKind.name,
'sourceApp': sourceApp,
'inboxEntryId': inboxEntryId,
'isSensitive': isSensitive,
'createdAt': createdAt.toUtc().toIso8601String(),
'updatedAt': updatedAt.toUtc().toIso8601String(),
};
}
factory PersonImportantDate.fromJson(Map<String, dynamic> json) {
final String sourceKindName =
json['sourceKind'] as String? ?? CaptureSourceKind.manual.name;
return PersonImportantDate(
id: json['id'] as String,
personId: json['personId'] as String,
label: json['label'] as String? ?? '',
date: DateTime.parse(json['date'] as String).toLocal(),
classification: json['classification'] as String? ?? 'important',
sourceKind: CaptureSourceKind.values.firstWhere(
(CaptureSourceKind value) => value.name == sourceKindName,
orElse: () => CaptureSourceKind.manual,
),
sourceApp: json['sourceApp'] as String?,
inboxEntryId: json['inboxEntryId'] as String?,
isSensitive: json['isSensitive'] as bool? ?? false,
createdAt: DateTime.parse(json['createdAt'] as String).toLocal(),
updatedAt: DateTime.parse(json['updatedAt'] as String).toLocal(),
);
}
}
/// Inferred preference signal that can later drive recommendations or prompts.
@immutable
class PersonPreferenceSignal {
const PersonPreferenceSignal({
required this.id,
required this.personId,
required this.key,
required this.label,
required this.category,
required this.polarity,
required this.confidence,
required this.status,
required this.firstSeenAt,
required this.lastSeenAt,
required this.occurrenceCount,
this.sourceApps = const <String>[],
this.evidenceMessageIds = const <String>[],
this.evidenceSnippets = const <String>[],
});
final String id;
final String personId;
final String key;
final String label;
final String category;
final PreferenceSignalPolarity polarity;
final double confidence;
final PreferenceSignalStatus status;
final DateTime firstSeenAt;
final DateTime lastSeenAt;
final int occurrenceCount;
final List<String> sourceApps;
final List<String> evidenceMessageIds;
final List<String> evidenceSnippets;
PersonPreferenceSignal copyWith({
String? id,
String? personId,
String? key,
String? label,
String? category,
PreferenceSignalPolarity? polarity,
double? confidence,
PreferenceSignalStatus? status,
DateTime? firstSeenAt,
DateTime? lastSeenAt,
int? occurrenceCount,
List<String>? sourceApps,
List<String>? evidenceMessageIds,
List<String>? evidenceSnippets,
}) {
return PersonPreferenceSignal(
id: id ?? this.id,
personId: personId ?? this.personId,
key: key ?? this.key,
label: label ?? this.label,
category: category ?? this.category,
polarity: polarity ?? this.polarity,
confidence: confidence ?? this.confidence,
status: status ?? this.status,
firstSeenAt: firstSeenAt ?? this.firstSeenAt,
lastSeenAt: lastSeenAt ?? this.lastSeenAt,
occurrenceCount: occurrenceCount ?? this.occurrenceCount,
sourceApps: sourceApps ?? this.sourceApps,
evidenceMessageIds: evidenceMessageIds ?? this.evidenceMessageIds,
evidenceSnippets: evidenceSnippets ?? this.evidenceSnippets,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'personId': personId,
'key': key,
'label': label,
'category': category,
'polarity': polarity.name,
'confidence': confidence,
'status': status.name,
'firstSeenAt': firstSeenAt.toUtc().toIso8601String(),
'lastSeenAt': lastSeenAt.toUtc().toIso8601String(),
'occurrenceCount': occurrenceCount,
'sourceApps': sourceApps,
'evidenceMessageIds': evidenceMessageIds,
'evidenceSnippets': evidenceSnippets,
};
}
factory PersonPreferenceSignal.fromJson(Map<String, dynamic> json) {
final String polarityName =
json['polarity'] as String? ?? PreferenceSignalPolarity.neutral.name;
final String statusName =
json['status'] as String? ?? PreferenceSignalStatus.inferred.name;
return PersonPreferenceSignal(
id: json['id'] as String,
personId: json['personId'] as String,
key: json['key'] as String,
label: json['label'] as String? ?? '',
category: json['category'] as String? ?? 'general',
polarity: PreferenceSignalPolarity.values.firstWhere(
(PreferenceSignalPolarity item) => item.name == polarityName,
orElse: () => PreferenceSignalPolarity.neutral,
),
confidence: (json['confidence'] as num?)?.toDouble() ?? 0,
status: PreferenceSignalStatus.values.firstWhere(
(PreferenceSignalStatus item) => item.name == statusName,
orElse: () => PreferenceSignalStatus.inferred,
),
firstSeenAt: DateTime.parse(
json['firstSeenAt'] as String? ??
DateTime.now().toUtc().toIso8601String(),
).toLocal(),
lastSeenAt: DateTime.parse(
json['lastSeenAt'] as String? ??
DateTime.now().toUtc().toIso8601String(),
).toLocal(),
occurrenceCount: (json['occurrenceCount'] as num?)?.toInt() ?? 1,
sourceApps: (json['sourceApps'] as List<dynamic>? ?? <dynamic>[])
.map((dynamic item) => '$item')
.toList(growable: false),
evidenceMessageIds:
(json['evidenceMessageIds'] as List<dynamic>? ?? <dynamic>[])
.map((dynamic item) => '$item')
.toList(growable: false),
evidenceSnippets:
(json['evidenceSnippets'] as List<dynamic>? ?? <dynamic>[])
.map((dynamic item) => '$item')
.toList(growable: false),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
# People Presentation
This folder holds the people workspace UI.
`people_view.dart` is now the composition root for the slice, with the heavy UI
split into library parts:
- `people_view_list.dart`: list, filters, empty states, and person cards.
- `people_view_detail.dart`: detail workspace, insights, and inline editors.
- `people_view_dialogs.dart`: modal editors and quick-capture flows.
- `providers/people_ui_state.dart`: search, selection, and sorting state.
Add new people UI inside this folder. Do not move slice-specific widgets back
into shared horizontal folders unless they are genuinely cross-feature.
@@ -0,0 +1,627 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
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/people/presentation/providers/people_ui_state.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart';
part 'people_view_detail.dart';
part 'people_view_dialogs.dart';
part 'people_view_list.dart';
enum _PersonQuickAction { capture, note, idea, reminder }
class PeopleView extends ConsumerWidget {
const PeopleView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final AsyncValue<LocalDataState> localData = ref.watch(
localRepositoryProvider,
);
final String searchQuery = ref.watch(peopleSearchQueryProvider);
final String? relationshipFilter = ref.watch(
peopleRelationshipFilterProvider,
);
final PeopleSortOption sortOption = ref.watch(peopleSortOptionProvider);
return localData.when(
data: (LocalDataState data) {
final List<PersonProfile> allPeople = data.people;
if (allPeople.isEmpty) {
return _EmptyPeopleView(onAdd: () => _handleAddPerson(context, ref));
}
final List<PersonProfile> people = _applyPeopleFilters(
allPeople,
searchQuery: searchQuery,
relationshipFilter: relationshipFilter,
sortOption: sortOption,
);
final List<String> relationshipOptions = _relationshipOptions(
allPeople,
);
if (people.isEmpty) {
return _FilteredPeopleEmptyView(
onClear: () {
ref.read(peopleSearchQueryProvider.notifier).set('');
ref.read(peopleRelationshipFilterProvider.notifier).set(null);
},
);
}
final String selectedId =
ref.watch(selectedPersonIdProvider) ?? people.first.id;
final PersonProfile selected = people.firstWhere(
(PersonProfile person) => person.id == selectedId,
orElse: () => people.first,
);
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool isCompact = constraints.maxWidth < 860;
if (isCompact) {
return _buildCompactLayout(
context,
ref,
data,
people,
selected,
relationshipOptions,
);
}
return _buildWideLayout(
context,
ref,
data,
people,
selected,
relationshipOptions,
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (Object error, StackTrace stackTrace) {
return const Center(child: Text('Unable to load people'));
},
);
}
List<PersonProfile> _applyPeopleFilters(
List<PersonProfile> people, {
required String searchQuery,
required String? relationshipFilter,
required PeopleSortOption sortOption,
}) {
final String query = searchQuery.trim().toLowerCase();
final String? relationship = relationshipFilter?.trim().toLowerCase();
final List<PersonProfile> filtered = people
.where((PersonProfile person) {
if (relationship != null &&
relationship.isNotEmpty &&
person.relationship.trim().toLowerCase() != relationship) {
return false;
}
if (query.isEmpty) {
return true;
}
final String haystack = <String>[
person.name,
...person.aliases,
person.relationship,
person.location ?? '',
person.notes,
...person.tags,
].join(' ').toLowerCase();
return haystack.contains(query);
})
.toList(growable: true);
filtered.sort((PersonProfile a, PersonProfile b) {
switch (sortOption) {
case PeopleSortOption.affinity:
final int score = b.affinityScore.compareTo(a.affinityScore);
if (score != 0) {
return score;
}
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
case PeopleSortOption.nextMoment:
final int next = a.nextMoment.compareTo(b.nextMoment);
if (next != 0) {
return next;
}
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
case PeopleSortOption.alphabetical:
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
}
});
return filtered;
}
List<String> _relationshipOptions(List<PersonProfile> people) {
final Set<String> seen = <String>{};
final List<String> values = <String>[];
for (final PersonProfile person in people) {
final String relationship = person.relationship.trim();
if (relationship.isEmpty) {
continue;
}
final String key = relationship.toLowerCase();
if (seen.add(key)) {
values.add(relationship);
}
}
values.sort(
(String a, String b) => a.toLowerCase().compareTo(b.toLowerCase()),
);
return values;
}
Future<void> _handleAddPerson(BuildContext context, WidgetRef ref) async {
final List<PersonProfile> existingPeople =
ref.read(localRepositoryProvider).asData?.value.people ??
const <PersonProfile>[];
final _PersonDraft? draft = await _showPersonEditor(
context,
title: 'Add Person',
existingPeople: existingPeople,
);
if (draft == null) {
return;
}
await ref
.read(localRepositoryProvider.notifier)
.addPerson(
name: draft.name,
relationship: draft.relationship,
notes: draft.notes,
tags: draft.tags,
location: draft.location,
aliases: draft.aliases,
);
final LocalDataState? latest = ref
.read(localRepositoryProvider)
.asData
?.value;
if (latest == null) {
return;
}
for (final PersonProfile person in latest.people) {
final bool locationMatches =
(person.location ?? '').trim() == (draft.location ?? '').trim();
if (person.name == draft.name &&
person.relationship == draft.relationship &&
locationMatches) {
ref.read(selectedPersonIdProvider.notifier).select(person.id);
break;
}
}
}
Future<void> _handleEditPerson(
BuildContext context,
WidgetRef ref,
PersonProfile person,
) async {
final List<PersonProfile> existingPeople =
ref.read(localRepositoryProvider).asData?.value.people ??
const <PersonProfile>[];
final _PersonDraft? draft = await _showPersonEditor(
context,
title: 'Edit Person',
initial: _PersonDraft(
name: person.name,
relationship: person.relationship,
notes: person.notes,
tags: person.tags,
aliases: person.aliases,
location: person.location,
),
existingPeople: existingPeople,
editingPersonId: person.id,
);
if (draft == null) {
return;
}
await ref
.read(localRepositoryProvider.notifier)
.updatePerson(
person.copyWith(
name: draft.name,
relationship: draft.relationship,
notes: draft.notes,
tags: draft.tags,
aliases: draft.aliases,
location: draft.location,
),
);
}
Future<void> _handleDeletePerson(
BuildContext context,
WidgetRef ref,
String personId,
) async {
final bool? confirmed = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Delete person?'),
content: const Text(
'This will remove the person and related moments from local storage.',
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete'),
),
],
);
},
);
if (confirmed != true) {
return;
}
await ref.read(localRepositoryProvider.notifier).deletePerson(personId);
ref.read(selectedPersonIdProvider.notifier).clear();
}
Widget _buildWideLayout(
BuildContext context,
WidgetRef ref,
LocalDataState data,
List<PersonProfile> people,
PersonProfile selected,
List<String> relationshipOptions,
) {
return Padding(
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28),
child: Row(
children: <Widget>[
Expanded(
flex: 5,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_PeopleHeader(
onAdd: () => _handleAddPerson(context, ref),
onMerge: () => _handleMergePeople(context, ref, people),
compact: false,
),
const SizedBox(height: 12),
_PeopleListControls(
compact: false,
relationshipOptions: relationshipOptions,
),
const SizedBox(height: 20),
Expanded(
child: ListView.separated(
itemCount: people.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (BuildContext context, int index) {
final PersonProfile person = people[index];
return _PersonListItem(
person: person,
selectedId: selected.id,
compact: false,
onSelect: () {
ref
.read(selectedPersonIdProvider.notifier)
.select(person.id);
},
);
},
),
),
],
),
),
const SizedBox(width: 18),
Expanded(
flex: 6,
child: FrostedCard(
child: SingleChildScrollView(
child: _PersonDetail(
person: selected,
data: data,
onEdit: () => _handleEditPerson(context, ref, selected),
onDelete: () =>
_handleDeletePerson(context, ref, selected.id),
onQuickAction: (_PersonQuickAction action) =>
_handleQuickAction(context, ref, selected, action),
),
),
),
),
],
),
);
}
Widget _buildCompactLayout(
BuildContext context,
WidgetRef ref,
LocalDataState data,
List<PersonProfile> people,
PersonProfile selected,
List<String> relationshipOptions,
) {
return Padding(
padding: const EdgeInsets.fromLTRB(18, 16, 18, 20),
child: ListView(
children: <Widget>[
_PeopleHeader(
onAdd: () => _handleAddPerson(context, ref),
onMerge: () => _handleMergePeople(context, ref, people),
compact: true,
),
const SizedBox(height: 10),
_PeopleListControls(
compact: true,
relationshipOptions: relationshipOptions,
),
const SizedBox(height: 14),
FrostedCard(
child: _PersonDetail(
person: selected,
data: data,
compact: true,
onEdit: () => _handleEditPerson(context, ref, selected),
onDelete: () => _handleDeletePerson(context, ref, selected.id),
onQuickAction: (_PersonQuickAction action) =>
_handleQuickAction(context, ref, selected, action),
),
),
const SizedBox(height: 14),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Row(
children: <Widget>[
Text(
'All profiles',
style: Theme.of(context).textTheme.titleMedium,
),
const Spacer(),
Text(
'${people.length}',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
),
const SizedBox(height: 10),
...people.map(
(PersonProfile person) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _PersonListItem(
person: person,
selectedId: selected.id,
compact: true,
onSelect: () {
ref.read(selectedPersonIdProvider.notifier).select(person.id);
},
),
),
),
],
),
);
}
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<void> _handleMergePeople(
BuildContext context,
WidgetRef ref,
List<PersonProfile> people,
) async {
if (people.length < 2) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Need at least two profiles to merge.')),
);
return;
}
final List<PersonProfile> duplicateCandidates = _duplicateCandidates(
people,
);
if (duplicateCandidates.length < 2) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'No duplicate-name profiles found. Edit names first if needed.',
),
),
);
return;
}
final _MergePeopleDraft? draft = await showDialog<_MergePeopleDraft>(
context: context,
builder: (BuildContext context) =>
_MergePeopleDialog(people: duplicateCandidates),
);
if (draft == null) {
return;
}
await ref
.read(localRepositoryProvider.notifier)
.mergePersonProfiles(
sourcePersonId: draft.sourcePersonId,
targetPersonId: draft.targetPersonId,
);
ref.read(selectedPersonIdProvider.notifier).select(draft.targetPersonId);
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Profiles merged successfully.')),
);
}
List<PersonProfile> _duplicateCandidates(List<PersonProfile> people) {
final Map<String, List<PersonProfile>> grouped =
<String, List<PersonProfile>>{};
for (final PersonProfile person in people) {
final String key = _normalizeNameKey(person.name);
if (key.isEmpty) {
continue;
}
grouped.putIfAbsent(key, () => <PersonProfile>[]).add(person);
}
final List<PersonProfile> result = <PersonProfile>[];
for (final List<PersonProfile> group in grouped.values) {
if (group.length > 1) {
result.addAll(group);
}
}
return result;
}
String _normalizeNameKey(String value) {
return value
.trim()
.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9]+'), ' ')
.replaceAll(RegExp(r'\s+'), ' ');
}
Future<_PersonDraft?> _showPersonEditor(
BuildContext context, {
required String title,
_PersonDraft? initial,
List<PersonProfile> existingPeople = const <PersonProfile>[],
String? editingPersonId,
}) {
final bool compact = MediaQuery.sizeOf(context).width < 720;
if (compact) {
return showModalBottomSheet<_PersonDraft>(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
builder: (BuildContext context) {
return _PersonEditorBottomSheet(
title: title,
initial: initial,
existingPeople: existingPeople,
editingPersonId: editingPersonId,
);
},
);
}
return showDialog<_PersonDraft>(
context: context,
builder: (BuildContext context) {
return _PersonEditorDialog(
title: title,
initial: initial,
existingPeople: existingPeople,
editingPersonId: editingPersonId,
);
},
);
}
Future<String?> _showQuickTextCaptureDialog(
BuildContext context, {
required String title,
required String hintText,
}) async {
return showDialog<String>(
context: context,
builder: (BuildContext context) {
return _QuickTextCaptureDialog(title: title, hintText: hintText);
},
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,528 @@
part of 'people_view.dart';
class _EmptyPeopleView extends StatelessWidget {
const _EmptyPeopleView({required this.onAdd});
final VoidCallback onAdd;
@override
Widget build(BuildContext context) {
return Center(
child: FrostedCard(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
'No people yet',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Add the first relationship profile to start tracking moments.',
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.person_add_alt_1_rounded),
label: const Text('Add Person'),
),
],
),
),
);
}
}
class _FilteredPeopleEmptyView extends StatelessWidget {
const _FilteredPeopleEmptyView({required this.onClear});
final VoidCallback onClear;
@override
Widget build(BuildContext context) {
return Center(
child: FrostedCard(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const Icon(
Icons.search_off_rounded,
size: 34,
color: AppTheme.textSecondary,
),
const SizedBox(height: 10),
Text(
'No matching people',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 6),
Text(
'Try another search, relationship filter, or clear the current filters.',
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: onClear,
icon: const Icon(Icons.restart_alt_rounded),
label: const Text('Clear Filters'),
),
],
),
),
);
}
}
class _PeopleListControls extends ConsumerWidget {
const _PeopleListControls({
required this.compact,
required this.relationshipOptions,
});
final bool compact;
final List<String> relationshipOptions;
@override
Widget build(BuildContext context, WidgetRef ref) {
final String query = ref.watch(peopleSearchQueryProvider);
final String? selectedRelationship = ref.watch(
peopleRelationshipFilterProvider,
);
final PeopleSortOption sortOption = ref.watch(peopleSortOptionProvider);
return FrostedCard(
padding: EdgeInsets.all(compact ? 12 : 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: TextFormField(
key: ValueKey<String>('people-search-$query'),
initialValue: query,
onChanged: (String value) {
ref.read(peopleSearchQueryProvider.notifier).set(value);
},
decoration: const InputDecoration(
hintText: 'Search name, tags, notes, location',
prefixIcon: Icon(Icons.search_rounded),
isDense: true,
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
PopupMenuButton<PeopleSortOption>(
tooltip: 'Sort',
onSelected: (PeopleSortOption value) {
ref.read(peopleSortOptionProvider.notifier).set(value);
},
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<PeopleSortOption>>[
const PopupMenuItem<PeopleSortOption>(
value: PeopleSortOption.affinity,
child: Text('Sort by affinity'),
),
const PopupMenuItem<PeopleSortOption>(
value: PeopleSortOption.nextMoment,
child: Text('Sort by next moment'),
),
const PopupMenuItem<PeopleSortOption>(
value: PeopleSortOption.alphabetical,
child: Text('Sort A-Z'),
),
],
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 10,
),
decoration: BoxDecoration(
color: const Color(0xFFF3F8FB),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const Icon(Icons.sort_rounded, size: 18),
if (!compact) ...<Widget>[
const SizedBox(width: 6),
Text(switch (sortOption) {
PeopleSortOption.affinity => 'Affinity',
PeopleSortOption.nextMoment => 'Next',
PeopleSortOption.alphabetical => 'A-Z',
}),
],
],
),
),
),
],
),
const SizedBox(height: 10),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: <Widget>[
_FilterChipButton(
label: 'All',
selected: selectedRelationship == null,
onTap: () {
ref
.read(peopleRelationshipFilterProvider.notifier)
.set(null);
},
),
...relationshipOptions.map(
(String relationship) => Padding(
padding: const EdgeInsets.only(left: 8),
child: _FilterChipButton(
label: relationship,
selected:
selectedRelationship?.toLowerCase() ==
relationship.toLowerCase(),
onTap: () {
final PeopleRelationshipFilterNotifier controller = ref
.read(peopleRelationshipFilterProvider.notifier);
final bool isSelected =
selectedRelationship?.toLowerCase() ==
relationship.toLowerCase();
controller.set(isSelected ? null : relationship);
},
),
),
),
],
),
),
],
),
);
}
}
class _FilterChipButton extends StatelessWidget {
const _FilterChipButton({
required this.label,
required this.selected,
required this.onTap,
});
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(99),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: selected ? const Color(0xFFEAF7FB) : const Color(0xFFF4F8FB),
borderRadius: BorderRadius.circular(99),
border: Border.all(
color: selected
? const Color(0xFFB8E4EE)
: Colors.white.withValues(alpha: 0.8),
),
),
child: Text(
label,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: selected ? AppTheme.primary : AppTheme.textSecondary,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
),
),
),
);
}
}
class _PeopleHeader extends StatelessWidget {
const _PeopleHeader({
required this.onAdd,
required this.onMerge,
required this.compact,
});
final VoidCallback onAdd;
final VoidCallback onMerge;
final bool compact;
@override
Widget build(BuildContext context) {
final TextTheme textTheme = Theme.of(context).textTheme;
if (compact) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text('People', style: textTheme.headlineMedium),
const SizedBox(height: 6),
Text(
'Track what matters to each relationship and follow through.',
style: textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.person_add_alt_1_rounded),
label: const Text('Add person'),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: onMerge,
icon: const Icon(Icons.merge_type_rounded),
label: const Text('Merge duplicates'),
),
],
);
}
return Row(
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text('People', style: textTheme.headlineMedium),
const SizedBox(height: 6),
Text(
'Track what matters to each relationship and follow through.',
style: textTheme.bodyLarge?.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
),
FilledButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.person_add_alt_1_rounded),
label: const Text('Add'),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: onMerge,
icon: const Icon(Icons.merge_type_rounded),
label: const Text('Merge'),
),
],
);
}
}
class _PersonListItem extends StatelessWidget {
const _PersonListItem({
required this.person,
required this.selectedId,
required this.compact,
required this.onSelect,
});
final PersonProfile person;
final String selectedId;
final bool compact;
final VoidCallback onSelect;
@override
Widget build(BuildContext context) {
final bool isSelected = person.id == selectedId;
return InkWell(
onTap: onSelect,
borderRadius: BorderRadius.circular(20),
child: FrostedCard(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
_AvatarSeed(name: person.name),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
person.name,
style: Theme.of(context).textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
person.relationship,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTheme.textSecondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 8),
_ScorePill(score: person.affinityScore),
if (isSelected)
const Padding(
padding: EdgeInsets.only(left: 10),
child: Icon(Icons.check_circle, color: Color(0xFF1AB6C8)),
),
],
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
_InlineMetaPill(
icon: Icons.schedule_rounded,
label: _dateTimeLabel(person.nextMoment),
),
if ((person.location ?? '').trim().isNotEmpty)
_InlineMetaPill(
icon: Icons.location_on_outlined,
label: person.location!.trim(),
),
...person.tags
.take(compact ? 2 : 3)
.map(
(String tag) => _InlineMetaPill(
icon: Icons.sell_outlined,
label: tag,
),
),
],
),
],
),
),
);
}
}
class _InlineMetaPill extends StatelessWidget {
const _InlineMetaPill({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
decoration: BoxDecoration(
color: const Color(0xFFF1F7FA),
borderRadius: BorderRadius.circular(99),
border: Border.all(color: Colors.white),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(icon, size: 14, color: AppTheme.textSecondary),
const SizedBox(width: 6),
Flexible(
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary),
),
),
],
),
);
}
}
class _AvatarSeed extends StatelessWidget {
const _AvatarSeed({required this.name, this.size = 48});
final String name;
final double size;
@override
Widget build(BuildContext context) {
final String initials = name
.split(' ')
.where((String part) => part.isNotEmpty)
.take(2)
.map((String part) => part[0].toUpperCase())
.join();
return Container(
width: size,
height: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(size / 2),
gradient: const LinearGradient(
colors: <Color>[Color(0xFF1AB6C8), Color(0xFF2274E0)],
),
),
alignment: Alignment.center,
child: Text(
initials,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
);
}
}
class _ScorePill extends StatelessWidget {
const _ScorePill({required this.score});
final int score;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFEEF7F3),
borderRadius: BorderRadius.circular(99),
),
child: Text(
'$score%',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: const Color(0xFF1D9C66),
fontWeight: FontWeight.w700,
),
),
);
}
}
String _dateTimeLabel(DateTime value) {
final DateTime local = value.toLocal();
final String month = local.month.toString().padLeft(2, '0');
final String day = local.day.toString().padLeft(2, '0');
final String hour = local.hour.toString().padLeft(2, '0');
final String minute = local.minute.toString().padLeft(2, '0');
return '$month/$day $hour:$minute';
}
String _shortDateTimeLabel(DateTime value) {
final DateTime local = value.toLocal();
final String year = local.year.toString();
final String month = local.month.toString().padLeft(2, '0');
final String day = local.day.toString().padLeft(2, '0');
final String hour = local.hour.toString().padLeft(2, '0');
final String minute = local.minute.toString().padLeft(2, '0');
return '$year-$month-$day $hour:$minute';
}
@@ -0,0 +1,4 @@
# People UI State
This folder contains lightweight Riverpod state used only by the people
workspace, such as search text, selected profile, and sort/filter controls.
@@ -0,0 +1,70 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// Current sort mode for the people list.
enum PeopleSortOption { affinity, nextMoment, alphabetical }
/// Selected profile in the people workspace.
class SelectedPersonIdNotifier extends Notifier<String?> {
@override
String? build() => null;
void select(String id) {
state = id;
}
void clear() {
state = null;
}
}
final NotifierProvider<SelectedPersonIdNotifier, String?>
selectedPersonIdProvider = NotifierProvider<SelectedPersonIdNotifier, String?>(
SelectedPersonIdNotifier.new,
);
/// Search query applied to the people list.
class PeopleSearchQueryNotifier extends Notifier<String> {
@override
String build() => '';
void set(String value) {
state = value;
}
}
final NotifierProvider<PeopleSearchQueryNotifier, String>
peopleSearchQueryProvider = NotifierProvider<PeopleSearchQueryNotifier, String>(
PeopleSearchQueryNotifier.new,
);
/// Relationship filter applied to the people list.
class PeopleRelationshipFilterNotifier extends Notifier<String?> {
@override
String? build() => null;
void set(String? value) {
state = value;
}
}
final NotifierProvider<PeopleRelationshipFilterNotifier, String?>
peopleRelationshipFilterProvider =
NotifierProvider<PeopleRelationshipFilterNotifier, String?>(
PeopleRelationshipFilterNotifier.new,
);
/// Selected sort mode for the people list.
class PeopleSortOptionNotifier extends Notifier<PeopleSortOption> {
@override
PeopleSortOption build() => PeopleSortOption.affinity;
void set(PeopleSortOption value) {
state = value;
}
}
final NotifierProvider<PeopleSortOptionNotifier, PeopleSortOption>
peopleSortOptionProvider =
NotifierProvider<PeopleSortOptionNotifier, PeopleSortOption>(
PeopleSortOptionNotifier.new,
);
+9
View File
@@ -0,0 +1,9 @@
# Reminders Slice
This slice owns reminder rules and local scheduling behavior.
- `domain/reminder_models.dart`: reminder cadence and rule models.
- `presentation/reminders_view.dart`: reminder management screen.
- `application/`: notification listeners, local scheduling, and providers.
Work here when you change reminder UX or local notification behavior.
@@ -0,0 +1,4 @@
# Reminders Application
Reminder scheduling orchestration and provider wiring live here. Start here when
the behavior of reminder execution or platform scheduling needs to change.
@@ -0,0 +1,18 @@
import 'dart:async';
/// Broadcasts reminder notification tap intents to the UI layer.
class ReminderNotificationIntentBus {
final StreamController<String> _controller =
StreamController<String>.broadcast();
Stream<String> get intents => _controller.stream;
void emitTap(String reminderId) {
if (reminderId.trim().isEmpty) {
return;
}
_controller.add(reminderId.trim());
}
Future<void> close() => _controller.close();
}

Some files were not shown because too many files have changed in this diff Show More