Add persisted local state and baseline CRUD flows
This commit is contained in:
@@ -12,6 +12,10 @@ class SelectedPersonIdNotifier extends Notifier<String?> {
|
||||
void select(String id) {
|
||||
state = id;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
state = null;
|
||||
}
|
||||
}
|
||||
|
||||
final NotifierProvider<SelectedPersonIdNotifier, String?>
|
||||
@@ -24,114 +28,286 @@ class PeopleView extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final List<PersonProfile> people = ref
|
||||
.watch(localRepositoryProvider)
|
||||
.people();
|
||||
final String selectedId =
|
||||
ref.watch(selectedPersonIdProvider) ?? people.first.id;
|
||||
final PersonProfile selected = people.firstWhere(
|
||||
(PersonProfile person) => person.id == selectedId,
|
||||
orElse: () => people.first,
|
||||
final AsyncValue<LocalDataState> localData = ref.watch(
|
||||
localRepositoryProvider,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'People',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Track what matters to each relationship and follow through.',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
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];
|
||||
final bool isSelected = person.id == selected.id;
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
ref
|
||||
.read(selectedPersonIdProvider.notifier)
|
||||
.select(person.id);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: FrostedCard(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
return localData.when(
|
||||
data: (LocalDataState data) {
|
||||
final List<PersonProfile> people = data.people;
|
||||
if (people.isEmpty) {
|
||||
return _EmptyPeopleView(onAdd: () => _handleAddPerson(context, ref));
|
||||
}
|
||||
|
||||
final String selectedId =
|
||||
ref.watch(selectedPersonIdProvider) ?? people.first.id;
|
||||
final PersonProfile selected = people.firstWhere(
|
||||
(PersonProfile person) => person.id == selectedId,
|
||||
orElse: () => people.first,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
person.relationship,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
'People',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Track what matters to each relationship and follow through.',
|
||||
style: Theme.of(context).textTheme.bodyLarge
|
||||
?.copyWith(color: AppTheme.textSecondary),
|
||||
),
|
||||
_ScorePill(score: person.affinityScore),
|
||||
if (isSelected)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 10),
|
||||
child: Icon(
|
||||
Icons.check_circle,
|
||||
color: Color(0xFF1AB6C8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
FilledButton.icon(
|
||||
onPressed: () => _handleAddPerson(context, ref),
|
||||
icon: const Icon(Icons.person_add_alt_1_rounded),
|
||||
label: const Text('Add'),
|
||||
),
|
||||
],
|
||||
),
|
||||
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];
|
||||
final bool isSelected = person.id == selected.id;
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
ref
|
||||
.read(selectedPersonIdProvider.notifier)
|
||||
.select(person.id);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: FrostedCard(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: 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,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
person.relationship,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_ScorePill(score: person.affinityScore),
|
||||
if (isSelected)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 10),
|
||||
child: Icon(
|
||||
Icons.check_circle,
|
||||
color: Color(0xFF1AB6C8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 18),
|
||||
Expanded(
|
||||
flex: 6,
|
||||
child: FrostedCard(
|
||||
child: _PersonDetail(
|
||||
person: selected,
|
||||
onEdit: () => _handleEditPerson(context, ref, selected),
|
||||
onDelete: () =>
|
||||
_handleDeletePerson(context, ref, selected.id),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (Object error, StackTrace stackTrace) {
|
||||
return const Center(child: Text('Unable to load people'));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleAddPerson(BuildContext context, WidgetRef ref) async {
|
||||
final _PersonDraft? draft = await showDialog<_PersonDraft>(
|
||||
context: context,
|
||||
builder: (BuildContext context) =>
|
||||
const _PersonEditorDialog(title: 'Add Person'),
|
||||
);
|
||||
|
||||
if (draft == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.addPerson(
|
||||
name: draft.name,
|
||||
relationship: draft.relationship,
|
||||
notes: draft.notes,
|
||||
tags: draft.tags,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleEditPerson(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
PersonProfile person,
|
||||
) async {
|
||||
final _PersonDraft? draft = await showDialog<_PersonDraft>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => _PersonEditorDialog(
|
||||
title: 'Edit Person',
|
||||
initial: _PersonDraft(
|
||||
name: person.name,
|
||||
relationship: person.relationship,
|
||||
notes: person.notes,
|
||||
tags: person.tags,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (draft == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.updatePerson(
|
||||
person.copyWith(
|
||||
name: draft.name,
|
||||
relationship: draft.relationship,
|
||||
notes: draft.notes,
|
||||
tags: draft.tags,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 18),
|
||||
Expanded(
|
||||
flex: 6,
|
||||
child: FrostedCard(child: _PersonDetail(person: selected)),
|
||||
),
|
||||
],
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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 _PersonDetail extends StatelessWidget {
|
||||
const _PersonDetail({required this.person});
|
||||
const _PersonDetail({
|
||||
required this.person,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
final PersonProfile person;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -160,7 +336,16 @@ class _PersonDetail extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
_ScorePill(score: person.affinityScore),
|
||||
IconButton(
|
||||
onPressed: onEdit,
|
||||
icon: const Icon(Icons.edit_rounded),
|
||||
tooltip: 'Edit person',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: onDelete,
|
||||
icon: const Icon(Icons.delete_outline_rounded),
|
||||
tooltip: 'Delete person',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
@@ -178,7 +363,7 @@ class _PersonDetail extends StatelessWidget {
|
||||
runSpacing: 8,
|
||||
children: person.tags
|
||||
.map((String tag) => _DetailTag(label: tag))
|
||||
.toList(),
|
||||
.toList(growable: false),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text('Notes', style: Theme.of(context).textTheme.titleMedium),
|
||||
@@ -194,6 +379,123 @@ class _PersonDetail extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _PersonEditorDialog extends StatefulWidget {
|
||||
const _PersonEditorDialog({required this.title, this.initial});
|
||||
|
||||
final String title;
|
||||
final _PersonDraft? initial;
|
||||
|
||||
@override
|
||||
State<_PersonEditorDialog> createState() => _PersonEditorDialogState();
|
||||
}
|
||||
|
||||
class _PersonEditorDialogState extends State<_PersonEditorDialog> {
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _relationshipController;
|
||||
late final TextEditingController _tagsController;
|
||||
late final TextEditingController _notesController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameController = TextEditingController(text: widget.initial?.name ?? '');
|
||||
_relationshipController = TextEditingController(
|
||||
text: widget.initial?.relationship ?? '',
|
||||
);
|
||||
_tagsController = TextEditingController(
|
||||
text: widget.initial?.tags.join(', ') ?? '',
|
||||
);
|
||||
_notesController = TextEditingController(text: widget.initial?.notes ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_relationshipController.dispose();
|
||||
_tagsController.dispose();
|
||||
_notesController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.title),
|
||||
content: SingleChildScrollView(
|
||||
child: SizedBox(
|
||||
width: 420,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
),
|
||||
TextField(
|
||||
controller: _relationshipController,
|
||||
decoration: const InputDecoration(labelText: 'Relationship'),
|
||||
),
|
||||
TextField(
|
||||
controller: _tagsController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Tags (comma separated)',
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: _notesController,
|
||||
maxLines: 3,
|
||||
decoration: const InputDecoration(labelText: 'Notes'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final String name = _nameController.text.trim();
|
||||
final String relationship = _relationshipController.text.trim();
|
||||
if (name.isEmpty || relationship.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final _PersonDraft draft = _PersonDraft(
|
||||
name: name,
|
||||
relationship: relationship,
|
||||
notes: _notesController.text.trim(),
|
||||
tags: _tagsController.text
|
||||
.split(',')
|
||||
.map((String tag) => tag.trim())
|
||||
.where((String tag) => tag.isNotEmpty)
|
||||
.toList(growable: false),
|
||||
);
|
||||
Navigator.of(context).pop(draft);
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PersonDraft {
|
||||
const _PersonDraft({
|
||||
required this.name,
|
||||
required this.relationship,
|
||||
required this.notes,
|
||||
required this.tags,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String relationship;
|
||||
final String notes;
|
||||
final List<String> tags;
|
||||
}
|
||||
|
||||
class _DetailTag extends StatelessWidget {
|
||||
const _DetailTag({required this.label});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user