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
+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,
),