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,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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user