1394 lines
40 KiB
Dart
1394 lines
40 KiB
Dart
import 'dart:convert';
|
|
|
|
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/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';
|
|
import 'package:relationship_saver/features/sync/sync_state.dart';
|
|
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
|
|
|
class SyncView extends ConsumerStatefulWidget {
|
|
const SyncView({super.key});
|
|
|
|
@override
|
|
ConsumerState<SyncView> createState() => _SyncViewState();
|
|
}
|
|
|
|
class _SyncViewState extends ConsumerState<SyncView> {
|
|
bool _busy = false;
|
|
String _status = 'Ready. Local-first mode keeps app usable without sync.';
|
|
final Set<String> _expandedRejections = <String>{};
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final AsyncValue<SyncState> syncState = ref.watch(
|
|
syncQueueRepositoryProvider,
|
|
);
|
|
|
|
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>[
|
|
Text('Sync', style: Theme.of(context).textTheme.headlineMedium),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
'Queue local changes, then push and pull when network is available.',
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Expanded(
|
|
child: syncState.when(
|
|
data: (SyncState state) =>
|
|
_buildBody(context, state, compact),
|
|
loading: () =>
|
|
const Center(child: CircularProgressIndicator()),
|
|
error: (Object error, StackTrace stackTrace) {
|
|
return Center(
|
|
child: Text(
|
|
'Unable to load sync state. $error',
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildBody(BuildContext context, SyncState state, bool compact) {
|
|
return ListView(
|
|
children: <Widget>[
|
|
FrostedCard(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Wrap(
|
|
spacing: 10,
|
|
runSpacing: 10,
|
|
children: <Widget>[
|
|
FilledButton.icon(
|
|
onPressed: _busy ? null : _syncNow,
|
|
icon: const Icon(Icons.sync_rounded),
|
|
label: const Text('Sync Now'),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: _busy ? null : _pushPending,
|
|
icon: const Icon(Icons.upload_rounded),
|
|
label: const Text('Push Pending'),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: _busy ? null : _pullChanges,
|
|
icon: const Icon(Icons.download_rounded),
|
|
label: const Text('Pull Changes'),
|
|
),
|
|
TextButton.icon(
|
|
onPressed: _busy ? null : _clearQueue,
|
|
icon: const Icon(Icons.clear_all_rounded),
|
|
label: const Text('Clear Queue'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
_status,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
FrostedCard(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Text(
|
|
'Queue Status',
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
const SizedBox(height: 10),
|
|
_infoRow(
|
|
label: 'Pending changes',
|
|
value: '${state.pendingChanges.length}',
|
|
compact: compact,
|
|
),
|
|
_infoRow(
|
|
label: 'Cursor',
|
|
value: state.cursor ?? 'not set',
|
|
compact: compact,
|
|
),
|
|
_infoRow(
|
|
label: 'Last sync',
|
|
value: _formatDateTime(state.lastSyncAt),
|
|
compact: compact,
|
|
),
|
|
_infoRow(
|
|
label: 'Last attempt',
|
|
value: _formatDateTime(state.lastAttemptAt),
|
|
compact: compact,
|
|
),
|
|
_infoRow(
|
|
label: 'Last rejected count',
|
|
value: '${state.lastRejected.length}',
|
|
compact: compact,
|
|
),
|
|
_infoRow(
|
|
label: 'Last error',
|
|
value: state.lastError ?? 'none',
|
|
compact: compact,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
if (state.lastRejected.isNotEmpty) ...<Widget>[
|
|
FrostedCard(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: <Widget>[
|
|
Text(
|
|
'Rejected Changes',
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
TextButton.icon(
|
|
onPressed: _busy ? null : _requeueRejected,
|
|
icon: const Icon(Icons.replay_rounded),
|
|
label: const Text('Requeue'),
|
|
),
|
|
TextButton.icon(
|
|
onPressed: _busy ? null : _dismissRejections,
|
|
icon: const Icon(Icons.done_all_rounded),
|
|
label: const Text('Dismiss'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
...state.lastRejected.map((MutationRejection rejection) {
|
|
final ChangeEnvelope? matched = _findRejectedChange(
|
|
state.lastRejectedChanges,
|
|
rejection.clientMutationId,
|
|
);
|
|
final bool expanded = _expandedRejections.contains(
|
|
rejection.clientMutationId,
|
|
);
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 10),
|
|
child: _rejectionTile(
|
|
rejection,
|
|
matched: matched,
|
|
expanded: expanded,
|
|
onToggleExpanded: matched?.payload == null
|
|
? null
|
|
: () => _toggleRejectionExpanded(
|
|
rejection.clientMutationId,
|
|
),
|
|
onOpenEntity:
|
|
matched != null &&
|
|
_supportsEntityRepair(matched.entityType)
|
|
? () => _openEntityViewFor(matched)
|
|
: null,
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
],
|
|
FrostedCard(
|
|
child: Text(
|
|
'Sync policy\n• Core usage never blocks on backend\n• Local changes queue first and remain safe offline\n• Pull applies backend envelopes into local state',
|
|
style: Theme.of(context).textTheme.bodyLarge,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _infoRow({
|
|
required String label,
|
|
required String value,
|
|
required bool compact,
|
|
}) {
|
|
if (compact) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Text(
|
|
label,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(value, style: Theme.of(context).textTheme.bodyMedium),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
SizedBox(width: 150, child: Text(label)),
|
|
Expanded(
|
|
child: Text(
|
|
value,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _rejectionTile(
|
|
MutationRejection rejection, {
|
|
required ChangeEnvelope? matched,
|
|
required bool expanded,
|
|
required VoidCallback? onToggleExpanded,
|
|
required VoidCallback? onOpenEntity,
|
|
}) {
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.72),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: <Widget>[
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFEAF7FB),
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
child: Text(
|
|
rejection.code,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.labelMedium?.copyWith(color: AppTheme.primary),
|
|
),
|
|
),
|
|
Text(
|
|
'Mutation ${rejection.clientMutationId}',
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
if (matched != null)
|
|
Text(
|
|
'${matched.entityType}:${matched.entityId}',
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
if (onToggleExpanded != null)
|
|
IconButton(
|
|
onPressed: onToggleExpanded,
|
|
tooltip: expanded ? 'Hide payload' : 'Show payload',
|
|
icon: Icon(
|
|
expanded
|
|
? Icons.keyboard_arrow_up_rounded
|
|
: Icons.keyboard_arrow_down_rounded,
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
rejection.message,
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
),
|
|
if (expanded && matched?.payload != null) ...<Widget>[
|
|
const SizedBox(height: 10),
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFF3F8FB),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: SelectableText(
|
|
const JsonEncoder.withIndent(' ').convert(matched!.payload),
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
),
|
|
],
|
|
if (onOpenEntity != null) ...<Widget>[
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 8,
|
|
children: <Widget>[
|
|
TextButton.icon(
|
|
onPressed: onOpenEntity,
|
|
icon: const Icon(Icons.open_in_new_rounded),
|
|
label: const Text('Open Related Screen'),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
ChangeEnvelope? _findRejectedChange(
|
|
List<ChangeEnvelope> changes,
|
|
String clientMutationId,
|
|
) {
|
|
for (final ChangeEnvelope change in changes) {
|
|
if (change.clientMutationId == clientMutationId) {
|
|
return change;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
String _formatDateTime(DateTime? value) {
|
|
if (value == null) {
|
|
return 'never';
|
|
}
|
|
|
|
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';
|
|
}
|
|
|
|
Future<void> _syncNow() async {
|
|
await _run((SyncCoordinator coordinator) => coordinator.syncNow());
|
|
}
|
|
|
|
Future<void> _pushPending() async {
|
|
await _run((SyncCoordinator coordinator) => coordinator.pushPending());
|
|
}
|
|
|
|
Future<void> _pullChanges() async {
|
|
await _run((SyncCoordinator coordinator) => coordinator.pullRemote());
|
|
}
|
|
|
|
Future<void> _clearQueue() async {
|
|
setState(() {
|
|
_busy = true;
|
|
});
|
|
|
|
try {
|
|
await ref.read(syncQueueRepositoryProvider.notifier).clearQueue();
|
|
setState(() {
|
|
_status = 'Cleared pending sync queue.';
|
|
});
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_busy = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _dismissRejections() async {
|
|
setState(() {
|
|
_busy = true;
|
|
});
|
|
|
|
try {
|
|
await ref.read(syncQueueRepositoryProvider.notifier).clearRejections();
|
|
setState(() {
|
|
_status = 'Dismissed latest rejected-mutation details.';
|
|
});
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_busy = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _requeueRejected() async {
|
|
setState(() {
|
|
_busy = true;
|
|
});
|
|
|
|
try {
|
|
await ref
|
|
.read(syncQueueRepositoryProvider.notifier)
|
|
.requeueRejectedChanges();
|
|
setState(() {
|
|
_status = 'Requeued rejected changes. Sync again after local fixes.';
|
|
});
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_busy = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _run(
|
|
Future<SyncRunResult> Function(SyncCoordinator coordinator) action,
|
|
) async {
|
|
setState(() {
|
|
_busy = true;
|
|
_status = 'Running sync operation...';
|
|
});
|
|
|
|
try {
|
|
final SyncRunResult result = await action(
|
|
ref.read(syncCoordinatorProvider),
|
|
);
|
|
setState(() {
|
|
_status = result.message;
|
|
});
|
|
} catch (error) {
|
|
setState(() {
|
|
_status = 'Sync operation failed unexpectedly. ($error)';
|
|
});
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_busy = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
void _toggleRejectionExpanded(String clientMutationId) {
|
|
setState(() {
|
|
if (_expandedRejections.contains(clientMutationId)) {
|
|
_expandedRejections.remove(clientMutationId);
|
|
} else {
|
|
_expandedRejections.add(clientMutationId);
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _openEntityViewFor(ChangeEnvelope matched) async {
|
|
if (!_supportsEntityRepair(matched.entityType)) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('No editor available for ${matched.entityType}.'),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
await Navigator.of(context).push<void>(
|
|
MaterialPageRoute<void>(
|
|
builder: (BuildContext context) => _EntityResolveScreen(
|
|
title: _titleForEntityType(matched.entityType),
|
|
entityType: matched.entityType,
|
|
entityId: matched.entityId,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
bool _supportsEntityRepair(String entityType) {
|
|
switch (entityType) {
|
|
case 'person':
|
|
case 'capture':
|
|
case 'giftIdea':
|
|
case 'eventIdea':
|
|
case 'reminderRule':
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
String _titleForEntityType(String entityType) {
|
|
switch (entityType) {
|
|
case 'person':
|
|
return 'Resolve Person';
|
|
case 'capture':
|
|
return 'Resolve Moment';
|
|
case 'giftIdea':
|
|
case 'eventIdea':
|
|
return 'Resolve Idea';
|
|
case 'reminderRule':
|
|
return 'Resolve Reminder';
|
|
default:
|
|
return 'Resolve Item';
|
|
}
|
|
}
|
|
}
|
|
|
|
class _EntityResolveScreen extends ConsumerWidget {
|
|
const _EntityResolveScreen({
|
|
required this.title,
|
|
required this.entityType,
|
|
required this.entityId,
|
|
});
|
|
|
|
final String title;
|
|
final String entityType;
|
|
final String entityId;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final AsyncValue<LocalDataState> localData = ref.watch(
|
|
localRepositoryProvider,
|
|
);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(title: Text(title)),
|
|
body: Column(
|
|
children: <Widget>[
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
|
color: const Color(0xFFEAF7FB),
|
|
child: Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: <Widget>[
|
|
Text('Focus: $entityType:$entityId'),
|
|
TextButton.icon(
|
|
onPressed: () async {
|
|
await Clipboard.setData(
|
|
ClipboardData(text: '$entityType:$entityId'),
|
|
);
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Entity ID copied.')),
|
|
);
|
|
}
|
|
},
|
|
icon: const Icon(Icons.copy_rounded),
|
|
label: const Text('Copy'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
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';
|
|
}
|