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 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', '#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 _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 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 _delete() async { final confirmed = await showDialog( 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()!.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 _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(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( 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(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()!.primary, ), onPressed: _saving ? null : _save, child: _saving ? const SizedBox( height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2), ) : Text(_isCreate ? 'Create' : 'Save'), ), ), ], ), ), ); } }