Deep-link sync rejections to direct entity repair forms
This commit is contained in:
+23
-2
@@ -2,6 +2,26 @@
|
||||
|
||||
Updated: 2026-02-15
|
||||
|
||||
## Latest Milestone (2026-02-15): Direct Entity Repair Forms From Sync
|
||||
|
||||
Implemented deep-link repair forms for rejected sync items:
|
||||
|
||||
- Updated `lib/features/sync/sync_view.dart`:
|
||||
- `Open Related Screen` now routes into dedicated entity-specific repair
|
||||
forms, not just general list screens
|
||||
- supported entity repair forms:
|
||||
- `person`
|
||||
- `capture`
|
||||
- `giftIdea`/`eventIdea`
|
||||
- `reminderRule`
|
||||
- each form updates local state via `LocalRepository` directly
|
||||
- focus banner keeps exact `entityType:entityId` context with copy action
|
||||
|
||||
Validation for this milestone:
|
||||
|
||||
- `flutter analyze` -> pass
|
||||
- `flutter test` -> pass
|
||||
|
||||
## Latest Milestone (2026-02-15): Notification Tap Routing
|
||||
|
||||
Implemented initial in-app routing for reminder notification taps:
|
||||
@@ -264,8 +284,9 @@ Validation for this milestone:
|
||||
- permission UX implemented in settings.
|
||||
- notification tap routing implemented for reminder screen handoff.
|
||||
2. Conflict resolution UX:
|
||||
- reject/requeue + payload inspect + related-screen navigation implemented.
|
||||
- future enhancement: deep-link directly to specific entity edit form.
|
||||
- reject/requeue + payload inspect + direct entity repair forms implemented.
|
||||
- future enhancement: auto-select and highlight the exact row in main feature
|
||||
screens after save.
|
||||
3. Sync trigger maturity:
|
||||
- implemented via reachability gating; future improvement is explicit OS
|
||||
connectivity event subscription.
|
||||
|
||||
@@ -4,10 +4,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/core/config/app_theme.dart';
|
||||
import 'package:relationship_saver/features/ideas/ideas_view.dart';
|
||||
import 'package:relationship_saver/features/moments/moments_view.dart';
|
||||
import 'package:relationship_saver/features/people/people_view.dart';
|
||||
import 'package:relationship_saver/features/reminders/reminders_view.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';
|
||||
import 'package:relationship_saver/features/sync/sync_coordinator.dart';
|
||||
import 'package:relationship_saver/features/sync/sync_queue_repository.dart';
|
||||
@@ -214,7 +212,7 @@ class _SyncViewState extends ConsumerState<SyncView> {
|
||||
),
|
||||
onOpenEntity:
|
||||
matched != null &&
|
||||
_entityViewForType(matched.entityType) != null
|
||||
_supportsEntityRepair(matched.entityType)
|
||||
? () => _openEntityViewFor(matched)
|
||||
: null,
|
||||
),
|
||||
@@ -512,8 +510,7 @@ class _SyncViewState extends ConsumerState<SyncView> {
|
||||
}
|
||||
|
||||
Future<void> _openEntityViewFor(ChangeEnvelope matched) async {
|
||||
final Widget? view = _entityViewForType(matched.entityType);
|
||||
if (view == null) {
|
||||
if (!_supportsEntityRepair(matched.entityType)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('No editor available for ${matched.entityType}.'),
|
||||
@@ -528,25 +525,21 @@ class _SyncViewState extends ConsumerState<SyncView> {
|
||||
title: _titleForEntityType(matched.entityType),
|
||||
entityType: matched.entityType,
|
||||
entityId: matched.entityId,
|
||||
child: view,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget? _entityViewForType(String entityType) {
|
||||
bool _supportsEntityRepair(String entityType) {
|
||||
switch (entityType) {
|
||||
case 'person':
|
||||
return const PeopleView();
|
||||
case 'capture':
|
||||
return const MomentsView();
|
||||
case 'giftIdea':
|
||||
case 'eventIdea':
|
||||
return const IdeasView();
|
||||
case 'reminderRule':
|
||||
return const RemindersView();
|
||||
return true;
|
||||
default:
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,21 +560,23 @@ class _SyncViewState extends ConsumerState<SyncView> {
|
||||
}
|
||||
}
|
||||
|
||||
class _EntityResolveScreen extends StatelessWidget {
|
||||
class _EntityResolveScreen extends ConsumerWidget {
|
||||
const _EntityResolveScreen({
|
||||
required this.title,
|
||||
required this.entityType,
|
||||
required this.entityId,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String entityType;
|
||||
final String entityId;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final AsyncValue<LocalDataState> localData = ref.watch(
|
||||
localRepositoryProvider,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(title)),
|
||||
body: Column(
|
||||
@@ -613,9 +608,786 @@ class _EntityResolveScreen extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(child: child),
|
||||
Expanded(
|
||||
child: localData.when(
|
||||
data: (LocalDataState data) => _buildEditor(context, ref, data),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (Object error, StackTrace stackTrace) =>
|
||||
const Center(child: Text('Unable to load local data')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEditor(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
LocalDataState data,
|
||||
) {
|
||||
switch (entityType) {
|
||||
case 'person':
|
||||
final PersonProfile? person = _findPersonById(data.people, entityId);
|
||||
if (person == null) {
|
||||
return _MissingEntityView(entityType: entityType, entityId: entityId);
|
||||
}
|
||||
return _PersonRepairForm(
|
||||
person: person,
|
||||
onSave: (PersonProfile updated) async {
|
||||
await ref
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.updatePerson(updated);
|
||||
},
|
||||
);
|
||||
case 'capture':
|
||||
final RelationshipMoment? moment = _findMomentById(
|
||||
data.moments,
|
||||
entityId,
|
||||
);
|
||||
if (moment == null) {
|
||||
return _MissingEntityView(entityType: entityType, entityId: entityId);
|
||||
}
|
||||
return _MomentRepairForm(
|
||||
moment: moment,
|
||||
people: data.people,
|
||||
onSave: (RelationshipMoment updated) async {
|
||||
await ref
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.updateMoment(updated);
|
||||
},
|
||||
);
|
||||
case 'giftIdea':
|
||||
case 'eventIdea':
|
||||
final RelationshipIdea? idea = _findIdeaById(data.ideas, entityId);
|
||||
if (idea == null) {
|
||||
return _MissingEntityView(entityType: entityType, entityId: entityId);
|
||||
}
|
||||
return _IdeaRepairForm(
|
||||
idea: idea,
|
||||
people: data.people,
|
||||
onSave: (RelationshipIdea updated) async {
|
||||
await ref
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.updateIdea(updated);
|
||||
},
|
||||
);
|
||||
case 'reminderRule':
|
||||
final ReminderRule? reminder = _findReminderById(
|
||||
data.reminders,
|
||||
entityId,
|
||||
);
|
||||
if (reminder == null) {
|
||||
return _MissingEntityView(entityType: entityType, entityId: entityId);
|
||||
}
|
||||
return _ReminderRepairForm(
|
||||
reminder: reminder,
|
||||
people: data.people,
|
||||
onSave: (ReminderRule updated) async {
|
||||
await ref
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.updateReminder(updated);
|
||||
},
|
||||
);
|
||||
default:
|
||||
return _MissingEntityView(entityType: entityType, entityId: entityId);
|
||||
}
|
||||
}
|
||||
|
||||
PersonProfile? _findPersonById(List<PersonProfile> people, String id) {
|
||||
for (final PersonProfile person in people) {
|
||||
if (person.id == id) {
|
||||
return person;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
RelationshipMoment? _findMomentById(
|
||||
List<RelationshipMoment> moments,
|
||||
String id,
|
||||
) {
|
||||
for (final RelationshipMoment moment in moments) {
|
||||
if (moment.id == id) {
|
||||
return moment;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
RelationshipIdea? _findIdeaById(List<RelationshipIdea> ideas, String id) {
|
||||
for (final RelationshipIdea idea in ideas) {
|
||||
if (idea.id == id) {
|
||||
return idea;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
ReminderRule? _findReminderById(List<ReminderRule> reminders, String id) {
|
||||
for (final ReminderRule reminder in reminders) {
|
||||
if (reminder.id == id) {
|
||||
return reminder;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class _MissingEntityView extends StatelessWidget {
|
||||
const _MissingEntityView({required this.entityType, required this.entityId});
|
||||
|
||||
final String entityType;
|
||||
final String entityId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Text(
|
||||
'Could not find $entityType:$entityId in local data.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PersonRepairForm extends StatefulWidget {
|
||||
const _PersonRepairForm({required this.person, required this.onSave});
|
||||
|
||||
final PersonProfile person;
|
||||
final Future<void> Function(PersonProfile updated) onSave;
|
||||
|
||||
@override
|
||||
State<_PersonRepairForm> createState() => _PersonRepairFormState();
|
||||
}
|
||||
|
||||
class _PersonRepairFormState extends State<_PersonRepairForm> {
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _relationshipController;
|
||||
late final TextEditingController _tagsController;
|
||||
late final TextEditingController _notesController;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameController = TextEditingController(text: widget.person.name);
|
||||
_relationshipController = TextEditingController(
|
||||
text: widget.person.relationship,
|
||||
);
|
||||
_tagsController = TextEditingController(
|
||||
text: widget.person.tags.join(', '),
|
||||
);
|
||||
_notesController = TextEditingController(text: widget.person.notes);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_relationshipController.dispose();
|
||||
_tagsController.dispose();
|
||||
_notesController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _RepairFormScaffold(
|
||||
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'),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: _saving ? null : _save,
|
||||
icon: _saving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.save_rounded),
|
||||
label: const Text('Save Changes'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final String name = _nameController.text.trim();
|
||||
final String relationship = _relationshipController.text.trim();
|
||||
if (name.isEmpty || relationship.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Name and relationship are required.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_saving = true;
|
||||
});
|
||||
try {
|
||||
await widget.onSave(
|
||||
widget.person.copyWith(
|
||||
name: name,
|
||||
relationship: relationship,
|
||||
tags: _tagsController.text
|
||||
.split(',')
|
||||
.map((String e) => e.trim())
|
||||
.where((String e) => e.isNotEmpty)
|
||||
.toList(growable: false),
|
||||
notes: _notesController.text.trim(),
|
||||
),
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Person updated.')));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_saving = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _MomentRepairForm extends StatefulWidget {
|
||||
const _MomentRepairForm({
|
||||
required this.moment,
|
||||
required this.people,
|
||||
required this.onSave,
|
||||
});
|
||||
|
||||
final RelationshipMoment moment;
|
||||
final List<PersonProfile> people;
|
||||
final Future<void> Function(RelationshipMoment updated) onSave;
|
||||
|
||||
@override
|
||||
State<_MomentRepairForm> createState() => _MomentRepairFormState();
|
||||
}
|
||||
|
||||
class _MomentRepairFormState extends State<_MomentRepairForm> {
|
||||
late final TextEditingController _titleController;
|
||||
late final TextEditingController _summaryController;
|
||||
late String _personId;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_titleController = TextEditingController(text: widget.moment.title);
|
||||
_summaryController = TextEditingController(text: widget.moment.summary);
|
||||
_personId =
|
||||
widget.people.any((PersonProfile p) => p.id == widget.moment.personId)
|
||||
? widget.moment.personId
|
||||
: (widget.people.isEmpty
|
||||
? widget.moment.personId
|
||||
: widget.people.first.id);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleController.dispose();
|
||||
_summaryController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _RepairFormScaffold(
|
||||
children: <Widget>[
|
||||
if (widget.people.isNotEmpty)
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _personId,
|
||||
items: widget.people
|
||||
.map(
|
||||
(PersonProfile person) => DropdownMenuItem<String>(
|
||||
value: person.id,
|
||||
child: Text(person.name),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
onChanged: (String? value) {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_personId = value;
|
||||
});
|
||||
},
|
||||
decoration: const InputDecoration(labelText: 'Person'),
|
||||
),
|
||||
TextField(
|
||||
controller: _titleController,
|
||||
decoration: const InputDecoration(labelText: 'Title'),
|
||||
),
|
||||
TextField(
|
||||
controller: _summaryController,
|
||||
maxLines: 4,
|
||||
decoration: const InputDecoration(labelText: 'Summary'),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: _saving ? null : _save,
|
||||
icon: _saving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.save_rounded),
|
||||
label: const Text('Save Changes'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final String summary = _summaryController.text.trim();
|
||||
if (summary.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Summary is required.')));
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_saving = true;
|
||||
});
|
||||
try {
|
||||
await widget.onSave(
|
||||
widget.moment.copyWith(
|
||||
personId: _personId,
|
||||
title: _titleController.text.trim().isEmpty
|
||||
? widget.moment.title
|
||||
: _titleController.text.trim(),
|
||||
summary: summary,
|
||||
),
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Moment updated.')));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_saving = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _IdeaRepairForm extends StatefulWidget {
|
||||
const _IdeaRepairForm({
|
||||
required this.idea,
|
||||
required this.people,
|
||||
required this.onSave,
|
||||
});
|
||||
|
||||
final RelationshipIdea idea;
|
||||
final List<PersonProfile> people;
|
||||
final Future<void> Function(RelationshipIdea updated) onSave;
|
||||
|
||||
@override
|
||||
State<_IdeaRepairForm> createState() => _IdeaRepairFormState();
|
||||
}
|
||||
|
||||
class _IdeaRepairFormState extends State<_IdeaRepairForm> {
|
||||
late final TextEditingController _titleController;
|
||||
late final TextEditingController _detailsController;
|
||||
late IdeaType _type;
|
||||
String? _personId;
|
||||
late bool _archived;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_titleController = TextEditingController(text: widget.idea.title);
|
||||
_detailsController = TextEditingController(text: widget.idea.details);
|
||||
_type = widget.idea.type;
|
||||
_personId = widget.idea.personId;
|
||||
_archived = widget.idea.isArchived;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleController.dispose();
|
||||
_detailsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _RepairFormScaffold(
|
||||
children: <Widget>[
|
||||
TextField(
|
||||
controller: _titleController,
|
||||
decoration: const InputDecoration(labelText: 'Title'),
|
||||
),
|
||||
DropdownButtonFormField<IdeaType>(
|
||||
initialValue: _type,
|
||||
items: IdeaType.values
|
||||
.map(
|
||||
(IdeaType type) => DropdownMenuItem<IdeaType>(
|
||||
value: type,
|
||||
child: Text(type == IdeaType.gift ? 'Gift' : 'Event'),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
onChanged: (IdeaType? value) {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_type = value;
|
||||
});
|
||||
},
|
||||
decoration: const InputDecoration(labelText: 'Type'),
|
||||
),
|
||||
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'),
|
||||
),
|
||||
TextField(
|
||||
controller: _detailsController,
|
||||
maxLines: 4,
|
||||
decoration: const InputDecoration(labelText: 'Details'),
|
||||
),
|
||||
SwitchListTile.adaptive(
|
||||
value: _archived,
|
||||
onChanged: (bool value) {
|
||||
setState(() {
|
||||
_archived = value;
|
||||
});
|
||||
},
|
||||
title: const Text('Archived'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: _saving ? null : _save,
|
||||
icon: _saving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.save_rounded),
|
||||
label: const Text('Save Changes'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final String title = _titleController.text.trim();
|
||||
if (title.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Title is required.')));
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_saving = true;
|
||||
});
|
||||
try {
|
||||
await widget.onSave(
|
||||
widget.idea.copyWith(
|
||||
title: title,
|
||||
details: _detailsController.text.trim(),
|
||||
type: _type,
|
||||
personId: _personId,
|
||||
isArchived: _archived,
|
||||
),
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Idea updated.')));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_saving = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _ReminderRepairForm extends StatefulWidget {
|
||||
const _ReminderRepairForm({
|
||||
required this.reminder,
|
||||
required this.people,
|
||||
required this.onSave,
|
||||
});
|
||||
|
||||
final ReminderRule reminder;
|
||||
final List<PersonProfile> people;
|
||||
final Future<void> Function(ReminderRule updated) onSave;
|
||||
|
||||
@override
|
||||
State<_ReminderRepairForm> createState() => _ReminderRepairFormState();
|
||||
}
|
||||
|
||||
class _ReminderRepairFormState extends State<_ReminderRepairForm> {
|
||||
late final TextEditingController _titleController;
|
||||
late ReminderCadence _cadence;
|
||||
late DateTime _nextAt;
|
||||
late bool _enabled;
|
||||
String? _personId;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_titleController = TextEditingController(text: widget.reminder.title);
|
||||
_cadence = widget.reminder.cadence;
|
||||
_nextAt = widget.reminder.nextAt;
|
||||
_enabled = widget.reminder.enabled;
|
||||
_personId = widget.reminder.personId;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _RepairFormScaffold(
|
||||
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'),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: <Widget>[
|
||||
Text('Next at: ${_formatDateTime(_nextAt)}'),
|
||||
TextButton.icon(
|
||||
onPressed: _pickDateTime,
|
||||
icon: const Icon(Icons.schedule_rounded),
|
||||
label: const Text('Change'),
|
||||
),
|
||||
],
|
||||
),
|
||||
SwitchListTile.adaptive(
|
||||
value: _enabled,
|
||||
onChanged: (bool value) {
|
||||
setState(() {
|
||||
_enabled = value;
|
||||
});
|
||||
},
|
||||
title: const Text('Enabled'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: _saving ? null : _save,
|
||||
icon: _saving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.save_rounded),
|
||||
label: const Text('Save Changes'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDateTime() async {
|
||||
final DateTime? date = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _nextAt,
|
||||
firstDate: DateTime.now().subtract(const Duration(days: 365 * 3)),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365 * 10)),
|
||||
);
|
||||
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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final String title = _titleController.text.trim();
|
||||
if (title.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Title is required.')));
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_saving = true;
|
||||
});
|
||||
try {
|
||||
await widget.onSave(
|
||||
widget.reminder.copyWith(
|
||||
title: title,
|
||||
cadence: _cadence,
|
||||
nextAt: _nextAt,
|
||||
enabled: _enabled,
|
||||
personId: _personId,
|
||||
),
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Reminder updated.')));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_saving = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _RepairFormScaffold extends StatelessWidget {
|
||||
const _RepairFormScaffold({required this.children});
|
||||
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 680),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: children
|
||||
.expand((Widget child) sync* {
|
||||
yield child;
|
||||
yield const SizedBox(height: 12);
|
||||
})
|
||||
.toList(growable: false),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _labelForCadence(ReminderCadence cadence) {
|
||||
switch (cadence) {
|
||||
case ReminderCadence.daily:
|
||||
return 'Daily';
|
||||
case ReminderCadence.weekly:
|
||||
return 'Weekly';
|
||||
case ReminderCadence.monthly:
|
||||
return 'Monthly';
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDateTime(DateTime value) {
|
||||
final DateTime local = value.toLocal();
|
||||
final String month = local.month.toString().padLeft(2, '0');
|
||||
final String day = local.day.toString().padLeft(2, '0');
|
||||
final String hour = local.hour.toString().padLeft(2, '0');
|
||||
final String minute = local.minute.toString().padLeft(2, '0');
|
||||
return '${local.year}-$month-$day $hour:$minute';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user