Files
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

562 lines
22 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/shared/frosted_card.dart';
class RemindersView extends ConsumerWidget {
const RemindersView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final AsyncValue<LocalDataState> localData = ref.watch(
localRepositoryProvider,
);
return localData.when(
data: (LocalDataState data) {
final List<ReminderRule> reminders = data.reminders;
final Map<String, PersonProfile> peopleById = <String, PersonProfile>{
for (final PersonProfile person in data.people) person.id: person,
};
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 760;
return Padding(
padding: EdgeInsets.fromLTRB(
compact ? 16 : 28,
compact ? 16 : 24,
compact ? 16 : 28,
compact ? 20 : 28,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (compact)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Reminders',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Set recurring nudges so good intentions become habits.',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: () =>
_addReminder(context, ref, data.people),
icon: const Icon(Icons.alarm_add_rounded),
label: const Text('Add Reminder'),
),
],
)
else
Row(
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Reminders',
style: Theme.of(
context,
).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Set recurring nudges so good intentions become habits.',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppTheme.textSecondary),
),
],
),
),
FilledButton.icon(
onPressed: () =>
_addReminder(context, ref, data.people),
icon: const Icon(Icons.alarm_add_rounded),
label: const Text('Add Reminder'),
),
],
),
const SizedBox(height: 16),
Expanded(
child: ListView.separated(
itemCount: reminders.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (BuildContext context, int index) {
final ReminderRule reminder = reminders[index];
final PersonProfile? person = reminder.personId == null
? null
: peopleById[reminder.personId!];
return FrostedCard(
child: compact
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: Text(
reminder.title,
style: Theme.of(
context,
).textTheme.titleMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
Switch(
value: reminder.enabled,
onChanged: (_) {
ref
.read(
localRepositoryProvider
.notifier,
)
.toggleReminderEnabled(
reminder.id,
);
},
),
],
),
Text(
'${_labelForCadence(reminder.cadence)}${_formatDateTime(reminder.nextAt)}',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 4),
Text(
person == null
? 'Unassigned'
: person.name,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: <Widget>[
OutlinedButton.icon(
onPressed: () => _editReminder(
context,
ref,
data.people,
reminder,
),
icon: const Icon(Icons.edit_rounded),
label: const Text('Edit'),
),
TextButton.icon(
onPressed: () {
ref
.read(
localRepositoryProvider
.notifier,
)
.deleteReminder(reminder.id);
},
icon: const Icon(
Icons.delete_outline_rounded,
),
label: const Text('Delete'),
),
],
),
],
)
: Row(
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
Text(
reminder.title,
style: Theme.of(
context,
).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
'${_labelForCadence(reminder.cadence)}${_formatDateTime(reminder.nextAt)}',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 4),
Text(
person == null
? 'Unassigned'
: person.name,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
),
Switch(
value: reminder.enabled,
onChanged: (_) {
ref
.read(
localRepositoryProvider.notifier,
)
.toggleReminderEnabled(reminder.id);
},
),
IconButton(
onPressed: () => _editReminder(
context,
ref,
data.people,
reminder,
),
icon: const Icon(Icons.edit_rounded),
tooltip: 'Edit',
),
IconButton(
onPressed: () {
ref
.read(
localRepositoryProvider.notifier,
)
.deleteReminder(reminder.id);
},
icon: const Icon(
Icons.delete_outline_rounded,
),
tooltip: 'Delete',
),
],
),
);
},
),
),
],
),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (Object error, StackTrace stackTrace) {
return const Center(child: Text('Unable to load reminders'));
},
);
}
Future<void> _addReminder(
BuildContext context,
WidgetRef ref,
List<PersonProfile> people,
) async {
final _ReminderDraft? draft = await showDialog<_ReminderDraft>(
context: context,
builder: (BuildContext context) =>
_ReminderEditorDialog(title: 'Add Reminder', people: people),
);
if (draft == null) {
return;
}
await ref
.read(localRepositoryProvider.notifier)
.addReminder(
title: draft.title,
cadence: draft.cadence,
nextAt: draft.nextAt,
personId: draft.personId,
);
}
Future<void> _editReminder(
BuildContext context,
WidgetRef ref,
List<PersonProfile> people,
ReminderRule reminder,
) async {
final _ReminderDraft? draft = await showDialog<_ReminderDraft>(
context: context,
builder: (BuildContext context) => _ReminderEditorDialog(
title: 'Edit Reminder',
people: people,
initial: _ReminderDraft(
personId: reminder.personId,
title: reminder.title,
cadence: reminder.cadence,
nextAt: reminder.nextAt,
),
),
);
if (draft == null) {
return;
}
await ref
.read(localRepositoryProvider.notifier)
.updateReminder(
reminder.copyWith(
personId: draft.personId,
title: draft.title,
cadence: draft.cadence,
nextAt: draft.nextAt,
),
);
}
}
class _ReminderEditorDialog extends StatefulWidget {
const _ReminderEditorDialog({
required this.title,
required this.people,
this.initial,
});
final String title;
final List<PersonProfile> people;
final _ReminderDraft? initial;
@override
State<_ReminderEditorDialog> createState() => _ReminderEditorDialogState();
}
class _ReminderEditorDialogState extends State<_ReminderEditorDialog> {
late final TextEditingController _titleController;
late ReminderCadence _cadence;
late DateTime _nextAt;
String? _personId;
@override
void initState() {
super.initState();
_titleController = TextEditingController(text: widget.initial?.title ?? '');
_cadence = widget.initial?.cadence ?? ReminderCadence.weekly;
_nextAt =
widget.initial?.nextAt ?? DateTime.now().add(const Duration(days: 2));
_personId = widget.initial?.personId;
}
@override
void dispose() {
_titleController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.title),
content: SingleChildScrollView(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
TextField(
controller: _titleController,
decoration: const InputDecoration(labelText: 'Title'),
),
DropdownButtonFormField<ReminderCadence>(
initialValue: _cadence,
items: ReminderCadence.values
.map(
(ReminderCadence cadence) =>
DropdownMenuItem<ReminderCadence>(
value: cadence,
child: Text(_labelForCadence(cadence)),
),
)
.toList(growable: false),
onChanged: (ReminderCadence? value) {
if (value == null) {
return;
}
setState(() {
_cadence = value;
});
},
decoration: const InputDecoration(labelText: 'Cadence'),
),
DropdownButtonFormField<String?>(
initialValue: _personId,
items: <DropdownMenuItem<String?>>[
const DropdownMenuItem<String?>(
value: null,
child: Text('Unassigned'),
),
...widget.people.map(
(PersonProfile person) => DropdownMenuItem<String?>(
value: person.id,
child: Text(person.name),
),
),
],
onChanged: (String? value) {
setState(() {
_personId = value;
});
},
decoration: const InputDecoration(labelText: 'Person'),
),
const SizedBox(height: 12),
LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 360;
if (compact) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
_formatDateTime(_nextAt),
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _pickDateTime,
icon: const Icon(Icons.schedule_rounded),
label: const Text('Pick Time'),
),
],
);
}
return Row(
children: <Widget>[
Expanded(
child: Text(
_formatDateTime(_nextAt),
style: Theme.of(context).textTheme.bodyLarge,
),
),
OutlinedButton.icon(
onPressed: _pickDateTime,
icon: const Icon(Icons.schedule_rounded),
label: const Text('Pick Time'),
),
],
);
},
),
],
),
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final String title = _titleController.text.trim();
if (title.isEmpty) {
return;
}
Navigator.of(context).pop(
_ReminderDraft(
personId: _personId,
title: title,
cadence: _cadence,
nextAt: _nextAt,
),
);
},
child: const Text('Save'),
),
],
);
}
Future<void> _pickDateTime() async {
final DateTime now = DateTime.now();
final DateTime? date = await showDatePicker(
context: context,
initialDate: _nextAt,
firstDate: now.subtract(const Duration(days: 1)),
lastDate: now.add(const Duration(days: 3650)),
);
if (date == null || !mounted) {
return;
}
final TimeOfDay? time = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(_nextAt),
);
if (time == null || !mounted) {
return;
}
setState(() {
_nextAt = DateTime(
date.year,
date.month,
date.day,
time.hour,
time.minute,
);
});
}
}
class _ReminderDraft {
const _ReminderDraft({
required this.personId,
required this.title,
required this.cadence,
required this.nextAt,
});
final String? personId;
final String title;
final ReminderCadence cadence;
final DateTime nextAt;
}
String _labelForCadence(ReminderCadence cadence) {
return switch (cadence) {
ReminderCadence.daily => 'Daily',
ReminderCadence.weekly => 'Weekly',
ReminderCadence.monthly => 'Monthly',
};
}
String _formatDateTime(DateTime value) {
final String month = value.month.toString().padLeft(2, '0');
final String day = value.day.toString().padLeft(2, '0');
final String hour = value.hour.toString().padLeft(2, '0');
final String minute = value.minute.toString().padLeft(2, '0');
return '$month/$day/${value.year} $hour:$minute';
}