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,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