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