feat: add local-first private AI digest workflow

Migrate app code into canonical feature slices, add phone-only AI digest scheduling and review, wire local notification/background task support, and cover the flow with tests.
This commit is contained in:
Rijad Zuzo
2026-05-17 00:17:20 +02:00
parent dab50abf0e
commit f655adfbea
212 changed files with 24178 additions and 15895 deletions
+7
View File
@@ -0,0 +1,7 @@
# App State
`local_data_state.dart` is the aggregate snapshot that the prototype persists.
Treat it as composition, not as a domain home. New models should usually be
added in a feature slice first, then referenced from the aggregate state if the
prototype store needs them persisted.
+380
View File
@@ -0,0 +1,380 @@
// ignore_for_file: sort_constructors_first
import 'package:flutter/foundation.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:relationship_saver/features/dashboard/domain/dashboard_models.dart';
import 'package:relationship_saver/features/ideas/domain/idea_models.dart';
import 'package:relationship_saver/features/moments/domain/moment_models.dart';
import 'package:relationship_saver/features/people/domain/person_models.dart';
import 'package:relationship_saver/features/reminders/domain/reminder_models.dart';
import 'package:relationship_saver/features/share_intake/domain/share_models.dart';
/// Aggregate local snapshot used by the prototype app store.
///
/// During this prototyping phase the app still uses one persisted snapshot for
/// most product state. The types inside it are now feature-owned, so each slice
/// can evolve without hiding its models in a horizontal "local" module.
@immutable
class LocalDataState {
const LocalDataState({
required this.people,
required this.moments,
required this.ideas,
required this.reminders,
required this.tasks,
this.dismissedSignalIds = const <String>[],
this.sourceLinks = const <SourceProfileLink>[],
this.sharedMessages = const <SharedMessageEntry>[],
this.sharedInbox = const <SharedInboxEntry>[],
this.personFacts = const <PersonFact>[],
this.importantDates = const <PersonImportantDate>[],
this.preferenceSignals = const <PersonPreferenceSignal>[],
this.aiSuggestionDrafts = const <AiSuggestionDraft>[],
});
final List<PersonProfile> people;
final List<RelationshipMoment> moments;
final List<RelationshipIdea> ideas;
final List<ReminderRule> reminders;
final List<DashboardTask> tasks;
final List<String> dismissedSignalIds;
final List<SourceProfileLink> sourceLinks;
final List<SharedMessageEntry> sharedMessages;
final List<SharedInboxEntry> sharedInbox;
final List<PersonFact> personFacts;
final List<PersonImportantDate> importantDates;
final List<PersonPreferenceSignal> preferenceSignals;
final List<AiSuggestionDraft> aiSuggestionDrafts;
LocalDataState copyWith({
List<PersonProfile>? people,
List<RelationshipMoment>? moments,
List<RelationshipIdea>? ideas,
List<ReminderRule>? reminders,
List<DashboardTask>? tasks,
List<String>? dismissedSignalIds,
List<SourceProfileLink>? sourceLinks,
List<SharedMessageEntry>? sharedMessages,
List<SharedInboxEntry>? sharedInbox,
List<PersonFact>? personFacts,
List<PersonImportantDate>? importantDates,
List<PersonPreferenceSignal>? preferenceSignals,
List<AiSuggestionDraft>? aiSuggestionDrafts,
}) {
return LocalDataState(
people: people ?? this.people,
moments: moments ?? this.moments,
ideas: ideas ?? this.ideas,
reminders: reminders ?? this.reminders,
tasks: tasks ?? this.tasks,
dismissedSignalIds: dismissedSignalIds ?? this.dismissedSignalIds,
sourceLinks: sourceLinks ?? this.sourceLinks,
sharedMessages: sharedMessages ?? this.sharedMessages,
sharedInbox: sharedInbox ?? this.sharedInbox,
personFacts: personFacts ?? this.personFacts,
importantDates: importantDates ?? this.importantDates,
preferenceSignals: preferenceSignals ?? this.preferenceSignals,
aiSuggestionDrafts: aiSuggestionDrafts ?? this.aiSuggestionDrafts,
);
}
DashboardSummary summary({DateTime? now}) {
final DateTime baseline = now ?? DateTime.now();
return DashboardSummary(
activePeople: people.length,
upcomingPlans: people
.where((PersonProfile p) => p.nextMoment.isAfter(baseline))
.length,
pendingIdeas: ideas
.where((RelationshipIdea idea) => !idea.isArchived)
.length,
weeklyConsistency: moments.isEmpty
? 0
: (70 + moments.length * 4).clamp(0, 100),
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'people': people
.map((PersonProfile person) => person.toJson())
.toList(growable: false),
'moments': moments
.map((RelationshipMoment moment) => moment.toJson())
.toList(growable: false),
'ideas': ideas
.map((RelationshipIdea idea) => idea.toJson())
.toList(growable: false),
'reminders': reminders
.map((ReminderRule reminder) => reminder.toJson())
.toList(growable: false),
'tasks': tasks
.map((DashboardTask task) => task.toJson())
.toList(growable: false),
'dismissedSignalIds': dismissedSignalIds,
'sourceLinks': sourceLinks
.map((SourceProfileLink link) => link.toJson())
.toList(growable: false),
'sharedMessages': sharedMessages
.map((SharedMessageEntry entry) => entry.toJson())
.toList(growable: false),
'sharedInbox': sharedInbox
.map((SharedInboxEntry entry) => entry.toJson())
.toList(growable: false),
'personFacts': personFacts
.map((PersonFact fact) => fact.toJson())
.toList(growable: false),
'importantDates': importantDates
.map((PersonImportantDate value) => value.toJson())
.toList(growable: false),
'preferenceSignals': preferenceSignals
.map((PersonPreferenceSignal signal) => signal.toJson())
.toList(growable: false),
'aiSuggestionDrafts': aiSuggestionDrafts
.map((AiSuggestionDraft draft) => draft.toJson())
.toList(growable: false),
};
}
factory LocalDataState.fromJson(Map<String, dynamic> json) {
return LocalDataState(
people: (json['people'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic person) =>
PersonProfile.fromJson(person as Map<String, dynamic>),
)
.toList(growable: false),
moments: (json['moments'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic moment) =>
RelationshipMoment.fromJson(moment as Map<String, dynamic>),
)
.toList(growable: false),
ideas: (json['ideas'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic idea) =>
RelationshipIdea.fromJson(idea as Map<String, dynamic>),
)
.toList(growable: false),
reminders: (json['reminders'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic reminder) =>
ReminderRule.fromJson(reminder as Map<String, dynamic>),
)
.toList(growable: false),
tasks: (json['tasks'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic task) =>
DashboardTask.fromJson(task as Map<String, dynamic>),
)
.toList(growable: false),
dismissedSignalIds:
(json['dismissedSignalIds'] as List<dynamic>? ?? <dynamic>[])
.map((dynamic value) => '$value')
.toList(growable: false),
sourceLinks: (json['sourceLinks'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic link) =>
SourceProfileLink.fromJson(link as Map<String, dynamic>),
)
.toList(growable: false),
sharedMessages: (json['sharedMessages'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic entry) =>
SharedMessageEntry.fromJson(entry as Map<String, dynamic>),
)
.toList(growable: false),
sharedInbox: (json['sharedInbox'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic entry) =>
SharedInboxEntry.fromJson(entry as Map<String, dynamic>),
)
.toList(growable: false),
personFacts: (json['personFacts'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic fact) => PersonFact.fromJson(fact as Map<String, dynamic>),
)
.toList(growable: false),
importantDates: (json['importantDates'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic value) =>
PersonImportantDate.fromJson(value as Map<String, dynamic>),
)
.toList(growable: false),
preferenceSignals:
(json['preferenceSignals'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic signal) => PersonPreferenceSignal.fromJson(
signal as Map<String, dynamic>,
),
)
.toList(growable: false),
aiSuggestionDrafts:
(json['aiSuggestionDrafts'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic draft) =>
AiSuggestionDraft.fromJson(draft as Map<String, dynamic>),
)
.toList(growable: false),
);
}
static LocalDataState seed() {
return LocalDataState(
people: <PersonProfile>[
PersonProfile(
id: 'p-ava',
name: 'Ava Hart',
relationship: 'Partner',
affinityScore: 92,
nextMoment: DateTime(2026, 2, 16, 19, 30),
tags: <String>['coffee', 'books', 'slow mornings'],
notes: 'Prefers thoughtful plans over expensive plans.',
aliases: <String>['Aves'],
location: 'Austin, TX',
lastUpdatedAt: DateTime(2026, 2, 14, 9, 0),
lastInteractedAt: DateTime(2026, 2, 12, 18, 30),
),
PersonProfile(
id: 'p-jordan',
name: 'Jordan Lee',
relationship: 'Close Friend',
affinityScore: 78,
nextMoment: DateTime(2026, 2, 18, 18, 0),
tags: <String>['running', 'street food'],
notes: 'Has a race on Sunday; ask how training is going.',
location: 'Chicago, IL',
lastUpdatedAt: DateTime(2026, 2, 13, 8, 30),
lastInteractedAt: DateTime(2026, 2, 11, 19, 0),
),
PersonProfile(
id: 'p-mila',
name: 'Mila Stone',
relationship: 'Sister',
affinityScore: 84,
nextMoment: DateTime(2026, 2, 20, 12, 0),
tags: <String>['plants', 'retro music'],
notes: 'Birthday prep should start this week.',
aliases: <String>['Millie'],
location: 'Seattle, WA',
lastUpdatedAt: DateTime(2026, 2, 13, 14, 20),
lastInteractedAt: DateTime(2026, 2, 12, 14, 20),
),
],
moments: <RelationshipMoment>[
RelationshipMoment(
id: 'm-1',
personId: 'p-ava',
title: 'Sunday Walk Tradition',
summary: 'Short walk + no-phone hour worked really well.',
at: DateTime(2026, 2, 9, 9, 30),
type: 'ritual',
),
RelationshipMoment(
id: 'm-2',
personId: 'p-jordan',
title: 'Post-workout Check-in',
summary: 'Shared training playlist and grabbed smoothies.',
at: DateTime(2026, 2, 11, 19, 0),
type: 'support',
),
RelationshipMoment(
id: 'm-3',
personId: 'p-mila',
title: 'Gift Idea Captured',
summary: 'Found a vintage lamp from her saved style board.',
at: DateTime(2026, 2, 12, 14, 20),
type: 'gift',
),
],
ideas: <RelationshipIdea>[
RelationshipIdea(
id: 'i-1',
personId: 'p-ava',
type: IdeaType.gift,
title: 'Handwritten recipe journal',
details: 'Collect 10 memories and recipes from this year.',
createdAt: DateTime(2026, 2, 12, 9),
),
RelationshipIdea(
id: 'i-2',
personId: 'p-mila',
type: IdeaType.event,
title: 'Plant market + brunch date',
details: 'Saturday morning slot; book nearby cafe in advance.',
createdAt: DateTime(2026, 2, 13, 11),
),
],
reminders: <ReminderRule>[
ReminderRule(
id: 'r-1',
personId: 'p-jordan',
title: 'Weekly check-in message',
cadence: ReminderCadence.weekly,
nextAt: DateTime(2026, 2, 18, 18),
),
ReminderRule(
id: 'r-2',
personId: 'p-ava',
title: 'Sunday ritual planning',
cadence: ReminderCadence.weekly,
nextAt: DateTime(2026, 2, 16, 10),
),
],
tasks: <DashboardTask>[
DashboardTask(
id: 't-1',
title: 'Plan Friday dinner with Ava',
description: 'Keep it low-key: ramen + bookstore stop.',
when: DateTime(2026, 2, 16, 17, 0),
),
DashboardTask(
id: 't-2',
title: 'Send race-day encouragement to Jordan',
description: 'Voice note before 8:00 AM.',
when: DateTime(2026, 2, 17, 7, 30),
),
DashboardTask(
id: 't-3',
title: 'Finalize Mila birthday shortlist',
description: 'Pick 1 meaningful gift and 1 backup.',
when: DateTime(2026, 2, 18, 20, 0),
),
],
personFacts: <PersonFact>[
PersonFact(
id: 'pf-1',
personId: 'p-ava',
type: CapturedFactType.like,
text: 'Quiet brunch spots and recipe-book stores',
label: 'Weekend preference',
sourceKind: CaptureSourceKind.manual,
createdAt: DateTime(2026, 2, 10, 8, 0),
updatedAt: DateTime(2026, 2, 10, 8, 0),
),
PersonFact(
id: 'pf-2',
personId: 'p-mila',
type: CapturedFactType.giftIdea,
text: 'Vintage ceramic planter in muted green',
label: 'Gift lead',
sourceKind: CaptureSourceKind.manual,
createdAt: DateTime(2026, 2, 12, 14, 25),
updatedAt: DateTime(2026, 2, 12, 14, 25),
),
],
importantDates: <PersonImportantDate>[
PersonImportantDate(
id: 'pd-1',
personId: 'p-mila',
label: 'Birthday',
date: DateTime(2026, 3, 3),
classification: 'birthday',
sourceKind: CaptureSourceKind.manual,
createdAt: DateTime(2026, 2, 12, 14, 30),
updatedAt: DateTime(2026, 2, 12, 14, 30),
),
],
);
}
}