Files
Rijad Zuzo f655adfbea 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.
2026-05-17 00:17:20 +02:00

539 lines
17 KiB
Dart

// 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),
);
}
}