tasq/lib/screens/announcements/announcements_screen.dart

411 lines
15 KiB
Dart

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';
import '../../providers/profile_provider.dart';
import '../../theme/m3_motion.dart';
import '../../utils/app_time.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/m3_card.dart';
import '../../widgets/profile_avatar.dart';
import '../../widgets/responsive_body.dart';
import 'announcement_comments_section.dart';
import 'create_announcement_dialog.dart';
class AnnouncementsScreen extends ConsumerStatefulWidget {
const AnnouncementsScreen({super.key});
@override
ConsumerState<AnnouncementsScreen> createState() =>
_AnnouncementsScreenState();
}
class _AnnouncementsScreenState extends ConsumerState<AnnouncementsScreen> {
final Set<String> _expandedComments = {};
@override
Widget build(BuildContext context) {
final announcementsAsync = ref.watch(announcementsProvider);
final profiles = ref.watch(profilesProvider).valueOrNull ?? [];
final currentProfile = ref.watch(currentProfileProvider).valueOrNull;
final currentUserId = ref.watch(currentUserIdProvider);
final role = currentProfile?.role ?? 'standard';
final canCreate = const ['admin', 'dispatcher', 'programmer', 'it_staff']
.contains(role);
final hasValue = announcementsAsync.hasValue;
final hasError = announcementsAsync.hasError;
final items = announcementsAsync.valueOrNull ?? [];
final showSkeleton = !hasValue && !hasError;
return Scaffold(
floatingActionButton: canCreate
? M3ExpandedFab(
heroTag: 'announcement_fab',
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),
),
),
)
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
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (showSkeleton) {
// Placeholder card for shimmer
return _buildPlaceholderCard(context);
}
final announcement = items[index];
return _AnnouncementCard(
announcement: announcement,
profiles: profiles,
currentUserId: currentUserId,
canCreate: canCreate,
isExpanded:
_expandedComments.contains(announcement.id),
onToggleComments: () {
setState(() {
if (_expandedComments.contains(announcement.id)) {
_expandedComments.remove(announcement.id);
} else {
_expandedComments.add(announcement.id);
}
});
},
onEdit: () => showCreateAnnouncementDialog(
context,
editing: announcement,
),
onDelete: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete Announcement'),
content: const Text(
'Are you sure you want to delete this announcement?'),
actions: [
TextButton(
onPressed: () =>
Navigator.of(ctx).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () =>
Navigator.of(ctx).pop(true),
child: const Text('Delete'),
),
],
),
);
if (confirm == true) {
await ref
.read(announcementsControllerProvider)
.deleteAnnouncement(announcement.id);
}
},
);
},
childCount: showSkeleton ? 5 : items.length,
),
),
// Bottom padding so FAB doesn't cover last card
const SliverPadding(padding: EdgeInsets.only(bottom: 80)),
],
),
),
),
);
}
Widget _buildPlaceholderCard(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 6),
child: M3Card.elevated(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const CircleAvatar(radius: 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,
),
],
),
),
],
),
const SizedBox(height: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200),
child: Container(
width: double.infinity, height: 16, color: Colors.grey),
),
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),
),
],
),
),
),
);
}
}
class _AnnouncementCard extends ConsumerWidget {
const _AnnouncementCard({
required this.announcement,
required this.profiles,
required this.currentUserId,
required this.canCreate,
required this.isExpanded,
required this.onToggleComments,
required this.onEdit,
required this.onDelete,
});
final Announcement announcement;
final List profiles;
final String? currentUserId;
final bool canCreate;
final bool isExpanded;
final VoidCallback onToggleComments;
final VoidCallback onEdit;
final VoidCallback onDelete;
@override
Widget build(BuildContext context, WidgetRef ref) {
final cs = Theme.of(context).colorScheme;
final tt = Theme.of(context).textTheme;
// Resolve author
String authorName = 'Unknown';
String? avatarUrl;
for (final p in profiles) {
if (p.id == announcement.authorId) {
authorName = p.fullName;
avatarUrl = p.avatarUrl;
break;
}
}
final isOwner = announcement.authorId == currentUserId;
// Comment count
final commentsAsync =
ref.watch(announcementCommentsProvider(announcement.id));
final commentCount = commentsAsync.valueOrNull?.length ?? 0;
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: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 8, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ProfileAvatar(
fullName: authorName,
avatarUrl: avatarUrl,
radius: 20,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
authorName,
style: tt.titleSmall
?.copyWith(fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
_relativeTime(announcement.createdAt),
style: tt.labelSmall
?.copyWith(color: cs.onSurfaceVariant),
),
],
),
),
if (isOwner)
PopupMenuButton<String>(
onSelected: (value) {
switch (value) {
case 'edit':
onEdit();
case 'delete':
onDelete();
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'edit', child: Text('Edit')),
const PopupMenuItem(
value: 'delete', child: Text('Delete')),
],
),
],
),
),
// Title + Body
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Text(
announcement.title,
style: tt.titleMedium?.copyWith(fontWeight: FontWeight.w600),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 0),
child: Text(announcement.body, style: tt.bodyMedium),
),
// Visible roles chips
Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 0),
child: Wrap(
spacing: 6,
runSpacing: 4,
children: announcement.visibleRoles.map((role) {
return Chip(
label: Text(
_roleLabel(role),
style: tt.labelSmall,
),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
}).toList(),
),
),
// Comment toggle row
Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 0),
child: TextButton.icon(
onPressed: onToggleComments,
icon: Icon(
isExpanded
? Icons.expand_less
: Icons.comment_outlined,
size: 18,
),
label: Text(
commentCount > 0
? '$commentCount comment${commentCount == 1 ? '' : 's'}'
: 'Comment',
),
style: TextButton.styleFrom(
foregroundColor: cs.onSurfaceVariant,
textStyle: tt.labelMedium,
),
),
),
// Comments section
if (isExpanded)
AnnouncementCommentsSection(
announcementId: announcement.id),
],
),
),
);
}
}
String _relativeTime(DateTime dt) {
final now = AppTime.now();
final diff = now.difference(dt);
if (diff.inMinutes < 1) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
if (diff.inDays < 7) return '${diff.inDays}d ago';
return AppTime.formatDate(dt);
}
String _roleLabel(String role) {
const labels = {
'admin': 'Admin',
'dispatcher': 'Dispatcher',
'programmer': 'Programmer',
'it_staff': 'IT Staff',
'standard': 'Standard',
};
return labels[role] ?? role;
}