f655adfbea
Migrate app code into canonical feature slices, add phone-only AI digest scheduling and review, wire local notification/background task support, and cover the flow with tests.
628 lines
19 KiB
Dart
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);
|
|
},
|
|
);
|
|
}
|
|
}
|