f655adfbea
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.
539 lines
17 KiB
Dart
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),
|
|
);
|
|
}
|
|
}
|