import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../models/announcement.dart'; import '../../providers/announcements_provider.dart'; import '../../theme/m3_motion.dart'; import '../../utils/app_time.dart'; import '../../utils/snackbar.dart'; import '../../widgets/app_breakpoints.dart'; /// All user roles available for announcement visibility. const _allRoles = ['admin', 'dispatcher', 'programmer', 'it_staff', 'standard']; /// Default visible roles (standard is excluded by default). const _defaultVisibleRoles = ['admin', 'dispatcher', 'programmer', 'it_staff']; /// Human-readable labels for each role. const _roleLabels = { 'admin': 'Admin', 'dispatcher': 'Dispatcher', 'programmer': 'Programmer', 'it_staff': 'IT Staff', 'standard': 'Standard', }; /// Push notification interval options (minutes → display label). /// [null] means no scheduled push. const _pushIntervalOptions = [ (null, 'No scheduled push'), (1, 'Every minute'), (5, 'Every 5 minutes'), (10, 'Every 10 minutes'), (15, 'Every 15 minutes'), (30, 'Every 30 minutes'), (60, 'Every hour'), (120, 'Every 2 hours'), (360, 'Every 6 hours'), (720, 'Every 12 hours'), (1440, 'Daily'), ]; String _formatDt(DateTime dt) => '${AppTime.formatDate(dt)} ${AppTime.formatTime(dt)}'; /// Picks a date+time using the platform date and time pickers. /// Returns a Manila-timezone [DateTime] or [null] if cancelled. Future pickDateTime( BuildContext context, { DateTime? initial, }) async { final now = AppTime.now(); final startDate = initial ?? now; final date = await showDatePicker( context: context, initialDate: startDate, firstDate: now.subtract(const Duration(days: 1)), lastDate: now.add(const Duration(days: 365)), ); if (date == null || !context.mounted) return null; final time = await showTimePicker( context: context, initialTime: TimeOfDay.fromDateTime(initial ?? now), ); if (time == null) return null; return AppTime.fromComponents( year: date.year, month: date.month, day: date.day, hour: time.hour, minute: time.minute, ); } /// Shows the create/edit announcement dialog. /// /// On mobile, uses a full-screen bottom sheet; on desktop, a centered dialog. Future showCreateAnnouncementDialog( BuildContext context, { Announcement? editing, }) async { final width = MediaQuery.sizeOf(context).width; if (width < AppBreakpoints.tablet) { await m3ShowBottomSheet( context: context, isScrollControlled: true, builder: (ctx) => _CreateAnnouncementContent(editing: editing), ); } else { await m3ShowDialog( context: context, builder: (ctx) => Dialog( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560), child: _CreateAnnouncementContent(editing: editing), ), ), ); } } /// Shows a focused dialog to edit only the banner settings of an announcement. Future showBannerSettingsDialog( BuildContext context, { required Announcement announcement, }) async { final width = MediaQuery.sizeOf(context).width; if (width < AppBreakpoints.tablet) { await m3ShowBottomSheet( context: context, isScrollControlled: true, builder: (ctx) => _BannerSettingsContent(announcement: announcement), ); } else { await m3ShowDialog( context: context, builder: (ctx) => Dialog( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480), child: _BannerSettingsContent(announcement: announcement), ), ), ); } } // ───────────────────────────────────────────────────────────────────────────── // Create / Edit dialog // ───────────────────────────────────────────────────────────────────────────── class _CreateAnnouncementContent extends ConsumerStatefulWidget { const _CreateAnnouncementContent({this.editing}); final Announcement? editing; @override ConsumerState<_CreateAnnouncementContent> createState() => _CreateAnnouncementContentState(); } class _CreateAnnouncementContentState extends ConsumerState<_CreateAnnouncementContent> { late final TextEditingController _titleCtrl; late final TextEditingController _bodyCtrl; late Set _selectedRoles; bool _isTemplate = false; bool _submitting = false; // Template selection String? _selectedTemplateId; // Banner state bool _bannerEnabled = false; bool _bannerShowImmediately = true; // false = custom date/time DateTime? _bannerShowAt; bool _bannerHideManual = true; // false = auto hide at custom date/time DateTime? _bannerHideAt; int? _pushIntervalMinutes; // null = no scheduled push @override void initState() { super.initState(); final source = widget.editing; _titleCtrl = TextEditingController(text: source?.title ?? ''); _bodyCtrl = TextEditingController(text: source?.body ?? ''); _selectedRoles = source != null ? Set.from(source.visibleRoles) : Set.from(_defaultVisibleRoles); _isTemplate = widget.editing?.isTemplate ?? false; // Banner initialisation from existing data _bannerEnabled = source?.bannerEnabled ?? false; _bannerShowAt = source?.bannerShowAt; _bannerShowImmediately = source?.bannerShowAt == null; _bannerHideAt = source?.bannerHideAt; _bannerHideManual = source?.bannerHideAt == null; _pushIntervalMinutes = source?.pushIntervalMinutes; } @override void dispose() { _titleCtrl.dispose(); _bodyCtrl.dispose(); super.dispose(); } bool get _canSubmit { final hasContent = _titleCtrl.text.trim().isNotEmpty && _bodyCtrl.text.trim().isNotEmpty; final hasRoles = _selectedRoles.isNotEmpty; return hasContent && hasRoles && !_submitting; } void _applyTemplate(Announcement template) { setState(() { _selectedTemplateId = template.id; _titleCtrl.text = template.title; _bodyCtrl.text = template.body; _selectedRoles = Set.from(template.visibleRoles); }); } void _clearTemplate() { setState(() { _selectedTemplateId = null; }); } Future _pickShowAt() async { final dt = await pickDateTime(context, initial: _bannerShowAt); if (dt != null) setState(() => _bannerShowAt = dt); } Future _pickHideAt() async { final dt = await pickDateTime(context, initial: _bannerHideAt); if (dt != null) setState(() => _bannerHideAt = dt); } Future _submit() async { if (!_canSubmit) return; setState(() => _submitting = true); try { final ctrl = ref.read(announcementsControllerProvider); final showAt = _bannerEnabled && !_bannerShowImmediately ? _bannerShowAt : null; final hideAt = _bannerEnabled && !_bannerHideManual ? _bannerHideAt : null; final interval = _bannerEnabled ? _pushIntervalMinutes : null; if (widget.editing != null) { await ctrl.updateAnnouncement( id: widget.editing!.id, title: _titleCtrl.text.trim(), body: _bodyCtrl.text.trim(), visibleRoles: _selectedRoles.toList(), isTemplate: _isTemplate, bannerEnabled: _bannerEnabled, bannerShowAt: showAt, bannerHideAt: hideAt, pushIntervalMinutes: interval, clearBannerShowAt: _bannerShowImmediately, clearBannerHideAt: _bannerHideManual, clearPushInterval: interval == null, ); } else { await ctrl.createAnnouncement( title: _titleCtrl.text.trim(), body: _bodyCtrl.text.trim(), visibleRoles: _selectedRoles.toList(), isTemplate: _isTemplate, templateId: _selectedTemplateId, bannerEnabled: _bannerEnabled, bannerShowAt: showAt, bannerHideAt: hideAt, pushIntervalMinutes: interval, ); } if (mounted) Navigator.of(context).pop(); showSuccessSnackBarGlobal( widget.editing != null ? 'Announcement updated.' : 'Announcement posted.'); } on AnnouncementNotificationException { if (mounted) { final messenger = ScaffoldMessenger.of(context); Navigator.of(context).pop(); messenger.showSnackBar( const SnackBar( content: Text('Posted, but some notifications failed to send.'), ), ); } } catch (e) { if (mounted) showErrorSnackBar(context, 'Failed to save: $e'); } finally { if (mounted) setState(() => _submitting = false); } } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final tt = Theme.of(context).textTheme; final isEditing = widget.editing != null; final templates = ref .watch(announcementsProvider) .valueOrNull ?.where((a) => a.isTemplate) .toList() ?? []; return SingleChildScrollView( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( isEditing ? 'Edit Announcement' : 'New Announcement', style: tt.titleLarge?.copyWith(fontWeight: FontWeight.w600), ), const SizedBox(height: 20), // Template selector (only for new announcements) if (!isEditing && templates.isNotEmpty) ...[ DropdownButtonFormField( decoration: InputDecoration( labelText: 'Post from template (optional)', border: const OutlineInputBorder(), suffixIcon: _selectedTemplateId != null ? IconButton( icon: const Icon(Icons.clear), tooltip: 'Clear template', onPressed: _clearTemplate, ) : null, ), key: ValueKey(_selectedTemplateId), initialValue: _selectedTemplateId, items: [ const DropdownMenuItem( value: null, child: Text('No template'), ), ...templates.map( (t) => DropdownMenuItem( value: t.id, child: Text( t.title.isEmpty ? '(Untitled)' : t.title, overflow: TextOverflow.ellipsis, ), ), ), ], onChanged: (id) { if (id == null) { _clearTemplate(); } else { final tmpl = templates.firstWhere((t) => t.id == id); _applyTemplate(tmpl); } }, ), const SizedBox(height: 16), ], // Title field TextField( controller: _titleCtrl, decoration: const InputDecoration( labelText: 'Title', border: OutlineInputBorder(), ), textInputAction: TextInputAction.next, onChanged: (_) => setState(() {}), ), const SizedBox(height: 16), // Body field TextField( controller: _bodyCtrl, decoration: const InputDecoration( labelText: 'Body', border: OutlineInputBorder(), alignLabelWithHint: true, ), maxLines: 6, minLines: 3, onChanged: (_) => setState(() {}), ), const SizedBox(height: 16), // Role visibility Text('Visible to:', style: tt.labelLarge), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 6, children: _allRoles.map((role) { final selected = _selectedRoles.contains(role); return FilterChip( label: Text(_roleLabels[role] ?? role), selected: selected, onSelected: (val) { setState(() { if (val) { _selectedRoles.add(role); } else { _selectedRoles.remove(role); } }); }, ); }).toList(), ), const SizedBox(height: 16), // Template toggle SwitchListTile( contentPadding: EdgeInsets.zero, title: Text('Save as template', style: tt.bodyMedium), subtitle: Text( 'Templates can be selected when creating new announcements.', style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), ), value: _isTemplate, onChanged: (val) => setState(() => _isTemplate = val), ), // ── Banner notification section ───────────────────────────────── const Divider(height: 24), SwitchListTile( contentPadding: EdgeInsets.zero, title: Row( children: [ Icon(Icons.campaign_outlined, size: 18, color: cs.primary), const SizedBox(width: 6), Text('Banner Notification', style: tt.bodyMedium), ], ), subtitle: Text( 'Pin a prominent banner at the top of the Announcements screen.', style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), ), value: _bannerEnabled, onChanged: (val) => setState(() => _bannerEnabled = val), ), if (_bannerEnabled) ...[ const SizedBox(height: 12), _BannerOptionsPanel( showImmediately: _bannerShowImmediately, showAt: _bannerShowAt, hideManual: _bannerHideManual, hideAt: _bannerHideAt, pushIntervalMinutes: _pushIntervalMinutes, onShowImmediatelyChanged: (v) => setState(() => _bannerShowImmediately = v), onPickShowAt: _pickShowAt, onHideManualChanged: (v) => setState(() => _bannerHideManual = v), onPickHideAt: _pickHideAt, onIntervalChanged: (v) => setState(() => _pushIntervalMinutes = v), ), ], const SizedBox(height: 20), // Action buttons Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel'), ), const SizedBox(width: 12), FilledButton( onPressed: _canSubmit ? _submit : null, child: _submitting ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white), ) : Text(isEditing ? 'Save' : 'Post'), ), ], ), ], ), ); } } // ───────────────────────────────────────────────────────────────────────────── // Banner Settings dialog (post-creation editing) // ───────────────────────────────────────────────────────────────────────────── class _BannerSettingsContent extends ConsumerStatefulWidget { const _BannerSettingsContent({required this.announcement}); final Announcement announcement; @override ConsumerState<_BannerSettingsContent> createState() => _BannerSettingsContentState(); } class _BannerSettingsContentState extends ConsumerState<_BannerSettingsContent> { late bool _bannerEnabled; late bool _bannerShowImmediately; DateTime? _bannerShowAt; late bool _bannerHideManual; DateTime? _bannerHideAt; int? _pushIntervalMinutes; bool _submitting = false; @override void initState() { super.initState(); final a = widget.announcement; _bannerEnabled = a.bannerEnabled; _bannerShowAt = a.bannerShowAt; _bannerShowImmediately = a.bannerShowAt == null; _bannerHideAt = a.bannerHideAt; _bannerHideManual = a.bannerHideAt == null; _pushIntervalMinutes = a.pushIntervalMinutes; } Future _pickShowAt() async { final dt = await pickDateTime(context, initial: _bannerShowAt); if (dt != null) setState(() => _bannerShowAt = dt); } Future _pickHideAt() async { final dt = await pickDateTime(context, initial: _bannerHideAt); if (dt != null) setState(() => _bannerHideAt = dt); } Future _save() async { setState(() => _submitting = true); try { final showAt = !_bannerShowImmediately ? _bannerShowAt : null; final hideAt = !_bannerHideManual ? _bannerHideAt : null; final interval = _bannerEnabled ? _pushIntervalMinutes : null; await ref.read(announcementsControllerProvider).updateBannerSettings( id: widget.announcement.id, bannerEnabled: _bannerEnabled, bannerShowAt: showAt, bannerHideAt: hideAt, pushIntervalMinutes: interval, ); if (mounted) Navigator.of(context).pop(); showSuccessSnackBarGlobal('Banner settings saved.'); } catch (e) { if (mounted) showErrorSnackBar(context, 'Failed to save: $e'); } finally { if (mounted) setState(() => _submitting = false); } } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final tt = Theme.of(context).textTheme; return SingleChildScrollView( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ Icon(Icons.campaign_outlined, color: cs.primary), const SizedBox(width: 8), Text( 'Banner Settings', style: tt.titleLarge?.copyWith(fontWeight: FontWeight.w600), ), ], ), const SizedBox(height: 4), Text( widget.announcement.title, style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), maxLines: 2, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 20), SwitchListTile( contentPadding: EdgeInsets.zero, title: Text('Banner enabled', style: tt.bodyMedium), value: _bannerEnabled, onChanged: (v) => setState(() => _bannerEnabled = v), ), if (_bannerEnabled) ...[ const SizedBox(height: 12), _BannerOptionsPanel( showImmediately: _bannerShowImmediately, showAt: _bannerShowAt, hideManual: _bannerHideManual, hideAt: _bannerHideAt, pushIntervalMinutes: _pushIntervalMinutes, onShowImmediatelyChanged: (v) => setState(() => _bannerShowImmediately = v), onPickShowAt: _pickShowAt, onHideManualChanged: (v) => setState(() => _bannerHideManual = v), onPickHideAt: _pickHideAt, onIntervalChanged: (v) => setState(() => _pushIntervalMinutes = v), ), ], const SizedBox(height: 20), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel'), ), const SizedBox(width: 12), FilledButton( onPressed: _submitting ? null : _save, child: _submitting ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white), ) : const Text('Save'), ), ], ), ], ), ); } } // ───────────────────────────────────────────────────────────────────────────── // Shared banner options sub-panel (show from / stop showing / push interval) // ───────────────────────────────────────────────────────────────────────────── class _BannerOptionsPanel extends StatelessWidget { const _BannerOptionsPanel({ required this.showImmediately, required this.showAt, required this.hideManual, required this.hideAt, required this.pushIntervalMinutes, required this.onShowImmediatelyChanged, required this.onPickShowAt, required this.onHideManualChanged, required this.onPickHideAt, required this.onIntervalChanged, }); final bool showImmediately; final DateTime? showAt; final bool hideManual; final DateTime? hideAt; final int? pushIntervalMinutes; final ValueChanged onShowImmediatelyChanged; final VoidCallback onPickShowAt; final ValueChanged onHideManualChanged; final VoidCallback onPickHideAt; final ValueChanged onIntervalChanged; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final tt = Theme.of(context).textTheme; return Container( decoration: BoxDecoration( color: cs.primaryContainer.withValues(alpha: 0.35), borderRadius: BorderRadius.circular(12), border: Border.all(color: cs.primary.withValues(alpha: 0.25)), ), padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // ── Show from ──────────────────────────────────────────────────── Text('Show banner from', style: tt.labelMedium?.copyWith(color: cs.primary)), const SizedBox(height: 8), SegmentedButton( segments: const [ ButtonSegment(value: true, label: Text('Auto (now)')), ButtonSegment(value: false, label: Text('Custom')), ], selected: {showImmediately}, onSelectionChanged: (s) => onShowImmediatelyChanged(s.first), style: SegmentedButton.styleFrom( visualDensity: VisualDensity.compact, ), ), if (!showImmediately) ...[ const SizedBox(height: 8), OutlinedButton.icon( onPressed: onPickShowAt, icon: const Icon(Icons.calendar_today_outlined, size: 16), label: Text( showAt != null ? _formatDt(showAt!) : 'Pick date & time', ), ), ], const SizedBox(height: 16), // ── Stop showing ───────────────────────────────────────────────── Text('Stop showing', style: tt.labelMedium?.copyWith(color: cs.primary)), const SizedBox(height: 8), SegmentedButton( segments: const [ ButtonSegment(value: true, label: Text('Manual (admin/poster)')), ButtonSegment(value: false, label: Text('Auto')), ], selected: {hideManual}, onSelectionChanged: (s) => onHideManualChanged(s.first), style: SegmentedButton.styleFrom( visualDensity: VisualDensity.compact, ), ), if (!hideManual) ...[ const SizedBox(height: 8), OutlinedButton.icon( onPressed: onPickHideAt, icon: const Icon(Icons.event_busy_outlined, size: 16), label: Text( hideAt != null ? _formatDt(hideAt!) : 'Pick end date & time', ), ), ], const SizedBox(height: 16), // ── Push notification interval ──────────────────────────────── Text('Push reminders', style: tt.labelMedium?.copyWith(color: cs.primary)), const SizedBox(height: 8), DropdownButtonFormField( key: ValueKey(pushIntervalMinutes), decoration: const InputDecoration( border: OutlineInputBorder(), isDense: true, contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10), ), initialValue: pushIntervalMinutes, items: _pushIntervalOptions .map((opt) => DropdownMenuItem( value: opt.$1, child: Text(opt.$2), )) .toList(), onChanged: (v) => onIntervalChanged(v), ), if (pushIntervalMinutes != null) Padding( padding: const EdgeInsets.only(top: 6), child: Text( 'A push notification will be sent to all visible users at this interval while the banner is active.', style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), ), ), ], ), ); } }