Skeleton loading

This commit is contained in:
Marc Rejohn Castillano 2026-02-28 21:47:24 +08:00
parent c5e859ad88
commit d3239d8c76
9 changed files with 2755 additions and 2285 deletions

View File

@ -20,15 +20,19 @@ final realtimeControllerProvider = ChangeNotifierProvider<RealtimeController>((
/// connection when the app returns to the foreground or when auth tokens /// connection when the app returns to the foreground or when auth tokens
/// are refreshed. /// are refreshed.
class RealtimeController extends ChangeNotifier { class RealtimeController extends ChangeNotifier {
RealtimeController(this._client) {
_init();
}
final SupabaseClient _client; final SupabaseClient _client;
bool isConnecting = false; bool isConnecting = false;
bool isFailed = false;
String? lastError;
int attempts = 0;
final int maxAttempts;
bool _disposed = false; bool _disposed = false;
RealtimeController(this._client, {this.maxAttempts = 4}) {
_init();
}
void _init() { void _init() {
try { try {
// Listen for auth changes and try to recover the realtime connection // Listen for auth changes and try to recover the realtime connection
@ -39,7 +43,9 @@ class RealtimeController extends ChangeNotifier {
recoverConnection(); recoverConnection();
} }
}); });
} catch (_) {} } catch (e) {
debugPrint('RealtimeController._init error: $e');
}
} }
/// Try to reconnect the realtime client using a small exponential backoff. /// Try to reconnect the realtime client using a small exponential backoff.
@ -47,15 +53,15 @@ class RealtimeController extends ChangeNotifier {
if (_disposed) return; if (_disposed) return;
if (isConnecting) return; if (isConnecting) return;
isFailed = false;
lastError = null;
isConnecting = true; isConnecting = true;
notifyListeners(); notifyListeners();
try { try {
int attempt = 0;
int maxAttempts = 4;
int delaySeconds = 1; int delaySeconds = 1;
while (attempt < maxAttempts && !_disposed) { while (attempts < maxAttempts && !_disposed) {
attempt++; attempts++;
try { try {
// Best-effort disconnect then connect so the realtime client picks // Best-effort disconnect then connect so the realtime client picks
// up any refreshed tokens. // up any refreshed tokens.
@ -82,11 +88,17 @@ class RealtimeController extends ChangeNotifier {
// Give the socket a moment to stabilise. // Give the socket a moment to stabilise.
await Future.delayed(const Duration(seconds: 1)); await Future.delayed(const Duration(seconds: 1));
// Exit early; we don't have a reliable sync API for connection // Success (best-effort). Reset attempt counter and clear failure.
// state across all platforms, so treat this as a best-effort attempts = 0;
// resurrection. isFailed = false;
lastError = null;
break; break;
} catch (_) { } catch (e) {
lastError = e.toString();
if (attempts >= maxAttempts) {
isFailed = true;
break;
}
await Future.delayed(Duration(seconds: delaySeconds)); await Future.delayed(Duration(seconds: delaySeconds));
delaySeconds = delaySeconds * 2; delaySeconds = delaySeconds * 2;
} }
@ -99,6 +111,16 @@ class RealtimeController extends ChangeNotifier {
} }
} }
/// Retry a failed recovery attempt.
Future<void> retry() async {
if (_disposed) return;
attempts = 0;
isFailed = false;
lastError = null;
notifyListeners();
await recoverConnection();
}
@override @override
void dispose() { void dispose() {
_disposed = true; _disposed = true;

View File

@ -10,6 +10,8 @@ import '../../providers/profile_provider.dart';
import '../../providers/tasks_provider.dart'; import '../../providers/tasks_provider.dart';
import '../../providers/tickets_provider.dart'; import '../../providers/tickets_provider.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
import '../../providers/realtime_controller.dart';
import 'package:skeletonizer/skeletonizer.dart';
import '../../theme/app_surfaces.dart'; import '../../theme/app_surfaces.dart';
import '../../widgets/mono_text.dart'; import '../../widgets/mono_text.dart';
import '../../widgets/status_pill.dart'; import '../../widgets/status_pill.dart';
@ -294,7 +296,13 @@ class DashboardScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final realtime = ProviderScope.containerOf(
context,
).read(realtimeControllerProvider);
return ResponsiveBody( return ResponsiveBody(
child: Skeletonizer(
enabled: realtime.isConnecting,
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final sections = <Widget>[ final sections = <Widget>[
@ -322,7 +330,8 @@ class DashboardScreen extends StatelessWidget {
_cardGrid(context, [ _cardGrid(context, [
_MetricCard( _MetricCard(
title: 'Tasks created', title: 'Tasks created',
valueBuilder: (metrics) => metrics.tasksCreatedToday.toString(), valueBuilder: (metrics) =>
metrics.tasksCreatedToday.toString(),
), ),
_MetricCard( _MetricCard(
title: 'Tasks completed', title: 'Tasks completed',
@ -339,7 +348,8 @@ class DashboardScreen extends StatelessWidget {
_cardGrid(context, [ _cardGrid(context, [
_MetricCard( _MetricCard(
title: 'Avg response', title: 'Avg response',
valueBuilder: (metrics) => _formatDuration(metrics.avgResponse), valueBuilder: (metrics) =>
_formatDuration(metrics.avgResponse),
), ),
_MetricCard( _MetricCard(
title: 'Avg triage', title: 'Avg triage',
@ -375,17 +385,62 @@ class DashboardScreen extends StatelessWidget {
], ],
); );
return SingleChildScrollView( return Stack(
children: [
SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 24), padding: const EdgeInsets.only(bottom: 24),
child: Center( child: Center(
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight), constraints: BoxConstraints(
minHeight: constraints.maxHeight,
),
child: content, child: content,
), ),
), ),
),
if (realtime.isConnecting)
Positioned.fill(
child: AbsorbPointer(
absorbing: true,
child: Container(
color: Theme.of(
context,
).colorScheme.surface.withAlpha((0.35 * 255).round()),
alignment: Alignment.topCenter,
padding: const EdgeInsets.only(top: 36),
child: SizedBox(
width: 280,
child: Card(
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
SizedBox(width: 12),
Expanded(
child: Text('Reconnecting realtime…'),
),
],
),
),
),
),
),
),
),
],
); );
}, },
), ),
),
); );
} }

View File

@ -22,6 +22,8 @@ import '../../providers/profile_provider.dart';
import '../../providers/tasks_provider.dart'; import '../../providers/tasks_provider.dart';
import '../../providers/tickets_provider.dart'; import '../../providers/tickets_provider.dart';
import '../../providers/typing_provider.dart'; import '../../providers/typing_provider.dart';
import '../../providers/realtime_controller.dart';
import 'package:skeletonizer/skeletonizer.dart';
import '../../utils/app_time.dart'; import '../../utils/app_time.dart';
import '../../utils/snackbar.dart'; import '../../utils/snackbar.dart';
import '../../widgets/app_breakpoints.dart'; import '../../widgets/app_breakpoints.dart';
@ -235,7 +237,19 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
WidgetsBinding.instance.addPostFrameCallback((_) => _updateSaveAnim()); WidgetsBinding.instance.addPostFrameCallback((_) => _updateSaveAnim());
return ResponsiveBody( final realtime = ref.watch(realtimeControllerProvider);
final isRetrieving =
realtime.isConnecting ||
tasksAsync.isLoading ||
ticketsAsync.isLoading ||
officesAsync.isLoading ||
profileAsync.isLoading ||
assignmentsAsync.isLoading ||
taskMessagesAsync.isLoading;
return Skeletonizer(
enabled: isRetrieving,
child: ResponsiveBody(
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final isWide = constraints.maxWidth >= AppBreakpoints.desktop; final isWide = constraints.maxWidth >= AppBreakpoints.desktop;
@ -346,7 +360,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
if (!canEdit) return const SizedBox.shrink(); if (!canEdit) return const SizedBox.shrink();
return IconButton( return IconButton(
tooltip: 'Edit task', tooltip: 'Edit task',
onPressed: () => _showEditTaskDialog(ctx, ref, task), onPressed: () =>
_showEditTaskDialog(ctx, ref, task),
icon: const Icon(Icons.edit), icon: const Icon(Icons.edit),
); );
}, },
@ -409,7 +424,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
if (officeServiceId != null && if (officeServiceId != null &&
(servicesAsync.valueOrNull == null || (servicesAsync.valueOrNull == null ||
(servicesAsync.valueOrNull?.isEmpty ?? true))) { (servicesAsync.valueOrNull?.isEmpty ??
true))) {
final servicesOnce = await ref.read( final servicesOnce = await ref.read(
servicesOnceProvider.future, servicesOnceProvider.future,
); );
@ -444,7 +460,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
], ],
// warning banner for completed tasks with missing metadata // warning banner for completed tasks with missing metadata
if (task.status == 'completed' && task.hasIncompleteDetails) ...[ if (task.status == 'completed' &&
task.hasIncompleteDetails) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
children: [ children: [
@ -456,8 +473,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
Expanded( Expanded(
child: Text( child: Text(
'Task completed but some details are still empty.', 'Task completed but some details are still empty.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium
color: Theme.of(context).colorScheme.onSurfaceVariant, ?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
), ),
), ),
), ),
@ -478,7 +498,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
children: [ children: [
TabBar( TabBar(
labelColor: Theme.of(context).colorScheme.onSurface, labelColor: Theme.of(context).colorScheme.onSurface,
indicatorColor: Theme.of(context).colorScheme.primary, indicatorColor: Theme.of(
context,
).colorScheme.primary,
tabs: const [ tabs: const [
Tab(text: 'Assignees'), Tab(text: 'Assignees'),
Tab(text: 'Type & Category'), Tab(text: 'Type & Category'),
@ -526,7 +548,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
const SizedBox(height: 8), const SizedBox(height: 8),
_MetaBadge( _MetaBadge(
label: 'Category', label: 'Category',
value: task.requestCategory ?? 'None', value:
task.requestCategory ?? 'None',
), ),
] else ...[ ] else ...[
const Text('Type'), const Text('Type'),
@ -534,99 +557,6 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
DropdownButtonFormField<String?>( DropdownButtonFormField<String?>(
initialValue: task.requestType, initialValue: task.requestType,
decoration: InputDecoration( decoration: InputDecoration(
suffixIcon: _typeSaving
? SizedBox(
width: 16,
height: 16,
child: ScaleTransition(
scale: _savePulse,
child: const Icon(
Icons.save,
size: 14,
),
),
)
: _typeSaved
? SizedBox(
width: 16,
height: 16,
child: Stack(
alignment:
Alignment.center,
children: const [
Icon(
Icons.save,
size: 14,
color: Colors.green,
),
Positioned(
right: -2,
bottom: -2,
child: Icon(
Icons.check,
size: 10,
color: Colors.white,
),
),
],
),
)
: null,
),
items: [
const DropdownMenuItem(
value: null,
child: Text('None'),
),
for (final t in requestTypeOptions)
DropdownMenuItem(
value: t,
child: Text(t),
),
],
onChanged: (v) async {
setState(() {
_typeSaving = true;
_typeSaved = false;
});
try {
await ref
.read(tasksControllerProvider)
.updateTask(
taskId: task.id,
requestType: v,
);
setState(
() => _typeSaved =
v != null && v.isNotEmpty,
);
} catch (_) {
} finally {
setState(
() => _typeSaving = false,
);
if (_typeSaved) {
Future.delayed(
const Duration(seconds: 2),
() {
if (mounted) {
setState(
() =>
_typeSaved = false,
);
}
},
);
}
}
},
),
if (task.requestType == 'Other') ...[
const SizedBox(height: 8),
TextFormField(
initialValue: task.requestTypeOther,
decoration: InputDecoration(
hintText: 'Details',
suffixIcon: _typeSaving suffixIcon: _typeSaving
? SizedBox( ? SizedBox(
width: 16, width: 16,
@ -667,6 +597,105 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
) )
: null, : null,
), ),
items: [
const DropdownMenuItem(
value: null,
child: Text('None'),
),
for (final t
in requestTypeOptions)
DropdownMenuItem(
value: t,
child: Text(t),
),
],
onChanged: (v) async {
setState(() {
_typeSaving = true;
_typeSaved = false;
});
try {
await ref
.read(
tasksControllerProvider,
)
.updateTask(
taskId: task.id,
requestType: v,
);
setState(
() => _typeSaved =
v != null && v.isNotEmpty,
);
} catch (_) {
} finally {
setState(
() => _typeSaving = false,
);
if (_typeSaved) {
Future.delayed(
const Duration(seconds: 2),
() {
if (mounted) {
setState(
() => _typeSaved =
false,
);
}
},
);
}
}
},
),
if (task.requestType == 'Other') ...[
const SizedBox(height: 8),
TextFormField(
initialValue:
task.requestTypeOther,
decoration: InputDecoration(
hintText: 'Details',
suffixIcon: _typeSaving
? SizedBox(
width: 16,
height: 16,
child: ScaleTransition(
scale: _savePulse,
child: const Icon(
Icons.save,
size: 14,
),
),
)
: _typeSaved
? SizedBox(
width: 16,
height: 16,
child: Stack(
alignment:
Alignment.center,
children: const [
Icon(
Icons.save,
size: 14,
color:
Colors.green,
),
Positioned(
right: -2,
bottom: -2,
child: Icon(
Icons.check,
size: 10,
color: Colors
.white,
),
),
],
),
)
: null,
),
onChanged: (text) async { onChanged: (text) async {
setState(() { setState(() {
_typeSaving = true; _typeSaving = true;
@ -695,7 +724,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
); );
if (_typeSaved) { if (_typeSaved) {
Future.delayed( Future.delayed(
const Duration(seconds: 2), const Duration(
seconds: 2,
),
() { () {
if (mounted) { if (mounted) {
setState( setState(
@ -747,7 +778,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
child: Icon( child: Icon(
Icons.check, Icons.check,
size: 10, size: 10,
color: Colors.white, color:
Colors.white,
), ),
), ),
], ],
@ -774,7 +806,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
}); });
try { try {
await ref await ref
.read(tasksControllerProvider) .read(
tasksControllerProvider,
)
.updateTask( .updateTask(
taskId: task.id, taskId: task.id,
requestCategory: v, requestCategory: v,
@ -862,7 +896,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
child: Icon( child: Icon(
Icons.check, Icons.check,
size: 10, size: 10,
color: Colors.white, color:
Colors.white,
), ),
), ),
], ],
@ -873,7 +908,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
onChanged: (v) { onChanged: (v) {
_requestedDebounce?.cancel(); _requestedDebounce?.cancel();
_requestedDebounce = Timer( _requestedDebounce = Timer(
const Duration(milliseconds: 700), const Duration(
milliseconds: 700,
),
() async { () async {
final name = v.trim(); final name = v.trim();
setState(() { setState(() {
@ -947,7 +984,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
: p.fullName, : p.fullName,
) )
.where( .where(
(n) => n.toLowerCase().contains( (n) =>
n.toLowerCase().contains(
pattern.toLowerCase(), pattern.toLowerCase(),
), ),
) )
@ -978,8 +1016,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
}, },
itemBuilder: (context, suggestion) => itemBuilder: (context, suggestion) =>
ListTile(title: Text(suggestion)), ListTile(title: Text(suggestion)),
onSuggestionSelected: onSuggestionSelected: (suggestion) async {
(suggestion) async {
_requestedDebounce?.cancel(); _requestedDebounce?.cancel();
_requestedController.text = _requestedController.text =
suggestion; suggestion;
@ -989,9 +1026,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
}); });
try { try {
await ref await ref
.read( .read(tasksControllerProvider)
tasksControllerProvider,
)
.updateTask( .updateTask(
taskId: task.id, taskId: task.id,
requestedBy: requestedBy:
@ -1018,8 +1053,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
} catch (_) { } catch (_) {
} finally { } finally {
setState( setState(
() => () => _requestedSaving = false,
_requestedSaving = false,
); );
if (_requestedSaved) { if (_requestedSaved) {
Future.delayed( Future.delayed(
@ -1027,8 +1061,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
() { () {
if (mounted) { if (mounted) {
setState( setState(
() => () => _requestedSaved =
_requestedSaved =
false, false,
); );
} }
@ -1083,7 +1116,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
child: Icon( child: Icon(
Icons.check, Icons.check,
size: 10, size: 10,
color: Colors.white, color:
Colors.white,
), ),
), ),
], ],
@ -1094,7 +1128,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
onChanged: (v) { onChanged: (v) {
_notedDebounce?.cancel(); _notedDebounce?.cancel();
_notedDebounce = Timer( _notedDebounce = Timer(
const Duration(milliseconds: 700), const Duration(
milliseconds: 700,
),
() async { () async {
final name = v.trim(); final name = v.trim();
setState(() { setState(() {
@ -1142,7 +1178,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
() { () {
if (mounted) { if (mounted) {
setState( setState(
() => _notedSaved = () =>
_notedSaved =
false, false,
); );
} }
@ -1167,7 +1204,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
: p.fullName, : p.fullName,
) )
.where( .where(
(n) => n.toLowerCase().contains( (n) =>
n.toLowerCase().contains(
pattern.toLowerCase(), pattern.toLowerCase(),
), ),
) )
@ -1242,7 +1280,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
); );
if (_notedSaved) { if (_notedSaved) {
Future.delayed( Future.delayed(
const Duration(seconds: 2), const Duration(
seconds: 2,
),
() { () {
if (mounted) { if (mounted) {
setState( setState(
@ -1301,7 +1341,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
child: Icon( child: Icon(
Icons.check, Icons.check,
size: 10, size: 10,
color: Colors.white, color:
Colors.white,
), ),
), ),
], ],
@ -1312,7 +1353,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
onChanged: (v) { onChanged: (v) {
_receivedDebounce?.cancel(); _receivedDebounce?.cancel();
_receivedDebounce = Timer( _receivedDebounce = Timer(
const Duration(milliseconds: 700), const Duration(
milliseconds: 700,
),
() async { () async {
final name = v.trim(); final name = v.trim();
setState(() { setState(() {
@ -1326,7 +1369,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
) )
.updateTask( .updateTask(
taskId: task.id, taskId: task.id,
receivedBy: name.isEmpty receivedBy:
name.isEmpty
? null ? null
: name, : name,
); );
@ -1386,7 +1430,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
: p.fullName, : p.fullName,
) )
.where( .where(
(n) => n.toLowerCase().contains( (n) =>
n.toLowerCase().contains(
pattern.toLowerCase(), pattern.toLowerCase(),
), ),
) )
@ -1457,15 +1502,19 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
} catch (_) { } catch (_) {
} finally { } finally {
setState( setState(
() => _receivedSaving = false, () =>
_receivedSaving = false,
); );
if (_receivedSaved) { if (_receivedSaved) {
Future.delayed( Future.delayed(
const Duration(seconds: 2), const Duration(
seconds: 2,
),
() { () {
if (mounted) { if (mounted) {
setState( setState(
() => _receivedSaved = () =>
_receivedSaved =
false, false,
); );
} }
@ -1484,7 +1533,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
Padding( Padding(
padding: const EdgeInsets.only(top: 8.0), padding: const EdgeInsets.only(top: 8.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment:
CrossAxisAlignment.start,
children: [ children: [
const Text('Action taken'), const Text('Action taken'),
const SizedBox(height: 6), const SizedBox(height: 6),
@ -1498,7 +1548,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
context, context,
).colorScheme.outline, ).colorScheme.outline,
), ),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(
8,
),
), ),
child: Stack( child: Stack(
children: [ children: [
@ -1634,7 +1686,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
'Insert link', 'Insert link',
), ),
content: TextField( content: TextField(
controller: urlCtrl, controller:
urlCtrl,
decoration: decoration:
const InputDecoration( const InputDecoration(
hintText: hintText:
@ -1647,7 +1700,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
Navigator.of( Navigator.of(
ctx, ctx,
).pop(), ).pop(),
child: const Text( child:
const Text(
'Cancel', 'Cancel',
), ),
), ),
@ -1656,10 +1710,12 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
Navigator.of( Navigator.of(
ctx, ctx,
).pop( ).pop(
urlCtrl.text urlCtrl
.text
.trim(), .trim(),
), ),
child: const Text( child:
const Text(
'Insert', 'Insert',
), ),
), ),
@ -1682,7 +1738,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
sel.extentOffset; sel.extentOffset;
if (!sel.isCollapsed && if (!sel.isCollapsed &&
end > start) { end > start) {
final len = end - start; final len =
end - start;
try { try {
_actionController _actionController
?.document ?.document
@ -1693,11 +1750,17 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
} catch (_) {} } catch (_) {}
_actionController _actionController
?.document ?.document
.insert(start, res); .insert(
start,
res,
);
} else { } else {
_actionController _actionController
?.document ?.document
.insert(start, res); .insert(
start,
res,
);
} }
}, },
), ),
@ -1738,9 +1801,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
tasksControllerProvider, tasksControllerProvider,
) )
.uploadActionImage( .uploadActionImage(
taskId: task.id, taskId:
task.id,
bytes: bytes, bytes: bytes,
extension: ext, extension:
ext,
); );
} catch (e) { } catch (e) {
showErrorSnackBar( showErrorSnackBar(
@ -1789,11 +1854,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
child: quill.QuillEditor.basic( child: quill.QuillEditor.basic(
controller: controller:
_actionController!, _actionController!,
focusNode: _actionFocusNode, focusNode:
_actionFocusNode,
scrollController: scrollController:
_actionScrollController, _actionScrollController,
config: config: quill.QuillEditorConfig(
quill.QuillEditorConfig(
embedBuilders: const [ embedBuilders: const [
_ImageEmbedBuilder(), _ImageEmbedBuilder(),
], ],
@ -1840,7 +1905,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
child: Icon( child: Icon(
Icons.check, Icons.check,
size: 10, size: 10,
color: Colors.white, color:
Colors.white,
), ),
), ),
], ],
@ -1934,7 +2000,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
bottom: 6, bottom: 6,
), ),
child: Container( child: Container(
padding: const EdgeInsets.symmetric( padding:
const EdgeInsets.symmetric(
horizontal: 10, horizontal: 10,
vertical: 6, vertical: 6,
), ),
@ -1994,7 +2061,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
Expanded( Expanded(
child: TextField( child: TextField(
controller: _messageController, controller: _messageController,
decoration: const InputDecoration( decoration:
const InputDecoration(
hintText: 'Message...', hintText: 'Message...',
), ),
textInputAction: textInputAction:
@ -2002,7 +2070,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
enabled: canSendMessages, enabled: canSendMessages,
onChanged: (_) => onChanged: (_) =>
_handleComposerChanged( _handleComposerChanged(
profilesAsync.valueOrNull ?? profilesAsync
.valueOrNull ??
[], [],
ref.read( ref.read(
currentUserIdProvider, currentUserIdProvider,
@ -2013,7 +2082,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
onSubmitted: (_) => onSubmitted: (_) =>
_handleSendMessage( _handleSendMessage(
task, task,
profilesAsync.valueOrNull ?? profilesAsync
.valueOrNull ??
[], [],
ref.read( ref.read(
currentUserIdProvider, currentUserIdProvider,
@ -2029,7 +2099,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
onPressed: canSendMessages onPressed: canSendMessages
? () => _handleSendMessage( ? () => _handleSendMessage(
task, task,
profilesAsync.valueOrNull ?? profilesAsync
.valueOrNull ??
[], [],
ref.read( ref.read(
currentUserIdProvider, currentUserIdProvider,
@ -2082,15 +2153,56 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
// keeping the chat/activity panel filling the remaining viewport // keeping the chat/activity panel filling the remaining viewport
// (and scrolling internally). Use a CustomScrollView to provide a // (and scrolling internally). Use a CustomScrollView to provide a
// bounded height for the tabbed card via SliverFillRemaining. // bounded height for the tabbed card via SliverFillRemaining.
return CustomScrollView( return Stack(
children: [
CustomScrollView(
slivers: [ slivers: [
SliverToBoxAdapter(child: detailsCard), SliverToBoxAdapter(child: detailsCard),
const SliverToBoxAdapter(child: SizedBox(height: 12)), const SliverToBoxAdapter(child: SizedBox(height: 12)),
SliverFillRemaining(hasScrollBody: true, child: tabbedCard), SliverFillRemaining(hasScrollBody: true, child: tabbedCard),
], ],
),
if (isRetrieving)
Positioned.fill(
child: AbsorbPointer(
absorbing: true,
child: Container(
color: Theme.of(
context,
).colorScheme.surface.withAlpha((0.35 * 255).round()),
alignment: Alignment.topCenter,
padding: const EdgeInsets.only(top: 36),
child: SizedBox(
width: 280,
child: Card(
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
SizedBox(width: 12),
Expanded(child: Text('Retrieving updates…')),
],
),
),
),
),
),
),
),
],
); );
}, },
), ),
),
); );
} }

View File

@ -13,8 +13,11 @@ import '../../providers/notifications_provider.dart';
import '../../providers/profile_provider.dart'; import '../../providers/profile_provider.dart';
import '../../providers/tasks_provider.dart'; import '../../providers/tasks_provider.dart';
import '../../providers/tickets_provider.dart'; import '../../providers/tickets_provider.dart';
import '../../providers/realtime_controller.dart';
import '../../providers/typing_provider.dart'; import '../../providers/typing_provider.dart';
import '../../widgets/mono_text.dart'; import '../../widgets/mono_text.dart';
import '../../widgets/reconnect_overlay.dart';
import 'package:skeletonizer/skeletonizer.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
import '../../widgets/tasq_adaptive_list.dart'; import '../../widgets/tasq_adaptive_list.dart';
import '../../widgets/typing_dots.dart'; import '../../widgets/typing_dots.dart';
@ -52,6 +55,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
String? _selectedAssigneeId; String? _selectedAssigneeId;
DateTimeRange? _selectedDateRange; DateTimeRange? _selectedDateRange;
late final TabController _tabController; late final TabController _tabController;
bool _isSwitchingTab = false;
@override @override
void dispose() { void dispose() {
@ -66,8 +70,15 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
super.initState(); super.initState();
_tabController = TabController(length: 2, vsync: this); _tabController = TabController(length: 2, vsync: this);
_tabController.addListener(() { _tabController.addListener(() {
// rebuild when tab changes so filters shown/hidden update // briefly show a skeleton when switching tabs so the UI can
setState(() {}); // navigate ahead and avoid a janky synchronous rebuild.
if (!_isSwitchingTab) {
setState(() => _isSwitchingTab = true);
Future.delayed(const Duration(milliseconds: 150), () {
if (!mounted) return;
setState(() => _isSwitchingTab = false);
});
}
}); });
} }
@ -90,6 +101,17 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
final notificationsAsync = ref.watch(notificationsProvider); final notificationsAsync = ref.watch(notificationsProvider);
final profilesAsync = ref.watch(profilesProvider); final profilesAsync = ref.watch(profilesProvider);
final assignmentsAsync = ref.watch(taskAssignmentsProvider); final assignmentsAsync = ref.watch(taskAssignmentsProvider);
final realtime = ref.watch(realtimeControllerProvider);
final showSkeleton =
realtime.isConnecting ||
tasksAsync.maybeWhen(loading: () => true, orElse: () => false) ||
ticketsAsync.maybeWhen(loading: () => true, orElse: () => false) ||
officesAsync.maybeWhen(loading: () => true, orElse: () => false) ||
profilesAsync.maybeWhen(loading: () => true, orElse: () => false) ||
assignmentsAsync.maybeWhen(loading: () => true, orElse: () => false) ||
profileAsync.maybeWhen(loading: () => true, orElse: () => false);
final effectiveShowSkeleton = showSkeleton || _isSwitchingTab;
final canCreate = profileAsync.maybeWhen( final canCreate = profileAsync.maybeWhen(
data: (profile) => data: (profile) =>
@ -117,6 +139,8 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
children: [ children: [
ResponsiveBody( ResponsiveBody(
maxWidth: double.infinity, maxWidth: double.infinity,
child: Skeletonizer(
enabled: effectiveShowSkeleton,
child: tasksAsync.when( child: tasksAsync.when(
data: (tasks) { data: (tasks) {
if (tasks.isEmpty) { if (tasks.isEmpty) {
@ -149,7 +173,8 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
final assignmentsByTask = <String, TaskAssignment>{}; final assignmentsByTask = <String, TaskAssignment>{};
for (final a in assignments) { for (final a in assignments) {
final current = assignmentsByTask[a.taskId]; final current = assignmentsByTask[a.taskId];
if (current == null || a.createdAt.isAfter(current.createdAt)) { if (current == null ||
a.createdAt.isAfter(current.createdAt)) {
assignmentsByTask[a.taskId] = a; assignmentsByTask[a.taskId] = a;
} }
} }
@ -241,7 +266,9 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
final next = await showDateRangePicker( final next = await showDateRangePicker(
context: context, context: context,
firstDate: DateTime(2020), firstDate: DateTime(2020),
lastDate: AppTime.now().add(const Duration(days: 365)), lastDate: AppTime.now().add(
const Duration(days: 365),
),
currentDate: AppTime.now(), currentDate: AppTime.now(),
initialDateRange: _selectedDateRange, initialDateRange: _selectedDateRange,
); );
@ -280,6 +307,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
onRowTap: (task) => context.go('/tasks/${task.id}'), onRowTap: (task) => context.go('/tasks/${task.id}'),
summaryDashboard: summary, summaryDashboard: summary,
filterHeader: filterHeader, filterHeader: filterHeader,
skeletonMode: effectiveShowSkeleton,
onRequestRefresh: () { onRequestRefresh: () {
// For server-side pagination, update the query provider // For server-side pagination, update the query provider
ref.read(tasksQueryProvider.notifier).state = ref.read(tasksQueryProvider.notifier).state =
@ -442,7 +470,8 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
? <Task>[] ? <Task>[]
: filteredTasks : filteredTasks
.where( .where(
(t) => latestAssigneeByTaskId[t.id] == currentUserId, (t) =>
latestAssigneeByTaskId[t.id] == currentUserId,
) )
.toList(); .toList();
@ -457,9 +486,8 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
child: Text( child: Text(
'Tasks', 'Tasks',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith( style: Theme.of(context).textTheme.titleLarge
fontWeight: FontWeight.w700, ?.copyWith(fontWeight: FontWeight.w700),
),
), ),
), ),
), ),
@ -488,11 +516,12 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
], ],
); );
}, },
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const SizedBox.shrink(),
error: (error, _) => error: (error, _) =>
Center(child: Text('Failed to load tasks: $error')), Center(child: Text('Failed to load tasks: $error')),
), ),
), ),
),
if (canCreate) if (canCreate)
Positioned( Positioned(
right: 16, right: 16,
@ -505,6 +534,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
), ),
), ),
), ),
const ReconnectOverlay(),
], ],
); );
} }

View File

@ -10,8 +10,11 @@ import '../../models/ticket.dart';
import '../../providers/notifications_provider.dart'; import '../../providers/notifications_provider.dart';
import '../../providers/profile_provider.dart'; import '../../providers/profile_provider.dart';
import '../../providers/tickets_provider.dart'; import '../../providers/tickets_provider.dart';
import '../../providers/realtime_controller.dart';
import '../../providers/typing_provider.dart'; import '../../providers/typing_provider.dart';
import '../../widgets/mono_text.dart'; import '../../widgets/mono_text.dart';
import '../../widgets/reconnect_overlay.dart';
import 'package:skeletonizer/skeletonizer.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
import '../../widgets/tasq_adaptive_list.dart'; import '../../widgets/tasq_adaptive_list.dart';
import '../../widgets/typing_dots.dart'; import '../../widgets/typing_dots.dart';
@ -30,6 +33,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
String? _selectedOfficeId; String? _selectedOfficeId;
String? _selectedStatus; String? _selectedStatus;
DateTimeRange? _selectedDateRange; DateTimeRange? _selectedDateRange;
bool _isInitial = true;
@override @override
void dispose() { void dispose() {
@ -50,11 +54,29 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
final officesAsync = ref.watch(officesProvider); final officesAsync = ref.watch(officesProvider);
final notificationsAsync = ref.watch(notificationsProvider); final notificationsAsync = ref.watch(notificationsProvider);
final profilesAsync = ref.watch(profilesProvider); final profilesAsync = ref.watch(profilesProvider);
final realtime = ref.watch(realtimeControllerProvider);
final showSkeleton =
realtime.isConnecting ||
ticketsAsync.maybeWhen(loading: () => true, orElse: () => false) ||
officesAsync.maybeWhen(loading: () => true, orElse: () => false) ||
profilesAsync.maybeWhen(loading: () => true, orElse: () => false) ||
notificationsAsync.maybeWhen(loading: () => true, orElse: () => false);
if (_isInitial) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() => _isInitial = false);
});
}
final effectiveShowSkeleton = showSkeleton || _isInitial;
return Stack( return Stack(
children: [ children: [
ResponsiveBody( ResponsiveBody(
maxWidth: double.infinity, maxWidth: double.infinity,
child: Skeletonizer(
enabled: effectiveShowSkeleton,
child: ticketsAsync.when( child: ticketsAsync.when(
data: (tickets) { data: (tickets) {
if (tickets.isEmpty) { if (tickets.isEmpty) {
@ -65,7 +87,8 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
office.id: office, office.id: office,
}; };
final profileById = <String, Profile>{ final profileById = <String, Profile>{
for (final profile in profilesAsync.valueOrNull ?? <Profile>[]) for (final profile
in profilesAsync.valueOrNull ?? <Profile>[])
profile.id: profile, profile.id: profile,
}; };
final unreadByTicketId = _unreadByTicketId(notificationsAsync); final unreadByTicketId = _unreadByTicketId(notificationsAsync);
@ -143,7 +166,9 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
final next = await showDateRangePicker( final next = await showDateRangePicker(
context: context, context: context,
firstDate: DateTime(2020), firstDate: DateTime(2020),
lastDate: AppTime.now().add(const Duration(days: 365)), lastDate: AppTime.now().add(
const Duration(days: 365),
),
currentDate: AppTime.now(), currentDate: AppTime.now(),
initialDateRange: _selectedDateRange, initialDateRange: _selectedDateRange,
); );
@ -175,6 +200,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
onRowTap: (ticket) => context.go('/tickets/${ticket.id}'), onRowTap: (ticket) => context.go('/tickets/${ticket.id}'),
summaryDashboard: summaryDashboard, summaryDashboard: summaryDashboard,
filterHeader: filterHeader, filterHeader: filterHeader,
skeletonMode: effectiveShowSkeleton,
onRequestRefresh: () { onRequestRefresh: () {
// For server-side pagination, update the query provider // For server-side pagination, update the query provider
// This will trigger a reload with new pagination parameters // This will trigger a reload with new pagination parameters
@ -289,9 +315,8 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
child: Text( child: Text(
'Tickets', 'Tickets',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith( style: Theme.of(context).textTheme.titleLarge
fontWeight: FontWeight.w700, ?.copyWith(fontWeight: FontWeight.w700),
),
), ),
), ),
), ),
@ -299,11 +324,12 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
], ],
); );
}, },
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const SizedBox.shrink(),
error: (error, _) => error: (error, _) =>
Center(child: Text('Failed to load tickets: $error')), Center(child: Text('Failed to load tickets: $error')),
), ),
), ),
),
Positioned( Positioned(
right: 16, right: 16,
bottom: 16, bottom: 16,
@ -315,6 +341,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
), ),
), ),
), ),
const ReconnectOverlay(),
], ],
); );
} }

View File

@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/realtime_controller.dart';
class ReconnectOverlay extends ConsumerWidget {
const ReconnectOverlay({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final ctrl = ref.watch(realtimeControllerProvider);
if (!ctrl.isConnecting && !ctrl.isFailed) return const SizedBox.shrink();
if (ctrl.isFailed) {
return Positioned.fill(
child: AbsorbPointer(
absorbing: true,
child: Center(
child: SizedBox(
width: 420,
child: Card(
elevation: 6,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Realtime connection failed',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
ctrl.lastError ??
'Unable to reconnect after multiple attempts.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => ctrl.retry(),
child: const Text('Retry'),
),
],
),
],
),
),
),
),
),
),
);
}
// isConnecting: show richer skeleton-like placeholders
return Positioned.fill(
child: AbsorbPointer(
absorbing: true,
child: Container(
color: Theme.of(
context,
).colorScheme.surface.withAlpha((0.35 * 255).round()),
child: Center(
child: SizedBox(
width: 640,
child: Card(
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Container(
height: 20,
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
),
const SizedBox(height: 12),
// chips row
Row(
children: [
for (var i = 0; i < 3; i++)
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Container(
width: 100,
height: 14,
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
),
),
],
),
const SizedBox(height: 16),
// lines representing content
for (var i = 0; i < 4; i++) ...[
Container(
height: 12,
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
),
],
],
),
),
),
),
),
),
),
);
}
}

View File

@ -1,6 +1,8 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
// skeleton rendering is controlled by the caller's `Skeletonizer` wrapper
// so this widget doesn't import `skeletonizer` directly.
import '../theme/app_typography.dart'; import '../theme/app_typography.dart';
import '../theme/app_surfaces.dart'; import '../theme/app_surfaces.dart';
@ -59,6 +61,7 @@ class TasQAdaptiveList<T> extends StatelessWidget {
this.onRequestRefresh, this.onRequestRefresh,
this.onPageChanged, this.onPageChanged,
this.isLoading = false, this.isLoading = false,
this.skeletonMode = false,
}); });
/// The list of items to display. /// The list of items to display.
@ -117,6 +120,13 @@ class TasQAdaptiveList<T> extends StatelessWidget {
/// If true, shows a loading indicator for server-side pagination. /// If true, shows a loading indicator for server-side pagination.
final bool isLoading; final bool isLoading;
/// When true the widget renders skeleton placeholders for the
/// dashboard, filter panel and list items instead of the real content.
/// This is intended to provide a single-source skeleton UI for screens
/// that wrap the whole body in a `Skeletonizer` and want consistent
/// sectioned placeholders.
final bool skeletonMode;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return LayoutBuilder( return LayoutBuilder(
@ -135,6 +145,54 @@ class TasQAdaptiveList<T> extends StatelessWidget {
Widget _buildMobile(BuildContext context, BoxConstraints constraints) { Widget _buildMobile(BuildContext context, BoxConstraints constraints) {
final hasBoundedHeight = constraints.hasBoundedHeight; final hasBoundedHeight = constraints.hasBoundedHeight;
if (skeletonMode) {
// Render structured skeleton sections: summary, filters, and list.
final summary = summaryDashboard == null
? const SizedBox.shrink()
: Column(
children: [
SizedBox(width: double.infinity, child: summaryDashboard!),
const SizedBox(height: 12),
],
);
final filter = filterHeader == null
? const SizedBox.shrink()
: Column(
children: [
ExpansionTile(
title: const Text('Filters'),
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: filterHeader!,
),
],
),
const SizedBox(height: 8),
],
);
final skeletonList = ListView.separated(
padding: const EdgeInsets.only(bottom: 24),
itemCount: 6,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) => _loadingTile(context),
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (summaryDashboard != null) ...[summary],
if (filterHeader != null) ...[filter],
Expanded(child: _buildInfiniteScrollListener(skeletonList)),
],
),
);
}
// Mobile: Single-column with infinite scroll listeners // Mobile: Single-column with infinite scroll listeners
final listView = ListView.separated( final listView = ListView.separated(
padding: const EdgeInsets.only(bottom: 24), padding: const EdgeInsets.only(bottom: 24),
@ -142,14 +200,8 @@ class TasQAdaptiveList<T> extends StatelessWidget {
separatorBuilder: (context, index) => const SizedBox(height: 12), separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index >= items.length) { if (index >= items.length) {
// Loading indicator for infinite scroll // Loading skeleton for infinite scroll (non-blocking shimmer)
return Padding( return _loadingTile(context);
padding: const EdgeInsets.only(top: 8),
child: SizedBox(
height: 24,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
);
} }
final item = items[index]; final item = items[index];
final actions = rowActions?.call(item) ?? const <Widget>[]; final actions = rowActions?.call(item) ?? const <Widget>[];
@ -169,13 +221,7 @@ class TasQAdaptiveList<T> extends StatelessWidget {
separatorBuilder: (context, index) => const SizedBox(height: 12), separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index >= items.length) { if (index >= items.length) {
return Padding( return _loadingTile(context);
padding: const EdgeInsets.only(top: 8),
child: SizedBox(
height: 24,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
);
} }
final item = items[index]; final item = items[index];
final actions = rowActions?.call(item) ?? const <Widget>[]; final actions = rowActions?.call(item) ?? const <Widget>[];
@ -245,6 +291,44 @@ class TasQAdaptiveList<T> extends StatelessWidget {
); );
} }
Widget _loadingTile(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: SizedBox(
height: 72,
child: Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(height: 12, color: Colors.white),
const SizedBox(height: 8),
Container(height: 10, width: 150, color: Colors.white),
],
),
),
],
),
),
),
),
);
}
Widget _buildDesktop(BuildContext context, BoxConstraints constraints) { Widget _buildDesktop(BuildContext context, BoxConstraints constraints) {
final dataSource = _TasQTableSource<T>( final dataSource = _TasQTableSource<T>(
context: context, context: context,

View File

@ -1301,6 +1301,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.1" version: "2.4.1"
skeletonizer:
dependency: "direct main"
description:
name: skeletonizer
sha256: "9f38f9b47ec3cf2235a6a4f154a88a95432bc55ba98b3e2eb6ced5c1974bc122"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter

View File

@ -34,6 +34,7 @@ dependencies:
firebase_messaging: ^16.1.1 firebase_messaging: ^16.1.1
shared_preferences: ^2.2.0 shared_preferences: ^2.2.0
uuid: ^4.1.0 uuid: ^4.1.0
skeletonizer: ^2.1.3
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: