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,456 @@
|
||||
// ignore_for_file: sort_constructors_first
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Supported raw payload shapes entering the app from the share pipeline.
|
||||
enum SharedPayloadType { text, url, textWithUrl }
|
||||
|
||||
/// User-facing assignment outcome for a shared payload.
|
||||
enum ShareAssignmentStatus {
|
||||
needsPersonSelection,
|
||||
assignedDirectly,
|
||||
savedToInbox,
|
||||
discarded,
|
||||
}
|
||||
|
||||
/// Structured capture types that shared or manual content can resolve into.
|
||||
enum CapturedFactType {
|
||||
note,
|
||||
like,
|
||||
dislike,
|
||||
importantDate,
|
||||
giftIdea,
|
||||
placeIdea,
|
||||
activityIdea,
|
||||
misc,
|
||||
}
|
||||
|
||||
/// Tracks how a fact or date entered local storage.
|
||||
enum CaptureSourceKind { manual, shareSheet, shareInbox }
|
||||
|
||||
/// Normalized input payload shared from another app.
|
||||
@immutable
|
||||
class SharedPayload {
|
||||
const SharedPayload({
|
||||
required this.sourceApp,
|
||||
required this.payloadType,
|
||||
required this.rawText,
|
||||
required this.createdAt,
|
||||
required this.receivedAt,
|
||||
required this.platform,
|
||||
this.url,
|
||||
this.sourceDisplayName,
|
||||
this.sourceUserId,
|
||||
this.sourceThreadId,
|
||||
this.parsingHints = const <String>[],
|
||||
this.metadata = const <String, String>{},
|
||||
this.dedupeKey,
|
||||
});
|
||||
|
||||
final String sourceApp;
|
||||
final SharedPayloadType payloadType;
|
||||
final String rawText;
|
||||
final String? url;
|
||||
final DateTime createdAt;
|
||||
final DateTime receivedAt;
|
||||
final String platform;
|
||||
final String? sourceDisplayName;
|
||||
final String? sourceUserId;
|
||||
final String? sourceThreadId;
|
||||
final List<String> parsingHints;
|
||||
final Map<String, String> metadata;
|
||||
final String? dedupeKey;
|
||||
|
||||
SharedPayload copyWith({
|
||||
String? sourceApp,
|
||||
SharedPayloadType? payloadType,
|
||||
String? rawText,
|
||||
String? url,
|
||||
DateTime? createdAt,
|
||||
DateTime? receivedAt,
|
||||
String? platform,
|
||||
String? sourceDisplayName,
|
||||
String? sourceUserId,
|
||||
String? sourceThreadId,
|
||||
List<String>? parsingHints,
|
||||
Map<String, String>? metadata,
|
||||
String? dedupeKey,
|
||||
}) {
|
||||
return SharedPayload(
|
||||
sourceApp: sourceApp ?? this.sourceApp,
|
||||
payloadType: payloadType ?? this.payloadType,
|
||||
rawText: rawText ?? this.rawText,
|
||||
url: url ?? this.url,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
receivedAt: receivedAt ?? this.receivedAt,
|
||||
platform: platform ?? this.platform,
|
||||
sourceDisplayName: sourceDisplayName ?? this.sourceDisplayName,
|
||||
sourceUserId: sourceUserId ?? this.sourceUserId,
|
||||
sourceThreadId: sourceThreadId ?? this.sourceThreadId,
|
||||
parsingHints: parsingHints ?? this.parsingHints,
|
||||
metadata: metadata ?? this.metadata,
|
||||
dedupeKey: dedupeKey ?? this.dedupeKey,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'sourceApp': sourceApp,
|
||||
'payloadType': payloadType.name,
|
||||
'rawText': rawText,
|
||||
'url': url,
|
||||
'createdAt': createdAt.toUtc().toIso8601String(),
|
||||
'receivedAt': receivedAt.toUtc().toIso8601String(),
|
||||
'platform': platform,
|
||||
'sourceDisplayName': sourceDisplayName,
|
||||
'sourceUserId': sourceUserId,
|
||||
'sourceThreadId': sourceThreadId,
|
||||
'parsingHints': parsingHints,
|
||||
'metadata': metadata,
|
||||
'dedupeKey': dedupeKey,
|
||||
};
|
||||
}
|
||||
|
||||
factory SharedPayload.fromJson(Map<String, dynamic> json) {
|
||||
final String payloadTypeName =
|
||||
json['payloadType'] as String? ?? SharedPayloadType.text.name;
|
||||
return SharedPayload(
|
||||
sourceApp: json['sourceApp'] as String? ?? 'share_sheet',
|
||||
payloadType: SharedPayloadType.values.firstWhere(
|
||||
(SharedPayloadType value) => value.name == payloadTypeName,
|
||||
orElse: () => SharedPayloadType.text,
|
||||
),
|
||||
rawText: json['rawText'] as String? ?? '',
|
||||
url: json['url'] as String?,
|
||||
createdAt: DateTime.parse(
|
||||
json['createdAt'] as String? ??
|
||||
DateTime.now().toUtc().toIso8601String(),
|
||||
).toLocal(),
|
||||
receivedAt: DateTime.parse(
|
||||
json['receivedAt'] as String? ??
|
||||
DateTime.now().toUtc().toIso8601String(),
|
||||
).toLocal(),
|
||||
platform: json['platform'] as String? ?? 'unknown',
|
||||
sourceDisplayName: json['sourceDisplayName'] as String?,
|
||||
sourceUserId: json['sourceUserId'] as String?,
|
||||
sourceThreadId: json['sourceThreadId'] as String?,
|
||||
parsingHints: (json['parsingHints'] as List<dynamic>? ?? <dynamic>[])
|
||||
.map((dynamic item) => '$item')
|
||||
.toList(growable: false),
|
||||
metadata:
|
||||
(json['metadata'] as Map<dynamic, dynamic>? ?? <dynamic, dynamic>{})
|
||||
.map(
|
||||
(dynamic key, dynamic value) =>
|
||||
MapEntry<String, String>('$key', '$value'),
|
||||
),
|
||||
dedupeKey: json['dedupeKey'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draft extracted from a shared payload before it becomes a persistent fact.
|
||||
@immutable
|
||||
class CapturedFactDraft {
|
||||
const CapturedFactDraft({
|
||||
required this.type,
|
||||
required this.text,
|
||||
this.label,
|
||||
this.dateValue,
|
||||
this.confidence,
|
||||
this.isSensitive = false,
|
||||
this.needsReview = false,
|
||||
});
|
||||
|
||||
final CapturedFactType type;
|
||||
final String text;
|
||||
final String? label;
|
||||
final DateTime? dateValue;
|
||||
final double? confidence;
|
||||
final bool isSensitive;
|
||||
final bool needsReview;
|
||||
|
||||
CapturedFactDraft copyWith({
|
||||
CapturedFactType? type,
|
||||
String? text,
|
||||
String? label,
|
||||
DateTime? dateValue,
|
||||
double? confidence,
|
||||
bool? isSensitive,
|
||||
bool? needsReview,
|
||||
}) {
|
||||
return CapturedFactDraft(
|
||||
type: type ?? this.type,
|
||||
text: text ?? this.text,
|
||||
label: label ?? this.label,
|
||||
dateValue: dateValue ?? this.dateValue,
|
||||
confidence: confidence ?? this.confidence,
|
||||
isSensitive: isSensitive ?? this.isSensitive,
|
||||
needsReview: needsReview ?? this.needsReview,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'type': type.name,
|
||||
'text': text,
|
||||
'label': label,
|
||||
'dateValue': dateValue?.toUtc().toIso8601String(),
|
||||
'confidence': confidence,
|
||||
'isSensitive': isSensitive,
|
||||
'needsReview': needsReview,
|
||||
};
|
||||
}
|
||||
|
||||
factory CapturedFactDraft.fromJson(Map<String, dynamic> json) {
|
||||
final String typeName =
|
||||
json['type'] as String? ?? CapturedFactType.note.name;
|
||||
return CapturedFactDraft(
|
||||
type: CapturedFactType.values.firstWhere(
|
||||
(CapturedFactType value) => value.name == typeName,
|
||||
orElse: () => CapturedFactType.note,
|
||||
),
|
||||
text: json['text'] as String? ?? '',
|
||||
label: json['label'] as String?,
|
||||
dateValue: json['dateValue'] == null
|
||||
? null
|
||||
: DateTime.parse(json['dateValue'] as String).toLocal(),
|
||||
confidence: (json['confidence'] as num?)?.toDouble(),
|
||||
isSensitive: json['isSensitive'] as bool? ?? false,
|
||||
needsReview: json['needsReview'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Durable record of a share that was already attached to a person.
|
||||
@immutable
|
||||
class SharedMessageEntry {
|
||||
const SharedMessageEntry({
|
||||
required this.id,
|
||||
required this.profileId,
|
||||
required this.payload,
|
||||
required this.importedAt,
|
||||
required this.resolvedAutomatically,
|
||||
this.assignmentStatus = ShareAssignmentStatus.assignedDirectly,
|
||||
this.draft,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String profileId;
|
||||
final SharedPayload payload;
|
||||
final DateTime importedAt;
|
||||
final bool resolvedAutomatically;
|
||||
final ShareAssignmentStatus assignmentStatus;
|
||||
final CapturedFactDraft? draft;
|
||||
|
||||
String get sourceApp => payload.sourceApp;
|
||||
String get messageText => payload.rawText;
|
||||
DateTime get sharedAt => payload.createdAt;
|
||||
SharedPayloadType get payloadType => payload.payloadType;
|
||||
String? get url => payload.url;
|
||||
String? get sourceDisplayName => payload.sourceDisplayName;
|
||||
String? get sourceUserId => payload.sourceUserId;
|
||||
String? get sourceThreadId => payload.sourceThreadId;
|
||||
|
||||
SharedMessageEntry copyWith({
|
||||
String? id,
|
||||
String? profileId,
|
||||
SharedPayload? payload,
|
||||
DateTime? importedAt,
|
||||
bool? resolvedAutomatically,
|
||||
ShareAssignmentStatus? assignmentStatus,
|
||||
CapturedFactDraft? draft,
|
||||
}) {
|
||||
return SharedMessageEntry(
|
||||
id: id ?? this.id,
|
||||
profileId: profileId ?? this.profileId,
|
||||
payload: payload ?? this.payload,
|
||||
importedAt: importedAt ?? this.importedAt,
|
||||
resolvedAutomatically:
|
||||
resolvedAutomatically ?? this.resolvedAutomatically,
|
||||
assignmentStatus: assignmentStatus ?? this.assignmentStatus,
|
||||
draft: draft ?? this.draft,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'id': id,
|
||||
'profileId': profileId,
|
||||
'payload': payload.toJson(),
|
||||
'importedAt': importedAt.toUtc().toIso8601String(),
|
||||
'resolvedAutomatically': resolvedAutomatically,
|
||||
'assignmentStatus': assignmentStatus.name,
|
||||
'draft': draft?.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
factory SharedMessageEntry.fromJson(Map<String, dynamic> json) {
|
||||
final String assignmentStatusName =
|
||||
json['assignmentStatus'] as String? ??
|
||||
ShareAssignmentStatus.assignedDirectly.name;
|
||||
final SharedPayload payload = json['payload'] is Map<String, dynamic>
|
||||
? SharedPayload.fromJson(json['payload'] as Map<String, dynamic>)
|
||||
: SharedPayload(
|
||||
sourceApp: json['sourceApp'] as String? ?? 'share_sheet',
|
||||
payloadType: SharedPayloadType.text,
|
||||
rawText: json['messageText'] as String? ?? '',
|
||||
createdAt: DateTime.parse(
|
||||
json['sharedAt'] as String? ??
|
||||
DateTime.now().toUtc().toIso8601String(),
|
||||
).toLocal(),
|
||||
receivedAt: DateTime.parse(
|
||||
json['importedAt'] as String? ??
|
||||
DateTime.now().toUtc().toIso8601String(),
|
||||
).toLocal(),
|
||||
platform: 'legacy',
|
||||
sourceDisplayName: json['sourceDisplayName'] as String?,
|
||||
sourceUserId: json['sourceUserId'] as String?,
|
||||
sourceThreadId: json['sourceThreadId'] as String?,
|
||||
);
|
||||
return SharedMessageEntry(
|
||||
id: json['id'] as String,
|
||||
profileId: json['profileId'] as String,
|
||||
payload: payload,
|
||||
importedAt: DateTime.parse(json['importedAt'] as String).toLocal(),
|
||||
resolvedAutomatically: json['resolvedAutomatically'] as bool? ?? true,
|
||||
assignmentStatus: ShareAssignmentStatus.values.firstWhere(
|
||||
(ShareAssignmentStatus value) => value.name == assignmentStatusName,
|
||||
orElse: () => ShareAssignmentStatus.assignedDirectly,
|
||||
),
|
||||
draft: json['draft'] is Map<String, dynamic>
|
||||
? CapturedFactDraft.fromJson(json['draft'] as Map<String, dynamic>)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Why the share flow parked a payload in the inbox instead of saving directly.
|
||||
enum SharedInboxReason {
|
||||
ambiguousProfileMatch,
|
||||
nearProfileConflict,
|
||||
missingIdentity,
|
||||
}
|
||||
|
||||
/// Unresolved share payload waiting for manual triage.
|
||||
@immutable
|
||||
class SharedInboxEntry {
|
||||
const SharedInboxEntry({
|
||||
required this.id,
|
||||
required this.payload,
|
||||
required this.reason,
|
||||
required this.candidateProfileIds,
|
||||
required this.normalizedDisplayName,
|
||||
this.assignmentStatus = ShareAssignmentStatus.needsPersonSelection,
|
||||
this.targetPersonId,
|
||||
this.sourceFingerprint,
|
||||
this.draft,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final SharedPayload payload;
|
||||
final SharedInboxReason reason;
|
||||
final List<String> candidateProfileIds;
|
||||
final String normalizedDisplayName;
|
||||
final ShareAssignmentStatus assignmentStatus;
|
||||
final String? targetPersonId;
|
||||
final String? sourceFingerprint;
|
||||
final CapturedFactDraft? draft;
|
||||
|
||||
String get sourceApp => payload.sourceApp;
|
||||
String get messageText => payload.rawText;
|
||||
DateTime get sharedAt => payload.createdAt;
|
||||
DateTime get receivedAt => payload.receivedAt;
|
||||
SharedPayloadType get payloadType => payload.payloadType;
|
||||
String? get url => payload.url;
|
||||
String? get sourceDisplayName => payload.sourceDisplayName;
|
||||
String? get sourceUserId => payload.sourceUserId;
|
||||
String? get sourceThreadId => payload.sourceThreadId;
|
||||
|
||||
SharedInboxEntry copyWith({
|
||||
String? id,
|
||||
SharedPayload? payload,
|
||||
SharedInboxReason? reason,
|
||||
List<String>? candidateProfileIds,
|
||||
String? normalizedDisplayName,
|
||||
ShareAssignmentStatus? assignmentStatus,
|
||||
String? targetPersonId,
|
||||
String? sourceFingerprint,
|
||||
CapturedFactDraft? draft,
|
||||
}) {
|
||||
return SharedInboxEntry(
|
||||
id: id ?? this.id,
|
||||
payload: payload ?? this.payload,
|
||||
reason: reason ?? this.reason,
|
||||
candidateProfileIds: candidateProfileIds ?? this.candidateProfileIds,
|
||||
normalizedDisplayName:
|
||||
normalizedDisplayName ?? this.normalizedDisplayName,
|
||||
assignmentStatus: assignmentStatus ?? this.assignmentStatus,
|
||||
targetPersonId: targetPersonId ?? this.targetPersonId,
|
||||
sourceFingerprint: sourceFingerprint ?? this.sourceFingerprint,
|
||||
draft: draft ?? this.draft,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'id': id,
|
||||
'payload': payload.toJson(),
|
||||
'reason': reason.name,
|
||||
'candidateProfileIds': candidateProfileIds,
|
||||
'normalizedDisplayName': normalizedDisplayName,
|
||||
'assignmentStatus': assignmentStatus.name,
|
||||
'targetPersonId': targetPersonId,
|
||||
'sourceFingerprint': sourceFingerprint,
|
||||
'draft': draft?.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
factory SharedInboxEntry.fromJson(Map<String, dynamic> json) {
|
||||
final String reasonName =
|
||||
json['reason'] as String? ?? SharedInboxReason.missingIdentity.name;
|
||||
final String assignmentStatusName =
|
||||
json['assignmentStatus'] as String? ??
|
||||
ShareAssignmentStatus.needsPersonSelection.name;
|
||||
final SharedPayload payload = json['payload'] is Map<String, dynamic>
|
||||
? SharedPayload.fromJson(json['payload'] as Map<String, dynamic>)
|
||||
: SharedPayload(
|
||||
sourceApp: json['sourceApp'] as String? ?? 'share_sheet',
|
||||
payloadType: SharedPayloadType.text,
|
||||
rawText: json['messageText'] as String? ?? '',
|
||||
createdAt: DateTime.parse(
|
||||
json['sharedAt'] as String? ??
|
||||
DateTime.now().toUtc().toIso8601String(),
|
||||
).toLocal(),
|
||||
receivedAt: DateTime.parse(
|
||||
json['receivedAt'] as String? ??
|
||||
DateTime.now().toUtc().toIso8601String(),
|
||||
).toLocal(),
|
||||
platform: 'legacy',
|
||||
sourceDisplayName: json['sourceDisplayName'] as String?,
|
||||
sourceUserId: json['sourceUserId'] as String?,
|
||||
sourceThreadId: json['sourceThreadId'] as String?,
|
||||
);
|
||||
return SharedInboxEntry(
|
||||
id: json['id'] as String,
|
||||
payload: payload,
|
||||
reason: SharedInboxReason.values.firstWhere(
|
||||
(SharedInboxReason item) => item.name == reasonName,
|
||||
orElse: () => SharedInboxReason.missingIdentity,
|
||||
),
|
||||
candidateProfileIds:
|
||||
(json['candidateProfileIds'] as List<dynamic>? ?? <dynamic>[])
|
||||
.map((dynamic id) => '$id')
|
||||
.toList(growable: false),
|
||||
normalizedDisplayName: json['normalizedDisplayName'] as String? ?? '',
|
||||
assignmentStatus: ShareAssignmentStatus.values.firstWhere(
|
||||
(ShareAssignmentStatus value) => value.name == assignmentStatusName,
|
||||
orElse: () => ShareAssignmentStatus.needsPersonSelection,
|
||||
),
|
||||
targetPersonId: json['targetPersonId'] as String?,
|
||||
sourceFingerprint: json['sourceFingerprint'] as String?,
|
||||
draft: json['draft'] is Map<String, dynamic>
|
||||
? CapturedFactDraft.fromJson(json['draft'] as Map<String, dynamic>)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user