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:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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/`.
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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
Reference in New Issue
Block a user