fe10067761
Phase 1 cached only notes. Phase 2 brings the read-through pattern to every domain the app reads in bulk: - Drift schema v2: 5 new cached_* tables + onUpgrade migration. Calendar events use range-scoped read/write (replaceEventsInRange) so disjoint month fetches don't clobber each other; milestones are per-project. - Wrap TasksRepository, ProjectsRepository, MilestonesRepository, and ChatRepository (conversation list only) with NetworkException fallback. Writes hit the API then sync the cache. - New EventsRepository wrapping EventsApi; calendar_provider and event_form_sheet repointed at the repository. - OfflineBanner now surfaces getLatestSync() — most-recent timestamp across all domains — so the hint reads sensibly regardless of screen. flutter analyze clean; existing tests pass. Phase 3 (offline write queue) and Phase 4 (read-only UI indicators) still to come. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
478 lines
16 KiB
Dart
478 lines
16 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:lucide_icons/lucide_icons.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../core/theme.dart';
|
|
import '../../data/models/calendar_event.dart';
|
|
import '../../providers/api_client_provider.dart';
|
|
import '../../providers/calendar_provider.dart';
|
|
|
|
class EventFormSheet extends ConsumerStatefulWidget {
|
|
/// null = create mode; non-null = edit mode.
|
|
final CalendarEvent? event;
|
|
|
|
/// Pre-fills start date in create mode. Ignored in edit mode.
|
|
final DateTime? initialDate;
|
|
|
|
final CalendarNotifier notifier;
|
|
|
|
const EventFormSheet({
|
|
super.key,
|
|
required this.event,
|
|
required this.initialDate,
|
|
required this.notifier,
|
|
});
|
|
|
|
@override
|
|
ConsumerState<EventFormSheet> createState() => _EventFormSheetState();
|
|
}
|
|
|
|
class _EventFormSheetState extends ConsumerState<EventFormSheet> {
|
|
late final TextEditingController _titleCtrl;
|
|
late final TextEditingController _descCtrl;
|
|
late final TextEditingController _locationCtrl;
|
|
|
|
late DateTime _startDt;
|
|
DateTime? _endDt;
|
|
late bool _allDay;
|
|
String? _recurrence; // null = None; one of the FREQ= strings otherwise
|
|
String _color = '';
|
|
bool _saving = false;
|
|
|
|
bool get _isCreate => widget.event == null;
|
|
|
|
/// Maps RRULE string (or null) to display label for the dropdown.
|
|
static const Map<String?, String> _knownRrules = {
|
|
null: 'None',
|
|
'FREQ=DAILY': 'Daily',
|
|
'FREQ=WEEKLY': 'Weekly',
|
|
'FREQ=MONTHLY': 'Monthly',
|
|
'FREQ=YEARLY': 'Yearly',
|
|
};
|
|
|
|
/// True when editing an event whose RRULE is not one of the 5 known patterns.
|
|
bool get _isCustomRrule =>
|
|
widget.event?.recurrence != null &&
|
|
!_knownRrules.containsKey(widget.event!.recurrence);
|
|
|
|
static const List<String> _presetColors = [
|
|
'#EF4444',
|
|
'#F59E0B',
|
|
'#10B981',
|
|
'#7C3AED',
|
|
'#8B5CF6',
|
|
'#EC4899',
|
|
];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final e = widget.event;
|
|
_titleCtrl = TextEditingController(text: e?.title ?? '');
|
|
_descCtrl = TextEditingController(text: e?.description ?? '');
|
|
_locationCtrl = TextEditingController(text: e?.location ?? '');
|
|
|
|
if (e != null) {
|
|
_startDt = e.startDt.toLocal();
|
|
_endDt = e.endDt?.toLocal();
|
|
_allDay = e.allDay;
|
|
// Custom RRULEs are displayed as read-only; leave _recurrence = null.
|
|
_recurrence = _isCustomRrule ? null : e.recurrence;
|
|
_color = e.color;
|
|
} else {
|
|
final base = widget.initialDate ?? DateTime.now();
|
|
final now = DateTime.now();
|
|
final hour = now.minute >= 30 ? (now.hour + 1) % 24 : now.hour;
|
|
_startDt = DateTime(base.year, base.month, base.day, hour);
|
|
_allDay = false;
|
|
_recurrence = null;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_titleCtrl.dispose();
|
|
_descCtrl.dispose();
|
|
_locationCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
// ── Save ──────────────────────────────────────────────────────────────────
|
|
|
|
Future<void> _save() async {
|
|
if (_titleCtrl.text.trim().isEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Title is required.')),
|
|
);
|
|
return;
|
|
}
|
|
setState(() => _saving = true);
|
|
try {
|
|
// Preserve unrecognised RRULEs unchanged on save.
|
|
final rrule = _isCustomRrule ? widget.event!.recurrence : _recurrence;
|
|
final payload = <String, dynamic>{
|
|
'title': _titleCtrl.text.trim(),
|
|
'start_dt': _startDt.toUtc().toIso8601String(),
|
|
if (_endDt != null) 'end_dt': _endDt!.toUtc().toIso8601String(),
|
|
'all_day': _allDay,
|
|
'description': _descCtrl.text.trim(),
|
|
'location': _locationCtrl.text.trim(),
|
|
'color': _color,
|
|
'recurrence': rrule,
|
|
};
|
|
final repo = ref.read(eventsRepositoryProvider);
|
|
if (_isCreate) {
|
|
final created = await repo.createEvent(payload);
|
|
widget.notifier.addEvent(created);
|
|
} else {
|
|
final updated = await repo.updateEvent(widget.event!.id, payload);
|
|
widget.notifier.updateEvent(updated);
|
|
}
|
|
if (mounted) Navigator.of(context).pop();
|
|
} catch (_) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Failed to save event.')),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _saving = false);
|
|
}
|
|
}
|
|
|
|
// ── Delete ────────────────────────────────────────────────────────────────
|
|
|
|
Future<void> _delete() async {
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: const Text('Delete this event?'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext, false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext, true),
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: Theme.of(dialogContext).extension<ActionColors>()!.destructive,
|
|
),
|
|
child: const Text('Delete'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirmed != true || !mounted) return;
|
|
setState(() => _saving = true);
|
|
try {
|
|
await ref.read(eventsRepositoryProvider).deleteEvent(widget.event!.id);
|
|
widget.notifier.removeEvent(widget.event!.id, widget.event!.startDt);
|
|
if (mounted) Navigator.of(context).pop();
|
|
} catch (_) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Failed to delete event.')),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _saving = false);
|
|
}
|
|
}
|
|
|
|
// ── Date/time pickers ─────────────────────────────────────────────────────
|
|
|
|
Future<void> _pickStartDate() async {
|
|
final d = await showDatePicker(
|
|
context: context,
|
|
initialDate: _startDt,
|
|
firstDate: DateTime(2000),
|
|
lastDate: DateTime(2100),
|
|
);
|
|
if (d != null) {
|
|
setState(() {
|
|
_startDt = DateTime(
|
|
d.year, d.month, d.day, _startDt.hour, _startDt.minute);
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _pickStartTime() async {
|
|
final t = await showTimePicker(
|
|
context: context,
|
|
initialTime: TimeOfDay.fromDateTime(_startDt),
|
|
);
|
|
if (t != null) {
|
|
setState(() {
|
|
_startDt = DateTime(
|
|
_startDt.year, _startDt.month, _startDt.day, t.hour, t.minute);
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _pickEndDate() async {
|
|
final d = await showDatePicker(
|
|
context: context,
|
|
initialDate: _endDt ?? _startDt,
|
|
firstDate: DateTime(2000),
|
|
lastDate: DateTime(2100),
|
|
);
|
|
if (d != null) {
|
|
setState(() {
|
|
final prev = _endDt ?? _startDt;
|
|
_endDt =
|
|
DateTime(d.year, d.month, d.day, prev.hour, prev.minute);
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _pickEndTime() async {
|
|
final t = await showTimePicker(
|
|
context: context,
|
|
initialTime: TimeOfDay.fromDateTime(_endDt ?? _startDt),
|
|
);
|
|
if (t != null) {
|
|
setState(() {
|
|
final prev = _endDt ?? _startDt;
|
|
_endDt = DateTime(
|
|
prev.year, prev.month, prev.day, t.hour, t.minute);
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Formatting helpers ────────────────────────────────────────────────────
|
|
|
|
String _fmtDate(DateTime dt) {
|
|
const months = [
|
|
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
|
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
|
];
|
|
return '${months[dt.month - 1]} ${dt.day}, ${dt.year}';
|
|
}
|
|
|
|
String _fmtTime(DateTime dt) =>
|
|
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
|
|
|
// ── Build ─────────────────────────────────────────────────────────────────
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding:
|
|
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Header
|
|
Row(
|
|
children: [
|
|
Text(
|
|
_isCreate ? 'New Event' : 'Edit Event',
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const Spacer(),
|
|
if (!_isCreate)
|
|
IconButton(
|
|
icon: const Icon(LucideIcons.trash2),
|
|
color: Theme.of(context).colorScheme.error,
|
|
onPressed: _saving ? null : _delete,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Title
|
|
TextField(
|
|
controller: _titleCtrl,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Title',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
textCapitalization: TextCapitalization.sentences,
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
// All-day toggle
|
|
SwitchListTile(
|
|
title: const Text('All day'),
|
|
value: _allDay,
|
|
onChanged: (v) => setState(() => _allDay = v),
|
|
contentPadding: EdgeInsets.zero,
|
|
),
|
|
|
|
// Start date
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(LucideIcons.calendarDays),
|
|
title: Text(_fmtDate(_startDt)),
|
|
subtitle: const Text('Start date'),
|
|
onTap: _pickStartDate,
|
|
),
|
|
|
|
// Start time (hidden when all-day)
|
|
if (!_allDay)
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(LucideIcons.clock),
|
|
title: Text(_fmtTime(_startDt)),
|
|
subtitle: const Text('Start time'),
|
|
onTap: _pickStartTime,
|
|
),
|
|
|
|
// End date
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(LucideIcons.calendarCheck),
|
|
title: Text(
|
|
_endDt != null ? _fmtDate(_endDt!) : 'No end date'),
|
|
subtitle: const Text('End date'),
|
|
onTap: _pickEndDate,
|
|
trailing: _endDt != null
|
|
? IconButton(
|
|
icon: const Icon(LucideIcons.x),
|
|
onPressed: () => setState(() => _endDt = null),
|
|
)
|
|
: null,
|
|
),
|
|
|
|
// End time (hidden when all-day or no end date)
|
|
if (!_allDay && _endDt != null)
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(LucideIcons.clock),
|
|
title: Text(_fmtTime(_endDt!)),
|
|
subtitle: const Text('End time'),
|
|
onTap: _pickEndTime,
|
|
),
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
// Repeat — show read-only tile for custom RRULEs
|
|
if (_isCustomRrule)
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(LucideIcons.repeat),
|
|
title: const Text('Custom (read-only)'),
|
|
subtitle: Text(widget.event!.recurrence ?? ''),
|
|
)
|
|
else
|
|
Row(
|
|
children: [
|
|
const Icon(LucideIcons.repeat),
|
|
const SizedBox(width: 16),
|
|
DropdownButton<String?>(
|
|
value: _recurrence,
|
|
underline: const SizedBox.shrink(),
|
|
items: _knownRrules.entries
|
|
.map((entry) => DropdownMenuItem<String?>(
|
|
value: entry.key,
|
|
child: Text(entry.value),
|
|
))
|
|
.toList(),
|
|
onChanged: (v) => setState(() => _recurrence = v),
|
|
),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
// Description
|
|
TextField(
|
|
controller: _descCtrl,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Description',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
maxLines: 3,
|
|
textCapitalization: TextCapitalization.sentences,
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
// Location
|
|
TextField(
|
|
controller: _locationCtrl,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Location',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Color chips
|
|
Text('Color', style: Theme.of(context).textTheme.labelMedium),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
// "No color" chip
|
|
GestureDetector(
|
|
onTap: () => setState(() => _color = ''),
|
|
child: Container(
|
|
width: 32,
|
|
height: 32,
|
|
margin: const EdgeInsets.only(right: 8),
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
border: Border.all(
|
|
color: _color.isEmpty
|
|
? Theme.of(context).colorScheme.primary
|
|
: Theme.of(context).colorScheme.outline,
|
|
width: _color.isEmpty ? 2 : 1,
|
|
),
|
|
),
|
|
child: const Icon(LucideIcons.ban, size: 16),
|
|
),
|
|
),
|
|
..._presetColors.map((hex) {
|
|
final c =
|
|
Color(int.parse(hex.replaceFirst('#', '0xFF')));
|
|
return GestureDetector(
|
|
onTap: () => setState(() => _color = hex),
|
|
child: Container(
|
|
width: 32,
|
|
height: 32,
|
|
margin: const EdgeInsets.only(right: 8),
|
|
decoration: BoxDecoration(
|
|
color: c,
|
|
shape: BoxShape.circle,
|
|
border: _color == hex
|
|
? Border.all(
|
|
color: Theme.of(context)
|
|
.colorScheme
|
|
.primary,
|
|
width: 2,
|
|
)
|
|
: null,
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
// Save button — Moss action-primary per Hybrid rule
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: FilledButton(
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: Theme.of(context).extension<ActionColors>()!.primary,
|
|
),
|
|
onPressed: _saving ? null : _save,
|
|
child: _saving
|
|
? const SizedBox(
|
|
height: 20,
|
|
width: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: Text(_isCreate ? 'Create' : 'Save'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|