315 lines
9.8 KiB
Dart
315 lines
9.8 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/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',
|
|
};
|
|
|
|
/// 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),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
@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;
|
|
}
|
|
|
|
@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> _submit() async {
|
|
if (!_canSubmit) return;
|
|
setState(() => _submitting = true);
|
|
try {
|
|
final ctrl = ref.read(announcementsControllerProvider);
|
|
if (widget.editing != null) {
|
|
await ctrl.updateAnnouncement(
|
|
id: widget.editing!.id,
|
|
title: _titleCtrl.text.trim(),
|
|
body: _bodyCtrl.text.trim(),
|
|
visibleRoles: _selectedRoles.toList(),
|
|
isTemplate: _isTemplate,
|
|
);
|
|
} else {
|
|
await ctrl.createAnnouncement(
|
|
title: _titleCtrl.text.trim(),
|
|
body: _bodyCtrl.text.trim(),
|
|
visibleRoles: _selectedRoles.toList(),
|
|
isTemplate: _isTemplate,
|
|
templateId: _selectedTemplateId,
|
|
);
|
|
}
|
|
if (mounted) Navigator.of(context).pop();
|
|
} on AnnouncementNotificationException {
|
|
// Saved successfully; only push notification delivery failed.
|
|
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;
|
|
|
|
// Get available templates from the stream (filter client-side)
|
|
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: [
|
|
// Dialog title
|
|
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),
|
|
),
|
|
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'),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|