Deep-link sync rejections to direct entity repair forms
This commit is contained in:
+23
-2
@@ -2,6 +2,26 @@
|
|||||||
|
|
||||||
Updated: 2026-02-15
|
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
|
## Latest Milestone (2026-02-15): Notification Tap Routing
|
||||||
|
|
||||||
Implemented initial in-app routing for reminder notification taps:
|
Implemented initial in-app routing for reminder notification taps:
|
||||||
@@ -264,8 +284,9 @@ Validation for this milestone:
|
|||||||
- permission UX implemented in settings.
|
- permission UX implemented in settings.
|
||||||
- notification tap routing implemented for reminder screen handoff.
|
- notification tap routing implemented for reminder screen handoff.
|
||||||
2. Conflict resolution UX:
|
2. Conflict resolution UX:
|
||||||
- reject/requeue + payload inspect + related-screen navigation implemented.
|
- reject/requeue + payload inspect + direct entity repair forms implemented.
|
||||||
- future enhancement: deep-link directly to specific entity edit form.
|
- future enhancement: auto-select and highlight the exact row in main feature
|
||||||
|
screens after save.
|
||||||
3. Sync trigger maturity:
|
3. Sync trigger maturity:
|
||||||
- implemented via reachability gating; future improvement is explicit OS
|
- implemented via reachability gating; future improvement is explicit OS
|
||||||
connectivity event subscription.
|
connectivity event subscription.
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:relationship_saver/core/config/app_theme.dart';
|
import 'package:relationship_saver/core/config/app_theme.dart';
|
||||||
import 'package:relationship_saver/features/ideas/ideas_view.dart';
|
import 'package:relationship_saver/features/local/local_models.dart';
|
||||||
import 'package:relationship_saver/features/moments/moments_view.dart';
|
import 'package:relationship_saver/features/local/local_repository.dart';
|
||||||
import 'package:relationship_saver/features/people/people_view.dart';
|
|
||||||
import 'package:relationship_saver/features/reminders/reminders_view.dart';
|
|
||||||
import 'package:relationship_saver/features/shared/frosted_card.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_coordinator.dart';
|
||||||
import 'package:relationship_saver/features/sync/sync_queue_repository.dart';
|
import 'package:relationship_saver/features/sync/sync_queue_repository.dart';
|
||||||
@@ -214,7 +212,7 @@ class _SyncViewState extends ConsumerState<SyncView> {
|
|||||||
),
|
),
|
||||||
onOpenEntity:
|
onOpenEntity:
|
||||||
matched != null &&
|
matched != null &&
|
||||||
_entityViewForType(matched.entityType) != null
|
_supportsEntityRepair(matched.entityType)
|
||||||
? () => _openEntityViewFor(matched)
|
? () => _openEntityViewFor(matched)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
@@ -512,8 +510,7 @@ class _SyncViewState extends ConsumerState<SyncView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openEntityViewFor(ChangeEnvelope matched) async {
|
Future<void> _openEntityViewFor(ChangeEnvelope matched) async {
|
||||||
final Widget? view = _entityViewForType(matched.entityType);
|
if (!_supportsEntityRepair(matched.entityType)) {
|
||||||
if (view == null) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('No editor available for ${matched.entityType}.'),
|
content: Text('No editor available for ${matched.entityType}.'),
|
||||||
@@ -528,25 +525,21 @@ class _SyncViewState extends ConsumerState<SyncView> {
|
|||||||
title: _titleForEntityType(matched.entityType),
|
title: _titleForEntityType(matched.entityType),
|
||||||
entityType: matched.entityType,
|
entityType: matched.entityType,
|
||||||
entityId: matched.entityId,
|
entityId: matched.entityId,
|
||||||
child: view,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget? _entityViewForType(String entityType) {
|
bool _supportsEntityRepair(String entityType) {
|
||||||
switch (entityType) {
|
switch (entityType) {
|
||||||
case 'person':
|
case 'person':
|
||||||
return const PeopleView();
|
|
||||||
case 'capture':
|
case 'capture':
|
||||||
return const MomentsView();
|
|
||||||
case 'giftIdea':
|
case 'giftIdea':
|
||||||
case 'eventIdea':
|
case 'eventIdea':
|
||||||
return const IdeasView();
|
|
||||||
case 'reminderRule':
|
case 'reminderRule':
|
||||||
return const RemindersView();
|
return true;
|
||||||
default:
|
default:
|
||||||
return null;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,21 +560,23 @@ class _SyncViewState extends ConsumerState<SyncView> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EntityResolveScreen extends StatelessWidget {
|
class _EntityResolveScreen extends ConsumerWidget {
|
||||||
const _EntityResolveScreen({
|
const _EntityResolveScreen({
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.entityType,
|
required this.entityType,
|
||||||
required this.entityId,
|
required this.entityId,
|
||||||
required this.child,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final String title;
|
final String title;
|
||||||
final String entityType;
|
final String entityType;
|
||||||
final String entityId;
|
final String entityId;
|
||||||
final Widget child;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final AsyncValue<LocalDataState> localData = ref.watch(
|
||||||
|
localRepositoryProvider,
|
||||||
|
);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(title)),
|
appBar: AppBar(title: Text(title)),
|
||||||
body: Column(
|
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