UI Enhancements in IT Service Request, Announcements, Workforce and notification fixes
This commit is contained in:
parent
049ab2c794
commit
872c2aab87
|
|
@ -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?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
128
lib/widgets/announcement_banner.dart
Normal file
128
lib/widgets/announcement_banner.dart
Normal 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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.'
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
|
|
||||||
133
supabase/migrations/20260322210000_announcement_banner.sql
Normal file
133
supabase/migrations/20260322210000_announcement_banner.sql
Normal 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;
|
||||||
|
$$;
|
||||||
|
|
@ -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;
|
||||||
|
$$;
|
||||||
|
|
@ -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;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
$$;
|
||||||
Loading…
Reference in New Issue
Block a user