Offline Support

This commit is contained in:
2026-04-27 06:20:39 +08:00
parent 7d8851a94a
commit 48cd3bcd60
121 changed files with 14798 additions and 2214 deletions
+41 -23
View File
@@ -258,30 +258,48 @@ class _OfficesScreenState extends ConsumerState<OfficesScreen> {
return;
}
setState(() => saving = true);
final controller = ref.read(
officesControllerProvider,
);
if (office == null) {
await controller.createOffice(
name: name,
serviceId: selectedServiceId,
);
} else {
await controller.updateOffice(
id: office.id,
name: name,
serviceId: selectedServiceId,
);
}
ref.invalidate(officesProvider);
if (context.mounted) {
Navigator.of(dialogContext).pop();
showSuccessSnackBar(
context,
office == null
? 'Office "$name" has been created successfully.'
: 'Office "$name" has been updated successfully.',
try {
final controller = ref.read(
officesControllerProvider,
);
if (office == null) {
await controller.createOffice(
name: name,
serviceId: selectedServiceId,
);
} else {
await controller.updateOffice(
id: office.id,
name: name,
serviceId: selectedServiceId,
);
}
ref.invalidate(officesProvider);
if (context.mounted) {
Navigator.of(dialogContext).pop();
showSuccessSnackBar(
context,
office == null
? 'Office "$name" has been created successfully.'
: 'Office "$name" has been updated successfully.',
);
}
} catch (e) {
if (!dialogContext.mounted) return;
if (isOfflineSaveError(e)) {
Navigator.of(dialogContext).pop();
showSuccessSnackBarGlobal(
office == null
? 'Office "$name" saved offline — will sync when connected.'
: 'Office "$name" update saved offline — will sync when connected.',
);
} else {
showErrorSnackBar(context, 'Failed to save: $e');
}
} finally {
if (dialogContext.mounted) {
setState(() => saving = false);
}
}
},
child: saving
@@ -57,9 +57,12 @@ class _AnnouncementCommentsSectionState
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to post comment: $e')),
);
if (isOfflineSaveError(e)) {
_controller.clear();
showSuccessSnackBar(context, 'Comment saved offline — will sync when connected.');
} else {
showErrorSnackBar(context, 'Failed to post comment: $e');
}
}
} finally {
if (mounted) setState(() => _sending = false);
@@ -14,6 +14,7 @@ import '../../widgets/app_page_header.dart';
import '../../widgets/m3_card.dart';
import '../../widgets/profile_avatar.dart';
import '../../widgets/responsive_body.dart';
import '../../widgets/sync_pending_badge.dart';
import 'announcement_comments_section.dart';
import 'create_announcement_dialog.dart';
@@ -317,6 +318,14 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
ref.watch(announcementCommentsProvider(widget.announcement.id));
final commentCount = commentsAsync.valueOrNull?.length ?? 0;
// Pending-sync state
final pendingNew = ref
.watch(offlinePendingAnnouncementsProvider)
.any((a) => a.id == widget.announcement.id);
final pendingUpdates = ref.watch(offlinePendingAnnouncementUpdatesProvider);
final isAnnouncementPending =
pendingNew || pendingUpdates.containsKey(widget.announcement.id);
// Rebuild UI when cooldown is active.
if (_inCooldown) {
Future.delayed(const Duration(milliseconds: 500), () {
@@ -428,9 +437,21 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
// Title + Body
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Text(
widget.announcement.title,
style: tt.titleMedium?.copyWith(fontWeight: FontWeight.w600),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
widget.announcement.title,
style: tt.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
),
),
if (isAnnouncementPending) ...[
const SizedBox(width: 8),
SyncPendingBadge(isPending: true, compact: true),
],
],
),
),
Padding(
@@ -269,7 +269,14 @@ class _CreateAnnouncementContentState
);
}
} catch (e) {
if (mounted) showErrorSnackBar(context, 'Failed to save: $e');
if (mounted) {
if (isOfflineSaveError(e)) {
Navigator.of(context).pop();
showSuccessSnackBarGlobal('Announcement saved offline — will sync when connected.');
} else {
showErrorSnackBar(context, 'Failed to save: $e');
}
}
} finally {
if (mounted) setState(() => _submitting = false);
}
@@ -537,7 +544,14 @@ class _BannerSettingsContentState
if (mounted) Navigator.of(context).pop();
showSuccessSnackBarGlobal('Banner settings saved.');
} catch (e) {
if (mounted) showErrorSnackBar(context, 'Failed to save: $e');
if (mounted) {
if (isOfflineSaveError(e)) {
Navigator.of(context).pop();
showSuccessSnackBarGlobal('Banner settings saved offline — will sync when connected.');
} else {
showErrorSnackBar(context, 'Failed to save: $e');
}
}
} finally {
if (mounted) setState(() => _submitting = false);
}
+82 -10
View File
@@ -41,6 +41,7 @@ import '../../widgets/gemini_button.dart';
import '../../widgets/multi_select_picker.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/responsive_body.dart';
import '../../widgets/sync_pending_badge.dart';
class AttendanceScreen extends ConsumerStatefulWidget {
const AttendanceScreen({super.key});
@@ -362,6 +363,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
final profile = ref.watch(currentProfileProvider).valueOrNull;
final schedulesAsync = ref.watch(dutySchedulesProvider);
final logsAsync = ref.watch(attendanceLogsProvider);
final pendingCheckIns = ref.watch(offlinePendingCheckInsProvider);
final allowTracking = profile?.allowTracking ?? false;
// local state for optimistic switch update. We only trust `_trackingLocal`
// while a save is in flight after that the server-side value (`allowTracking`)
@@ -433,8 +435,12 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
// (Note: this override should not prevent the overtime check-in from being shown.)
// Show overtime check-in when the user has no schedule today, or their last
// scheduled shift has already ended.
// scheduled shift has already ended. Guard with hasValue so the card never
// flashes during the initial stream loading phase (valueOrNull returns []
// while loading, making hasScheduleToday false prematurely).
final showOvertimeCard =
schedulesAsync.hasValue &&
logsAsync.hasValue &&
(activeOvertimeLog.isEmpty && _overtimeLogId == null) &&
activeLog.isEmpty &&
(!hasScheduleToday || hasScheduleEnded);
@@ -786,6 +792,15 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
),
),
),
if (pendingCheckIns.any(
(l) => l.dutyScheduleId == schedule.id,
)) ...[
SyncPendingBadge(
isPending: true,
compact: true,
),
const SizedBox(width: 6),
],
_statusChip(context, statusLabel),
],
),
@@ -4228,7 +4243,11 @@ class _MyScheduleTabState extends ConsumerState<_MyScheduleTab> {
}
if (!context.mounted) return;
if (!alreadyProcessed) {
showErrorSnackBar(context, 'Failed: $e');
if (isOfflineSaveError(e)) {
showSuccessSnackBar(context, 'Response saved offline — will sync when connected.');
} else {
showErrorSnackBar(context, 'Failed: $e');
}
}
} finally {
// Refresh the swap list — the new provider fires an immediate REST poll
@@ -4872,6 +4891,7 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
final slipsAsync = ref.watch(passSlipsProvider);
final profilesAsync = ref.watch(profilesProvider);
final activeSlip = ref.watch(activePassSlipProvider);
final pendingSlips = ref.watch(offlinePendingPassSlipsProvider);
final isAdmin = profile?.role == 'admin' || profile?.role == 'dispatcher';
final Map<String, Profile> profileById = {
@@ -4985,6 +5005,7 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
slip,
profileById,
showActions: true,
isPending: pendingSlips.any((p) => p.id == slip.id),
),
),
if (slips.where((s) => s.status == 'pending').isEmpty)
@@ -5017,6 +5038,7 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
slip,
profileById,
showActions: false,
isPending: pendingSlips.any((p) => p.id == slip.id),
),
),
if (slips.isEmpty)
@@ -5044,6 +5066,7 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
PassSlip slip,
Map<String, Profile> profileById, {
required bool showActions,
bool isPending = false,
}) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
@@ -5073,6 +5096,10 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
Row(
children: [
Expanded(child: Text(name, style: theme.textTheme.titleSmall)),
if (isPending) ...[
SyncPendingBadge(isPending: true, compact: true),
const SizedBox(width: 8),
],
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
@@ -5151,7 +5178,11 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
}
} catch (e) {
if (mounted) {
showErrorSnackBar(context, 'Failed: $e');
if (isOfflineSaveError(e)) {
showSuccessSnackBar(context, 'Approval saved offline — will sync when connected.');
} else {
showErrorSnackBar(context, 'Failed: $e');
}
}
} finally {
if (mounted) setState(() => _submitting = false);
@@ -5167,7 +5198,11 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
}
} catch (e) {
if (mounted) {
showErrorSnackBar(context, 'Failed: $e');
if (isOfflineSaveError(e)) {
showSuccessSnackBar(context, 'Rejection saved offline — will sync when connected.');
} else {
showErrorSnackBar(context, 'Failed: $e');
}
}
} finally {
if (mounted) setState(() => _submitting = false);
@@ -5183,7 +5218,11 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
}
} catch (e) {
if (mounted) {
showErrorSnackBar(context, 'Failed: $e');
if (isOfflineSaveError(e)) {
showSuccessSnackBar(context, 'Completion saved offline — will sync when connected.');
} else {
showErrorSnackBar(context, 'Failed: $e');
}
}
} finally {
if (mounted) setState(() => _submitting = false);
@@ -5212,6 +5251,7 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
final profile = ref.watch(currentProfileProvider).valueOrNull;
final leavesAsync = ref.watch(leavesProvider);
final profilesAsync = ref.watch(profilesProvider);
final pendingLeavesList = ref.watch(offlinePendingLeavesProvider);
if (profile == null) {
return const Center(child: CircularProgressIndicator());
@@ -5259,6 +5299,7 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
leave,
profileById,
showApproval: true,
isPending: pendingLeavesList.any((l) => l.id == leave.id),
),
),
const SizedBox(height: 24),
@@ -5292,6 +5333,7 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
leave,
profileById,
showApproval: false,
isPending: pendingLeavesList.any((l) => l.id == leave.id),
),
),
@@ -5314,6 +5356,7 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
leave,
profileById,
showApproval: false,
isPending: pendingLeavesList.any((l) => l.id == leave.id),
),
),
if (leaves
@@ -5343,6 +5386,7 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
LeaveOfAbsence leave,
Map<String, Profile> profileById, {
required bool showApproval,
bool isPending = false,
}) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
@@ -5372,6 +5416,10 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
Row(
children: [
Expanded(child: Text(name, style: theme.textTheme.titleSmall)),
if (isPending) ...[
SyncPendingBadge(isPending: true, compact: true),
const SizedBox(width: 8),
],
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
@@ -5473,7 +5521,11 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
}
} catch (e) {
if (mounted) {
showErrorSnackBar(context, 'Failed: $e');
if (isOfflineSaveError(e)) {
showSuccessSnackBar(context, 'Approval saved offline — will sync when connected.');
} else {
showErrorSnackBar(context, 'Failed: $e');
}
}
} finally {
if (mounted) setState(() => _submitting = false);
@@ -5491,7 +5543,11 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
}
} catch (e) {
if (mounted) {
showErrorSnackBar(context, 'Failed: $e');
if (isOfflineSaveError(e)) {
showSuccessSnackBar(context, 'Rejection saved offline — will sync when connected.');
} else {
showErrorSnackBar(context, 'Failed: $e');
}
}
} finally {
if (mounted) setState(() => _submitting = false);
@@ -5509,7 +5565,11 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
}
} catch (e) {
if (mounted) {
showErrorSnackBar(context, 'Failed: $e');
if (isOfflineSaveError(e)) {
showSuccessSnackBar(context, 'Cancellation saved offline — will sync when connected.');
} else {
showErrorSnackBar(context, 'Failed: $e');
}
}
} finally {
if (mounted) setState(() => _submitting = false);
@@ -5618,7 +5678,14 @@ class _PassSlipDialogState extends ConsumerState<_PassSlipDialog> {
widget.onSubmitted();
}
} catch (e) {
if (mounted) showErrorSnackBar(context, 'Failed: $e');
if (!mounted) return;
if (isOfflineSaveError(e)) {
Navigator.of(context).pop();
showSuccessSnackBarGlobal('Pass slip request saved offline — will sync when connected.');
widget.onSubmitted();
} else {
showErrorSnackBar(context, 'Failed: $e');
}
} finally {
if (mounted) setState(() => _submitting = false);
}
@@ -5893,7 +5960,12 @@ class _FileLeaveDialogState extends ConsumerState<_FileLeaveDialog> {
widget.onSubmitted();
}
} catch (e) {
if (mounted) {
if (!mounted) return;
if (isOfflineSaveError(e)) {
Navigator.of(context).pop();
showSuccessSnackBarGlobal('Leave request saved offline — will sync when connected.');
widget.onSubmitted();
} else {
showErrorSnackBar(context, 'Failed to file leave: $e');
}
} finally {
@@ -1335,7 +1335,7 @@ class _ItServiceRequestDetailScreenState
_finishSave(success: true);
} catch (e) {
debugPrint('Save field error: $e');
_finishSave(success: false);
_finishSave(success: isOfflineSaveError(e));
}
}
@@ -1350,7 +1350,7 @@ class _ItServiceRequestDetailScreenState
_finishSave(success: true);
} catch (e) {
debugPrint('Save datetime error: $e');
_finishSave(success: false);
_finishSave(success: isOfflineSaveError(e));
}
}
@@ -1485,7 +1485,13 @@ class _ItServiceRequestDetailScreenState
if (mounted) showSuccessSnackBar(context, 'Text improved successfully');
} catch (e) {
if (mounted) showErrorSnackBar(context, 'Error: $e');
if (mounted) {
if (isOfflineSaveError(e)) {
showSuccessSnackBar(context, 'Saved offline — will sync when connected.');
} else {
showErrorSnackBar(context, 'Error: $e');
}
}
} finally {
setProcessing(false);
}
@@ -1525,7 +1531,13 @@ class _ItServiceRequestDetailScreenState
if (mounted) showSuccessSnackBar(context, 'Request approved');
} catch (e) {
if (mounted) showErrorSnackBar(context, 'Error: $e');
if (mounted) {
if (isOfflineSaveError(e)) {
showSuccessSnackBar(context, 'Approval saved offline — will sync when connected.');
} else {
showErrorSnackBar(context, 'Error: $e');
}
}
}
}
@@ -1630,7 +1642,13 @@ class _ItServiceRequestDetailScreenState
);
}
} catch (e) {
if (mounted) showErrorSnackBar(context, 'Error: $e');
if (mounted) {
if (isOfflineSaveError(e)) {
showSuccessSnackBar(context, 'Status change saved offline — will sync when connected.');
} else {
showErrorSnackBar(context, 'Error: $e');
}
}
}
}
@@ -9,6 +9,7 @@ import '../../models/it_service_request.dart';
import '../../models/it_service_request_assignment.dart';
import '../../models/office.dart';
import '../../models/profile.dart';
import '../../brick/cache_helpers.dart';
import '../../providers/it_service_request_provider.dart';
import '../../providers/profile_provider.dart';
import '../../providers/realtime_controller.dart';
@@ -23,6 +24,7 @@ import '../../widgets/app_page_header.dart';
import '../../widgets/app_state_view.dart';
import '../../widgets/responsive_body.dart';
import '../../widgets/status_pill.dart';
import '../../widgets/sync_pending_badge.dart';
class ItServiceRequestsListScreen extends ConsumerStatefulWidget {
const ItServiceRequestsListScreen({super.key});
@@ -67,6 +69,26 @@ class _ItServiceRequestsListScreenState
@override
Widget build(BuildContext context) {
final requestsAsync = ref.watch(itServiceRequestsProvider);
final offlinePending = ref.watch(offlinePendingItServiceRequestsProvider);
final pendingUpdates =
ref.watch(offlinePendingItServiceRequestUpdatesProvider);
final isrPendingIds = {
...offlinePending.map((r) => r.id),
...pendingUpdates.keys,
};
ref.listen<AsyncValue<List<ItServiceRequest>>>(itServiceRequestsProvider, (_, next) {
final live = next.valueOrNull;
if (live == null || live.isEmpty) return;
final pending = ref.read(offlinePendingItServiceRequestsProvider);
if (pending.isEmpty) return;
final liveIds = live.map((r) => r.id).toSet();
final stillPending = pending.where((r) => !liveIds.contains(r.id)).toList();
if (stillPending.length != pending.length) {
ref.read(offlinePendingItServiceRequestsProvider.notifier).state = stillPending;
}
});
final profileAsync = ref.watch(currentProfileProvider);
final profilesAsync = ref.watch(profilesProvider);
final officesAsync = ref.watch(officesProvider);
@@ -131,8 +153,12 @@ class _ItServiceRequestsListScreenState
],
);
}
final allRequests =
final liveRequests =
requestsAsync.valueOrNull ?? <ItServiceRequest>[];
final liveIds = liveRequests.map((r) => r.id).toSet();
final pendingOnly =
offlinePending.where((r) => !liveIds.contains(r.id)).toList();
final allRequests = [...pendingOnly, ...liveRequests];
if (allRequests.isEmpty) {
return const AppEmptyView(
icon: Icons.miscellaneous_services_outlined,
@@ -277,12 +303,14 @@ class _ItServiceRequestsListScreenState
officeById: officeById,
profileById: profileById,
assignments: assignments,
pendingIds: isrPendingIds,
),
_RequestList(
requests: _applyFilters(allRequests),
officeById: officeById,
profileById: profileById,
assignments: assignments,
pendingIds: isrPendingIds,
),
],
)
@@ -291,6 +319,7 @@ class _ItServiceRequestsListScreenState
officeById: officeById,
profileById: profileById,
assignments: assignments,
pendingIds: isrPendingIds,
),
),
],
@@ -427,12 +456,31 @@ class _ItServiceRequestsListScreenState
requestedByUserId: profile?.id,
status: (profile?.role == 'standard') ? 'pending_approval' : 'draft',
);
if (context.mounted) {
final isOffline = data['pending'] == true;
if (isOffline) {
final localId = data['id'] as String;
final allCached = await cachedListFromBrick<ItServiceRequest>();
final local = allCached.where((r) => r.id == localId).firstOrNull;
if (local != null && context.mounted) {
final current = ref.read(offlinePendingItServiceRequestsProvider);
ref.read(offlinePendingItServiceRequestsProvider.notifier).state = [
local,
...current,
];
}
if (context.mounted) {
showSuccessSnackBar(
context,
'Request saved offline — will sync when connected.',
);
}
} else if (context.mounted) {
showSuccessSnackBar(context, 'Request created');
context.go('/it-service-requests/${data['id']}');
}
} catch (e) {
if (context.mounted) showErrorSnackBar(context, 'Error: $e');
if (!context.mounted) return;
showErrorSnackBar(context, 'Error: $e');
}
}
@@ -600,12 +648,14 @@ class _RequestList extends StatelessWidget {
required this.officeById,
required this.profileById,
required this.assignments,
required this.pendingIds,
});
final List<ItServiceRequest> requests;
final Map<String, Office> officeById;
final Map<String, Profile> profileById;
final List<ItServiceRequestAssignment> assignments;
final Set<String> pendingIds;
@override
Widget build(BuildContext context) {
@@ -631,6 +681,7 @@ class _RequestList extends StatelessWidget {
request: request,
officeName: office,
assignedStaff: assignedStaff,
isPending: pendingIds.contains(request.id),
),
);
},
@@ -643,11 +694,13 @@ class _RequestTile extends StatelessWidget {
required this.request,
this.officeName,
required this.assignedStaff,
this.isPending = false,
});
final ItServiceRequest request;
final String? officeName;
final List<String> assignedStaff;
final bool isPending;
@override
Widget build(BuildContext context) {
@@ -669,11 +722,22 @@ class _RequestTile extends StatelessWidget {
],
),
const SizedBox(height: 8),
Text(
request.eventName,
style: tt.titleMedium?.copyWith(fontWeight: FontWeight.w600),
maxLines: 2,
overflow: TextOverflow.ellipsis,
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
request.eventName,
style: tt.titleMedium?.copyWith(fontWeight: FontWeight.w600),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
if (isPending) ...[
const SizedBox(width: 8),
SyncPendingBadge(isPending: true, compact: true),
],
],
),
const SizedBox(height: 4),
if (request.services.isNotEmpty)
+8 -3
View File
@@ -423,10 +423,15 @@ class _ItJobTileState extends ConsumerState<_ItJobTile> {
// The realtime stream may arrive with a slight delay; clearing here
// causes a visible revert flash before the stream catches up.
} catch (e) {
// Revert optimistic state on failure only
if (mounted) {
setState(() => _optimisticChecked = !val);
showErrorSnackBar(context, 'Failed to update: $e');
if (isOfflineSaveError(e)) {
// Brick queued the write — keep optimistic state, just notify user
showSuccessSnackBar(context, 'Saved offline — will sync when connected.');
} else {
// Revert optimistic state on real failure
setState(() => _optimisticChecked = !val);
showErrorSnackBar(context, 'Failed to update: $e');
}
}
}
}
+168 -12
View File
@@ -34,6 +34,7 @@ import '../../widgets/mono_text.dart';
import '../../widgets/responsive_body.dart';
import '../../widgets/status_pill.dart';
import '../../theme/app_surfaces.dart';
import '../../widgets/sync_pending_badge.dart';
import '../../widgets/task_assignment_section.dart';
import '../../widgets/typing_dots.dart';
import '../../widgets/gemini_button.dart';
@@ -229,6 +230,25 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
child: Center(child: Text('Task not found.')),
);
}
// When the live stream delivers this task (i.e. it just synced from offline),
// remove it from the pending list. This is a safety-net duplicate of the same
// listener in tasks_list_screen; it fires even when the list screen is not
// in the widget tree (e.g. deep-linked directly to this route).
ref.listen<AsyncValue<List<Task>>>(tasksProvider, (_, next) {
final live = next.valueOrNull;
if (live == null) return;
final pending = ref.read(offlinePendingTasksProvider);
if (pending.isEmpty) return;
final liveIds = live.map((t) => t.id).toSet();
final stillPending =
pending.where((t) => !liveIds.contains(t.id)).toList();
if (stillPending.length != pending.length) {
ref.read(offlinePendingTasksProvider.notifier).state = stillPending;
}
});
final isPending = ref.watch(isTaskPendingProvider(task.id));
final ticketId = task.ticketId;
final typingChannelId = task.id;
final ticket = ticketId == null
@@ -248,7 +268,21 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
orElse: () => false,
);
final showAssign = canAssign && task.status != 'completed';
final assignments = assignmentsAsync.valueOrNull ?? <TaskAssignment>[];
final liveAssignments = assignmentsAsync.valueOrNull ?? <TaskAssignment>[];
final pendingAssignUserIds =
ref.watch(offlinePendingAssignmentsProvider)[task.id];
final assignments = pendingAssignUserIds != null
? [
...liveAssignments.where((a) => a.taskId != task.id),
...pendingAssignUserIds.map(
(uid) => TaskAssignment(
taskId: task.id,
userId: uid,
createdAt: DateTime.now(),
),
),
]
: liveAssignments;
final profileById = {
for (final profile in profilesAsync.valueOrNull ?? <Profile>[])
profile.id: profile,
@@ -276,9 +310,25 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
final typingState = ref.watch(typingIndicatorProvider(typingChannelId));
final canSendMessages = task.status != 'completed';
final rawPendingMsgs =
ref.watch(offlinePendingMessagesProvider)[widget.taskId] ?? [];
final pendingOfflineMessages = List.generate(rawPendingMsgs.length, (i) {
final payload = rawPendingMsgs[i];
return TicketMessage(
id: -(i + 1),
ticketId: payload['ticket_id'] as String?,
taskId: payload['task_id'] as String?,
senderId: payload['sender_id'] as String?,
content: payload['content'] as String? ?? '',
createdAt: payload['created_at'] != null
? DateTime.tryParse(payload['created_at'] as String) ?? DateTime.now()
: DateTime.now(),
);
});
final messagesAsync = _mergeMessages(
taskMessagesAsync,
ticketId == null ? null : ref.watch(ticketMessagesProvider(ticketId)),
pendingOffline: pendingOfflineMessages,
);
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -404,6 +454,10 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
final detailsContent = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isPending) ...[
const _TaskPendingBanner(),
const SizedBox(height: 12),
],
Center(
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -418,6 +472,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
?.copyWith(fontWeight: FontWeight.w700),
),
),
SyncPendingBadge(isPending: isPending, compact: true),
const SizedBox(width: 8),
Builder(
builder: (ctx) {
@@ -470,7 +525,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
_MetaBadge(label: 'Office', value: officeName),
_MetaBadge(
label: 'Task #',
value: task.taskNumber ?? task.id,
value: isPending && task.taskNumber == null
? 'Pending'
: (task.taskNumber ?? task.id),
isMono: true,
),
],
@@ -3495,10 +3552,23 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
AsyncValue<List<TicketMessage>> _mergeMessages(
AsyncValue<List<TicketMessage>> taskMessages,
AsyncValue<List<TicketMessage>>? ticketMessages,
) {
AsyncValue<List<TicketMessage>>? ticketMessages, {
List<TicketMessage> pendingOffline = const [],
}) {
void addPending(Map<int, TicketMessage> byId) {
for (final m in pendingOffline) {
byId[m.id] = m;
}
}
if (ticketMessages == null) {
return taskMessages;
return taskMessages.whenData((msgs) {
if (pendingOffline.isEmpty) return msgs;
final byId = <int, TicketMessage>{for (final m in msgs) m.id: m};
addPending(byId);
return byId.values.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
});
}
return taskMessages.when(
data: (taskData) => ticketMessages.when(
@@ -3507,6 +3577,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
for (final message in taskData) message.id: message,
for (final message in ticketData) message.id: message,
};
addPending(byId);
final merged = byId.values.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
return AsyncValue.data(merged);
@@ -4030,10 +4101,20 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
showSuccessSnackBar(context, 'Task updated');
} catch (e) {
if (!mounted) return;
showErrorSnackBar(
context,
'Failed to update task: $e',
);
if (isOfflineSaveError(e)) {
if (dialogContext.mounted) {
Navigator.of(dialogContext).pop();
}
showSuccessSnackBar(
context,
'Task update saved offline — will sync when connected.',
);
} else {
showErrorSnackBar(
context,
'Failed to update task: $e',
);
}
} finally {
if (dialogContext.mounted) {
setDialogState(() => saving = false);
@@ -4274,7 +4355,17 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
}
} catch (e) {
if (context.mounted) {
showErrorSnackBar(context, e.toString());
if (isOfflineSaveError(e)) {
if (dialogContext.mounted) {
Navigator.of(dialogContext).pop();
}
showSuccessSnackBar(
context,
'Task cancellation saved offline — will sync when connected.',
);
} else {
showErrorSnackBar(context, e.toString());
}
}
} finally {
if (context.mounted) {
@@ -4321,9 +4412,14 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
.read(tasksControllerProvider)
.updateTaskStatus(taskId: task.id, status: value);
} catch (e) {
// surface validation or other errors to user
if (mounted) {
showErrorSnackBarGlobal(e.toString());
if (isOfflineSaveError(e)) {
showSuccessSnackBarGlobal(
'Status update saved offline — will sync when connected.',
);
} else {
showErrorSnackBarGlobal(e.toString());
}
}
}
},
@@ -4622,6 +4718,66 @@ class _MetaBadge extends StatelessWidget {
}
}
class _TaskPendingBanner extends StatefulWidget {
const _TaskPendingBanner();
@override
State<_TaskPendingBanner> createState() => _TaskPendingBannerState();
}
class _TaskPendingBannerState extends State<_TaskPendingBanner>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat();
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Material(
color: scheme.errorContainer,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
RotationTransition(
turns: _ctrl,
child: Icon(
Icons.sync_rounded,
color: scheme.onErrorContainer,
size: 16,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Pending sync — created offline. Edits will queue automatically.',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: scheme.onErrorContainer,
),
),
),
],
),
),
);
}
}
extension _FirstOrNull<T> on Iterable<T> {
T? get firstOrNull => isEmpty ? null : first;
}
+167 -77
View File
@@ -33,6 +33,7 @@ import '../../widgets/app_state_view.dart';
import '../../utils/subject_suggestions.dart';
import '../../widgets/gemini_button.dart';
import '../../widgets/gemini_animated_text_field.dart';
import '../../widgets/sync_pending_badge.dart';
import 'it_job_checklist_tab.dart';
// request metadata options used in task creation/editing dialogs
@@ -118,7 +119,25 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
@override
Widget build(BuildContext context) {
final tasksAsync = ref.watch(tasksProvider);
final offlinePending = ref.watch(offlinePendingTasksProvider);
final pendingTaskUpdates = ref.watch(offlinePendingTaskUpdatesProvider);
final pendingAssignments = ref.watch(offlinePendingAssignmentsProvider);
ref.watch(tasksQueryProvider);
// When the live stream delivers a task whose UUID matches a pending offline
// task, remove it from the pending list the sync completed successfully.
ref.listen<AsyncValue<List<Task>>>(tasksProvider, (_, next) {
final live = next.valueOrNull;
if (live == null || live.isEmpty) return;
final pending = ref.read(offlinePendingTasksProvider);
if (pending.isEmpty) return;
final liveIds = live.map((t) => t.id).toSet();
final stillPending =
pending.where((t) => !liveIds.contains(t.id)).toList();
if (stillPending.length != pending.length) {
ref.read(offlinePendingTasksProvider.notifier).state = stillPending;
}
});
final ticketsAsync = ref.watch(ticketsProvider);
final officesAsync = ref.watch(officesProvider);
final profileAsync = ref.watch(currentProfileProvider);
@@ -139,7 +158,10 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
(!profilesAsync.hasValue && profilesAsync.isLoading) ||
(!assignmentsAsync.hasValue && assignmentsAsync.isLoading) ||
(!profileAsync.hasValue && profileAsync.isLoading);
final effectiveShowSkeleton = showSkeleton;
// Never skeleton when there are offline-pending tasks those cards have
// real data to display and the outer Skeletonizer would replace their text
// with gray bones, making them indistinguishable from loading placeholders.
final effectiveShowSkeleton = showSkeleton && offlinePending.isEmpty;
final canCreate = profileAsync.maybeWhen(
data: (profile) =>
@@ -195,7 +217,46 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
);
}
final tasks = tasksAsync.valueOrNull ?? <Task>[];
// Merge offline-pending tasks (created while offline) at the
// front of the list. Deduplicate by ID so when the realtime
// stream delivers the synced version, the pending copy drops out.
final liveTasks = tasksAsync.valueOrNull ?? <Task>[];
final liveIds = liveTasks.map((t) => t.id).toSet();
final pendingOnly =
offlinePending.where((t) => !liveIds.contains(t.id)).toList();
final tasks = [...pendingOnly, ...liveTasks].map((t) {
final upd = pendingTaskUpdates[t.id];
if (upd == null || upd.isEmpty) return t;
final newStatus = upd['status'] as String?;
if (newStatus == null) return t;
return Task(
id: t.id,
ticketId: t.ticketId,
taskNumber: t.taskNumber,
title: t.title,
description: t.description,
officeId: t.officeId,
status: newStatus,
priority: t.priority,
queueOrder: t.queueOrder,
createdAt: t.createdAt,
creatorId: t.creatorId,
startedAt: t.startedAt,
completedAt: t.completedAt,
requestedBy: t.requestedBy,
notedBy: t.notedBy,
receivedBy: t.receivedBy,
requestType: t.requestType,
requestTypeOther: t.requestTypeOther,
requestCategory: t.requestCategory,
actionTaken: t.actionTaken,
cancellationReason: t.cancellationReason,
cancelledAt: t.cancelledAt,
itJobPrinted: t.itJobPrinted,
itJobPrintedAt: t.itJobPrintedAt,
itJobReceivedById: t.itJobReceivedById,
);
}).toList();
final offices = officesAsync.valueOrNull ?? <Office>[];
final officesSorted = List<Office>.from(offices)
..sort(
@@ -241,6 +302,16 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
.add(a.userId);
}
// Apply offline-pending assignment overrides so list reflects
// changes made while disconnected before sync completes.
for (final entry in pendingAssignments.entries) {
final taskId = entry.key;
final userIds = entry.value;
latestAssigneeByTaskId[taskId] =
userIds.isNotEmpty ? userIds.first : null;
assignedUsersByTaskId[taskId] = userIds.toSet();
}
final filteredTasks = _applyTaskFilters(
tasks,
ticketById: ticketById,
@@ -365,7 +436,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
onRowTap: (task) => context.go('/tasks/${task.id}'),
summaryDashboard: summary,
filterHeader: filterHeader,
skeletonMode: effectiveShowSkeleton,
skeletonMode: effectiveShowSkeleton && tasksList.isEmpty,
onRequestRefresh: () {
// For server-side pagination, update the query provider
ref.read(tasksQueryProvider.notifier).state =
@@ -386,10 +457,27 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
final ticket = task.ticketId == null
? null
: ticketById[task.ticketId];
return Text(
task.title.isNotEmpty
? task.title
: (ticket?.subject ?? 'Task ${task.id}'),
final isPending =
offlinePending.any((t) => t.id == task.id);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
task.title.isNotEmpty
? task.title
: (ticket?.subject ??
'Task ${task.id}'),
),
),
if (isPending) ...[
const SizedBox(width: 6),
SyncPendingBadge(
isPending: true,
compact: true,
),
],
],
);
},
),
@@ -461,6 +549,8 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
typingIndicatorProvider(task.id),
);
final showTyping = typingState.userIds.isNotEmpty;
final isTaskPending =
offlinePending.any((t) => t.id == task.id);
return M3PressScale(
onTap: () => context.go('/tasks/${task.id}'),
@@ -480,6 +570,10 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isTaskPending) ...[
SyncPendingBadge(isPending: true),
const SizedBox(height: 4),
],
Text(subtitle),
const SizedBox(height: 2),
Text('Assigned: $assigned'),
@@ -530,17 +624,21 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
}
final currentUserId = profileAsync.valueOrNull?.id;
final myTasks = currentUserId == null
? <Task>[]
: filteredTasks
.where(
(t) =>
assignedUsersByTaskId[t.id]?.contains(
currentUserId,
) ??
false,
)
.toList();
// pendingOnly tasks were created by the current user on this
// device no creatorId check needed; they always go in My Tasks.
final pendingIds = {for (final t in pendingOnly) t.id};
final myTasks = [
...pendingOnly,
if (currentUserId != null)
...filteredTasks.where(
(t) =>
!pendingIds.contains(t.id) &&
(assignedUsersByTaskId[t.id]?.contains(
currentUserId,
) ??
false),
),
];
return Column(
mainAxisSize: MainAxisSize.max,
@@ -964,21 +1062,37 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
return;
}
setState(() => saving = true);
await ref
.read(tasksControllerProvider)
.createTask(
title: title,
description: description,
officeId: officeId,
requestType: selectedRequestType,
requestTypeOther: requestTypeOther,
requestCategory: selectedRequestCategory,
try {
await ref
.read(tasksControllerProvider)
.createTask(
title: title,
description: description,
officeId: officeId,
requestType: selectedRequestType,
requestTypeOther: requestTypeOther,
requestCategory: selectedRequestCategory,
);
if (context.mounted) {
Navigator.of(dialogContext).pop();
showSuccessSnackBarGlobal(
'Task "$title" has been created successfully.',
);
if (context.mounted) {
Navigator.of(dialogContext).pop();
showSuccessSnackBarGlobal(
'Task "$title" has been created successfully.',
);
}
} catch (e) {
if (!dialogContext.mounted) return;
if (isOfflineSaveError(e)) {
Navigator.of(dialogContext).pop();
showSuccessSnackBarGlobal(
'Task "$title" saved offline — will sync when connected.',
);
} else {
showErrorSnackBarGlobal('Failed to create task: $e');
}
} finally {
if (dialogContext.mounted) {
setState(() => saving = false);
}
}
},
child: saving
@@ -1150,43 +1264,23 @@ class _StatusSummaryRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (counts.isEmpty) {
return const SizedBox.shrink();
}
if (counts.isEmpty) return const SizedBox.shrink();
final entries = counts.entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth;
final maxPerRow = maxWidth >= 1000
? 4
: maxWidth >= 720
? 3
: maxWidth >= 480
? 2
: entries.length;
final perRow = entries.length < maxPerRow ? entries.length : maxPerRow;
final spacing = maxWidth < 480 ? 8.0 : 12.0;
final itemWidth = perRow == 0
? maxWidth
: (maxWidth - spacing * (perRow - 1)) / perRow;
return Wrap(
spacing: spacing,
runSpacing: spacing,
children: [
for (final entry in entries)
SizedBox(
width: itemWidth,
child: _StatusSummaryCard(
status: entry.key,
count: entry.value,
),
),
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (int i = 0; i < entries.length; i++) ...[
if (i > 0) const SizedBox(width: 8),
_StatusSummaryCard(
status: entries[i].key,
count: entries[i].value,
),
],
);
},
],
),
);
}
}
@@ -1214,32 +1308,28 @@ class _StatusSummaryCard extends StatelessWidget {
'completed' => scheme.onPrimaryContainer,
_ => scheme.onSurfaceVariant,
};
final label = status.replaceAll('_', ' ').toUpperCase();
// M3 Expressive: filled card with semantic tonal color, no shadow.
return Card(
return Material(
color: background,
elevation: 0,
shadowColor: Colors.transparent,
// summary cards are compact use compact token for consistent density
shape: AppSurfaces.of(context).compactShape,
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
status.toUpperCase(),
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: foreground,
fontWeight: FontWeight.w600,
letterSpacing: 0.4,
),
),
const SizedBox(height: 6),
const SizedBox(width: 6),
Text(
count.toString(),
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: foreground,
fontWeight: FontWeight.w700,
),
+16 -6
View File
@@ -518,9 +518,15 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
}
} catch (e) {
if (!mounted) return;
// ignore: use_build_context_synchronously
showErrorSnackBar(context, 'Failed to save team: $e');
return;
final offline = isOfflineSaveError(e);
if (offline) {
// ignore: use_build_context_synchronously
showSuccessSnackBar(context, 'Team saved offline — will sync when connected.');
} else {
// ignore: use_build_context_synchronously
showErrorSnackBar(context, 'Failed to save team: $e');
return;
}
}
ref.invalidate(teamsProvider);
@@ -660,9 +666,13 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
ref.invalidate(teamMembersProvider);
} catch (e) {
if (!mounted) return;
// ignore: use_build_context_synchronously
showErrorSnackBar(ctx, 'Failed to delete team: $e');
return;
if (isOfflineSaveError(e)) {
// ignore: use_build_context_synchronously
showSuccessSnackBar(ctx, 'Delete queued offline — will sync when connected.');
} else {
// ignore: use_build_context_synchronously
showErrorSnackBar(ctx, 'Failed to delete team: $e');
}
}
}
}
+14 -4
View File
@@ -987,10 +987,20 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
);
} catch (e) {
if (!screenContext.mounted) return;
showErrorSnackBar(
screenContext,
'Failed to update ticket: $e',
);
if (isOfflineSaveError(e)) {
if (dialogContext.mounted) {
Navigator.of(dialogContext).pop();
}
showSuccessSnackBar(
screenContext,
'Ticket update saved offline — will sync when connected.',
);
} else {
showErrorSnackBar(
screenContext,
'Failed to update ticket: $e',
);
}
} finally {
if (dialogContext.mounted) {
setDialogState(() => saving = false);
+97 -61
View File
@@ -23,6 +23,7 @@ import '../../theme/app_surfaces.dart';
import '../../utils/snackbar.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/app_state_view.dart';
import '../../widgets/sync_pending_badge.dart';
class TicketsListScreen extends ConsumerStatefulWidget {
const TicketsListScreen({super.key});
@@ -62,6 +63,22 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
final realtime = ref.watch(realtimeControllerProvider);
final ticketsAsync = ref.watch(ticketsProvider);
final offlinePending = ref.watch(offlinePendingTicketsProvider);
// When the live stream delivers a ticket whose UUID matches a pending offline
// ticket, remove it sync completed successfully.
ref.listen<AsyncValue<List<Ticket>>>(ticketsProvider, (_, next) {
final live = next.valueOrNull;
if (live == null || live.isEmpty) return;
final pending = ref.read(offlinePendingTicketsProvider);
if (pending.isEmpty) return;
final liveIds = live.map((t) => t.id).toSet();
final stillPending = pending.where((t) => !liveIds.contains(t.id)).toList();
if (stillPending.length != pending.length) {
ref.read(offlinePendingTicketsProvider.notifier).state = stillPending;
}
});
final officesAsync = ref.watch(officesProvider);
final notificationsAsync = ref.watch(notificationsProvider);
final profilesAsync = ref.watch(profilesProvider);
@@ -100,7 +117,13 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
);
}
final tickets = ticketsAsync.valueOrNull ?? <Ticket>[];
final liveTickets = ticketsAsync.valueOrNull ?? <Ticket>[];
final liveIds = liveTickets.map((t) => t.id).toSet();
final pendingOnly =
offlinePending.where((t) => !liveIds.contains(t.id)).toList();
final tickets = [...pendingOnly, ...liveTickets];
final pendingTicketIds =
offlinePending.map((t) => t.id).toSet();
final officeById = <String, Office>{
for (final office in officesAsync.valueOrNull ?? <Office>[])
office.id: office,
@@ -267,7 +290,18 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
),
TasQColumn<Ticket>(
header: 'Subject',
cellBuilder: (context, ticket) => Text(ticket.subject),
cellBuilder: (context, ticket) {
final isPending =
pendingTicketIds.contains(ticket.id);
if (!isPending) return Text(ticket.subject);
return Row(
children: [
Flexible(child: Text(ticket.subject)),
const SizedBox(width: 6),
SyncPendingBadge(isPending: true, compact: true),
],
);
},
),
TasQColumn<Ticket>(
header: 'Office',
@@ -300,6 +334,8 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
ticket.creatorId,
);
final hasMention = unreadByTicketId[ticket.id] == true;
final isTicketPending =
pendingTicketIds.contains(ticket.id);
final typingState = ref.watch(
typingIndicatorProvider(ticket.id),
);
@@ -332,6 +368,13 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isTicketPending) ...[
SyncPendingBadge(
isPending: true,
compact: true,
),
const SizedBox(width: 4),
],
_StatusBadge(status: ticket.status),
if (showTyping) ...[
const SizedBox(width: 6),
@@ -496,21 +539,38 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
return;
}
setState(() => saving = true);
await ref
.read(ticketsControllerProvider)
.createTicket(
subject: subject,
description: description,
officeId: selectedOffice!.id,
try {
final pendingTicket = await ref
.read(ticketsControllerProvider)
.createTicket(
subject: subject,
description: description,
officeId: selectedOffice!.id,
);
if (pendingTicket != null) {
// Offline: show immediately in the list until sync.
final current = ref.read(offlinePendingTicketsProvider);
ref.read(offlinePendingTicketsProvider.notifier).state = [
pendingTicket,
...current,
];
}
if (context.mounted) {
Navigator.of(dialogContext).pop();
showSuccessSnackBar(
context,
pendingTicket != null
? 'Ticket "$subject" saved offline — will sync when connected.'
: 'Ticket "$subject" has been created successfully.',
);
// Supabase stream will emit the new ticket no explicit
// invalidation required and avoids a temporary reload.
if (context.mounted) {
Navigator.of(dialogContext).pop();
showSuccessSnackBar(
context,
'Ticket "$subject" has been created successfully.',
);
}
} catch (e) {
if (!dialogContext.mounted) return;
showErrorSnackBarGlobal('Failed to create ticket: $e');
} finally {
if (dialogContext.mounted) {
setState(() => saving = false);
}
}
},
child: saving
@@ -616,43 +676,23 @@ class _StatusSummaryRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (counts.isEmpty) {
return const SizedBox.shrink();
}
if (counts.isEmpty) return const SizedBox.shrink();
final entries = counts.entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth;
final maxPerRow = maxWidth >= 1000
? 4
: maxWidth >= 720
? 3
: maxWidth >= 480
? 2
: entries.length;
final perRow = entries.length < maxPerRow ? entries.length : maxPerRow;
final spacing = maxWidth < 480 ? 8.0 : 12.0;
final itemWidth = perRow == 0
? maxWidth
: (maxWidth - spacing * (perRow - 1)) / perRow;
return Wrap(
spacing: spacing,
runSpacing: spacing,
children: [
for (final entry in entries)
SizedBox(
width: itemWidth,
child: _StatusSummaryCard(
status: entry.key,
count: entry.value,
),
),
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (int i = 0; i < entries.length; i++) ...[
if (i > 0) const SizedBox(width: 8),
_StatusSummaryCard(
status: entries[i].key,
count: entries[i].value,
),
],
);
},
],
),
);
}
}
@@ -680,32 +720,28 @@ class _StatusSummaryCard extends StatelessWidget {
'closed' => scheme.onPrimaryContainer,
_ => scheme.onSurfaceVariant,
};
final label = status.replaceAll('_', ' ').toUpperCase();
// M3 Expressive: filled card with semantic tonal color, no shadow.
return Card(
return Material(
color: background,
elevation: 0,
shadowColor: Colors.transparent,
// summary cards are compact use compact token for consistent density
shape: AppSurfaces.of(context).compactShape,
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
status.toUpperCase(),
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: foreground,
fontWeight: FontWeight.w600,
letterSpacing: 0.4,
),
),
const SizedBox(height: 6),
const SizedBox(width: 6),
Text(
count.toString(),
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: foreground,
fontWeight: FontWeight.w700,
),
+5 -1
View File
@@ -682,7 +682,11 @@ class _ScheduleTile extends ConsumerWidget {
ref.invalidate(dutySchedulesProvider);
} catch (e) {
if (!context.mounted) return;
showErrorSnackBar(context, 'Update failed: $e');
if (isOfflineSaveError(e)) {
showSuccessSnackBar(context, 'Schedule saved offline — will sync when connected.');
} else {
showErrorSnackBar(context, 'Update failed: $e');
}
}
}