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,4 @@
|
||||
# Reminders Presentation
|
||||
|
||||
Reminder list and editor UI belong here. Use this folder for reminder-facing
|
||||
widgets, not for scheduling adapters or background execution.
|
||||
@@ -0,0 +1,561 @@
|
||||
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';
|
||||
}
|
||||
Reference in New Issue
Block a user