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

This commit is contained in:
2026-03-22 18:00:10 +08:00
parent 049ab2c794
commit 872c2aab87
15 changed files with 1290 additions and 183 deletions
@@ -1,8 +1,8 @@
import 'dart:async';
import 'dart:math' as math show min;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import '../../models/announcement.dart';
import '../../providers/announcements_provider.dart';
@@ -47,68 +47,73 @@ class _AnnouncementsScreenState extends ConsumerState<AnnouncementsScreen> {
floatingActionButton: canCreate
? M3ExpandedFab(
heroTag: 'announcement_fab',
onPressed: () =>
showCreateAnnouncementDialog(context),
onPressed: () => showCreateAnnouncementDialog(context),
icon: const Icon(Icons.add),
label: const Text('New Announcement'),
)
: null,
body: ResponsiveBody(
child: Skeletonizer(
enabled: showSkeleton,
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: AppPageHeader(title: 'Announcements'),
),
if (hasError && !hasValue)
SliverFillRemaining(
child: Center(
child: Text(
'Failed to load announcements.',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: Theme.of(context).colorScheme.error),
),
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: AppPageHeader(title: 'Announcements'),
),
if (hasError && !hasValue)
SliverFillRemaining(
child: Center(
child: Text(
'Failed to load announcements.',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: Theme.of(context).colorScheme.error),
),
)
else if (!showSkeleton && items.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.campaign_outlined,
size: 64,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant),
const SizedBox(height: 12),
Text('No announcements yet',
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant)),
],
),
),
)
else if (showSkeleton)
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _buildPlaceholderCard(context),
childCount: 5,
),
)
else if (items.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.campaign_outlined,
size: 64,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant),
const SizedBox(height: 12),
Text('No announcements yet',
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant)),
],
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (showSkeleton) {
// Placeholder card for shimmer
return _buildPlaceholderCard(context);
}
final announcement = items[index];
return _AnnouncementCard(
key: ValueKey(announcement.id),
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final announcement = items[index];
final delay = Duration(
milliseconds: math.min(index, 8) * 60);
return M3FadeSlideIn(
key: ValueKey(announcement.id),
delay: delay,
child: _AnnouncementCard(
announcement: announcement,
profiles: profiles,
currentUserId: currentUserId,
@@ -157,15 +162,18 @@ class _AnnouncementsScreenState extends ConsumerState<AnnouncementsScreen> {
showSuccessSnackBarGlobal('Announcement deleted.');
}
},
);
},
childCount: showSkeleton ? 5 : items.length,
),
onBannerSettings: () => showBannerSettingsDialog(
context,
announcement: announcement,
),
),
);
},
childCount: items.length,
),
// Bottom padding so FAB doesn't cover last card
const SliverPadding(padding: EdgeInsets.only(bottom: 80)),
],
),
),
const SliverPadding(padding: EdgeInsets.only(bottom: 80)),
],
),
),
);
@@ -173,7 +181,7 @@ class _AnnouncementsScreenState extends ConsumerState<AnnouncementsScreen> {
Widget _buildPlaceholderCard(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 6),
padding: const EdgeInsets.symmetric(vertical: 6),
child: M3Card.elevated(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -182,43 +190,32 @@ class _AnnouncementsScreenState extends ConsumerState<AnnouncementsScreen> {
children: [
Row(
children: [
const CircleAvatar(radius: 18),
M3ShimmerBox(
width: 36,
height: 36,
borderRadius: BorderRadius.circular(18),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 120,
height: 14,
color: Colors.grey,
),
const SizedBox(height: 4),
Container(
width: 60,
height: 10,
color: Colors.grey,
),
M3ShimmerBox(width: 120, height: 13),
const SizedBox(height: 5),
M3ShimmerBox(width: 64, height: 10),
],
),
),
],
),
const SizedBox(height: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200),
child: Container(
width: double.infinity, height: 16, color: Colors.grey),
),
const SizedBox(height: 14),
M3ShimmerBox(width: 200, height: 15),
const SizedBox(height: 8),
Container(
width: double.infinity, height: 12, color: Colors.grey),
const SizedBox(height: 4),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 240),
child: Container(
width: double.infinity, height: 12, color: Colors.grey),
),
M3ShimmerBox(height: 12),
const SizedBox(height: 5),
M3ShimmerBox(width: 240, height: 12),
const SizedBox(height: 5),
M3ShimmerBox(width: 160, height: 12),
],
),
),
@@ -227,9 +224,12 @@ class _AnnouncementsScreenState extends ConsumerState<AnnouncementsScreen> {
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Announcement card
// ─────────────────────────────────────────────────────────────────────────────
class _AnnouncementCard extends ConsumerStatefulWidget {
const _AnnouncementCard({
super.key,
required this.announcement,
required this.profiles,
required this.currentUserId,
@@ -239,6 +239,7 @@ class _AnnouncementCard extends ConsumerStatefulWidget {
required this.onToggleComments,
required this.onEdit,
required this.onDelete,
required this.onBannerSettings,
});
final Announcement announcement;
@@ -250,6 +251,7 @@ class _AnnouncementCard extends ConsumerStatefulWidget {
final VoidCallback onToggleComments;
final VoidCallback onEdit;
final VoidCallback onDelete;
final VoidCallback onBannerSettings;
@override
ConsumerState<_AnnouncementCard> createState() => _AnnouncementCardState();
@@ -272,9 +274,14 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
bool get _inCooldown => _secondsRemaining > 0;
bool get _canResend =>
widget.announcement.authorId == widget.currentUserId ||
widget.currentUserRole == 'admin';
bool get _isOwner =>
widget.announcement.authorId == widget.currentUserId;
bool get _isAdmin => widget.currentUserRole == 'admin';
bool get _canManageBanner => _isOwner || _isAdmin;
bool get _canResend => _isOwner || _isAdmin;
Future<void> _resendNotification() async {
try {
@@ -283,7 +290,6 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
.resendAnnouncementNotification(widget.announcement);
if (mounted) setState(() => _sentAt = DateTime.now());
} on AnnouncementNotificationException {
// Partial failure — start cooldown to prevent spam
if (mounted) setState(() => _sentAt = DateTime.now());
} catch (e) {
if (mounted) showErrorSnackBar(context, 'Failed to send: $e');
@@ -306,27 +312,24 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
}
}
final isOwner = widget.announcement.authorId == widget.currentUserId;
// Comment count
final commentsAsync =
ref.watch(announcementCommentsProvider(widget.announcement.id));
final commentCount = commentsAsync.valueOrNull?.length ?? 0;
// Rebuild UI when cooldown is active to update the countdown display.
// This timer triggers rebuilds every 500ms while _inCooldown is true.
// Rebuild UI when cooldown is active.
if (_inCooldown) {
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) setState(() {});
});
}
final hasBanner = widget.announcement.bannerEnabled;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: M3Card.elevated(
child: Column(
// mainAxisSize.min prevents the Column from trying to fill infinite
// height when rendered inside a SliverList (unbounded vertical axis).
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -352,15 +355,38 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
_relativeTime(widget.announcement.createdAt),
style: tt.labelSmall
?.copyWith(color: cs.onSurfaceVariant),
Row(
children: [
Flexible(
child: Text(
_relativeTime(widget.announcement.createdAt),
style: tt.labelSmall
?.copyWith(color: cs.onSurfaceVariant),
),
),
if (hasBanner) ...[
const SizedBox(width: 6),
Tooltip(
message: widget.announcement.isBannerActive
? 'Banner active'
: 'Banner (inactive)',
child: Icon(
Icons.campaign,
size: 14,
color: widget.announcement.isBannerActive
? cs.primary
: cs.onSurfaceVariant,
),
),
],
],
),
],
),
),
if (isOwner)
// Popup menu: owner sees Edit/Delete/Banner;
// admin-only sees Delete/Banner
if (_isOwner || _canManageBanner)
PopupMenuButton<String>(
onSelected: (value) {
switch (value) {
@@ -368,13 +394,32 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
widget.onEdit();
case 'delete':
widget.onDelete();
case 'banner':
widget.onBannerSettings();
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'edit', child: Text('Edit')),
const PopupMenuItem(
value: 'delete', child: Text('Delete')),
if (_isOwner)
const PopupMenuItem(
value: 'edit', child: Text('Edit')),
if (_isOwner || _isAdmin)
const PopupMenuItem(
value: 'delete', child: Text('Delete')),
if (_canManageBanner)
PopupMenuItem(
value: 'banner',
child: Row(
children: [
Icon(Icons.campaign_outlined,
size: 18,
color: Theme.of(context)
.colorScheme
.primary),
const SizedBox(width: 8),
const Text('Banner Settings'),
],
),
),
],
),
],
@@ -400,10 +445,7 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
runSpacing: 4,
children: widget.announcement.visibleRoles.map((role) {
return Chip(
label: Text(
_roleLabel(role),
style: tt.labelSmall,
),
label: Text(_roleLabel(role), style: tt.labelSmall),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
@@ -411,7 +453,7 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
}).toList(),
),
),
// Bottom action row: comment toggle + optional resend button
// Bottom action row
Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 0),
child: Row(
@@ -492,6 +534,10 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
String _relativeTime(DateTime dt) {
final now = AppTime.now();
final diff = now.difference(dt);
@@ -512,3 +558,4 @@ String _roleLabel(String role) {
};
return labels[role] ?? role;
}
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/announcement.dart';
import '../../providers/announcements_provider.dart';
import '../../theme/m3_motion.dart';
import '../../utils/app_time.dart';
import '../../utils/snackbar.dart';
import '../../widgets/app_breakpoints.dart';
@@ -22,6 +23,57 @@ const _roleLabels = {
'standard': 'Standard',
};
/// Push notification interval options (minutes → display label).
/// [null] means no scheduled push.
const _pushIntervalOptions = [
(null, 'No scheduled push'),
(1, 'Every minute'),
(5, 'Every 5 minutes'),
(10, 'Every 10 minutes'),
(15, 'Every 15 minutes'),
(30, 'Every 30 minutes'),
(60, 'Every hour'),
(120, 'Every 2 hours'),
(360, 'Every 6 hours'),
(720, 'Every 12 hours'),
(1440, 'Daily'),
];
String _formatDt(DateTime dt) =>
'${AppTime.formatDate(dt)} ${AppTime.formatTime(dt)}';
/// Picks a date+time using the platform date and time pickers.
/// Returns a Manila-timezone [DateTime] or [null] if cancelled.
Future<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.
///
/// 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 {
const _CreateAnnouncementContent({this.editing});
@@ -70,6 +151,14 @@ class _CreateAnnouncementContentState
// Template selection
String? _selectedTemplateId;
// Banner state
bool _bannerEnabled = false;
bool _bannerShowImmediately = true; // false = custom date/time
DateTime? _bannerShowAt;
bool _bannerHideManual = true; // false = auto hide at custom date/time
DateTime? _bannerHideAt;
int? _pushIntervalMinutes; // null = no scheduled push
@override
void initState() {
super.initState();
@@ -80,6 +169,14 @@ class _CreateAnnouncementContentState
? Set<String>.from(source.visibleRoles)
: Set<String>.from(_defaultVisibleRoles);
_isTemplate = widget.editing?.isTemplate ?? false;
// Banner initialisation from existing data
_bannerEnabled = source?.bannerEnabled ?? false;
_bannerShowAt = source?.bannerShowAt;
_bannerShowImmediately = source?.bannerShowAt == null;
_bannerHideAt = source?.bannerHideAt;
_bannerHideManual = source?.bannerHideAt == null;
_pushIntervalMinutes = source?.pushIntervalMinutes;
}
@override
@@ -111,11 +208,25 @@ class _CreateAnnouncementContentState
});
}
Future<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 {
if (!_canSubmit) return;
setState(() => _submitting = true);
try {
final ctrl = ref.read(announcementsControllerProvider);
final showAt = _bannerEnabled && !_bannerShowImmediately ? _bannerShowAt : null;
final hideAt = _bannerEnabled && !_bannerHideManual ? _bannerHideAt : null;
final interval = _bannerEnabled ? _pushIntervalMinutes : null;
if (widget.editing != null) {
await ctrl.updateAnnouncement(
id: widget.editing!.id,
@@ -123,6 +234,13 @@ class _CreateAnnouncementContentState
body: _bodyCtrl.text.trim(),
visibleRoles: _selectedRoles.toList(),
isTemplate: _isTemplate,
bannerEnabled: _bannerEnabled,
bannerShowAt: showAt,
bannerHideAt: hideAt,
pushIntervalMinutes: interval,
clearBannerShowAt: _bannerShowImmediately,
clearBannerHideAt: _bannerHideManual,
clearPushInterval: interval == null,
);
} else {
await ctrl.createAnnouncement(
@@ -131,13 +249,16 @@ class _CreateAnnouncementContentState
visibleRoles: _selectedRoles.toList(),
isTemplate: _isTemplate,
templateId: _selectedTemplateId,
bannerEnabled: _bannerEnabled,
bannerShowAt: showAt,
bannerHideAt: hideAt,
pushIntervalMinutes: interval,
);
}
if (mounted) Navigator.of(context).pop();
showSuccessSnackBarGlobal(
widget.editing != null ? 'Announcement updated.' : 'Announcement posted.');
} on AnnouncementNotificationException {
// Saved successfully; only push notification delivery failed.
if (mounted) {
final messenger = ScaffoldMessenger.of(context);
Navigator.of(context).pop();
@@ -160,12 +281,12 @@ class _CreateAnnouncementContentState
final tt = Theme.of(context).textTheme;
final isEditing = widget.editing != null;
// Get available templates from the stream (filter client-side)
final templates = ref
.watch(announcementsProvider)
.valueOrNull
?.where((a) => a.isTemplate)
.toList() ?? [];
.watch(announcementsProvider)
.valueOrNull
?.where((a) => a.isTemplate)
.toList() ??
[];
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
@@ -173,7 +294,6 @@ class _CreateAnnouncementContentState
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Dialog title
Text(
isEditing ? 'Edit Announcement' : 'New Announcement',
style: tt.titleLarge?.copyWith(fontWeight: FontWeight.w600),
@@ -285,6 +405,45 @@ class _CreateAnnouncementContentState
value: _isTemplate,
onChanged: (val) => setState(() => _isTemplate = val),
),
// ── Banner notification section ─────────────────────────────────
const Divider(height: 24),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Row(
children: [
Icon(Icons.campaign_outlined, size: 18, color: cs.primary),
const SizedBox(width: 6),
Text('Banner Notification', style: tt.bodyMedium),
],
),
subtitle: Text(
'Pin a prominent banner at the top of the Announcements screen.',
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
),
value: _bannerEnabled,
onChanged: (val) => setState(() => _bannerEnabled = val),
),
if (_bannerEnabled) ...[
const SizedBox(height: 12),
_BannerOptionsPanel(
showImmediately: _bannerShowImmediately,
showAt: _bannerShowAt,
hideManual: _bannerHideManual,
hideAt: _bannerHideAt,
pushIntervalMinutes: _pushIntervalMinutes,
onShowImmediatelyChanged: (v) =>
setState(() => _bannerShowImmediately = v),
onPickShowAt: _pickShowAt,
onHideManualChanged: (v) =>
setState(() => _bannerHideManual = v),
onPickHideAt: _pickHideAt,
onIntervalChanged: (v) =>
setState(() => _pushIntervalMinutes = v),
),
],
const SizedBox(height: 20),
// Action buttons
@@ -314,3 +473,285 @@ class _CreateAnnouncementContentState
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Banner Settings dialog (post-creation editing)
// ─────────────────────────────────────────────────────────────────────────────
class _BannerSettingsContent extends ConsumerStatefulWidget {
const _BannerSettingsContent({required this.announcement});
final Announcement announcement;
@override
ConsumerState<_BannerSettingsContent> createState() =>
_BannerSettingsContentState();
}
class _BannerSettingsContentState
extends ConsumerState<_BannerSettingsContent> {
late bool _bannerEnabled;
late bool _bannerShowImmediately;
DateTime? _bannerShowAt;
late bool _bannerHideManual;
DateTime? _bannerHideAt;
int? _pushIntervalMinutes;
bool _submitting = false;
@override
void initState() {
super.initState();
final a = widget.announcement;
_bannerEnabled = a.bannerEnabled;
_bannerShowAt = a.bannerShowAt;
_bannerShowImmediately = a.bannerShowAt == null;
_bannerHideAt = a.bannerHideAt;
_bannerHideManual = a.bannerHideAt == null;
_pushIntervalMinutes = a.pushIntervalMinutes;
}
Future<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),
),
),
],
),
);
}
}