Skeleton loading
This commit is contained in:
parent
c5e859ad88
commit
d3239d8c76
|
|
@ -20,15 +20,19 @@ final realtimeControllerProvider = ChangeNotifierProvider<RealtimeController>((
|
|||
/// connection when the app returns to the foreground or when auth tokens
|
||||
/// are refreshed.
|
||||
class RealtimeController extends ChangeNotifier {
|
||||
RealtimeController(this._client) {
|
||||
_init();
|
||||
}
|
||||
|
||||
final SupabaseClient _client;
|
||||
|
||||
bool isConnecting = false;
|
||||
bool isFailed = false;
|
||||
String? lastError;
|
||||
int attempts = 0;
|
||||
final int maxAttempts;
|
||||
bool _disposed = false;
|
||||
|
||||
RealtimeController(this._client, {this.maxAttempts = 4}) {
|
||||
_init();
|
||||
}
|
||||
|
||||
void _init() {
|
||||
try {
|
||||
// Listen for auth changes and try to recover the realtime connection
|
||||
|
|
@ -39,7 +43,9 @@ class RealtimeController extends ChangeNotifier {
|
|||
recoverConnection();
|
||||
}
|
||||
});
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
debugPrint('RealtimeController._init error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to reconnect the realtime client using a small exponential backoff.
|
||||
|
|
@ -47,15 +53,15 @@ class RealtimeController extends ChangeNotifier {
|
|||
if (_disposed) return;
|
||||
if (isConnecting) return;
|
||||
|
||||
isFailed = false;
|
||||
lastError = null;
|
||||
isConnecting = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
int attempt = 0;
|
||||
int maxAttempts = 4;
|
||||
int delaySeconds = 1;
|
||||
while (attempt < maxAttempts && !_disposed) {
|
||||
attempt++;
|
||||
while (attempts < maxAttempts && !_disposed) {
|
||||
attempts++;
|
||||
try {
|
||||
// Best-effort disconnect then connect so the realtime client picks
|
||||
// up any refreshed tokens.
|
||||
|
|
@ -82,11 +88,17 @@ class RealtimeController extends ChangeNotifier {
|
|||
// Give the socket a moment to stabilise.
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
|
||||
// Exit early; we don't have a reliable sync API for connection
|
||||
// state across all platforms, so treat this as a best-effort
|
||||
// resurrection.
|
||||
// Success (best-effort). Reset attempt counter and clear failure.
|
||||
attempts = 0;
|
||||
isFailed = false;
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
lastError = e.toString();
|
||||
if (attempts >= maxAttempts) {
|
||||
isFailed = true;
|
||||
break;
|
||||
}
|
||||
await Future.delayed(Duration(seconds: delaySeconds));
|
||||
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
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import '../../providers/profile_provider.dart';
|
|||
import '../../providers/tasks_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../providers/realtime_controller.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import '../../theme/app_surfaces.dart';
|
||||
import '../../widgets/mono_text.dart';
|
||||
import '../../widgets/status_pill.dart';
|
||||
|
|
@ -294,7 +296,13 @@ class DashboardScreen extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final realtime = ProviderScope.containerOf(
|
||||
context,
|
||||
).read(realtimeControllerProvider);
|
||||
|
||||
return ResponsiveBody(
|
||||
child: Skeletonizer(
|
||||
enabled: realtime.isConnecting,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final sections = <Widget>[
|
||||
|
|
@ -322,7 +330,8 @@ class DashboardScreen extends StatelessWidget {
|
|||
_cardGrid(context, [
|
||||
_MetricCard(
|
||||
title: 'Tasks created',
|
||||
valueBuilder: (metrics) => metrics.tasksCreatedToday.toString(),
|
||||
valueBuilder: (metrics) =>
|
||||
metrics.tasksCreatedToday.toString(),
|
||||
),
|
||||
_MetricCard(
|
||||
title: 'Tasks completed',
|
||||
|
|
@ -339,7 +348,8 @@ class DashboardScreen extends StatelessWidget {
|
|||
_cardGrid(context, [
|
||||
_MetricCard(
|
||||
title: 'Avg response',
|
||||
valueBuilder: (metrics) => _formatDuration(metrics.avgResponse),
|
||||
valueBuilder: (metrics) =>
|
||||
_formatDuration(metrics.avgResponse),
|
||||
),
|
||||
_MetricCard(
|
||||
title: 'Avg triage',
|
||||
|
|
@ -375,17 +385,62 @@ class DashboardScreen extends StatelessWidget {
|
|||
],
|
||||
);
|
||||
|
||||
return SingleChildScrollView(
|
||||
return Stack(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
constraints: BoxConstraints(
|
||||
minHeight: constraints.maxHeight,
|
||||
),
|
||||
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…'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import '../../providers/profile_provider.dart';
|
|||
import '../../providers/tasks_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
import '../../providers/typing_provider.dart';
|
||||
import '../../providers/realtime_controller.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import '../../utils/app_time.dart';
|
||||
import '../../utils/snackbar.dart';
|
||||
import '../../widgets/app_breakpoints.dart';
|
||||
|
|
@ -235,7 +237,19 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
|
||||
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(
|
||||
builder: (context, constraints) {
|
||||
final isWide = constraints.maxWidth >= AppBreakpoints.desktop;
|
||||
|
|
@ -346,7 +360,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
if (!canEdit) return const SizedBox.shrink();
|
||||
return IconButton(
|
||||
tooltip: 'Edit task',
|
||||
onPressed: () => _showEditTaskDialog(ctx, ref, task),
|
||||
onPressed: () =>
|
||||
_showEditTaskDialog(ctx, ref, task),
|
||||
icon: const Icon(Icons.edit),
|
||||
);
|
||||
},
|
||||
|
|
@ -409,7 +424,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
|
||||
if (officeServiceId != null &&
|
||||
(servicesAsync.valueOrNull == null ||
|
||||
(servicesAsync.valueOrNull?.isEmpty ?? true))) {
|
||||
(servicesAsync.valueOrNull?.isEmpty ??
|
||||
true))) {
|
||||
final servicesOnce = await ref.read(
|
||||
servicesOnceProvider.future,
|
||||
);
|
||||
|
|
@ -444,7 +460,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
],
|
||||
|
||||
// warning banner for completed tasks with missing metadata
|
||||
if (task.status == 'completed' && task.hasIncompleteDetails) ...[
|
||||
if (task.status == 'completed' &&
|
||||
task.hasIncompleteDetails) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
|
|
@ -456,8 +473,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
Expanded(
|
||||
child: Text(
|
||||
'Task completed but some details are still empty.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -478,7 +498,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
children: [
|
||||
TabBar(
|
||||
labelColor: Theme.of(context).colorScheme.onSurface,
|
||||
indicatorColor: Theme.of(context).colorScheme.primary,
|
||||
indicatorColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
tabs: const [
|
||||
Tab(text: 'Assignees'),
|
||||
Tab(text: 'Type & Category'),
|
||||
|
|
@ -526,7 +548,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
const SizedBox(height: 8),
|
||||
_MetaBadge(
|
||||
label: 'Category',
|
||||
value: task.requestCategory ?? 'None',
|
||||
value:
|
||||
task.requestCategory ?? 'None',
|
||||
),
|
||||
] else ...[
|
||||
const Text('Type'),
|
||||
|
|
@ -534,99 +557,6 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
DropdownButtonFormField<String?>(
|
||||
initialValue: task.requestType,
|
||||
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
|
||||
? SizedBox(
|
||||
width: 16,
|
||||
|
|
@ -667,6 +597,105 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
)
|
||||
: 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 {
|
||||
setState(() {
|
||||
_typeSaving = true;
|
||||
|
|
@ -695,7 +724,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
);
|
||||
if (_typeSaved) {
|
||||
Future.delayed(
|
||||
const Duration(seconds: 2),
|
||||
const Duration(
|
||||
seconds: 2,
|
||||
),
|
||||
() {
|
||||
if (mounted) {
|
||||
setState(
|
||||
|
|
@ -747,7 +778,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
child: Icon(
|
||||
Icons.check,
|
||||
size: 10,
|
||||
color: Colors.white,
|
||||
color:
|
||||
Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -774,7 +806,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
});
|
||||
try {
|
||||
await ref
|
||||
.read(tasksControllerProvider)
|
||||
.read(
|
||||
tasksControllerProvider,
|
||||
)
|
||||
.updateTask(
|
||||
taskId: task.id,
|
||||
requestCategory: v,
|
||||
|
|
@ -862,7 +896,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
child: Icon(
|
||||
Icons.check,
|
||||
size: 10,
|
||||
color: Colors.white,
|
||||
color:
|
||||
Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -873,7 +908,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
onChanged: (v) {
|
||||
_requestedDebounce?.cancel();
|
||||
_requestedDebounce = Timer(
|
||||
const Duration(milliseconds: 700),
|
||||
const Duration(
|
||||
milliseconds: 700,
|
||||
),
|
||||
() async {
|
||||
final name = v.trim();
|
||||
setState(() {
|
||||
|
|
@ -947,7 +984,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
: p.fullName,
|
||||
)
|
||||
.where(
|
||||
(n) => n.toLowerCase().contains(
|
||||
(n) =>
|
||||
n.toLowerCase().contains(
|
||||
pattern.toLowerCase(),
|
||||
),
|
||||
)
|
||||
|
|
@ -978,8 +1016,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
},
|
||||
itemBuilder: (context, suggestion) =>
|
||||
ListTile(title: Text(suggestion)),
|
||||
onSuggestionSelected:
|
||||
(suggestion) async {
|
||||
onSuggestionSelected: (suggestion) async {
|
||||
_requestedDebounce?.cancel();
|
||||
_requestedController.text =
|
||||
suggestion;
|
||||
|
|
@ -989,9 +1026,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
});
|
||||
try {
|
||||
await ref
|
||||
.read(
|
||||
tasksControllerProvider,
|
||||
)
|
||||
.read(tasksControllerProvider)
|
||||
.updateTask(
|
||||
taskId: task.id,
|
||||
requestedBy:
|
||||
|
|
@ -1018,8 +1053,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
} catch (_) {
|
||||
} finally {
|
||||
setState(
|
||||
() =>
|
||||
_requestedSaving = false,
|
||||
() => _requestedSaving = false,
|
||||
);
|
||||
if (_requestedSaved) {
|
||||
Future.delayed(
|
||||
|
|
@ -1027,8 +1061,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
() {
|
||||
if (mounted) {
|
||||
setState(
|
||||
() =>
|
||||
_requestedSaved =
|
||||
() => _requestedSaved =
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
|
@ -1083,7 +1116,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
child: Icon(
|
||||
Icons.check,
|
||||
size: 10,
|
||||
color: Colors.white,
|
||||
color:
|
||||
Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -1094,7 +1128,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
onChanged: (v) {
|
||||
_notedDebounce?.cancel();
|
||||
_notedDebounce = Timer(
|
||||
const Duration(milliseconds: 700),
|
||||
const Duration(
|
||||
milliseconds: 700,
|
||||
),
|
||||
() async {
|
||||
final name = v.trim();
|
||||
setState(() {
|
||||
|
|
@ -1142,7 +1178,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
() {
|
||||
if (mounted) {
|
||||
setState(
|
||||
() => _notedSaved =
|
||||
() =>
|
||||
_notedSaved =
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
|
@ -1167,7 +1204,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
: p.fullName,
|
||||
)
|
||||
.where(
|
||||
(n) => n.toLowerCase().contains(
|
||||
(n) =>
|
||||
n.toLowerCase().contains(
|
||||
pattern.toLowerCase(),
|
||||
),
|
||||
)
|
||||
|
|
@ -1242,7 +1280,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
);
|
||||
if (_notedSaved) {
|
||||
Future.delayed(
|
||||
const Duration(seconds: 2),
|
||||
const Duration(
|
||||
seconds: 2,
|
||||
),
|
||||
() {
|
||||
if (mounted) {
|
||||
setState(
|
||||
|
|
@ -1301,7 +1341,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
child: Icon(
|
||||
Icons.check,
|
||||
size: 10,
|
||||
color: Colors.white,
|
||||
color:
|
||||
Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -1312,7 +1353,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
onChanged: (v) {
|
||||
_receivedDebounce?.cancel();
|
||||
_receivedDebounce = Timer(
|
||||
const Duration(milliseconds: 700),
|
||||
const Duration(
|
||||
milliseconds: 700,
|
||||
),
|
||||
() async {
|
||||
final name = v.trim();
|
||||
setState(() {
|
||||
|
|
@ -1326,7 +1369,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
)
|
||||
.updateTask(
|
||||
taskId: task.id,
|
||||
receivedBy: name.isEmpty
|
||||
receivedBy:
|
||||
name.isEmpty
|
||||
? null
|
||||
: name,
|
||||
);
|
||||
|
|
@ -1386,7 +1430,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
: p.fullName,
|
||||
)
|
||||
.where(
|
||||
(n) => n.toLowerCase().contains(
|
||||
(n) =>
|
||||
n.toLowerCase().contains(
|
||||
pattern.toLowerCase(),
|
||||
),
|
||||
)
|
||||
|
|
@ -1457,15 +1502,19 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
} catch (_) {
|
||||
} finally {
|
||||
setState(
|
||||
() => _receivedSaving = false,
|
||||
() =>
|
||||
_receivedSaving = false,
|
||||
);
|
||||
if (_receivedSaved) {
|
||||
Future.delayed(
|
||||
const Duration(seconds: 2),
|
||||
const Duration(
|
||||
seconds: 2,
|
||||
),
|
||||
() {
|
||||
if (mounted) {
|
||||
setState(
|
||||
() => _receivedSaved =
|
||||
() =>
|
||||
_receivedSaved =
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
|
@ -1484,7 +1533,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Action taken'),
|
||||
const SizedBox(height: 6),
|
||||
|
|
@ -1498,7 +1548,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
context,
|
||||
).colorScheme.outline,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: BorderRadius.circular(
|
||||
8,
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
|
|
@ -1634,7 +1686,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
'Insert link',
|
||||
),
|
||||
content: TextField(
|
||||
controller: urlCtrl,
|
||||
controller:
|
||||
urlCtrl,
|
||||
decoration:
|
||||
const InputDecoration(
|
||||
hintText:
|
||||
|
|
@ -1647,7 +1700,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
Navigator.of(
|
||||
ctx,
|
||||
).pop(),
|
||||
child: const Text(
|
||||
child:
|
||||
const Text(
|
||||
'Cancel',
|
||||
),
|
||||
),
|
||||
|
|
@ -1656,10 +1710,12 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
Navigator.of(
|
||||
ctx,
|
||||
).pop(
|
||||
urlCtrl.text
|
||||
urlCtrl
|
||||
.text
|
||||
.trim(),
|
||||
),
|
||||
child: const Text(
|
||||
child:
|
||||
const Text(
|
||||
'Insert',
|
||||
),
|
||||
),
|
||||
|
|
@ -1682,7 +1738,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
sel.extentOffset;
|
||||
if (!sel.isCollapsed &&
|
||||
end > start) {
|
||||
final len = end - start;
|
||||
final len =
|
||||
end - start;
|
||||
try {
|
||||
_actionController
|
||||
?.document
|
||||
|
|
@ -1693,11 +1750,17 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
} catch (_) {}
|
||||
_actionController
|
||||
?.document
|
||||
.insert(start, res);
|
||||
.insert(
|
||||
start,
|
||||
res,
|
||||
);
|
||||
} else {
|
||||
_actionController
|
||||
?.document
|
||||
.insert(start, res);
|
||||
.insert(
|
||||
start,
|
||||
res,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
|
@ -1738,9 +1801,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
tasksControllerProvider,
|
||||
)
|
||||
.uploadActionImage(
|
||||
taskId: task.id,
|
||||
taskId:
|
||||
task.id,
|
||||
bytes: bytes,
|
||||
extension: ext,
|
||||
extension:
|
||||
ext,
|
||||
);
|
||||
} catch (e) {
|
||||
showErrorSnackBar(
|
||||
|
|
@ -1789,11 +1854,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
child: quill.QuillEditor.basic(
|
||||
controller:
|
||||
_actionController!,
|
||||
focusNode: _actionFocusNode,
|
||||
focusNode:
|
||||
_actionFocusNode,
|
||||
scrollController:
|
||||
_actionScrollController,
|
||||
config:
|
||||
quill.QuillEditorConfig(
|
||||
config: quill.QuillEditorConfig(
|
||||
embedBuilders: const [
|
||||
_ImageEmbedBuilder(),
|
||||
],
|
||||
|
|
@ -1840,7 +1905,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
child: Icon(
|
||||
Icons.check,
|
||||
size: 10,
|
||||
color: Colors.white,
|
||||
color:
|
||||
Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -1934,7 +2000,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
bottom: 6,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 6,
|
||||
),
|
||||
|
|
@ -1994,7 +2061,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
Expanded(
|
||||
child: TextField(
|
||||
controller: _messageController,
|
||||
decoration: const InputDecoration(
|
||||
decoration:
|
||||
const InputDecoration(
|
||||
hintText: 'Message...',
|
||||
),
|
||||
textInputAction:
|
||||
|
|
@ -2002,7 +2070,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
enabled: canSendMessages,
|
||||
onChanged: (_) =>
|
||||
_handleComposerChanged(
|
||||
profilesAsync.valueOrNull ??
|
||||
profilesAsync
|
||||
.valueOrNull ??
|
||||
[],
|
||||
ref.read(
|
||||
currentUserIdProvider,
|
||||
|
|
@ -2013,7 +2082,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
onSubmitted: (_) =>
|
||||
_handleSendMessage(
|
||||
task,
|
||||
profilesAsync.valueOrNull ??
|
||||
profilesAsync
|
||||
.valueOrNull ??
|
||||
[],
|
||||
ref.read(
|
||||
currentUserIdProvider,
|
||||
|
|
@ -2029,7 +2099,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
onPressed: canSendMessages
|
||||
? () => _handleSendMessage(
|
||||
task,
|
||||
profilesAsync.valueOrNull ??
|
||||
profilesAsync
|
||||
.valueOrNull ??
|
||||
[],
|
||||
ref.read(
|
||||
currentUserIdProvider,
|
||||
|
|
@ -2082,15 +2153,56 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
// keeping the chat/activity panel filling the remaining viewport
|
||||
// (and scrolling internally). Use a CustomScrollView to provide a
|
||||
// bounded height for the tabbed card via SliverFillRemaining.
|
||||
return CustomScrollView(
|
||||
return Stack(
|
||||
children: [
|
||||
CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: detailsCard),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 12)),
|
||||
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…')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,8 +13,11 @@ import '../../providers/notifications_provider.dart';
|
|||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/tasks_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
import '../../providers/realtime_controller.dart';
|
||||
import '../../providers/typing_provider.dart';
|
||||
import '../../widgets/mono_text.dart';
|
||||
import '../../widgets/reconnect_overlay.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../widgets/tasq_adaptive_list.dart';
|
||||
import '../../widgets/typing_dots.dart';
|
||||
|
|
@ -52,6 +55,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
|||
String? _selectedAssigneeId;
|
||||
DateTimeRange? _selectedDateRange;
|
||||
late final TabController _tabController;
|
||||
bool _isSwitchingTab = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
|
|
@ -66,8 +70,15 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
|||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
_tabController.addListener(() {
|
||||
// rebuild when tab changes so filters shown/hidden update
|
||||
setState(() {});
|
||||
// briefly show a skeleton when switching tabs so the UI can
|
||||
// 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 profilesAsync = ref.watch(profilesProvider);
|
||||
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(
|
||||
data: (profile) =>
|
||||
|
|
@ -117,6 +139,8 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
|||
children: [
|
||||
ResponsiveBody(
|
||||
maxWidth: double.infinity,
|
||||
child: Skeletonizer(
|
||||
enabled: effectiveShowSkeleton,
|
||||
child: tasksAsync.when(
|
||||
data: (tasks) {
|
||||
if (tasks.isEmpty) {
|
||||
|
|
@ -149,7 +173,8 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
|||
final assignmentsByTask = <String, TaskAssignment>{};
|
||||
for (final a in assignments) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -241,7 +266,9 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
|||
final next = await showDateRangePicker(
|
||||
context: context,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: AppTime.now().add(const Duration(days: 365)),
|
||||
lastDate: AppTime.now().add(
|
||||
const Duration(days: 365),
|
||||
),
|
||||
currentDate: AppTime.now(),
|
||||
initialDateRange: _selectedDateRange,
|
||||
);
|
||||
|
|
@ -280,6 +307,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
|||
onRowTap: (task) => context.go('/tasks/${task.id}'),
|
||||
summaryDashboard: summary,
|
||||
filterHeader: filterHeader,
|
||||
skeletonMode: effectiveShowSkeleton,
|
||||
onRequestRefresh: () {
|
||||
// For server-side pagination, update the query provider
|
||||
ref.read(tasksQueryProvider.notifier).state =
|
||||
|
|
@ -442,7 +470,8 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
|||
? <Task>[]
|
||||
: filteredTasks
|
||||
.where(
|
||||
(t) => latestAssigneeByTaskId[t.id] == currentUserId,
|
||||
(t) =>
|
||||
latestAssigneeByTaskId[t.id] == currentUserId,
|
||||
)
|
||||
.toList();
|
||||
|
||||
|
|
@ -457,9 +486,8 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
|||
child: Text(
|
||||
'Tasks',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
style: Theme.of(context).textTheme.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -488,11 +516,12 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
|||
],
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (error, _) =>
|
||||
Center(child: Text('Failed to load tasks: $error')),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (canCreate)
|
||||
Positioned(
|
||||
right: 16,
|
||||
|
|
@ -505,6 +534,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
|||
),
|
||||
),
|
||||
),
|
||||
const ReconnectOverlay(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,11 @@ import '../../models/ticket.dart';
|
|||
import '../../providers/notifications_provider.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
import '../../providers/realtime_controller.dart';
|
||||
import '../../providers/typing_provider.dart';
|
||||
import '../../widgets/mono_text.dart';
|
||||
import '../../widgets/reconnect_overlay.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../widgets/tasq_adaptive_list.dart';
|
||||
import '../../widgets/typing_dots.dart';
|
||||
|
|
@ -30,6 +33,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
|||
String? _selectedOfficeId;
|
||||
String? _selectedStatus;
|
||||
DateTimeRange? _selectedDateRange;
|
||||
bool _isInitial = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
|
|
@ -50,11 +54,29 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
|||
final officesAsync = ref.watch(officesProvider);
|
||||
final notificationsAsync = ref.watch(notificationsProvider);
|
||||
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(
|
||||
children: [
|
||||
ResponsiveBody(
|
||||
maxWidth: double.infinity,
|
||||
child: Skeletonizer(
|
||||
enabled: effectiveShowSkeleton,
|
||||
child: ticketsAsync.when(
|
||||
data: (tickets) {
|
||||
if (tickets.isEmpty) {
|
||||
|
|
@ -65,7 +87,8 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
|||
office.id: office,
|
||||
};
|
||||
final profileById = <String, Profile>{
|
||||
for (final profile in profilesAsync.valueOrNull ?? <Profile>[])
|
||||
for (final profile
|
||||
in profilesAsync.valueOrNull ?? <Profile>[])
|
||||
profile.id: profile,
|
||||
};
|
||||
final unreadByTicketId = _unreadByTicketId(notificationsAsync);
|
||||
|
|
@ -143,7 +166,9 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
|||
final next = await showDateRangePicker(
|
||||
context: context,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: AppTime.now().add(const Duration(days: 365)),
|
||||
lastDate: AppTime.now().add(
|
||||
const Duration(days: 365),
|
||||
),
|
||||
currentDate: AppTime.now(),
|
||||
initialDateRange: _selectedDateRange,
|
||||
);
|
||||
|
|
@ -175,6 +200,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
|||
onRowTap: (ticket) => context.go('/tickets/${ticket.id}'),
|
||||
summaryDashboard: summaryDashboard,
|
||||
filterHeader: filterHeader,
|
||||
skeletonMode: effectiveShowSkeleton,
|
||||
onRequestRefresh: () {
|
||||
// For server-side pagination, update the query provider
|
||||
// This will trigger a reload with new pagination parameters
|
||||
|
|
@ -289,9 +315,8 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
|||
child: Text(
|
||||
'Tickets',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
style: Theme.of(context).textTheme.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -299,11 +324,12 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
|||
],
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (error, _) =>
|
||||
Center(child: Text('Failed to load tickets: $error')),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
|
|
@ -315,6 +341,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
|||
),
|
||||
),
|
||||
),
|
||||
const ReconnectOverlay(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
131
lib/widgets/reconnect_overlay.dart
Normal file
131
lib/widgets/reconnect_overlay.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1301,6 +1301,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ dependencies:
|
|||
firebase_messaging: ^16.1.1
|
||||
shared_preferences: ^2.2.0
|
||||
uuid: ^4.1.0
|
||||
skeletonizer: ^2.1.3
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user