tasq/lib/screens/announcements/create_announcement_dialog.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'),
),
],
),
],
),
);
}
}