diff --git a/lib/screens/calendar/event_form_sheet.dart b/lib/screens/calendar/event_form_sheet.dart new file mode 100644 index 0000000..4197543 --- /dev/null +++ b/lib/screens/calendar/event_form_sheet.dart @@ -0,0 +1,473 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.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 createState() => _EventFormSheetState(); +} + +class _EventFormSheetState extends ConsumerState { + 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 _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 _presetColors = [ + '#EF4444', + '#F59E0B', + '#10B981', + '#6366F1', + '#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 _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 = { + '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 api = ref.read(eventsApiProvider); + if (_isCreate) { + final created = await api.createEvent(payload); + widget.notifier.addEvent(created); + } else { + final updated = await api.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 _delete() async { + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Delete this event?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text( + 'Delete', + style: + TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() => _saving = true); + try { + await ref.read(eventsApiProvider).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 _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 _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 _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 _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(Icons.delete_outline), + 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(Icons.calendar_today_outlined), + 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(Icons.access_time_outlined), + title: Text(_fmtTime(_startDt)), + subtitle: const Text('Start time'), + onTap: _pickStartTime, + ), + + // End date + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.event_outlined), + title: Text( + _endDt != null ? _fmtDate(_endDt!) : 'No end date'), + subtitle: const Text('End date'), + onTap: _pickEndDate, + trailing: _endDt != null + ? IconButton( + icon: const Icon(Icons.clear), + 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(Icons.access_time_outlined), + 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(Icons.repeat_outlined), + title: const Text('Custom (read-only)'), + subtitle: Text(widget.event!.recurrence ?? ''), + ) + else + Row( + children: [ + const Icon(Icons.repeat_outlined), + const SizedBox(width: 16), + DropdownButton( + value: _recurrence, + underline: const SizedBox.shrink(), + items: _knownRrules.entries + .map((entry) => DropdownMenuItem( + 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(Icons.block, 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 + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(_isCreate ? 'Create' : 'Save'), + ), + ), + ], + ), + ), + ); + } +}