diff --git a/lib/models/announcement.dart b/lib/models/announcement.dart index 40c1be52..5c4761ad 100644 --- a/lib/models/announcement.dart +++ b/lib/models/announcement.dart @@ -11,6 +11,10 @@ class Announcement { this.templateId, required this.createdAt, required this.updatedAt, + required this.bannerEnabled, + this.bannerShowAt, + this.bannerHideAt, + this.pushIntervalMinutes, }); final String id; @@ -23,6 +27,29 @@ class Announcement { final DateTime createdAt; final DateTime updatedAt; + /// Whether a persistent banner is shown at the top of the announcements screen. + final bool bannerEnabled; + + /// When the banner should start showing. [null] means immediately. + final DateTime? bannerShowAt; + + /// When the banner should stop showing. [null] means it requires a manual + /// turn-off by the poster or an admin. + final DateTime? bannerHideAt; + + /// How often (in minutes) a scheduled push notification is sent while the + /// banner is active. [null] means no scheduled push. Max is 1440 (daily). + final int? pushIntervalMinutes; + + /// Whether the banner is currently active (visible) based on the current time. + bool get isBannerActive { + if (!bannerEnabled) return false; + final now = AppTime.now(); + if (bannerShowAt != null && now.isBefore(bannerShowAt!)) return false; + if (bannerHideAt != null && now.isAfter(bannerHideAt!)) return false; + return true; + } + @override bool operator ==(Object other) => identical(this, other) || @@ -34,6 +61,10 @@ class Announcement { body == other.body && isTemplate == other.isTemplate && templateId == other.templateId && + bannerEnabled == other.bannerEnabled && + bannerShowAt == other.bannerShowAt && + bannerHideAt == other.bannerHideAt && + pushIntervalMinutes == other.pushIntervalMinutes && createdAt == other.createdAt && updatedAt == other.updatedAt; @@ -45,15 +76,17 @@ class Announcement { body, isTemplate, templateId, + bannerEnabled, + bannerShowAt, + bannerHideAt, + pushIntervalMinutes, createdAt, updatedAt, ); factory Announcement.fromMap(Map map) { final rolesRaw = map['visible_roles']; - final roles = rolesRaw is List - ? rolesRaw.cast() - : []; + final roles = rolesRaw is List ? rolesRaw.cast() : []; return Announcement( id: map['id'] as String, @@ -65,6 +98,14 @@ class Announcement { templateId: map['template_id'] as String?, createdAt: AppTime.parse(map['created_at'] as String), updatedAt: AppTime.parse(map['updated_at'] as String), + bannerEnabled: map['banner_enabled'] as bool? ?? false, + bannerShowAt: map['banner_show_at'] != null + ? AppTime.parse(map['banner_show_at'] as String) + : null, + bannerHideAt: map['banner_hide_at'] != null + ? AppTime.parse(map['banner_hide_at'] as String) + : null, + pushIntervalMinutes: map['push_interval_minutes'] as int?, ); } } diff --git a/lib/providers/announcements_provider.dart b/lib/providers/announcements_provider.dart index fab01f56..c104b0dd 100644 --- a/lib/providers/announcements_provider.dart +++ b/lib/providers/announcements_provider.dart @@ -80,6 +80,14 @@ final announcementCommentsProvider = return wrapper.stream.map((result) => result.data); }); +/// Active banner announcements for the current user. +/// Returns only non-template announcements whose banner is currently in its +/// active time window ([Announcement.isBannerActive]). +final activeBannerAnnouncementsProvider = Provider>((ref) { + final all = ref.watch(announcementsProvider).valueOrNull ?? []; + return all.where((a) => !a.isTemplate && a.isBannerActive).toList(); +}); + final announcementsControllerProvider = Provider((ref) { final client = ref.watch(supabaseClientProvider); @@ -100,6 +108,10 @@ class AnnouncementsController { required List visibleRoles, bool isTemplate = false, String? templateId, + bool bannerEnabled = false, + DateTime? bannerShowAt, + DateTime? bannerHideAt, + int? pushIntervalMinutes, }) async { final authorId = _client.auth.currentUser?.id; if (authorId == null) return; @@ -111,6 +123,10 @@ class AnnouncementsController { 'visible_roles': visibleRoles, 'is_template': isTemplate, 'template_id': templateId, + 'banner_enabled': bannerEnabled, + 'banner_show_at': bannerShowAt?.toUtc().toIso8601String(), + 'banner_hide_at': bannerHideAt?.toUtc().toIso8601String(), + 'push_interval_minutes': pushIntervalMinutes, }; final result = await _client @@ -123,6 +139,12 @@ class AnnouncementsController { // Don't send notifications for templates (they are drafts for reuse) if (isTemplate) return; + // Skip the one-time creation push when a scheduled banner push is + // configured. The banner scheduler will send the first push on its own + // interval, so firing an extra push here would result in two back-to-back + // notifications for the same announcement. + if (bannerEnabled && pushIntervalMinutes != null) return; + // Query users whose role matches visible_roles, excluding the author try { final profiles = await _client @@ -161,6 +183,13 @@ class AnnouncementsController { required String body, required List visibleRoles, bool? isTemplate, + bool? bannerEnabled, + DateTime? bannerShowAt, + DateTime? bannerHideAt, + int? pushIntervalMinutes, + bool clearBannerShowAt = false, + bool clearBannerHideAt = false, + bool clearPushInterval = false, }) async { final payload = { 'title': title, @@ -168,12 +197,53 @@ class AnnouncementsController { 'visible_roles': visibleRoles, 'updated_at': AppTime.nowUtc().toIso8601String(), }; - if (isTemplate != null) { - payload['is_template'] = isTemplate; + if (isTemplate != null) payload['is_template'] = isTemplate; + if (bannerEnabled != null) payload['banner_enabled'] = bannerEnabled; + if (bannerShowAt != null) { + payload['banner_show_at'] = bannerShowAt.toUtc().toIso8601String(); + } else if (clearBannerShowAt) { + payload['banner_show_at'] = null; + } + if (bannerHideAt != null) { + payload['banner_hide_at'] = bannerHideAt.toUtc().toIso8601String(); + } else if (clearBannerHideAt) { + payload['banner_hide_at'] = null; + } + if (pushIntervalMinutes != null) { + payload['push_interval_minutes'] = pushIntervalMinutes; + } else if (clearPushInterval) { + payload['push_interval_minutes'] = null; } await _client.from('announcements').update(payload).eq('id', id); } + /// Update only the banner settings on an existing announcement. + /// Intended for the "Manage Banner" popup available to the poster and admins. + Future updateBannerSettings({ + required String id, + required bool bannerEnabled, + DateTime? bannerShowAt, + DateTime? bannerHideAt, + int? pushIntervalMinutes, + }) async { + await _client.from('announcements').update({ + 'banner_enabled': bannerEnabled, + 'banner_show_at': bannerShowAt?.toUtc().toIso8601String(), + 'banner_hide_at': bannerHideAt?.toUtc().toIso8601String(), + 'push_interval_minutes': pushIntervalMinutes, + 'updated_at': AppTime.nowUtc().toIso8601String(), + }).eq('id', id); + } + + /// Immediately stops a banner by setting [banner_hide_at] to now. + /// Usable by the poster or an admin. + Future dismissBanner(String id) async { + await _client.from('announcements').update({ + 'banner_hide_at': AppTime.nowUtc().toIso8601String(), + 'updated_at': AppTime.nowUtc().toIso8601String(), + }).eq('id', id); + } + /// Delete an announcement. Future deleteAnnouncement(String id) async { await _client.from('announcements').delete().eq('id', id); diff --git a/lib/screens/announcements/announcements_screen.dart b/lib/screens/announcements/announcements_screen.dart index 0060fdd0..220d6753 100644 --- a/lib/screens/announcements/announcements_screen.dart +++ b/lib/screens/announcements/announcements_screen.dart @@ -1,8 +1,8 @@ import 'dart:async'; +import 'dart:math' as math show min; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:skeletonizer/skeletonizer.dart'; import '../../models/announcement.dart'; import '../../providers/announcements_provider.dart'; @@ -47,68 +47,73 @@ class _AnnouncementsScreenState extends ConsumerState { floatingActionButton: canCreate ? M3ExpandedFab( heroTag: 'announcement_fab', - onPressed: () => - showCreateAnnouncementDialog(context), + onPressed: () => showCreateAnnouncementDialog(context), icon: const Icon(Icons.add), label: const Text('New Announcement'), ) : null, body: ResponsiveBody( - child: Skeletonizer( - enabled: showSkeleton, - child: CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: AppPageHeader(title: 'Announcements'), - ), - if (hasError && !hasValue) - SliverFillRemaining( - child: Center( - child: Text( - 'Failed to load announcements.', - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith( - color: Theme.of(context).colorScheme.error), - ), + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: AppPageHeader(title: 'Announcements'), + ), + + if (hasError && !hasValue) + SliverFillRemaining( + child: Center( + child: Text( + 'Failed to load announcements.', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: Theme.of(context).colorScheme.error), ), - ) - else if (!showSkeleton && items.isEmpty) - SliverFillRemaining( - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.campaign_outlined, - size: 64, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant), - const SizedBox(height: 12), - Text('No announcements yet', - style: Theme.of(context) - .textTheme - .bodyLarge - ?.copyWith( - color: Theme.of(context) - .colorScheme - .onSurfaceVariant)), - ], - ), + ), + ) + else if (showSkeleton) + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => _buildPlaceholderCard(context), + childCount: 5, + ), + ) + else if (items.isEmpty) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.campaign_outlined, + size: 64, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant), + const SizedBox(height: 12), + Text('No announcements yet', + style: Theme.of(context) + .textTheme + .bodyLarge + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant)), + ], ), - ) - else - SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - if (showSkeleton) { - // Placeholder card for shimmer - return _buildPlaceholderCard(context); - } - final announcement = items[index]; - return _AnnouncementCard( - key: ValueKey(announcement.id), + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final announcement = items[index]; + final delay = Duration( + milliseconds: math.min(index, 8) * 60); + return M3FadeSlideIn( + key: ValueKey(announcement.id), + delay: delay, + child: _AnnouncementCard( announcement: announcement, profiles: profiles, currentUserId: currentUserId, @@ -157,15 +162,18 @@ class _AnnouncementsScreenState extends ConsumerState { showSuccessSnackBarGlobal('Announcement deleted.'); } }, - ); - }, - childCount: showSkeleton ? 5 : items.length, - ), + onBannerSettings: () => showBannerSettingsDialog( + context, + announcement: announcement, + ), + ), + ); + }, + childCount: items.length, ), - // Bottom padding so FAB doesn't cover last card - const SliverPadding(padding: EdgeInsets.only(bottom: 80)), - ], - ), + ), + const SliverPadding(padding: EdgeInsets.only(bottom: 80)), + ], ), ), ); @@ -173,7 +181,7 @@ class _AnnouncementsScreenState extends ConsumerState { Widget _buildPlaceholderCard(BuildContext context) { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 6), + padding: const EdgeInsets.symmetric(vertical: 6), child: M3Card.elevated( child: Padding( padding: const EdgeInsets.all(16), @@ -182,43 +190,32 @@ class _AnnouncementsScreenState extends ConsumerState { children: [ Row( children: [ - const CircleAvatar(radius: 18), + M3ShimmerBox( + width: 36, + height: 36, + borderRadius: BorderRadius.circular(18), + ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 120, - height: 14, - color: Colors.grey, - ), - const SizedBox(height: 4), - Container( - width: 60, - height: 10, - color: Colors.grey, - ), + M3ShimmerBox(width: 120, height: 13), + const SizedBox(height: 5), + M3ShimmerBox(width: 64, height: 10), ], ), ), ], ), - const SizedBox(height: 12), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 200), - child: Container( - width: double.infinity, height: 16, color: Colors.grey), - ), + const SizedBox(height: 14), + M3ShimmerBox(width: 200, height: 15), const SizedBox(height: 8), - Container( - width: double.infinity, height: 12, color: Colors.grey), - const SizedBox(height: 4), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 240), - child: Container( - width: double.infinity, height: 12, color: Colors.grey), - ), + M3ShimmerBox(height: 12), + const SizedBox(height: 5), + M3ShimmerBox(width: 240, height: 12), + const SizedBox(height: 5), + M3ShimmerBox(width: 160, height: 12), ], ), ), @@ -227,9 +224,12 @@ class _AnnouncementsScreenState extends ConsumerState { } } +// ───────────────────────────────────────────────────────────────────────────── +// Announcement card +// ───────────────────────────────────────────────────────────────────────────── + class _AnnouncementCard extends ConsumerStatefulWidget { const _AnnouncementCard({ - super.key, required this.announcement, required this.profiles, required this.currentUserId, @@ -239,6 +239,7 @@ class _AnnouncementCard extends ConsumerStatefulWidget { required this.onToggleComments, required this.onEdit, required this.onDelete, + required this.onBannerSettings, }); final Announcement announcement; @@ -250,6 +251,7 @@ class _AnnouncementCard extends ConsumerStatefulWidget { final VoidCallback onToggleComments; final VoidCallback onEdit; final VoidCallback onDelete; + final VoidCallback onBannerSettings; @override ConsumerState<_AnnouncementCard> createState() => _AnnouncementCardState(); @@ -272,9 +274,14 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> { bool get _inCooldown => _secondsRemaining > 0; - bool get _canResend => - widget.announcement.authorId == widget.currentUserId || - widget.currentUserRole == 'admin'; + bool get _isOwner => + widget.announcement.authorId == widget.currentUserId; + + bool get _isAdmin => widget.currentUserRole == 'admin'; + + bool get _canManageBanner => _isOwner || _isAdmin; + + bool get _canResend => _isOwner || _isAdmin; Future _resendNotification() async { try { @@ -283,7 +290,6 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> { .resendAnnouncementNotification(widget.announcement); if (mounted) setState(() => _sentAt = DateTime.now()); } on AnnouncementNotificationException { - // Partial failure — start cooldown to prevent spam if (mounted) setState(() => _sentAt = DateTime.now()); } catch (e) { if (mounted) showErrorSnackBar(context, 'Failed to send: $e'); @@ -306,27 +312,24 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> { } } - final isOwner = widget.announcement.authorId == widget.currentUserId; - // Comment count final commentsAsync = ref.watch(announcementCommentsProvider(widget.announcement.id)); final commentCount = commentsAsync.valueOrNull?.length ?? 0; - // Rebuild UI when cooldown is active to update the countdown display. - // This timer triggers rebuilds every 500ms while _inCooldown is true. + // Rebuild UI when cooldown is active. if (_inCooldown) { Future.delayed(const Duration(milliseconds: 500), () { if (mounted) setState(() {}); }); } + final hasBanner = widget.announcement.bannerEnabled; + return Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: M3Card.elevated( child: Column( - // mainAxisSize.min prevents the Column from trying to fill infinite - // height when rendered inside a SliverList (unbounded vertical axis). mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -352,15 +355,38 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> { overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), - Text( - _relativeTime(widget.announcement.createdAt), - style: tt.labelSmall - ?.copyWith(color: cs.onSurfaceVariant), + Row( + children: [ + Flexible( + child: Text( + _relativeTime(widget.announcement.createdAt), + style: tt.labelSmall + ?.copyWith(color: cs.onSurfaceVariant), + ), + ), + if (hasBanner) ...[ + const SizedBox(width: 6), + Tooltip( + message: widget.announcement.isBannerActive + ? 'Banner active' + : 'Banner (inactive)', + child: Icon( + Icons.campaign, + size: 14, + color: widget.announcement.isBannerActive + ? cs.primary + : cs.onSurfaceVariant, + ), + ), + ], + ], ), ], ), ), - if (isOwner) + // Popup menu: owner sees Edit/Delete/Banner; + // admin-only sees Delete/Banner + if (_isOwner || _canManageBanner) PopupMenuButton( onSelected: (value) { switch (value) { @@ -368,13 +394,32 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> { widget.onEdit(); case 'delete': widget.onDelete(); + case 'banner': + widget.onBannerSettings(); } }, itemBuilder: (context) => [ - const PopupMenuItem( - value: 'edit', child: Text('Edit')), - const PopupMenuItem( - value: 'delete', child: Text('Delete')), + if (_isOwner) + const PopupMenuItem( + value: 'edit', child: Text('Edit')), + if (_isOwner || _isAdmin) + const PopupMenuItem( + value: 'delete', child: Text('Delete')), + if (_canManageBanner) + PopupMenuItem( + value: 'banner', + child: Row( + children: [ + Icon(Icons.campaign_outlined, + size: 18, + color: Theme.of(context) + .colorScheme + .primary), + const SizedBox(width: 8), + const Text('Banner Settings'), + ], + ), + ), ], ), ], @@ -400,10 +445,7 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> { runSpacing: 4, children: widget.announcement.visibleRoles.map((role) { return Chip( - label: Text( - _roleLabel(role), - style: tt.labelSmall, - ), + label: Text(_roleLabel(role), style: tt.labelSmall), visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, @@ -411,7 +453,7 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> { }).toList(), ), ), - // Bottom action row: comment toggle + optional resend button + // Bottom action row Padding( padding: const EdgeInsets.fromLTRB(8, 4, 8, 0), child: Row( @@ -492,6 +534,10 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> { } } +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + String _relativeTime(DateTime dt) { final now = AppTime.now(); final diff = now.difference(dt); @@ -512,3 +558,4 @@ String _roleLabel(String role) { }; return labels[role] ?? role; } + diff --git a/lib/screens/announcements/create_announcement_dialog.dart b/lib/screens/announcements/create_announcement_dialog.dart index 2a506b10..aacce8ad 100644 --- a/lib/screens/announcements/create_announcement_dialog.dart +++ b/lib/screens/announcements/create_announcement_dialog.dart @@ -4,6 +4,7 @@ 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'; @@ -22,6 +23,57 @@ const _roleLabels = { '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. @@ -49,6 +101,35 @@ Future showCreateAnnouncementDialog( } } +/// 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}); @@ -70,6 +151,14 @@ class _CreateAnnouncementContentState // 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(); @@ -80,6 +169,14 @@ class _CreateAnnouncementContentState ? 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 @@ -111,11 +208,25 @@ class _CreateAnnouncementContentState }); } + 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, @@ -123,6 +234,13 @@ class _CreateAnnouncementContentState 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( @@ -131,13 +249,16 @@ class _CreateAnnouncementContentState 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 { - // Saved successfully; only push notification delivery failed. if (mounted) { final messenger = ScaffoldMessenger.of(context); Navigator.of(context).pop(); @@ -160,12 +281,12 @@ class _CreateAnnouncementContentState final tt = Theme.of(context).textTheme; final isEditing = widget.editing != null; - // Get available templates from the stream (filter client-side) final templates = ref - .watch(announcementsProvider) - .valueOrNull - ?.where((a) => a.isTemplate) - .toList() ?? []; + .watch(announcementsProvider) + .valueOrNull + ?.where((a) => a.isTemplate) + .toList() ?? + []; return SingleChildScrollView( padding: const EdgeInsets.all(24), @@ -173,7 +294,6 @@ class _CreateAnnouncementContentState mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Dialog title Text( isEditing ? 'Edit Announcement' : 'New Announcement', style: tt.titleLarge?.copyWith(fontWeight: FontWeight.w600), @@ -285,6 +405,45 @@ class _CreateAnnouncementContentState 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 @@ -314,3 +473,285 @@ class _CreateAnnouncementContentState ); } } + +// ───────────────────────────────────────────────────────────────────────────── +// 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), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/attendance/attendance_screen.dart b/lib/screens/attendance/attendance_screen.dart index f3c36a34..853f69ce 100644 --- a/lib/screens/attendance/attendance_screen.dart +++ b/lib/screens/attendance/attendance_screen.dart @@ -766,7 +766,9 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { (_insideGeofence || hasGeofenceOverride) && !_checkingGeofence; - return Card( + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( @@ -944,6 +946,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { ], ), ), + ), ); }), ], @@ -5059,8 +5062,10 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> { statusColor = Colors.orange; } - return Card( - child: Padding( + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Card( + child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -5133,6 +5138,7 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> { ], ), ), + ), ); } @@ -5355,7 +5361,9 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> { statusColor = Colors.orange; } - return Card( + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Card( child: Padding( padding: const EdgeInsets.all(12), child: Column( @@ -5440,6 +5448,7 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> { ], ), ), + ), ); } diff --git a/lib/screens/it_service_requests/it_service_requests_list_screen.dart b/lib/screens/it_service_requests/it_service_requests_list_screen.dart index 3d76f562..8c300e08 100644 --- a/lib/screens/it_service_requests/it_service_requests_list_screen.dart +++ b/lib/screens/it_service_requests/it_service_requests_list_screen.dart @@ -1,9 +1,9 @@ import 'dart:async'; +import 'dart:math' as math show min; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:skeletonizer/skeletonizer.dart'; import '../../models/it_service_request.dart'; import '../../models/it_service_request_assignment.dart'; @@ -13,6 +13,7 @@ import '../../providers/it_service_request_provider.dart'; import '../../providers/profile_provider.dart'; import '../../providers/realtime_controller.dart'; import '../../providers/tickets_provider.dart'; +import '../../theme/m3_motion.dart'; import '../../utils/app_time.dart'; import '../../utils/snackbar.dart'; import '../../widgets/m3_card.dart'; @@ -100,27 +101,46 @@ class _ItServiceRequestsListScreenState children: [ ResponsiveBody( maxWidth: double.infinity, - child: Skeletonizer( - enabled: showSkeleton, - child: Builder( - builder: (context) { - if (requestsAsync.hasError && !requestsAsync.hasValue) { - return AppErrorView( - error: requestsAsync.error!, - onRetry: () => - ref.invalidate(itServiceRequestsProvider), - ); - } - final allRequests = - requestsAsync.valueOrNull ?? []; - if (allRequests.isEmpty && !showSkeleton) { - return const AppEmptyView( - icon: Icons.miscellaneous_services_outlined, - title: 'No service requests yet', - subtitle: - 'IT service requests submitted by your team will appear here.', - ); - } + child: Builder( + builder: (context) { + if (requestsAsync.hasError && !requestsAsync.hasValue) { + return AppErrorView( + error: requestsAsync.error!, + onRetry: () => + ref.invalidate(itServiceRequestsProvider), + ); + } + if (showSkeleton) { + return Column( + children: [ + const AppPageHeader( + title: 'IT Service Requests', + subtitle: 'Manage and track IT support tickets', + ), + Expanded( + child: ListView.separated( + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 8), + itemCount: 5, + separatorBuilder: (_, _) => + const SizedBox(height: 8), + itemBuilder: (_, _) => + _buildIsrShimmerCard(), + ), + ), + ], + ); + } + final allRequests = + requestsAsync.valueOrNull ?? []; + if (allRequests.isEmpty) { + return const AppEmptyView( + icon: Icons.miscellaneous_services_outlined, + title: 'No service requests yet', + subtitle: + 'IT service requests submitted by your team will appear here.', + ); + } final offices = officesAsync.valueOrNull ?? []; final officesSorted = List.from(offices) ..sort( @@ -278,13 +298,12 @@ class _ItServiceRequestsListScreenState }, ), ), - ), // FAB if (canCreate) Positioned( right: 16, bottom: 16, - child: FloatingActionButton.extended( + child: M3ExpandedFab( heroTag: 'create_isr', onPressed: () => _showCreateDialog(context), icon: const Icon(Icons.add), @@ -416,6 +435,38 @@ class _ItServiceRequestsListScreenState if (context.mounted) showErrorSnackBar(context, 'Error: $e'); } } + + Widget _buildIsrShimmerCard() { + return M3Card.elevated( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + M3ShimmerBox(width: 80, height: 13), + const Spacer(), + M3ShimmerBox(width: 72, height: 22, borderRadius: BorderRadius.circular(11)), + ], + ), + const SizedBox(height: 10), + M3ShimmerBox(height: 16), + const SizedBox(height: 6), + M3ShimmerBox(width: 200, height: 14), + const SizedBox(height: 10), + Row( + children: [ + M3ShimmerBox(width: 120, height: 12), + const SizedBox(width: 16), + M3ShimmerBox(width: 80, height: 12), + ], + ), + ], + ), + ), + ); + } } // --------------------------------------------------------------------------- @@ -561,9 +612,10 @@ class _RequestList extends StatelessWidget { if (requests.isEmpty) { return const Center(child: Text('No requests match the current filter.')); } - return ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16), + return ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), itemCount: requests.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), itemBuilder: (context, index) { final request = requests[index]; final assignedStaff = assignments @@ -573,10 +625,13 @@ class _RequestList extends StatelessWidget { final office = request.officeId != null ? officeById[request.officeId]?.name : null; - return _RequestTile( - request: request, - officeName: office, - assignedStaff: assignedStaff, + return M3FadeSlideIn( + delay: Duration(milliseconds: math.min(index, 8) * 50), + child: _RequestTile( + request: request, + officeName: office, + assignedStaff: assignedStaff, + ), ); }, ); diff --git a/lib/screens/workforce/workforce_screen.dart b/lib/screens/workforce/workforce_screen.dart index d640ccb2..7396191c 100644 --- a/lib/screens/workforce/workforce_screen.dart +++ b/lib/screens/workforce/workforce_screen.dart @@ -203,24 +203,29 @@ class _SchedulePanel extends ConsumerWidget { ?.copyWith(fontWeight: FontWeight.w700), ), const SizedBox(height: 8), - ...items.map( - (schedule) => _ScheduleTile( - schedule: schedule, - displayName: _scheduleName( - profileById, - schedule, - isAdmin, - rotationConfig, + for (int i = 0; i < items.length; i++) + M3FadeSlideIn( + delay: Duration(milliseconds: i * 40), + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _ScheduleTile( + schedule: items[i], + displayName: _scheduleName( + profileById, + items[i], + isAdmin, + rotationConfig, + ), + relieverLabels: _relieverLabelsFromIds( + items[i].relieverIds, + profileById, + ), + isMine: items[i].userId == currentUserId, + isAdmin: isAdmin, + role: profileById[items[i].userId]?.role, + ), ), - relieverLabels: _relieverLabelsFromIds( - schedule.relieverIds, - profileById, - ), - isMine: schedule.userId == currentUserId, - isAdmin: isAdmin, - role: profileById[schedule.userId]?.role, ), - ), ], ), ); diff --git a/lib/widgets/announcement_banner.dart b/lib/widgets/announcement_banner.dart new file mode 100644 index 00000000..7206188f --- /dev/null +++ b/lib/widgets/announcement_banner.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../providers/announcements_provider.dart'; + +/// A persistent, globally visible banner that appears at the top of every +/// authenticated screen when one or more [Announcement]s have an active banner. +/// +/// Mirrors the pattern used by [PassSlipCountdownBanner] and +/// [ShiftCountdownBanner]. Registered in [_ShellBackground] so it wraps all +/// shell-route screens. +/// +/// Tapping the banner navigates to `/announcements`. The X button dismisses +/// the leading announcement for the current session; if more remain they +/// continue to be shown. +class AnnouncementBanner extends ConsumerStatefulWidget { + const AnnouncementBanner({required this.child, super.key}); + + final Widget child; + + @override + ConsumerState createState() => + _AnnouncementBannerState(); +} + +class _AnnouncementBannerState extends ConsumerState { + /// IDs of banners the user has dismissed for this session. + final Set _sessionDismissed = {}; + + @override + Widget build(BuildContext context) { + final visible = ref + .watch(activeBannerAnnouncementsProvider) + .where((a) => !_sessionDismissed.contains(a.id)) + .toList(); + + if (visible.isEmpty) return widget.child; + + final first = visible.first; + final extra = visible.length - 1; + + final cs = Theme.of(context).colorScheme; + final tt = Theme.of(context).textTheme; + + return Column( + children: [ + Material( + child: InkWell( + onTap: () => context.go('/announcements'), + child: Container( + width: double.infinity, + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration(color: cs.primaryContainer), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Icons.campaign_rounded, + size: 22, + color: cs.onPrimaryContainer, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + extra > 0 + ? 'Announcement · +$extra more' + : 'Announcement', + style: tt.labelSmall?.copyWith( + color: + cs.onPrimaryContainer.withValues(alpha: 0.75), + letterSpacing: 0.3, + ), + ), + const SizedBox(height: 1), + Text( + first.title, + style: tt.bodyMedium?.copyWith( + color: cs.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Text( + 'Tap to view', + style: tt.bodySmall?.copyWith( + color: cs.onPrimaryContainer.withValues(alpha: 0.7), + ), + ), + Icon( + Icons.chevron_right, + size: 18, + color: cs.onPrimaryContainer.withValues(alpha: 0.7), + ), + const SizedBox(width: 4), + // Dismiss for session + InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () => + setState(() => _sessionDismissed.add(first.id)), + child: Padding( + padding: const EdgeInsets.all(4), + child: Icon( + Icons.close, + size: 18, + color: cs.onPrimaryContainer, + ), + ), + ), + ], + ), + ), + ), + ), + Expanded(child: widget.child), + ], + ); + } +} diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 42f97f43..512c6704 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -8,6 +8,7 @@ import 'package:go_router/go_router.dart'; import '../providers/auth_provider.dart'; import '../providers/notifications_provider.dart'; import '../providers/profile_provider.dart'; +import 'announcement_banner.dart'; import 'app_breakpoints.dart'; import 'profile_avatar.dart'; import 'pass_slip_countdown_banner.dart'; @@ -331,7 +332,9 @@ class _ShellBackground extends StatelessWidget { return ColoredBox( color: Theme.of(context).scaffoldBackgroundColor, child: ShiftCountdownBanner( - child: PassSlipCountdownBanner(child: child), + child: PassSlipCountdownBanner( + child: AnnouncementBanner(child: child), + ), ), ); } diff --git a/supabase/functions/process_scheduled_notifications/index.ts b/supabase/functions/process_scheduled_notifications/index.ts index bbf2997e..acbaeefb 100644 --- a/supabase/functions/process_scheduled_notifications/index.ts +++ b/supabase/functions/process_scheduled_notifications/index.ts @@ -63,15 +63,46 @@ async function processBatch() { return } + // Deduplicate announcement_banner rows: for each (announcement_id, user_id) + // pair, keep only the row with the highest epoch and immediately mark older + // ones as processed without sending FCM. This prevents double-pushes caused + // by stale rows from previous epochs appearing alongside the current epoch's + // row (e.g. if scheduled_for was set to now() instead of the next boundary, + // or if a pg_cron cycle was missed leaving old rows unprocessed). + const annBannerBest = new Map() + const staleIds: string[] = [] for (const r of rows) { + if (r.notify_type !== 'announcement_banner' || !r.announcement_id) continue + const key = `${r.announcement_id}:${r.user_id}` + const best = annBannerBest.get(key) + if (!best || (r.epoch ?? 0) > (best.epoch ?? 0)) { + if (best) staleIds.push(best.id) + annBannerBest.set(key, r) + } else { + staleIds.push(r.id) + } + } + if (staleIds.length > 0) { + console.log(`Skipping ${staleIds.length} stale announcement_banner row(s)`) + await supabase + .from('scheduled_notifications') + .update({ processed: true, processed_at: new Date().toISOString() }) + .in('id', staleIds) + } + const staleSet = new Set(staleIds) + + for (const r of rows.filter((r: any) => !staleSet.has(r.id))) { try { const scheduleId = r.schedule_id const userId = r.user_id const notifyType = r.notify_type const rowId = r.id - // Build a unique ID that accounts for all reference columns + epoch - const idSource = `${scheduleId || ''}-${r.task_id || ''}-${r.it_service_request_id || ''}-${r.pass_slip_id || ''}-${userId}-${notifyType}-${r.epoch || 0}` + // Build a unique ID that accounts for all reference columns + epoch. + // announcement_id is included so that concurrent banner announcements + // targeting the same user+epoch get distinct notificationIds — without + // it, try_mark_notification_pushed would silently drop the second one. + const idSource = `${scheduleId || ''}-${r.task_id || ''}-${r.it_service_request_id || ''}-${r.pass_slip_id || ''}-${r.announcement_id || ''}-${userId}-${notifyType}-${r.epoch || 0}` const notificationId = await uuidFromName(idSource) // Idempotency is handled by send_fcm via try_mark_notification_pushed. @@ -91,6 +122,7 @@ async function processBatch() { if (r.task_id) data.task_id = r.task_id if (r.it_service_request_id) data.it_service_request_id = r.it_service_request_id if (r.pass_slip_id) data.pass_slip_id = r.pass_slip_id + if (r.announcement_id) data.announcement_id = r.announcement_id switch (notifyType) { case 'start_15': @@ -148,6 +180,23 @@ async function processBatch() { body = 'Your pass slip has exceeded the 1-hour limit. Please return and complete it immediately.' data.navigate_to = '/attendance' break + case 'announcement_banner': { + const { data: ann } = await supabase + .from('announcements') + .select('title') + .eq('id', r.announcement_id) + .single() + const rawTitle = ann?.title ?? '' + const displayTitle = rawTitle.length > 80 + ? rawTitle.substring(0, 80) + '\u2026' + : rawTitle + title = 'Announcement Reminder' + body = displayTitle + ? `"${displayTitle}" — Please tap to review this announcement.` + : 'You have a pending announcement that requires your attention. Tap to view it.' + data.navigate_to = '/announcements' + break + } default: title = 'Reminder' body = 'You have a pending notification.' diff --git a/supabase/functions/send_fcm/index.ts b/supabase/functions/send_fcm/index.ts index b1d8b404..14049f53 100644 --- a/supabase/functions/send_fcm/index.ts +++ b/supabase/functions/send_fcm/index.ts @@ -38,11 +38,17 @@ Deno.serve(async (req) => { }) } - // Optional Idempotency (if you pass notification_id inside the data payload) + // Idempotency: if notification_id is provided, use try_mark_notification_pushed + // to ensure at-most-once delivery even under concurrent edge-function invocations. if (payload.data && payload.data.notification_id) { const { data: markData, error: markErr } = await supabase .rpc('try_mark_notification_pushed', { p_notification_id: payload.data.notification_id }) - + + if (markErr) { + console.error('try_mark_notification_pushed RPC error, skipping to be safe:', markErr) + return new Response('Idempotency check failed', { status: 200, headers: corsHeaders }) + } + if (markData === false) { console.log('Notification already pushed, skipping:', payload.data.notification_id) return new Response('Already pushed', { status: 200, headers: corsHeaders }) diff --git a/supabase/migrations/20260322210000_announcement_banner.sql b/supabase/migrations/20260322210000_announcement_banner.sql new file mode 100644 index 00000000..d40cca03 --- /dev/null +++ b/supabase/migrations/20260322210000_announcement_banner.sql @@ -0,0 +1,133 @@ +-- Migration: Banner notification support for announcements +-- Adds banner_enabled, show/hide times, and scheduled push intervals. +-- Hooks into the existing scheduled_notifications queue + pg_cron pipeline. + +-- ============================================================================ +-- 1. Banner columns on announcements +-- ============================================================================ +ALTER TABLE public.announcements + ADD COLUMN IF NOT EXISTS banner_enabled boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS banner_show_at timestamptz, -- null = show immediately + ADD COLUMN IF NOT EXISTS banner_hide_at timestamptz, -- null = manual off only + ADD COLUMN IF NOT EXISTS push_interval_minutes integer; -- null = no scheduled push + +-- Partial index: only index rows with active banners +CREATE INDEX IF NOT EXISTS idx_announcements_banner_active + ON public.announcements (banner_show_at, banner_hide_at) + WHERE banner_enabled = true AND push_interval_minutes IS NOT NULL; + +-- ============================================================================ +-- 2. Extend scheduled_notifications with announcement_id +-- ============================================================================ +ALTER TABLE public.scheduled_notifications + ADD COLUMN IF NOT EXISTS announcement_id uuid + REFERENCES public.announcements(id) ON DELETE CASCADE; + +CREATE INDEX IF NOT EXISTS idx_sched_notif_announcement + ON public.scheduled_notifications(announcement_id) + WHERE announcement_id IS NOT NULL; + +-- 2a. Rebuild unique index to include announcement_id +DROP INDEX IF EXISTS uniq_sched_notif; + +CREATE UNIQUE INDEX IF NOT EXISTS uniq_sched_notif ON public.scheduled_notifications ( + COALESCE(schedule_id, '00000000-0000-0000-0000-000000000000'), + COALESCE(task_id, '00000000-0000-0000-0000-000000000000'), + COALESCE(it_service_request_id, '00000000-0000-0000-0000-000000000000'), + COALESCE(pass_slip_id, '00000000-0000-0000-0000-000000000000'), + COALESCE(announcement_id, '00000000-0000-0000-0000-000000000000'), + user_id, + notify_type, + epoch +); + +-- 2b. Update CHECK constraint to allow announcement-only rows +ALTER TABLE public.scheduled_notifications + DROP CONSTRAINT IF EXISTS chk_at_least_one_ref; + +ALTER TABLE public.scheduled_notifications + ADD CONSTRAINT chk_at_least_one_ref CHECK ( + schedule_id IS NOT NULL + OR task_id IS NOT NULL + OR it_service_request_id IS NOT NULL + OR pass_slip_id IS NOT NULL + OR announcement_id IS NOT NULL + ); + +-- ============================================================================ +-- 3. Enqueue function for banner announcement push notifications +-- ============================================================================ +-- Called by enqueue_all_notifications() every minute via pg_cron. +-- For each active banner announcement with a push interval, inserts one +-- scheduled_notification per target user per interval epoch. +-- epoch = floor(unix_seconds / interval_seconds) ensures exactly one push +-- per user per interval window with ON CONFLICT DO NOTHING idempotency. +CREATE OR REPLACE FUNCTION public.enqueue_announcement_banner_notifications() +RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + ann RECORD; + usr RECORD; + v_epoch int; +BEGIN + FOR ann IN + SELECT a.id, a.author_id, a.visible_roles, a.push_interval_minutes + FROM public.announcements a + WHERE a.banner_enabled = true + AND a.push_interval_minutes IS NOT NULL + AND a.is_template = false + AND (a.banner_show_at IS NULL OR a.banner_show_at <= now()) + AND (a.banner_hide_at IS NULL OR a.banner_hide_at > now()) + LOOP + -- One epoch per interval window; prevents duplicate pushes within the window + v_epoch := FLOOR( + EXTRACT(EPOCH FROM now()) / (ann.push_interval_minutes * 60) + )::int; + + -- Purge rows that are ≥2 epochs stale (unprocessed due to missed cycles). + -- We keep epoch = v_epoch - 1 because that row's scheduled_for falls + -- exactly at the current epoch boundary and is about to be processed. + -- Deleting epoch < v_epoch would remove rows that are due RIGHT NOW + -- (epoch E's row has scheduled_for = (E+1)*interval = now), causing + -- all notifications to stop firing. + DELETE FROM public.scheduled_notifications + WHERE announcement_id = ann.id + AND notify_type = 'announcement_banner' + AND processed = false + AND epoch < v_epoch - 1; + + FOR usr IN + SELECT p.id + FROM public.profiles p + WHERE p.role::text = ANY(ann.visible_roles) + AND p.id <> ann.author_id + LOOP + INSERT INTO public.scheduled_notifications + (announcement_id, user_id, notify_type, scheduled_for, epoch) + VALUES + (ann.id, usr.id, 'announcement_banner', + to_timestamp(((v_epoch + 1)::bigint * ann.push_interval_minutes * 60)), + v_epoch) + ON CONFLICT DO NOTHING; + END LOOP; + END LOOP; +END; +$$; + +-- ============================================================================ +-- 4. Re-register master dispatcher to include banner announcements +-- ============================================================================ +CREATE OR REPLACE FUNCTION public.enqueue_all_notifications() +RETURNS void LANGUAGE plpgsql AS $$ +BEGIN + PERFORM public.enqueue_due_shift_notifications(); + PERFORM public.enqueue_overtime_idle_notifications(); + PERFORM public.enqueue_overtime_checkout_notifications(); + PERFORM public.enqueue_isr_event_notifications(); + PERFORM public.enqueue_isr_evidence_notifications(); + PERFORM public.enqueue_paused_task_notifications(); + PERFORM public.enqueue_backlog_notifications(); + PERFORM public.enqueue_pass_slip_expiry_notifications(); + PERFORM public.enqueue_pass_slip_expired_notifications(); -- added in 20260322150000; kept here + PERFORM public.enqueue_announcement_banner_notifications(); -- added in this migration +END; +$$; diff --git a/supabase/migrations/20260322220000_fix_announcement_banner_enqueue.sql b/supabase/migrations/20260322220000_fix_announcement_banner_enqueue.sql new file mode 100644 index 00000000..64f9c745 --- /dev/null +++ b/supabase/migrations/20260322220000_fix_announcement_banner_enqueue.sql @@ -0,0 +1,84 @@ +-- Fix: eliminate double push notifications for announcement banners. +-- +-- Root causes addressed: +-- 1. scheduled_for = now() made rows immediately eligible. Multiple consecutive +-- epoch rows (e.g. E and E+1) all had scheduled_for in the past, so all were +-- eligible simultaneously. ON CONFLICT DO NOTHING kept the old rows intact +-- (didn't update scheduled_for to the future), so every processBatch run +-- found 2+ eligible rows → 2+ FCM sends. +-- Fix A (SQL): scheduled_for = start of the NEXT epoch window (future). +-- Fix B (SQL): one-time DELETE of all pre-existing stale rows so the queue +-- starts clean. The edge function deduplication (Fix C) also +-- guards against any future accumulation. +-- Fix C (TS): processBatch deduplicates announcement_banner rows by +-- (announcement_id, user_id), keeping only the highest-epoch +-- row and marking stale ones processed without sending FCM. +-- +-- 2. epoch < v_epoch (too aggressive) deleted epoch E's row at the start of +-- epoch E+1 — the row that was due RIGHT NOW — stopping all notifications. +-- Fix: epoch < v_epoch - 1 keeps epoch E intact for processing. + +-- ============================================================================ +-- One-time cleanup: mark all pre-existing stale announcement_banner rows as +-- processed so they are no longer eligible. These were inserted with the old +-- scheduled_for = now() logic and have scheduled_for in the past, causing them +-- to appear in every processBatch query and generate duplicate pushes. +-- ============================================================================ +DELETE FROM public.scheduled_notifications +WHERE notify_type = 'announcement_banner' + AND processed = false; + +CREATE OR REPLACE FUNCTION public.enqueue_announcement_banner_notifications() +RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + ann RECORD; + usr RECORD; + v_epoch int; +BEGIN + FOR ann IN + SELECT a.id, a.author_id, a.visible_roles, a.push_interval_minutes + FROM public.announcements a + WHERE a.banner_enabled = true + AND a.push_interval_minutes IS NOT NULL + AND a.is_template = false + AND (a.banner_show_at IS NULL OR a.banner_show_at <= now()) + AND (a.banner_hide_at IS NULL OR a.banner_hide_at > now()) + LOOP + -- One epoch per interval window; prevents duplicate pushes within the window. + v_epoch := FLOOR( + EXTRACT(EPOCH FROM now()) / (ann.push_interval_minutes * 60) + )::int; + + -- Purge rows that are ≥2 epochs stale (unprocessed due to missed cycles). + -- We keep epoch = v_epoch - 1 because that row's scheduled_for falls + -- exactly at the current epoch boundary and is about to be processed. + -- Deleting epoch < v_epoch would remove rows that are due RIGHT NOW + -- (epoch E's row has scheduled_for = (E+1)*interval = now), causing + -- all notifications to stop firing — which is the bug this replaces. + DELETE FROM public.scheduled_notifications + WHERE announcement_id = ann.id + AND notify_type = 'announcement_banner' + AND processed = false + AND epoch < v_epoch - 1; + + FOR usr IN + SELECT p.id + FROM public.profiles p + WHERE p.role::text = ANY(ann.visible_roles) + AND p.id <> ann.author_id + LOOP + -- scheduled_for = start of the NEXT epoch window (not now()). + -- This guarantees the row only becomes eligible AFTER the current + -- epoch ends, so a concurrent or delayed edge-function instance can + -- never see two different epochs' rows as simultaneously due. + INSERT INTO public.scheduled_notifications + (announcement_id, user_id, notify_type, scheduled_for, epoch) + VALUES + (ann.id, usr.id, 'announcement_banner', + to_timestamp(((v_epoch + 1)::bigint * ann.push_interval_minutes * 60)), + v_epoch) + ON CONFLICT DO NOTHING; + END LOOP; + END LOOP; +END; +$$; diff --git a/supabase/migrations/20260322_finalize_notification_functions.sql b/supabase/migrations/20260322_finalize_notification_functions.sql index 9a568a08..b56d35ff 100644 --- a/supabase/migrations/20260322_finalize_notification_functions.sql +++ b/supabase/migrations/20260322_finalize_notification_functions.sql @@ -353,6 +353,10 @@ $$; -- ============================================================================ -- MASTER DISPATCHER -- ============================================================================ +-- NOTE: This file has an underscore at position 9 of its filename, which makes +-- it sort AFTER all 20260322NNNNNN_* files (underscore ASCII 95 > digits 48-57). +-- It therefore runs LAST among all 20260322 migrations and must include every +-- enqueue function defined by those earlier migrations. CREATE OR REPLACE FUNCTION public.enqueue_all_notifications() RETURNS void LANGUAGE plpgsql AS $$ BEGIN @@ -364,6 +368,8 @@ BEGIN PERFORM public.enqueue_paused_task_notifications(); PERFORM public.enqueue_backlog_notifications(); PERFORM public.enqueue_pass_slip_expiry_notifications(); + PERFORM public.enqueue_pass_slip_expired_notifications(); -- added in 20260322150000 + PERFORM public.enqueue_announcement_banner_notifications(); -- added in 20260322210000 END; $$; diff --git a/supabase/migrations/20260323000000_fix_enqueue_all_notifications.sql b/supabase/migrations/20260323000000_fix_enqueue_all_notifications.sql new file mode 100644 index 00000000..afacaafc --- /dev/null +++ b/supabase/migrations/20260323000000_fix_enqueue_all_notifications.sql @@ -0,0 +1,30 @@ +-- Migration: Definitive enqueue_all_notifications() dispatcher +-- +-- WHY THIS EXISTS: +-- 20260322_finalize_notification_functions.sql has an underscore at character +-- position 9 of its filename, which sorts AFTER all 20260322NNNNNN_* files +-- (underscore ASCII 95 > digits ASCII 48-57). This means _finalize_ always +-- runs LAST in the 20260322 group, overriding enqueue_all_notifications() +-- with a version that omits: +-- - enqueue_announcement_banner_notifications() (added in 20260322210000) +-- - enqueue_pass_slip_expired_notifications() (added in 20260322150000) +-- +-- This migration (20260323000000) sorts AFTER all 20260322* files and +-- redefines enqueue_all_notifications() as the single authoritative definition +-- that includes all 10 notification types. + +CREATE OR REPLACE FUNCTION public.enqueue_all_notifications() +RETURNS void LANGUAGE plpgsql AS $$ +BEGIN + PERFORM public.enqueue_due_shift_notifications(); + PERFORM public.enqueue_overtime_idle_notifications(); + PERFORM public.enqueue_overtime_checkout_notifications(); + PERFORM public.enqueue_isr_event_notifications(); + PERFORM public.enqueue_isr_evidence_notifications(); + PERFORM public.enqueue_paused_task_notifications(); + PERFORM public.enqueue_backlog_notifications(); + PERFORM public.enqueue_pass_slip_expiry_notifications(); + PERFORM public.enqueue_pass_slip_expired_notifications(); + PERFORM public.enqueue_announcement_banner_notifications(); +END; +$$;