tasq/lib/screens/announcements/create_announcement_dialog.dart

758 lines
26 KiB
Dart

import 'package:flutter/material.dart';
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';
/// All user roles available for announcement visibility.
const _allRoles = ['admin', 'dispatcher', 'programmer', 'it_staff', 'standard'];
/// Default visible roles (standard is excluded by default).
const _defaultVisibleRoles = ['admin', 'dispatcher', 'programmer', 'it_staff'];
/// Human-readable labels for each role.
const _roleLabels = {
'admin': 'Admin',
'dispatcher': 'Dispatcher',
'programmer': 'Programmer',
'it_staff': 'IT Staff',
'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.
Future<void> showCreateAnnouncementDialog(
BuildContext context, {
Announcement? editing,
}) async {
final width = MediaQuery.sizeOf(context).width;
if (width < AppBreakpoints.tablet) {
await m3ShowBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (ctx) => _CreateAnnouncementContent(editing: editing),
);
} else {
await m3ShowDialog<void>(
context: context,
builder: (ctx) => Dialog(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: _CreateAnnouncementContent(editing: editing),
),
),
);
}
}
/// 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});
final Announcement? editing;
@override
ConsumerState<_CreateAnnouncementContent> createState() =>
_CreateAnnouncementContentState();
}
class _CreateAnnouncementContentState
extends ConsumerState<_CreateAnnouncementContent> {
late final TextEditingController _titleCtrl;
late final TextEditingController _bodyCtrl;
late Set<String> _selectedRoles;
bool _isTemplate = false;
bool _submitting = false;
// 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();
final source = widget.editing;
_titleCtrl = TextEditingController(text: source?.title ?? '');
_bodyCtrl = TextEditingController(text: source?.body ?? '');
_selectedRoles = source != null
? 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
void dispose() {
_titleCtrl.dispose();
_bodyCtrl.dispose();
super.dispose();
}
bool get _canSubmit {
final hasContent =
_titleCtrl.text.trim().isNotEmpty && _bodyCtrl.text.trim().isNotEmpty;
final hasRoles = _selectedRoles.isNotEmpty;
return hasContent && hasRoles && !_submitting;
}
void _applyTemplate(Announcement template) {
setState(() {
_selectedTemplateId = template.id;
_titleCtrl.text = template.title;
_bodyCtrl.text = template.body;
_selectedRoles = Set<String>.from(template.visibleRoles);
});
}
void _clearTemplate() {
setState(() {
_selectedTemplateId = null;
});
}
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,
title: _titleCtrl.text.trim(),
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(
title: _titleCtrl.text.trim(),
body: _bodyCtrl.text.trim(),
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 {
if (mounted) {
final messenger = ScaffoldMessenger.of(context);
Navigator.of(context).pop();
messenger.showSnackBar(
const SnackBar(
content: Text('Posted, but some notifications failed to send.'),
),
);
}
} 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;
final isEditing = widget.editing != null;
final templates = ref
.watch(announcementsProvider)
.valueOrNull
?.where((a) => a.isTemplate)
.toList() ??
[];
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
isEditing ? 'Edit Announcement' : 'New Announcement',
style: tt.titleLarge?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 20),
// Template selector (only for new announcements)
if (!isEditing && templates.isNotEmpty) ...[
DropdownButtonFormField<String?>(
decoration: InputDecoration(
labelText: 'Post from template (optional)',
border: const OutlineInputBorder(),
suffixIcon: _selectedTemplateId != null
? IconButton(
icon: const Icon(Icons.clear),
tooltip: 'Clear template',
onPressed: _clearTemplate,
)
: null,
),
key: ValueKey(_selectedTemplateId),
initialValue: _selectedTemplateId,
items: [
const DropdownMenuItem<String?>(
value: null,
child: Text('No template'),
),
...templates.map(
(t) => DropdownMenuItem<String?>(
value: t.id,
child: Text(
t.title.isEmpty ? '(Untitled)' : t.title,
overflow: TextOverflow.ellipsis,
),
),
),
],
onChanged: (id) {
if (id == null) {
_clearTemplate();
} else {
final tmpl = templates.firstWhere((t) => t.id == id);
_applyTemplate(tmpl);
}
},
),
const SizedBox(height: 16),
],
// Title field
TextField(
controller: _titleCtrl,
decoration: const InputDecoration(
labelText: 'Title',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.next,
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 16),
// Body field
TextField(
controller: _bodyCtrl,
decoration: const InputDecoration(
labelText: 'Body',
border: OutlineInputBorder(),
alignLabelWithHint: true,
),
maxLines: 6,
minLines: 3,
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 16),
// Role visibility
Text('Visible to:', style: tt.labelLarge),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 6,
children: _allRoles.map((role) {
final selected = _selectedRoles.contains(role);
return FilterChip(
label: Text(_roleLabels[role] ?? role),
selected: selected,
onSelected: (val) {
setState(() {
if (val) {
_selectedRoles.add(role);
} else {
_selectedRoles.remove(role);
}
});
},
);
}).toList(),
),
const SizedBox(height: 16),
// Template toggle
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text('Save as template', style: tt.bodyMedium),
subtitle: Text(
'Templates can be selected when creating new announcements.',
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
),
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
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
const SizedBox(width: 12),
FilledButton(
onPressed: _canSubmit ? _submit : null,
child: _submitting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: Text(isEditing ? 'Save' : 'Post'),
),
],
),
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 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),
),
),
],
),
);
}
}