Skeleton loading

This commit is contained in:
2026-02-28 21:47:24 +08:00
parent c5e859ad88
commit d3239d8c76
9 changed files with 2755 additions and 2285 deletions
+131
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),
),
),
],
],
),
),
),
),
),
),
),
);
}
}
+99 -15
View File
@@ -1,6 +1,8 @@
import 'dart:math' as math;
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_surfaces.dart';
@@ -59,6 +61,7 @@ class TasQAdaptiveList<T> extends StatelessWidget {
this.onRequestRefresh,
this.onPageChanged,
this.isLoading = false,
this.skeletonMode = false,
});
/// 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.
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
Widget build(BuildContext context) {
return LayoutBuilder(
@@ -135,6 +145,54 @@ class TasQAdaptiveList<T> extends StatelessWidget {
Widget _buildMobile(BuildContext context, BoxConstraints constraints) {
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
final listView = ListView.separated(
padding: const EdgeInsets.only(bottom: 24),
@@ -142,14 +200,8 @@ class TasQAdaptiveList<T> extends StatelessWidget {
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) {
if (index >= items.length) {
// Loading indicator for infinite scroll
return Padding(
padding: const EdgeInsets.only(top: 8),
child: SizedBox(
height: 24,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
);
// Loading skeleton for infinite scroll (non-blocking shimmer)
return _loadingTile(context);
}
final item = items[index];
final actions = rowActions?.call(item) ?? const <Widget>[];
@@ -169,13 +221,7 @@ class TasQAdaptiveList<T> extends StatelessWidget {
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) {
if (index >= items.length) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: SizedBox(
height: 24,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
);
return _loadingTile(context);
}
final item = items[index];
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) {
final dataSource = _TasQTableSource<T>(
context: context,