Files
rely/lib/features/people/presentation/people_view.dart
T
Rijad Zuzo f655adfbea 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.
2026-05-17 00:17:20 +02:00

628 lines
19 KiB
Dart

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);
},
);
}
}