UI Enhancements in IT Service Request, Announcements, Workforce and notification fixes
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math show min;
|
||||
|
||||
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';
|
||||
@@ -47,68 +47,73 @@ class _AnnouncementsScreenState extends ConsumerState<AnnouncementsScreen> {
|
||||
floatingActionButton: canCreate
|
||||
? M3ExpandedFab(
|
||||
heroTag: 'announcement_fab',
|
||||
onPressed: () =>
|
||||
showCreateAnnouncementDialog(context),
|
||||
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),
|
||||
),
|
||||
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 if (showSkeleton)
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _buildPlaceholderCard(context),
|
||||
childCount: 5,
|
||||
),
|
||||
)
|
||||
else if (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(
|
||||
key: ValueKey(announcement.id),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final announcement = items[index];
|
||||
final delay = Duration(
|
||||
milliseconds: math.min(index, 8) * 60);
|
||||
return M3FadeSlideIn(
|
||||
key: ValueKey(announcement.id),
|
||||
delay: delay,
|
||||
child: _AnnouncementCard(
|
||||
announcement: announcement,
|
||||
profiles: profiles,
|
||||
currentUserId: currentUserId,
|
||||
@@ -157,15 +162,18 @@ class _AnnouncementsScreenState extends ConsumerState<AnnouncementsScreen> {
|
||||
showSuccessSnackBarGlobal('Announcement deleted.');
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
childCount: showSkeleton ? 5 : items.length,
|
||||
),
|
||||
onBannerSettings: () => showBannerSettingsDialog(
|
||||
context,
|
||||
announcement: announcement,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: items.length,
|
||||
),
|
||||
// Bottom padding so FAB doesn't cover last card
|
||||
const SliverPadding(padding: EdgeInsets.only(bottom: 80)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SliverPadding(padding: EdgeInsets.only(bottom: 80)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -173,7 +181,7 @@ class _AnnouncementsScreenState extends ConsumerState<AnnouncementsScreen> {
|
||||
|
||||
Widget _buildPlaceholderCard(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 6),
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: M3Card.elevated(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -182,43 +190,32 @@ class _AnnouncementsScreenState extends ConsumerState<AnnouncementsScreen> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const CircleAvatar(radius: 18),
|
||||
M3ShimmerBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: BorderRadius.circular(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,
|
||||
),
|
||||
M3ShimmerBox(width: 120, height: 13),
|
||||
const SizedBox(height: 5),
|
||||
M3ShimmerBox(width: 64, height: 10),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 200),
|
||||
child: Container(
|
||||
width: double.infinity, height: 16, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
M3ShimmerBox(width: 200, height: 15),
|
||||
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),
|
||||
),
|
||||
M3ShimmerBox(height: 12),
|
||||
const SizedBox(height: 5),
|
||||
M3ShimmerBox(width: 240, height: 12),
|
||||
const SizedBox(height: 5),
|
||||
M3ShimmerBox(width: 160, height: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -227,9 +224,12 @@ class _AnnouncementsScreenState extends ConsumerState<AnnouncementsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Announcement card
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _AnnouncementCard extends ConsumerStatefulWidget {
|
||||
const _AnnouncementCard({
|
||||
super.key,
|
||||
required this.announcement,
|
||||
required this.profiles,
|
||||
required this.currentUserId,
|
||||
@@ -239,6 +239,7 @@ class _AnnouncementCard extends ConsumerStatefulWidget {
|
||||
required this.onToggleComments,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
required this.onBannerSettings,
|
||||
});
|
||||
|
||||
final Announcement announcement;
|
||||
@@ -250,6 +251,7 @@ class _AnnouncementCard extends ConsumerStatefulWidget {
|
||||
final VoidCallback onToggleComments;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onBannerSettings;
|
||||
|
||||
@override
|
||||
ConsumerState<_AnnouncementCard> createState() => _AnnouncementCardState();
|
||||
@@ -272,9 +274,14 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
|
||||
|
||||
bool get _inCooldown => _secondsRemaining > 0;
|
||||
|
||||
bool get _canResend =>
|
||||
widget.announcement.authorId == widget.currentUserId ||
|
||||
widget.currentUserRole == 'admin';
|
||||
bool get _isOwner =>
|
||||
widget.announcement.authorId == widget.currentUserId;
|
||||
|
||||
bool get _isAdmin => widget.currentUserRole == 'admin';
|
||||
|
||||
bool get _canManageBanner => _isOwner || _isAdmin;
|
||||
|
||||
bool get _canResend => _isOwner || _isAdmin;
|
||||
|
||||
Future<void> _resendNotification() async {
|
||||
try {
|
||||
@@ -283,7 +290,6 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
|
||||
.resendAnnouncementNotification(widget.announcement);
|
||||
if (mounted) setState(() => _sentAt = DateTime.now());
|
||||
} on AnnouncementNotificationException {
|
||||
// Partial failure — start cooldown to prevent spam
|
||||
if (mounted) setState(() => _sentAt = DateTime.now());
|
||||
} catch (e) {
|
||||
if (mounted) showErrorSnackBar(context, 'Failed to send: $e');
|
||||
@@ -306,27 +312,24 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
|
||||
}
|
||||
}
|
||||
|
||||
final isOwner = widget.announcement.authorId == widget.currentUserId;
|
||||
|
||||
// Comment count
|
||||
final commentsAsync =
|
||||
ref.watch(announcementCommentsProvider(widget.announcement.id));
|
||||
final commentCount = commentsAsync.valueOrNull?.length ?? 0;
|
||||
|
||||
// Rebuild UI when cooldown is active to update the countdown display.
|
||||
// This timer triggers rebuilds every 500ms while _inCooldown is true.
|
||||
// Rebuild UI when cooldown is active.
|
||||
if (_inCooldown) {
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
final hasBanner = widget.announcement.bannerEnabled;
|
||||
|
||||
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: [
|
||||
@@ -352,15 +355,38 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_relativeTime(widget.announcement.createdAt),
|
||||
style: tt.labelSmall
|
||||
?.copyWith(color: cs.onSurfaceVariant),
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
_relativeTime(widget.announcement.createdAt),
|
||||
style: tt.labelSmall
|
||||
?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
if (hasBanner) ...[
|
||||
const SizedBox(width: 6),
|
||||
Tooltip(
|
||||
message: widget.announcement.isBannerActive
|
||||
? 'Banner active'
|
||||
: 'Banner (inactive)',
|
||||
child: Icon(
|
||||
Icons.campaign,
|
||||
size: 14,
|
||||
color: widget.announcement.isBannerActive
|
||||
? cs.primary
|
||||
: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isOwner)
|
||||
// Popup menu: owner sees Edit/Delete/Banner;
|
||||
// admin-only sees Delete/Banner
|
||||
if (_isOwner || _canManageBanner)
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
@@ -368,13 +394,32 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
|
||||
widget.onEdit();
|
||||
case 'delete':
|
||||
widget.onDelete();
|
||||
case 'banner':
|
||||
widget.onBannerSettings();
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'edit', child: Text('Edit')),
|
||||
const PopupMenuItem(
|
||||
value: 'delete', child: Text('Delete')),
|
||||
if (_isOwner)
|
||||
const PopupMenuItem(
|
||||
value: 'edit', child: Text('Edit')),
|
||||
if (_isOwner || _isAdmin)
|
||||
const PopupMenuItem(
|
||||
value: 'delete', child: Text('Delete')),
|
||||
if (_canManageBanner)
|
||||
PopupMenuItem(
|
||||
value: 'banner',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.campaign_outlined,
|
||||
size: 18,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Banner Settings'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -400,10 +445,7 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
|
||||
runSpacing: 4,
|
||||
children: widget.announcement.visibleRoles.map((role) {
|
||||
return Chip(
|
||||
label: Text(
|
||||
_roleLabel(role),
|
||||
style: tt.labelSmall,
|
||||
),
|
||||
label: Text(_roleLabel(role), style: tt.labelSmall),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
@@ -411,7 +453,7 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
// Bottom action row: comment toggle + optional resend button
|
||||
// Bottom action row
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 0),
|
||||
child: Row(
|
||||
@@ -492,6 +534,10 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
String _relativeTime(DateTime dt) {
|
||||
final now = AppTime.now();
|
||||
final diff = now.difference(dt);
|
||||
@@ -512,3 +558,4 @@ String _roleLabel(String role) {
|
||||
};
|
||||
return labels[role] ?? role;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user