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.
457 lines
15 KiB
Dart
457 lines
15 KiB
Dart
// 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,
|
|
);
|
|
}
|
|
}
|