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,12 @@
|
||||
# People Slice
|
||||
|
||||
This is the richest slice in the app and the main relationship workspace.
|
||||
|
||||
- `domain/person_models.dart`: person profile, facts, dates, preference signals,
|
||||
and source-link models.
|
||||
- `presentation/people_view.dart`: main people workspace screen.
|
||||
- `presentation/providers/people_ui_state.dart`: list/search/filter selection
|
||||
state for the people workspace.
|
||||
|
||||
If you want to extend person intelligence, start in `domain/` for data shape
|
||||
and then wire the UI through `presentation/`.
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
# People Presentation
|
||||
|
||||
This folder holds the people workspace UI.
|
||||
|
||||
`people_view.dart` is now the composition root for the slice, with the heavy UI
|
||||
split into library parts:
|
||||
|
||||
- `people_view_list.dart`: list, filters, empty states, and person cards.
|
||||
- `people_view_detail.dart`: detail workspace, insights, and inline editors.
|
||||
- `people_view_dialogs.dart`: modal editors and quick-capture flows.
|
||||
- `providers/people_ui_state.dart`: search, selection, and sorting state.
|
||||
|
||||
Add new people UI inside this folder. Do not move slice-specific widgets back
|
||||
into shared horizontal folders unless they are genuinely cross-feature.
|
||||
@@ -0,0 +1,627 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/core/config/app_theme.dart';
|
||||
import 'package:relationship_saver/features/local/local_models.dart';
|
||||
import 'package:relationship_saver/features/local/local_repository.dart';
|
||||
import 'package:relationship_saver/features/people/presentation/providers/people_ui_state.dart';
|
||||
import 'package:relationship_saver/features/shared/frosted_card.dart';
|
||||
|
||||
part 'people_view_detail.dart';
|
||||
part 'people_view_dialogs.dart';
|
||||
part 'people_view_list.dart';
|
||||
|
||||
enum _PersonQuickAction { capture, note, idea, reminder }
|
||||
|
||||
class PeopleView extends ConsumerWidget {
|
||||
const PeopleView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final AsyncValue<LocalDataState> localData = ref.watch(
|
||||
localRepositoryProvider,
|
||||
);
|
||||
final String searchQuery = ref.watch(peopleSearchQueryProvider);
|
||||
final String? relationshipFilter = ref.watch(
|
||||
peopleRelationshipFilterProvider,
|
||||
);
|
||||
final PeopleSortOption sortOption = ref.watch(peopleSortOptionProvider);
|
||||
|
||||
return localData.when(
|
||||
data: (LocalDataState data) {
|
||||
final List<PersonProfile> allPeople = data.people;
|
||||
if (allPeople.isEmpty) {
|
||||
return _EmptyPeopleView(onAdd: () => _handleAddPerson(context, ref));
|
||||
}
|
||||
|
||||
final List<PersonProfile> people = _applyPeopleFilters(
|
||||
allPeople,
|
||||
searchQuery: searchQuery,
|
||||
relationshipFilter: relationshipFilter,
|
||||
sortOption: sortOption,
|
||||
);
|
||||
final List<String> relationshipOptions = _relationshipOptions(
|
||||
allPeople,
|
||||
);
|
||||
|
||||
if (people.isEmpty) {
|
||||
return _FilteredPeopleEmptyView(
|
||||
onClear: () {
|
||||
ref.read(peopleSearchQueryProvider.notifier).set('');
|
||||
ref.read(peopleRelationshipFilterProvider.notifier).set(null);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final String selectedId =
|
||||
ref.watch(selectedPersonIdProvider) ?? people.first.id;
|
||||
final PersonProfile selected = people.firstWhere(
|
||||
(PersonProfile person) => person.id == selectedId,
|
||||
orElse: () => people.first,
|
||||
);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
final bool isCompact = constraints.maxWidth < 860;
|
||||
if (isCompact) {
|
||||
return _buildCompactLayout(
|
||||
context,
|
||||
ref,
|
||||
data,
|
||||
people,
|
||||
selected,
|
||||
relationshipOptions,
|
||||
);
|
||||
}
|
||||
return _buildWideLayout(
|
||||
context,
|
||||
ref,
|
||||
data,
|
||||
people,
|
||||
selected,
|
||||
relationshipOptions,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (Object error, StackTrace stackTrace) {
|
||||
return const Center(child: Text('Unable to load people'));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<PersonProfile> _applyPeopleFilters(
|
||||
List<PersonProfile> people, {
|
||||
required String searchQuery,
|
||||
required String? relationshipFilter,
|
||||
required PeopleSortOption sortOption,
|
||||
}) {
|
||||
final String query = searchQuery.trim().toLowerCase();
|
||||
final String? relationship = relationshipFilter?.trim().toLowerCase();
|
||||
|
||||
final List<PersonProfile> filtered = people
|
||||
.where((PersonProfile person) {
|
||||
if (relationship != null &&
|
||||
relationship.isNotEmpty &&
|
||||
person.relationship.trim().toLowerCase() != relationship) {
|
||||
return false;
|
||||
}
|
||||
if (query.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
final String haystack = <String>[
|
||||
person.name,
|
||||
...person.aliases,
|
||||
person.relationship,
|
||||
person.location ?? '',
|
||||
person.notes,
|
||||
...person.tags,
|
||||
].join(' ').toLowerCase();
|
||||
return haystack.contains(query);
|
||||
})
|
||||
.toList(growable: true);
|
||||
|
||||
filtered.sort((PersonProfile a, PersonProfile b) {
|
||||
switch (sortOption) {
|
||||
case PeopleSortOption.affinity:
|
||||
final int score = b.affinityScore.compareTo(a.affinityScore);
|
||||
if (score != 0) {
|
||||
return score;
|
||||
}
|
||||
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
||||
case PeopleSortOption.nextMoment:
|
||||
final int next = a.nextMoment.compareTo(b.nextMoment);
|
||||
if (next != 0) {
|
||||
return next;
|
||||
}
|
||||
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
||||
case PeopleSortOption.alphabetical:
|
||||
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
List<String> _relationshipOptions(List<PersonProfile> people) {
|
||||
final Set<String> seen = <String>{};
|
||||
final List<String> values = <String>[];
|
||||
for (final PersonProfile person in people) {
|
||||
final String relationship = person.relationship.trim();
|
||||
if (relationship.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
final String key = relationship.toLowerCase();
|
||||
if (seen.add(key)) {
|
||||
values.add(relationship);
|
||||
}
|
||||
}
|
||||
values.sort(
|
||||
(String a, String b) => a.toLowerCase().compareTo(b.toLowerCase()),
|
||||
);
|
||||
return values;
|
||||
}
|
||||
|
||||
Future<void> _handleAddPerson(BuildContext context, WidgetRef ref) async {
|
||||
final List<PersonProfile> existingPeople =
|
||||
ref.read(localRepositoryProvider).asData?.value.people ??
|
||||
const <PersonProfile>[];
|
||||
final _PersonDraft? draft = await _showPersonEditor(
|
||||
context,
|
||||
title: 'Add Person',
|
||||
existingPeople: existingPeople,
|
||||
);
|
||||
|
||||
if (draft == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.addPerson(
|
||||
name: draft.name,
|
||||
relationship: draft.relationship,
|
||||
notes: draft.notes,
|
||||
tags: draft.tags,
|
||||
location: draft.location,
|
||||
aliases: draft.aliases,
|
||||
);
|
||||
|
||||
final LocalDataState? latest = ref
|
||||
.read(localRepositoryProvider)
|
||||
.asData
|
||||
?.value;
|
||||
if (latest == null) {
|
||||
return;
|
||||
}
|
||||
for (final PersonProfile person in latest.people) {
|
||||
final bool locationMatches =
|
||||
(person.location ?? '').trim() == (draft.location ?? '').trim();
|
||||
if (person.name == draft.name &&
|
||||
person.relationship == draft.relationship &&
|
||||
locationMatches) {
|
||||
ref.read(selectedPersonIdProvider.notifier).select(person.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleEditPerson(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
PersonProfile person,
|
||||
) async {
|
||||
final List<PersonProfile> existingPeople =
|
||||
ref.read(localRepositoryProvider).asData?.value.people ??
|
||||
const <PersonProfile>[];
|
||||
final _PersonDraft? draft = await _showPersonEditor(
|
||||
context,
|
||||
title: 'Edit Person',
|
||||
initial: _PersonDraft(
|
||||
name: person.name,
|
||||
relationship: person.relationship,
|
||||
notes: person.notes,
|
||||
tags: person.tags,
|
||||
aliases: person.aliases,
|
||||
location: person.location,
|
||||
),
|
||||
existingPeople: existingPeople,
|
||||
editingPersonId: person.id,
|
||||
);
|
||||
|
||||
if (draft == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.updatePerson(
|
||||
person.copyWith(
|
||||
name: draft.name,
|
||||
relationship: draft.relationship,
|
||||
notes: draft.notes,
|
||||
tags: draft.tags,
|
||||
aliases: draft.aliases,
|
||||
location: draft.location,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleDeletePerson(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String personId,
|
||||
) async {
|
||||
final bool? confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Delete person?'),
|
||||
content: const Text(
|
||||
'This will remove the person and related moments from local storage.',
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (confirmed != true) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(localRepositoryProvider.notifier).deletePerson(personId);
|
||||
ref.read(selectedPersonIdProvider.notifier).clear();
|
||||
}
|
||||
|
||||
Widget _buildWideLayout(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
LocalDataState data,
|
||||
List<PersonProfile> people,
|
||||
PersonProfile selected,
|
||||
List<String> relationshipOptions,
|
||||
) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
_PeopleHeader(
|
||||
onAdd: () => _handleAddPerson(context, ref),
|
||||
onMerge: () => _handleMergePeople(context, ref, people),
|
||||
compact: false,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_PeopleListControls(
|
||||
compact: false,
|
||||
relationshipOptions: relationshipOptions,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
itemCount: people.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final PersonProfile person = people[index];
|
||||
return _PersonListItem(
|
||||
person: person,
|
||||
selectedId: selected.id,
|
||||
compact: false,
|
||||
onSelect: () {
|
||||
ref
|
||||
.read(selectedPersonIdProvider.notifier)
|
||||
.select(person.id);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 18),
|
||||
Expanded(
|
||||
flex: 6,
|
||||
child: FrostedCard(
|
||||
child: SingleChildScrollView(
|
||||
child: _PersonDetail(
|
||||
person: selected,
|
||||
data: data,
|
||||
onEdit: () => _handleEditPerson(context, ref, selected),
|
||||
onDelete: () =>
|
||||
_handleDeletePerson(context, ref, selected.id),
|
||||
onQuickAction: (_PersonQuickAction action) =>
|
||||
_handleQuickAction(context, ref, selected, action),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompactLayout(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
LocalDataState data,
|
||||
List<PersonProfile> people,
|
||||
PersonProfile selected,
|
||||
List<String> relationshipOptions,
|
||||
) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 16, 18, 20),
|
||||
child: ListView(
|
||||
children: <Widget>[
|
||||
_PeopleHeader(
|
||||
onAdd: () => _handleAddPerson(context, ref),
|
||||
onMerge: () => _handleMergePeople(context, ref, people),
|
||||
compact: true,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_PeopleListControls(
|
||||
compact: true,
|
||||
relationshipOptions: relationshipOptions,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
FrostedCard(
|
||||
child: _PersonDetail(
|
||||
person: selected,
|
||||
data: data,
|
||||
compact: true,
|
||||
onEdit: () => _handleEditPerson(context, ref, selected),
|
||||
onDelete: () => _handleDeletePerson(context, ref, selected.id),
|
||||
onQuickAction: (_PersonQuickAction action) =>
|
||||
_handleQuickAction(context, ref, selected, action),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'All profiles',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${people.length}',
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
...people.map(
|
||||
(PersonProfile person) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _PersonListItem(
|
||||
person: person,
|
||||
selectedId: selected.id,
|
||||
compact: true,
|
||||
onSelect: () {
|
||||
ref.read(selectedPersonIdProvider.notifier).select(person.id);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleQuickAction(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
PersonProfile person,
|
||||
_PersonQuickAction action,
|
||||
) async {
|
||||
switch (action) {
|
||||
case _PersonQuickAction.capture:
|
||||
final String? summary = await _showQuickTextCaptureDialog(
|
||||
context,
|
||||
title: 'Add Capture',
|
||||
hintText: 'What happened in this interaction?',
|
||||
);
|
||||
if (summary == null) {
|
||||
return;
|
||||
}
|
||||
await ref
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.addMoment(personId: person.id, summary: summary, type: 'capture');
|
||||
return;
|
||||
case _PersonQuickAction.note:
|
||||
final String? note = await _showQuickTextCaptureDialog(
|
||||
context,
|
||||
title: 'Add Note',
|
||||
hintText: 'Write a quick context note...',
|
||||
);
|
||||
if (note == null) {
|
||||
return;
|
||||
}
|
||||
await ref
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.addMoment(personId: person.id, summary: note, type: 'note');
|
||||
return;
|
||||
case _PersonQuickAction.idea:
|
||||
final _QuickIdeaDraft? idea = await showDialog<_QuickIdeaDraft>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => const _QuickIdeaDialog(),
|
||||
);
|
||||
if (idea == null) {
|
||||
return;
|
||||
}
|
||||
await ref
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.addIdea(
|
||||
type: idea.type,
|
||||
title: idea.title,
|
||||
details: idea.details,
|
||||
personId: person.id,
|
||||
);
|
||||
return;
|
||||
case _PersonQuickAction.reminder:
|
||||
final _QuickReminderDraft? reminder =
|
||||
await showDialog<_QuickReminderDraft>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => const _QuickReminderDialog(),
|
||||
);
|
||||
if (reminder == null) {
|
||||
return;
|
||||
}
|
||||
await ref
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.addReminder(
|
||||
title: reminder.title,
|
||||
cadence: reminder.cadence,
|
||||
nextAt: reminder.nextAt,
|
||||
personId: person.id,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleMergePeople(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
List<PersonProfile> people,
|
||||
) async {
|
||||
if (people.length < 2) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Need at least two profiles to merge.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final List<PersonProfile> duplicateCandidates = _duplicateCandidates(
|
||||
people,
|
||||
);
|
||||
if (duplicateCandidates.length < 2) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'No duplicate-name profiles found. Edit names first if needed.',
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final _MergePeopleDraft? draft = await showDialog<_MergePeopleDraft>(
|
||||
context: context,
|
||||
builder: (BuildContext context) =>
|
||||
_MergePeopleDialog(people: duplicateCandidates),
|
||||
);
|
||||
if (draft == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.mergePersonProfiles(
|
||||
sourcePersonId: draft.sourcePersonId,
|
||||
targetPersonId: draft.targetPersonId,
|
||||
);
|
||||
ref.read(selectedPersonIdProvider.notifier).select(draft.targetPersonId);
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Profiles merged successfully.')),
|
||||
);
|
||||
}
|
||||
|
||||
List<PersonProfile> _duplicateCandidates(List<PersonProfile> people) {
|
||||
final Map<String, List<PersonProfile>> grouped =
|
||||
<String, List<PersonProfile>>{};
|
||||
for (final PersonProfile person in people) {
|
||||
final String key = _normalizeNameKey(person.name);
|
||||
if (key.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
grouped.putIfAbsent(key, () => <PersonProfile>[]).add(person);
|
||||
}
|
||||
final List<PersonProfile> result = <PersonProfile>[];
|
||||
for (final List<PersonProfile> group in grouped.values) {
|
||||
if (group.length > 1) {
|
||||
result.addAll(group);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
String _normalizeNameKey(String value) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replaceAll(RegExp(r'[^a-z0-9]+'), ' ')
|
||||
.replaceAll(RegExp(r'\s+'), ' ');
|
||||
}
|
||||
|
||||
Future<_PersonDraft?> _showPersonEditor(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
_PersonDraft? initial,
|
||||
List<PersonProfile> existingPeople = const <PersonProfile>[],
|
||||
String? editingPersonId,
|
||||
}) {
|
||||
final bool compact = MediaQuery.sizeOf(context).width < 720;
|
||||
if (compact) {
|
||||
return showModalBottomSheet<_PersonDraft>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (BuildContext context) {
|
||||
return _PersonEditorBottomSheet(
|
||||
title: title,
|
||||
initial: initial,
|
||||
existingPeople: existingPeople,
|
||||
editingPersonId: editingPersonId,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return showDialog<_PersonDraft>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return _PersonEditorDialog(
|
||||
title: title,
|
||||
initial: initial,
|
||||
existingPeople: existingPeople,
|
||||
editingPersonId: editingPersonId,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _showQuickTextCaptureDialog(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String hintText,
|
||||
}) async {
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return _QuickTextCaptureDialog(title: title, hintText: hintText);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,528 @@
|
||||
part of 'people_view.dart';
|
||||
|
||||
class _EmptyPeopleView extends StatelessWidget {
|
||||
const _EmptyPeopleView({required this.onAdd});
|
||||
|
||||
final VoidCallback onAdd;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: FrostedCard(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'No people yet',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Add the first relationship profile to start tracking moments.',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: onAdd,
|
||||
icon: const Icon(Icons.person_add_alt_1_rounded),
|
||||
label: const Text('Add Person'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FilteredPeopleEmptyView extends StatelessWidget {
|
||||
const _FilteredPeopleEmptyView({required this.onClear});
|
||||
|
||||
final VoidCallback onClear;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: FrostedCard(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
Icons.search_off_rounded,
|
||||
size: 34,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'No matching people',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Try another search, relationship filter, or clear the current filters.',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onClear,
|
||||
icon: const Icon(Icons.restart_alt_rounded),
|
||||
label: const Text('Clear Filters'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PeopleListControls extends ConsumerWidget {
|
||||
const _PeopleListControls({
|
||||
required this.compact,
|
||||
required this.relationshipOptions,
|
||||
});
|
||||
|
||||
final bool compact;
|
||||
final List<String> relationshipOptions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final String query = ref.watch(peopleSearchQueryProvider);
|
||||
final String? selectedRelationship = ref.watch(
|
||||
peopleRelationshipFilterProvider,
|
||||
);
|
||||
final PeopleSortOption sortOption = ref.watch(peopleSortOptionProvider);
|
||||
|
||||
return FrostedCard(
|
||||
padding: EdgeInsets.all(compact ? 12 : 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
key: ValueKey<String>('people-search-$query'),
|
||||
initialValue: query,
|
||||
onChanged: (String value) {
|
||||
ref.read(peopleSearchQueryProvider.notifier).set(value);
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search name, tags, notes, location',
|
||||
prefixIcon: Icon(Icons.search_rounded),
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
PopupMenuButton<PeopleSortOption>(
|
||||
tooltip: 'Sort',
|
||||
onSelected: (PeopleSortOption value) {
|
||||
ref.read(peopleSortOptionProvider.notifier).set(value);
|
||||
},
|
||||
itemBuilder: (BuildContext context) =>
|
||||
<PopupMenuEntry<PeopleSortOption>>[
|
||||
const PopupMenuItem<PeopleSortOption>(
|
||||
value: PeopleSortOption.affinity,
|
||||
child: Text('Sort by affinity'),
|
||||
),
|
||||
const PopupMenuItem<PeopleSortOption>(
|
||||
value: PeopleSortOption.nextMoment,
|
||||
child: Text('Sort by next moment'),
|
||||
),
|
||||
const PopupMenuItem<PeopleSortOption>(
|
||||
value: PeopleSortOption.alphabetical,
|
||||
child: Text('Sort A-Z'),
|
||||
),
|
||||
],
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF3F8FB),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
const Icon(Icons.sort_rounded, size: 18),
|
||||
if (!compact) ...<Widget>[
|
||||
const SizedBox(width: 6),
|
||||
Text(switch (sortOption) {
|
||||
PeopleSortOption.affinity => 'Affinity',
|
||||
PeopleSortOption.nextMoment => 'Next',
|
||||
PeopleSortOption.alphabetical => 'A-Z',
|
||||
}),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
_FilterChipButton(
|
||||
label: 'All',
|
||||
selected: selectedRelationship == null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(peopleRelationshipFilterProvider.notifier)
|
||||
.set(null);
|
||||
},
|
||||
),
|
||||
...relationshipOptions.map(
|
||||
(String relationship) => Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: _FilterChipButton(
|
||||
label: relationship,
|
||||
selected:
|
||||
selectedRelationship?.toLowerCase() ==
|
||||
relationship.toLowerCase(),
|
||||
onTap: () {
|
||||
final PeopleRelationshipFilterNotifier controller = ref
|
||||
.read(peopleRelationshipFilterProvider.notifier);
|
||||
final bool isSelected =
|
||||
selectedRelationship?.toLowerCase() ==
|
||||
relationship.toLowerCase();
|
||||
controller.set(isSelected ? null : relationship);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FilterChipButton extends StatelessWidget {
|
||||
const _FilterChipButton({
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? const Color(0xFFEAF7FB) : const Color(0xFFF4F8FB),
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? const Color(0xFFB8E4EE)
|
||||
: Colors.white.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: selected ? AppTheme.primary : AppTheme.textSecondary,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PeopleHeader extends StatelessWidget {
|
||||
const _PeopleHeader({
|
||||
required this.onAdd,
|
||||
required this.onMerge,
|
||||
required this.compact,
|
||||
});
|
||||
|
||||
final VoidCallback onAdd;
|
||||
final VoidCallback onMerge;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TextTheme textTheme = Theme.of(context).textTheme;
|
||||
if (compact) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text('People', style: textTheme.headlineMedium),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Track what matters to each relationship and follow through.',
|
||||
style: textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: onAdd,
|
||||
icon: const Icon(Icons.person_add_alt_1_rounded),
|
||||
label: const Text('Add person'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onMerge,
|
||||
icon: const Icon(Icons.merge_type_rounded),
|
||||
label: const Text('Merge duplicates'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text('People', style: textTheme.headlineMedium),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Track what matters to each relationship and follow through.',
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: onAdd,
|
||||
icon: const Icon(Icons.person_add_alt_1_rounded),
|
||||
label: const Text('Add'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onMerge,
|
||||
icon: const Icon(Icons.merge_type_rounded),
|
||||
label: const Text('Merge'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PersonListItem extends StatelessWidget {
|
||||
const _PersonListItem({
|
||||
required this.person,
|
||||
required this.selectedId,
|
||||
required this.compact,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
final PersonProfile person;
|
||||
final String selectedId;
|
||||
final bool compact;
|
||||
final VoidCallback onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool isSelected = person.id == selectedId;
|
||||
return InkWell(
|
||||
onTap: onSelect,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: FrostedCard(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
_AvatarSeed(name: person.name),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
person.name,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
person.relationship,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ScorePill(score: person.affinityScore),
|
||||
if (isSelected)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 10),
|
||||
child: Icon(Icons.check_circle, color: Color(0xFF1AB6C8)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: <Widget>[
|
||||
_InlineMetaPill(
|
||||
icon: Icons.schedule_rounded,
|
||||
label: _dateTimeLabel(person.nextMoment),
|
||||
),
|
||||
if ((person.location ?? '').trim().isNotEmpty)
|
||||
_InlineMetaPill(
|
||||
icon: Icons.location_on_outlined,
|
||||
label: person.location!.trim(),
|
||||
),
|
||||
...person.tags
|
||||
.take(compact ? 2 : 3)
|
||||
.map(
|
||||
(String tag) => _InlineMetaPill(
|
||||
icon: Icons.sell_outlined,
|
||||
label: tag,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InlineMetaPill extends StatelessWidget {
|
||||
const _InlineMetaPill({required this.icon, required this.label});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF1F7FA),
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
border: Border.all(color: Colors.white),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Icon(icon, size: 14, color: AppTheme.textSecondary),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AvatarSeed extends StatelessWidget {
|
||||
const _AvatarSeed({required this.name, this.size = 48});
|
||||
|
||||
final String name;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final String initials = name
|
||||
.split(' ')
|
||||
.where((String part) => part.isNotEmpty)
|
||||
.take(2)
|
||||
.map((String part) => part[0].toUpperCase())
|
||||
.join();
|
||||
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(size / 2),
|
||||
gradient: const LinearGradient(
|
||||
colors: <Color>[Color(0xFF1AB6C8), Color(0xFF2274E0)],
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
initials,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScorePill extends StatelessWidget {
|
||||
const _ScorePill({required this.score});
|
||||
|
||||
final int score;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEEF7F3),
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
),
|
||||
child: Text(
|
||||
'$score%',
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: const Color(0xFF1D9C66),
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _dateTimeLabel(DateTime value) {
|
||||
final DateTime local = value.toLocal();
|
||||
final String month = local.month.toString().padLeft(2, '0');
|
||||
final String day = local.day.toString().padLeft(2, '0');
|
||||
final String hour = local.hour.toString().padLeft(2, '0');
|
||||
final String minute = local.minute.toString().padLeft(2, '0');
|
||||
return '$month/$day $hour:$minute';
|
||||
}
|
||||
|
||||
String _shortDateTimeLabel(DateTime value) {
|
||||
final DateTime local = value.toLocal();
|
||||
final String year = local.year.toString();
|
||||
final String month = local.month.toString().padLeft(2, '0');
|
||||
final String day = local.day.toString().padLeft(2, '0');
|
||||
final String hour = local.hour.toString().padLeft(2, '0');
|
||||
final String minute = local.minute.toString().padLeft(2, '0');
|
||||
return '$year-$month-$day $hour:$minute';
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# People UI State
|
||||
|
||||
This folder contains lightweight Riverpod state used only by the people
|
||||
workspace, such as search text, selected profile, and sort/filter controls.
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// Current sort mode for the people list.
|
||||
enum PeopleSortOption { affinity, nextMoment, alphabetical }
|
||||
|
||||
/// Selected profile in the people workspace.
|
||||
class SelectedPersonIdNotifier extends Notifier<String?> {
|
||||
@override
|
||||
String? build() => null;
|
||||
|
||||
void select(String id) {
|
||||
state = id;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
state = null;
|
||||
}
|
||||
}
|
||||
|
||||
final NotifierProvider<SelectedPersonIdNotifier, String?>
|
||||
selectedPersonIdProvider = NotifierProvider<SelectedPersonIdNotifier, String?>(
|
||||
SelectedPersonIdNotifier.new,
|
||||
);
|
||||
|
||||
/// Search query applied to the people list.
|
||||
class PeopleSearchQueryNotifier extends Notifier<String> {
|
||||
@override
|
||||
String build() => '';
|
||||
|
||||
void set(String value) {
|
||||
state = value;
|
||||
}
|
||||
}
|
||||
|
||||
final NotifierProvider<PeopleSearchQueryNotifier, String>
|
||||
peopleSearchQueryProvider = NotifierProvider<PeopleSearchQueryNotifier, String>(
|
||||
PeopleSearchQueryNotifier.new,
|
||||
);
|
||||
|
||||
/// Relationship filter applied to the people list.
|
||||
class PeopleRelationshipFilterNotifier extends Notifier<String?> {
|
||||
@override
|
||||
String? build() => null;
|
||||
|
||||
void set(String? value) {
|
||||
state = value;
|
||||
}
|
||||
}
|
||||
|
||||
final NotifierProvider<PeopleRelationshipFilterNotifier, String?>
|
||||
peopleRelationshipFilterProvider =
|
||||
NotifierProvider<PeopleRelationshipFilterNotifier, String?>(
|
||||
PeopleRelationshipFilterNotifier.new,
|
||||
);
|
||||
|
||||
/// Selected sort mode for the people list.
|
||||
class PeopleSortOptionNotifier extends Notifier<PeopleSortOption> {
|
||||
@override
|
||||
PeopleSortOption build() => PeopleSortOption.affinity;
|
||||
|
||||
void set(PeopleSortOption value) {
|
||||
state = value;
|
||||
}
|
||||
}
|
||||
|
||||
final NotifierProvider<PeopleSortOptionNotifier, PeopleSortOption>
|
||||
peopleSortOptionProvider =
|
||||
NotifierProvider<PeopleSortOptionNotifier, PeopleSortOption>(
|
||||
PeopleSortOptionNotifier.new,
|
||||
);
|
||||
Reference in New Issue
Block a user