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