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