UI Enhancements in IT Service Request, Announcements, Workforce and notification fixes
This commit is contained in:
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user