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:
@@ -1,19 +1,29 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/core/config/app_config.dart';
|
||||
import 'package:relationship_saver/features/local/local_models.dart';
|
||||
import 'package:relationship_saver/features/local/local_repository.dart';
|
||||
import 'package:relationship_saver/features/signals/domain/signals_feed_synthesizer.dart';
|
||||
import 'package:relationship_saver/integrations/backend/backend_gateway_provider.dart';
|
||||
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
||||
|
||||
class SignalsController extends AsyncNotifier<SignalsFeed> {
|
||||
final Set<String> _hiddenSignalIds = <String>{};
|
||||
|
||||
@override
|
||||
Future<SignalsFeed> build() async {
|
||||
return _load();
|
||||
final LocalDataState localData = await ref.watch(
|
||||
localRepositoryProvider.future,
|
||||
);
|
||||
return _load(localData: localData);
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncLoading<SignalsFeed>();
|
||||
try {
|
||||
final SignalsFeed feed = await _load();
|
||||
final LocalDataState localData = await ref.read(
|
||||
localRepositoryProvider.future,
|
||||
);
|
||||
final SignalsFeed feed = await _load(localData: localData);
|
||||
state = AsyncData<SignalsFeed>(feed);
|
||||
} catch (error, stackTrace) {
|
||||
state = AsyncError<SignalsFeed>(error, stackTrace);
|
||||
@@ -26,9 +36,25 @@ class SignalsController extends AsyncNotifier<SignalsFeed> {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref
|
||||
.read(backendGatewayProvider)
|
||||
.acknowledgeSignal(signalId: id, action: action);
|
||||
final SignalItem? item = current.items
|
||||
.where((SignalItem value) => value.id == id)
|
||||
.cast<SignalItem?>()
|
||||
.firstOrNull;
|
||||
if (item == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isLocalSignal(item)) {
|
||||
if (action == SignalAction.saved) {
|
||||
await _materializeLocalSignal(item);
|
||||
}
|
||||
await ref.read(localRepositoryProvider.notifier).dismissLocalSignal(id);
|
||||
} else {
|
||||
await ref
|
||||
.read(backendGatewayProvider)
|
||||
.acknowledgeSignal(signalId: id, action: action);
|
||||
_hiddenSignalIds.add(id);
|
||||
}
|
||||
|
||||
final List<SignalItem> updated = current.items
|
||||
.where((SignalItem item) => item.id != id)
|
||||
@@ -36,51 +62,114 @@ class SignalsController extends AsyncNotifier<SignalsFeed> {
|
||||
state = AsyncData<SignalsFeed>(current.copyWith(items: updated));
|
||||
}
|
||||
|
||||
Future<SignalsFeed> _load() async {
|
||||
try {
|
||||
return await ref.read(backendGatewayProvider).getSignalsFeed(limit: 20);
|
||||
} catch (_) {
|
||||
final LocalDataState localData = await ref.read(
|
||||
localRepositoryProvider.future,
|
||||
);
|
||||
final List<PersonProfile> people = localData.people;
|
||||
final PersonProfile defaultPerson = people.isNotEmpty
|
||||
? people.first
|
||||
: PersonProfile(
|
||||
id: 'person-default',
|
||||
name: 'Someone Important',
|
||||
relationship: 'Relationship',
|
||||
affinityScore: 70,
|
||||
nextMoment: DateTime(2026, 2, 20),
|
||||
tags: <String>[],
|
||||
notes: '',
|
||||
);
|
||||
final PersonProfile secondaryPerson = people.length > 1
|
||||
? people[1]
|
||||
: defaultPerson;
|
||||
final DateTime now = DateTime.now().toUtc();
|
||||
return SignalsFeed(
|
||||
cursor: 'fallback-signals',
|
||||
items: <SignalItem>[
|
||||
SignalItem(
|
||||
id: 'fallback-1',
|
||||
type: 'recommendation',
|
||||
title: 'Check in with ${defaultPerson.name} tonight',
|
||||
description: 'A short voice memo can keep momentum.',
|
||||
personId: defaultPerson.id,
|
||||
createdAt: now,
|
||||
),
|
||||
SignalItem(
|
||||
id: 'fallback-2',
|
||||
type: 'trend',
|
||||
title: 'Gift idea: practical + personal is trending',
|
||||
description: 'Combine utility with one sentimental detail.',
|
||||
personId: secondaryPerson.id,
|
||||
createdAt: now.subtract(const Duration(hours: 2)),
|
||||
),
|
||||
],
|
||||
);
|
||||
Future<SignalsFeed> _load({required LocalDataState localData}) async {
|
||||
final List<PersonProfile> people = localData.people;
|
||||
final List<SignalItem> items = <SignalItem>[
|
||||
...SignalsFeedSynthesizer.synthesize(state: localData),
|
||||
];
|
||||
|
||||
String? cursor;
|
||||
if (!AppConfig.useFakeBackend || items.isEmpty) {
|
||||
try {
|
||||
final SignalsFeed remote = await ref
|
||||
.read(backendGatewayProvider)
|
||||
.getSignalsFeed(limit: 20);
|
||||
cursor = remote.cursor;
|
||||
items.addAll(remote.items);
|
||||
} catch (_) {
|
||||
if (items.isEmpty) {
|
||||
items.addAll(_fallbackSignals(people));
|
||||
cursor = 'fallback-signals';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final List<SignalItem> visible = items
|
||||
.where((SignalItem item) => !_hiddenSignalIds.contains(item.id))
|
||||
.toList(growable: false);
|
||||
return SignalsFeed(cursor: cursor, items: visible);
|
||||
}
|
||||
|
||||
bool _isLocalSignal(SignalItem item) {
|
||||
return item.metadata?[signalMetadataSourceKey] == localSignalSource;
|
||||
}
|
||||
|
||||
Future<void> _materializeLocalSignal(SignalItem item) async {
|
||||
final Map<String, dynamic>? metadata = item.metadata;
|
||||
if (metadata == null) {
|
||||
return;
|
||||
}
|
||||
if (metadata[signalMetadataActionKey] != createReminderSignalAction) {
|
||||
return;
|
||||
}
|
||||
|
||||
final String? title = metadata[signalMetadataReminderTitleKey] as String?;
|
||||
final String? cadenceName =
|
||||
metadata[signalMetadataReminderCadenceKey] as String?;
|
||||
final String? nextAtRaw =
|
||||
metadata[signalMetadataReminderNextAtKey] as String?;
|
||||
if (title == null || cadenceName == null || nextAtRaw == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final LocalDataState localData = await ref.read(
|
||||
localRepositoryProvider.future,
|
||||
);
|
||||
final bool duplicate = localData.reminders.any((ReminderRule reminder) {
|
||||
return reminder.personId == item.personId &&
|
||||
reminder.title.trim().toLowerCase() == title.trim().toLowerCase();
|
||||
});
|
||||
if (duplicate) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.addReminder(
|
||||
title: title,
|
||||
cadence: ReminderCadence.values.firstWhere(
|
||||
(ReminderCadence value) => value.name == cadenceName,
|
||||
orElse: () => ReminderCadence.weekly,
|
||||
),
|
||||
nextAt: DateTime.parse(nextAtRaw).toLocal(),
|
||||
personId: item.personId,
|
||||
);
|
||||
}
|
||||
|
||||
List<SignalItem> _fallbackSignals(List<PersonProfile> people) {
|
||||
final PersonProfile defaultPerson = people.isNotEmpty
|
||||
? people.first
|
||||
: PersonProfile(
|
||||
id: 'person-default',
|
||||
name: 'Someone Important',
|
||||
relationship: 'Relationship',
|
||||
affinityScore: 70,
|
||||
nextMoment: DateTime(2026, 2, 20),
|
||||
tags: <String>[],
|
||||
notes: '',
|
||||
);
|
||||
final PersonProfile secondaryPerson = people.length > 1
|
||||
? people[1]
|
||||
: defaultPerson;
|
||||
final DateTime now = DateTime.now().toUtc();
|
||||
return <SignalItem>[
|
||||
SignalItem(
|
||||
id: 'fallback-1',
|
||||
type: 'recommendation',
|
||||
title: 'Check in with ${defaultPerson.name} tonight',
|
||||
description: 'A short voice memo can keep momentum.',
|
||||
personId: defaultPerson.id,
|
||||
createdAt: now,
|
||||
),
|
||||
SignalItem(
|
||||
id: 'fallback-2',
|
||||
type: 'trend',
|
||||
title: 'Gift idea: practical + personal is trending',
|
||||
description: 'Combine utility with one sentimental detail.',
|
||||
personId: secondaryPerson.id,
|
||||
createdAt: now.subtract(const Duration(hours: 2)),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,3 +178,7 @@ signalsControllerProvider =
|
||||
AsyncNotifierProvider<SignalsController, SignalsFeed>(
|
||||
SignalsController.new,
|
||||
);
|
||||
|
||||
extension<T> on Iterable<T> {
|
||||
T? get firstOrNull => isEmpty ? null : first;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user