UI Enhancements in IT Service Request, Announcements, Workforce and notification fixes

This commit is contained in:
Marc Rejohn Castillano 2026-03-22 18:00:10 +08:00
parent 049ab2c794
commit 872c2aab87
15 changed files with 1290 additions and 183 deletions

View File

@ -11,6 +11,10 @@ class Announcement {
this.templateId, this.templateId,
required this.createdAt, required this.createdAt,
required this.updatedAt, required this.updatedAt,
required this.bannerEnabled,
this.bannerShowAt,
this.bannerHideAt,
this.pushIntervalMinutes,
}); });
final String id; final String id;
@ -23,6 +27,29 @@ class Announcement {
final DateTime createdAt; final DateTime createdAt;
final DateTime updatedAt; 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 @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) ||
@ -34,6 +61,10 @@ class Announcement {
body == other.body && body == other.body &&
isTemplate == other.isTemplate && isTemplate == other.isTemplate &&
templateId == other.templateId && templateId == other.templateId &&
bannerEnabled == other.bannerEnabled &&
bannerShowAt == other.bannerShowAt &&
bannerHideAt == other.bannerHideAt &&
pushIntervalMinutes == other.pushIntervalMinutes &&
createdAt == other.createdAt && createdAt == other.createdAt &&
updatedAt == other.updatedAt; updatedAt == other.updatedAt;
@ -45,15 +76,17 @@ class Announcement {
body, body,
isTemplate, isTemplate,
templateId, templateId,
bannerEnabled,
bannerShowAt,
bannerHideAt,
pushIntervalMinutes,
createdAt, createdAt,
updatedAt, updatedAt,
); );
factory Announcement.fromMap(Map<String, dynamic> map) { factory Announcement.fromMap(Map<String, dynamic> map) {
final rolesRaw = map['visible_roles']; final rolesRaw = map['visible_roles'];
final roles = rolesRaw is List final roles = rolesRaw is List ? rolesRaw.cast<String>() : <String>[];
? rolesRaw.cast<String>()
: <String>[];
return Announcement( return Announcement(
id: map['id'] as String, id: map['id'] as String,
@ -65,6 +98,14 @@ class Announcement {
templateId: map['template_id'] as String?, templateId: map['template_id'] as String?,
createdAt: AppTime.parse(map['created_at'] as String), createdAt: AppTime.parse(map['created_at'] as String),
updatedAt: AppTime.parse(map['updated_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?,
); );
} }
} }

View File

@ -80,6 +80,14 @@ final announcementCommentsProvider =
return wrapper.stream.map((result) => result.data); 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<List<Announcement>>((ref) {
final all = ref.watch(announcementsProvider).valueOrNull ?? [];
return all.where((a) => !a.isTemplate && a.isBannerActive).toList();
});
final announcementsControllerProvider = final announcementsControllerProvider =
Provider<AnnouncementsController>((ref) { Provider<AnnouncementsController>((ref) {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
@ -100,6 +108,10 @@ class AnnouncementsController {
required List<String> visibleRoles, required List<String> visibleRoles,
bool isTemplate = false, bool isTemplate = false,
String? templateId, String? templateId,
bool bannerEnabled = false,
DateTime? bannerShowAt,
DateTime? bannerHideAt,
int? pushIntervalMinutes,
}) async { }) async {
final authorId = _client.auth.currentUser?.id; final authorId = _client.auth.currentUser?.id;
if (authorId == null) return; if (authorId == null) return;
@ -111,6 +123,10 @@ class AnnouncementsController {
'visible_roles': visibleRoles, 'visible_roles': visibleRoles,
'is_template': isTemplate, 'is_template': isTemplate,
'template_id': templateId, '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 final result = await _client
@ -123,6 +139,12 @@ class AnnouncementsController {
// Don't send notifications for templates (they are drafts for reuse) // Don't send notifications for templates (they are drafts for reuse)
if (isTemplate) return; 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 // Query users whose role matches visible_roles, excluding the author
try { try {
final profiles = await _client final profiles = await _client
@ -161,6 +183,13 @@ class AnnouncementsController {
required String body, required String body,
required List<String> visibleRoles, required List<String> visibleRoles,
bool? isTemplate, bool? isTemplate,
bool? bannerEnabled,
DateTime? bannerShowAt,
DateTime? bannerHideAt,
int? pushIntervalMinutes,
bool clearBannerShowAt = false,
bool clearBannerHideAt = false,
bool clearPushInterval = false,
}) async { }) async {
final payload = <String, dynamic>{ final payload = <String, dynamic>{
'title': title, 'title': title,
@ -168,12 +197,53 @@ class AnnouncementsController {
'visible_roles': visibleRoles, 'visible_roles': visibleRoles,
'updated_at': AppTime.nowUtc().toIso8601String(), 'updated_at': AppTime.nowUtc().toIso8601String(),
}; };
if (isTemplate != null) { if (isTemplate != null) payload['is_template'] = isTemplate;
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); 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<void> 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<void> 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. /// Delete an announcement.
Future<void> deleteAnnouncement(String id) async { Future<void> deleteAnnouncement(String id) async {
await _client.from('announcements').delete().eq('id', id); await _client.from('announcements').delete().eq('id', id);

View File

@ -1,8 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math show min;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import '../../models/announcement.dart'; import '../../models/announcement.dart';
import '../../providers/announcements_provider.dart'; import '../../providers/announcements_provider.dart';
@ -47,68 +47,73 @@ class _AnnouncementsScreenState extends ConsumerState<AnnouncementsScreen> {
floatingActionButton: canCreate floatingActionButton: canCreate
? M3ExpandedFab( ? M3ExpandedFab(
heroTag: 'announcement_fab', heroTag: 'announcement_fab',
onPressed: () => onPressed: () => showCreateAnnouncementDialog(context),
showCreateAnnouncementDialog(context),
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: const Text('New Announcement'), label: const Text('New Announcement'),
) )
: null, : null,
body: ResponsiveBody( body: ResponsiveBody(
child: Skeletonizer( child: CustomScrollView(
enabled: showSkeleton, slivers: [
child: CustomScrollView( SliverToBoxAdapter(
slivers: [ child: AppPageHeader(title: 'Announcements'),
SliverToBoxAdapter( ),
child: AppPageHeader(title: 'Announcements'),
), if (hasError && !hasValue)
if (hasError && !hasValue) SliverFillRemaining(
SliverFillRemaining( child: Center(
child: Center( child: Text(
child: Text( 'Failed to load announcements.',
'Failed to load announcements.', style: Theme.of(context)
style: Theme.of(context) .textTheme
.textTheme .bodyMedium
.bodyMedium ?.copyWith(
?.copyWith( color: Theme.of(context).colorScheme.error),
color: Theme.of(context).colorScheme.error),
),
), ),
) ),
else if (!showSkeleton && items.isEmpty) )
SliverFillRemaining( else if (showSkeleton)
child: Center( SliverList(
child: Column( delegate: SliverChildBuilderDelegate(
mainAxisSize: MainAxisSize.min, (context, index) => _buildPlaceholderCard(context),
children: [ childCount: 5,
Icon(Icons.campaign_outlined, ),
size: 64, )
color: Theme.of(context) else if (items.isEmpty)
.colorScheme SliverFillRemaining(
.onSurfaceVariant), child: Center(
const SizedBox(height: 12), child: Column(
Text('No announcements yet', mainAxisSize: MainAxisSize.min,
style: Theme.of(context) children: [
.textTheme Icon(Icons.campaign_outlined,
.bodyLarge size: 64,
?.copyWith( color: Theme.of(context)
color: Theme.of(context) .colorScheme
.colorScheme .onSurfaceVariant),
.onSurfaceVariant)), const SizedBox(height: 12),
], Text('No announcements yet',
), style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant)),
],
), ),
) ),
else )
SliverList( else
delegate: SliverChildBuilderDelegate( SliverList(
(context, index) { delegate: SliverChildBuilderDelegate(
if (showSkeleton) { (context, index) {
// Placeholder card for shimmer final announcement = items[index];
return _buildPlaceholderCard(context); final delay = Duration(
} milliseconds: math.min(index, 8) * 60);
final announcement = items[index]; return M3FadeSlideIn(
return _AnnouncementCard( key: ValueKey(announcement.id),
key: ValueKey(announcement.id), delay: delay,
child: _AnnouncementCard(
announcement: announcement, announcement: announcement,
profiles: profiles, profiles: profiles,
currentUserId: currentUserId, currentUserId: currentUserId,
@ -157,15 +162,18 @@ class _AnnouncementsScreenState extends ConsumerState<AnnouncementsScreen> {
showSuccessSnackBarGlobal('Announcement deleted.'); showSuccessSnackBarGlobal('Announcement deleted.');
} }
}, },
); onBannerSettings: () => showBannerSettingsDialog(
}, context,
childCount: showSkeleton ? 5 : items.length, 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<AnnouncementsScreen> {
Widget _buildPlaceholderCard(BuildContext context) { Widget _buildPlaceholderCard(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 6), padding: const EdgeInsets.symmetric(vertical: 6),
child: M3Card.elevated( child: M3Card.elevated(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@ -182,43 +190,32 @@ class _AnnouncementsScreenState extends ConsumerState<AnnouncementsScreen> {
children: [ children: [
Row( Row(
children: [ children: [
const CircleAvatar(radius: 18), M3ShimmerBox(
width: 36,
height: 36,
borderRadius: BorderRadius.circular(18),
),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( M3ShimmerBox(width: 120, height: 13),
width: 120, const SizedBox(height: 5),
height: 14, M3ShimmerBox(width: 64, height: 10),
color: Colors.grey,
),
const SizedBox(height: 4),
Container(
width: 60,
height: 10,
color: Colors.grey,
),
], ],
), ),
), ),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 14),
ConstrainedBox( M3ShimmerBox(width: 200, height: 15),
constraints: const BoxConstraints(maxWidth: 200),
child: Container(
width: double.infinity, height: 16, color: Colors.grey),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Container( M3ShimmerBox(height: 12),
width: double.infinity, height: 12, color: Colors.grey), const SizedBox(height: 5),
const SizedBox(height: 4), M3ShimmerBox(width: 240, height: 12),
ConstrainedBox( const SizedBox(height: 5),
constraints: const BoxConstraints(maxWidth: 240), M3ShimmerBox(width: 160, height: 12),
child: Container(
width: double.infinity, height: 12, color: Colors.grey),
),
], ],
), ),
), ),
@ -227,9 +224,12 @@ class _AnnouncementsScreenState extends ConsumerState<AnnouncementsScreen> {
} }
} }
//
// Announcement card
//
class _AnnouncementCard extends ConsumerStatefulWidget { class _AnnouncementCard extends ConsumerStatefulWidget {
const _AnnouncementCard({ const _AnnouncementCard({
super.key,
required this.announcement, required this.announcement,
required this.profiles, required this.profiles,
required this.currentUserId, required this.currentUserId,
@ -239,6 +239,7 @@ class _AnnouncementCard extends ConsumerStatefulWidget {
required this.onToggleComments, required this.onToggleComments,
required this.onEdit, required this.onEdit,
required this.onDelete, required this.onDelete,
required this.onBannerSettings,
}); });
final Announcement announcement; final Announcement announcement;
@ -250,6 +251,7 @@ class _AnnouncementCard extends ConsumerStatefulWidget {
final VoidCallback onToggleComments; final VoidCallback onToggleComments;
final VoidCallback onEdit; final VoidCallback onEdit;
final VoidCallback onDelete; final VoidCallback onDelete;
final VoidCallback onBannerSettings;
@override @override
ConsumerState<_AnnouncementCard> createState() => _AnnouncementCardState(); ConsumerState<_AnnouncementCard> createState() => _AnnouncementCardState();
@ -272,9 +274,14 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
bool get _inCooldown => _secondsRemaining > 0; bool get _inCooldown => _secondsRemaining > 0;
bool get _canResend => bool get _isOwner =>
widget.announcement.authorId == widget.currentUserId || widget.announcement.authorId == widget.currentUserId;
widget.currentUserRole == 'admin';
bool get _isAdmin => widget.currentUserRole == 'admin';
bool get _canManageBanner => _isOwner || _isAdmin;
bool get _canResend => _isOwner || _isAdmin;
Future<void> _resendNotification() async { Future<void> _resendNotification() async {
try { try {
@ -283,7 +290,6 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
.resendAnnouncementNotification(widget.announcement); .resendAnnouncementNotification(widget.announcement);
if (mounted) setState(() => _sentAt = DateTime.now()); if (mounted) setState(() => _sentAt = DateTime.now());
} on AnnouncementNotificationException { } on AnnouncementNotificationException {
// Partial failure start cooldown to prevent spam
if (mounted) setState(() => _sentAt = DateTime.now()); if (mounted) setState(() => _sentAt = DateTime.now());
} catch (e) { } catch (e) {
if (mounted) showErrorSnackBar(context, 'Failed to send: $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 // Comment count
final commentsAsync = final commentsAsync =
ref.watch(announcementCommentsProvider(widget.announcement.id)); ref.watch(announcementCommentsProvider(widget.announcement.id));
final commentCount = commentsAsync.valueOrNull?.length ?? 0; final commentCount = commentsAsync.valueOrNull?.length ?? 0;
// Rebuild UI when cooldown is active to update the countdown display. // Rebuild UI when cooldown is active.
// This timer triggers rebuilds every 500ms while _inCooldown is true.
if (_inCooldown) { if (_inCooldown) {
Future.delayed(const Duration(milliseconds: 500), () { Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) setState(() {}); if (mounted) setState(() {});
}); });
} }
final hasBanner = widget.announcement.bannerEnabled;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 6), padding: const EdgeInsets.symmetric(vertical: 6),
child: M3Card.elevated( child: M3Card.elevated(
child: Column( child: Column(
// mainAxisSize.min prevents the Column from trying to fill infinite
// height when rendered inside a SliverList (unbounded vertical axis).
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -352,15 +355,38 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Row(
_relativeTime(widget.announcement.createdAt), children: [
style: tt.labelSmall Flexible(
?.copyWith(color: cs.onSurfaceVariant), 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<String>( PopupMenuButton<String>(
onSelected: (value) { onSelected: (value) {
switch (value) { switch (value) {
@ -368,13 +394,32 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
widget.onEdit(); widget.onEdit();
case 'delete': case 'delete':
widget.onDelete(); widget.onDelete();
case 'banner':
widget.onBannerSettings();
} }
}, },
itemBuilder: (context) => [ itemBuilder: (context) => [
const PopupMenuItem( if (_isOwner)
value: 'edit', child: Text('Edit')), const PopupMenuItem(
const PopupMenuItem( value: 'edit', child: Text('Edit')),
value: 'delete', child: Text('Delete')), 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, runSpacing: 4,
children: widget.announcement.visibleRoles.map((role) { children: widget.announcement.visibleRoles.map((role) {
return Chip( return Chip(
label: Text( label: Text(_roleLabel(role), style: tt.labelSmall),
_roleLabel(role),
style: tt.labelSmall,
),
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
@ -411,7 +453,7 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
}).toList(), }).toList(),
), ),
), ),
// Bottom action row: comment toggle + optional resend button // Bottom action row
Padding( Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 0), padding: const EdgeInsets.fromLTRB(8, 4, 8, 0),
child: Row( child: Row(
@ -492,6 +534,10 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
} }
} }
//
// Helpers
//
String _relativeTime(DateTime dt) { String _relativeTime(DateTime dt) {
final now = AppTime.now(); final now = AppTime.now();
final diff = now.difference(dt); final diff = now.difference(dt);
@ -512,3 +558,4 @@ String _roleLabel(String role) {
}; };
return labels[role] ?? role; return labels[role] ?? role;
} }

View File

@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/announcement.dart'; import '../../models/announcement.dart';
import '../../providers/announcements_provider.dart'; import '../../providers/announcements_provider.dart';
import '../../theme/m3_motion.dart'; import '../../theme/m3_motion.dart';
import '../../utils/app_time.dart';
import '../../utils/snackbar.dart'; import '../../utils/snackbar.dart';
import '../../widgets/app_breakpoints.dart'; import '../../widgets/app_breakpoints.dart';
@ -22,6 +23,57 @@ const _roleLabels = {
'standard': 'Standard', '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<DateTime?> 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. /// Shows the create/edit announcement dialog.
/// ///
/// On mobile, uses a full-screen bottom sheet; on desktop, a centered dialog. /// On mobile, uses a full-screen bottom sheet; on desktop, a centered dialog.
@ -49,6 +101,35 @@ Future<void> showCreateAnnouncementDialog(
} }
} }
/// Shows a focused dialog to edit only the banner settings of an announcement.
Future<void> showBannerSettingsDialog(
BuildContext context, {
required Announcement announcement,
}) async {
final width = MediaQuery.sizeOf(context).width;
if (width < AppBreakpoints.tablet) {
await m3ShowBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (ctx) => _BannerSettingsContent(announcement: announcement),
);
} else {
await m3ShowDialog<void>(
context: context,
builder: (ctx) => Dialog(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: _BannerSettingsContent(announcement: announcement),
),
),
);
}
}
//
// Create / Edit dialog
//
class _CreateAnnouncementContent extends ConsumerStatefulWidget { class _CreateAnnouncementContent extends ConsumerStatefulWidget {
const _CreateAnnouncementContent({this.editing}); const _CreateAnnouncementContent({this.editing});
@ -70,6 +151,14 @@ class _CreateAnnouncementContentState
// Template selection // Template selection
String? _selectedTemplateId; 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 @override
void initState() { void initState() {
super.initState(); super.initState();
@ -80,6 +169,14 @@ class _CreateAnnouncementContentState
? Set<String>.from(source.visibleRoles) ? Set<String>.from(source.visibleRoles)
: Set<String>.from(_defaultVisibleRoles); : Set<String>.from(_defaultVisibleRoles);
_isTemplate = widget.editing?.isTemplate ?? false; _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 @override
@ -111,11 +208,25 @@ class _CreateAnnouncementContentState
}); });
} }
Future<void> _pickShowAt() async {
final dt = await pickDateTime(context, initial: _bannerShowAt);
if (dt != null) setState(() => _bannerShowAt = dt);
}
Future<void> _pickHideAt() async {
final dt = await pickDateTime(context, initial: _bannerHideAt);
if (dt != null) setState(() => _bannerHideAt = dt);
}
Future<void> _submit() async { Future<void> _submit() async {
if (!_canSubmit) return; if (!_canSubmit) return;
setState(() => _submitting = true); setState(() => _submitting = true);
try { try {
final ctrl = ref.read(announcementsControllerProvider); 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) { if (widget.editing != null) {
await ctrl.updateAnnouncement( await ctrl.updateAnnouncement(
id: widget.editing!.id, id: widget.editing!.id,
@ -123,6 +234,13 @@ class _CreateAnnouncementContentState
body: _bodyCtrl.text.trim(), body: _bodyCtrl.text.trim(),
visibleRoles: _selectedRoles.toList(), visibleRoles: _selectedRoles.toList(),
isTemplate: _isTemplate, isTemplate: _isTemplate,
bannerEnabled: _bannerEnabled,
bannerShowAt: showAt,
bannerHideAt: hideAt,
pushIntervalMinutes: interval,
clearBannerShowAt: _bannerShowImmediately,
clearBannerHideAt: _bannerHideManual,
clearPushInterval: interval == null,
); );
} else { } else {
await ctrl.createAnnouncement( await ctrl.createAnnouncement(
@ -131,13 +249,16 @@ class _CreateAnnouncementContentState
visibleRoles: _selectedRoles.toList(), visibleRoles: _selectedRoles.toList(),
isTemplate: _isTemplate, isTemplate: _isTemplate,
templateId: _selectedTemplateId, templateId: _selectedTemplateId,
bannerEnabled: _bannerEnabled,
bannerShowAt: showAt,
bannerHideAt: hideAt,
pushIntervalMinutes: interval,
); );
} }
if (mounted) Navigator.of(context).pop(); if (mounted) Navigator.of(context).pop();
showSuccessSnackBarGlobal( showSuccessSnackBarGlobal(
widget.editing != null ? 'Announcement updated.' : 'Announcement posted.'); widget.editing != null ? 'Announcement updated.' : 'Announcement posted.');
} on AnnouncementNotificationException { } on AnnouncementNotificationException {
// Saved successfully; only push notification delivery failed.
if (mounted) { if (mounted) {
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
Navigator.of(context).pop(); Navigator.of(context).pop();
@ -160,12 +281,12 @@ class _CreateAnnouncementContentState
final tt = Theme.of(context).textTheme; final tt = Theme.of(context).textTheme;
final isEditing = widget.editing != null; final isEditing = widget.editing != null;
// Get available templates from the stream (filter client-side)
final templates = ref final templates = ref
.watch(announcementsProvider) .watch(announcementsProvider)
.valueOrNull .valueOrNull
?.where((a) => a.isTemplate) ?.where((a) => a.isTemplate)
.toList() ?? []; .toList() ??
[];
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
@ -173,7 +294,6 @@ class _CreateAnnouncementContentState
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// Dialog title
Text( Text(
isEditing ? 'Edit Announcement' : 'New Announcement', isEditing ? 'Edit Announcement' : 'New Announcement',
style: tt.titleLarge?.copyWith(fontWeight: FontWeight.w600), style: tt.titleLarge?.copyWith(fontWeight: FontWeight.w600),
@ -285,6 +405,45 @@ class _CreateAnnouncementContentState
value: _isTemplate, value: _isTemplate,
onChanged: (val) => setState(() => _isTemplate = val), 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), const SizedBox(height: 20),
// Action buttons // 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<void> _pickShowAt() async {
final dt = await pickDateTime(context, initial: _bannerShowAt);
if (dt != null) setState(() => _bannerShowAt = dt);
}
Future<void> _pickHideAt() async {
final dt = await pickDateTime(context, initial: _bannerHideAt);
if (dt != null) setState(() => _bannerHideAt = dt);
}
Future<void> _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<bool> onShowImmediatelyChanged;
final VoidCallback onPickShowAt;
final ValueChanged<bool> onHideManualChanged;
final VoidCallback onPickHideAt;
final ValueChanged<int?> 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<bool>(
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<bool>(
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<int?>(
key: ValueKey(pushIntervalMinutes),
decoration: const InputDecoration(
border: OutlineInputBorder(),
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10),
),
initialValue: pushIntervalMinutes,
items: _pushIntervalOptions
.map((opt) => DropdownMenuItem<int?>(
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),
),
),
],
),
);
}
}

View File

@ -766,7 +766,9 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
(_insideGeofence || hasGeofenceOverride) && (_insideGeofence || hasGeofenceOverride) &&
!_checkingGeofence; !_checkingGeofence;
return Card( return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
@ -944,6 +946,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
], ],
), ),
), ),
),
); );
}), }),
], ],
@ -5059,8 +5062,10 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
statusColor = Colors.orange; statusColor = Colors.orange;
} }
return Card( return Padding(
child: Padding( padding: const EdgeInsets.only(bottom: 8),
child: Card(
child: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -5133,6 +5138,7 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
], ],
), ),
), ),
),
); );
} }
@ -5355,7 +5361,9 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
statusColor = Colors.orange; statusColor = Colors.orange;
} }
return Card( return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: Column( child: Column(
@ -5440,6 +5448,7 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
], ],
), ),
), ),
),
); );
} }

View File

@ -1,9 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math show min;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:skeletonizer/skeletonizer.dart';
import '../../models/it_service_request.dart'; import '../../models/it_service_request.dart';
import '../../models/it_service_request_assignment.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/profile_provider.dart';
import '../../providers/realtime_controller.dart'; import '../../providers/realtime_controller.dart';
import '../../providers/tickets_provider.dart'; import '../../providers/tickets_provider.dart';
import '../../theme/m3_motion.dart';
import '../../utils/app_time.dart'; import '../../utils/app_time.dart';
import '../../utils/snackbar.dart'; import '../../utils/snackbar.dart';
import '../../widgets/m3_card.dart'; import '../../widgets/m3_card.dart';
@ -100,27 +101,46 @@ class _ItServiceRequestsListScreenState
children: [ children: [
ResponsiveBody( ResponsiveBody(
maxWidth: double.infinity, maxWidth: double.infinity,
child: Skeletonizer( child: Builder(
enabled: showSkeleton, builder: (context) {
child: Builder( if (requestsAsync.hasError && !requestsAsync.hasValue) {
builder: (context) { return AppErrorView(
if (requestsAsync.hasError && !requestsAsync.hasValue) { error: requestsAsync.error!,
return AppErrorView( onRetry: () =>
error: requestsAsync.error!, ref.invalidate(itServiceRequestsProvider),
onRetry: () => );
ref.invalidate(itServiceRequestsProvider), }
); if (showSkeleton) {
} return Column(
final allRequests = children: [
requestsAsync.valueOrNull ?? <ItServiceRequest>[]; const AppPageHeader(
if (allRequests.isEmpty && !showSkeleton) { title: 'IT Service Requests',
return const AppEmptyView( subtitle: 'Manage and track IT support tickets',
icon: Icons.miscellaneous_services_outlined, ),
title: 'No service requests yet', Expanded(
subtitle: child: ListView.separated(
'IT service requests submitted by your team will appear here.', padding: const EdgeInsets.symmetric(
); horizontal: 16, vertical: 8),
} itemCount: 5,
separatorBuilder: (_, _) =>
const SizedBox(height: 8),
itemBuilder: (_, _) =>
_buildIsrShimmerCard(),
),
),
],
);
}
final allRequests =
requestsAsync.valueOrNull ?? <ItServiceRequest>[];
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 ?? <Office>[]; final offices = officesAsync.valueOrNull ?? <Office>[];
final officesSorted = List<Office>.from(offices) final officesSorted = List<Office>.from(offices)
..sort( ..sort(
@ -278,13 +298,12 @@ class _ItServiceRequestsListScreenState
}, },
), ),
), ),
),
// FAB // FAB
if (canCreate) if (canCreate)
Positioned( Positioned(
right: 16, right: 16,
bottom: 16, bottom: 16,
child: FloatingActionButton.extended( child: M3ExpandedFab(
heroTag: 'create_isr', heroTag: 'create_isr',
onPressed: () => _showCreateDialog(context), onPressed: () => _showCreateDialog(context),
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
@ -416,6 +435,38 @@ class _ItServiceRequestsListScreenState
if (context.mounted) showErrorSnackBar(context, 'Error: $e'); 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) { if (requests.isEmpty) {
return const Center(child: Text('No requests match the current filter.')); return const Center(child: Text('No requests match the current filter.'));
} }
return ListView.builder( return ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: requests.length, itemCount: requests.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final request = requests[index]; final request = requests[index];
final assignedStaff = assignments final assignedStaff = assignments
@ -573,10 +625,13 @@ class _RequestList extends StatelessWidget {
final office = request.officeId != null final office = request.officeId != null
? officeById[request.officeId]?.name ? officeById[request.officeId]?.name
: null; : null;
return _RequestTile( return M3FadeSlideIn(
request: request, delay: Duration(milliseconds: math.min(index, 8) * 50),
officeName: office, child: _RequestTile(
assignedStaff: assignedStaff, request: request,
officeName: office,
assignedStaff: assignedStaff,
),
); );
}, },
); );

View File

@ -203,24 +203,29 @@ class _SchedulePanel extends ConsumerWidget {
?.copyWith(fontWeight: FontWeight.w700), ?.copyWith(fontWeight: FontWeight.w700),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
...items.map( for (int i = 0; i < items.length; i++)
(schedule) => _ScheduleTile( M3FadeSlideIn(
schedule: schedule, delay: Duration(milliseconds: i * 40),
displayName: _scheduleName( child: Padding(
profileById, padding: const EdgeInsets.only(bottom: 8),
schedule, child: _ScheduleTile(
isAdmin, schedule: items[i],
rotationConfig, 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,
), ),
),
], ],
), ),
); );

View File

@ -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<AnnouncementBanner> createState() =>
_AnnouncementBannerState();
}
class _AnnouncementBannerState extends ConsumerState<AnnouncementBanner> {
/// IDs of banners the user has dismissed for this session.
final Set<String> _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),
],
);
}
}

View File

@ -8,6 +8,7 @@ import 'package:go_router/go_router.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
import '../providers/notifications_provider.dart'; import '../providers/notifications_provider.dart';
import '../providers/profile_provider.dart'; import '../providers/profile_provider.dart';
import 'announcement_banner.dart';
import 'app_breakpoints.dart'; import 'app_breakpoints.dart';
import 'profile_avatar.dart'; import 'profile_avatar.dart';
import 'pass_slip_countdown_banner.dart'; import 'pass_slip_countdown_banner.dart';
@ -331,7 +332,9 @@ class _ShellBackground extends StatelessWidget {
return ColoredBox( return ColoredBox(
color: Theme.of(context).scaffoldBackgroundColor, color: Theme.of(context).scaffoldBackgroundColor,
child: ShiftCountdownBanner( child: ShiftCountdownBanner(
child: PassSlipCountdownBanner(child: child), child: PassSlipCountdownBanner(
child: AnnouncementBanner(child: child),
),
), ),
); );
} }

View File

@ -63,15 +63,46 @@ async function processBatch() {
return 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<string, any>()
const staleIds: string[] = []
for (const r of rows) { 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 { try {
const scheduleId = r.schedule_id const scheduleId = r.schedule_id
const userId = r.user_id const userId = r.user_id
const notifyType = r.notify_type const notifyType = r.notify_type
const rowId = r.id const rowId = r.id
// Build a unique ID that accounts for all reference columns + epoch // 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}` // 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) const notificationId = await uuidFromName(idSource)
// Idempotency is handled by send_fcm via try_mark_notification_pushed. // 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.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.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.pass_slip_id) data.pass_slip_id = r.pass_slip_id
if (r.announcement_id) data.announcement_id = r.announcement_id
switch (notifyType) { switch (notifyType) {
case 'start_15': 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.' body = 'Your pass slip has exceeded the 1-hour limit. Please return and complete it immediately.'
data.navigate_to = '/attendance' data.navigate_to = '/attendance'
break 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: default:
title = 'Reminder' title = 'Reminder'
body = 'You have a pending notification.' body = 'You have a pending notification.'

View File

@ -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) { if (payload.data && payload.data.notification_id) {
const { data: markData, error: markErr } = await supabase const { data: markData, error: markErr } = await supabase
.rpc('try_mark_notification_pushed', { p_notification_id: payload.data.notification_id }) .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) { if (markData === false) {
console.log('Notification already pushed, skipping:', payload.data.notification_id) console.log('Notification already pushed, skipping:', payload.data.notification_id)
return new Response('Already pushed', { status: 200, headers: corsHeaders }) return new Response('Already pushed', { status: 200, headers: corsHeaders })

View File

@ -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;
$$;

View File

@ -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;
$$;

View File

@ -353,6 +353,10 @@ $$;
-- ============================================================================ -- ============================================================================
-- MASTER DISPATCHER -- 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() CREATE OR REPLACE FUNCTION public.enqueue_all_notifications()
RETURNS void LANGUAGE plpgsql AS $$ RETURNS void LANGUAGE plpgsql AS $$
BEGIN BEGIN
@ -364,6 +368,8 @@ BEGIN
PERFORM public.enqueue_paused_task_notifications(); PERFORM public.enqueue_paused_task_notifications();
PERFORM public.enqueue_backlog_notifications(); PERFORM public.enqueue_backlog_notifications();
PERFORM public.enqueue_pass_slip_expiry_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; END;
$$; $$;

View File

@ -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;
$$;