Per channel skeleton

This commit is contained in:
2026-03-01 20:10:38 +08:00
parent b9153a070f
commit 029e671367
13 changed files with 380 additions and 153 deletions
+33 -24
View File
@@ -55,7 +55,6 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
String? _selectedAssigneeId;
DateTimeRange? _selectedDateRange;
late final TabController _tabController;
bool _isSwitchingTab = false;
@override
void dispose() {
@@ -69,16 +68,10 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
// Rebuild when tab changes so filter header can show/hide the
// "Assigned staff" dropdown for the All Tasks tab.
_tabController.addListener(() {
// 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);
});
}
if (mounted) setState(() {});
});
}
@@ -103,15 +96,19 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
final assignmentsAsync = ref.watch(taskAssignmentsProvider);
final realtime = ref.watch(realtimeControllerProvider);
// Show skeleton only on initial load (no previous data) or when the
// tasks channel is recovering. Use per-channel check so unrelated
// channel issues (e.g. notifications) don't skeleton the tasks list.
final showSkeleton =
realtime.isAnyStreamRecovering ||
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;
realtime.isChannelRecovering('tasks') ||
realtime.isChannelRecovering('task_assignments') ||
(!tasksAsync.hasValue && tasksAsync.isLoading) ||
(!ticketsAsync.hasValue && ticketsAsync.isLoading) ||
(!officesAsync.hasValue && officesAsync.isLoading) ||
(!profilesAsync.hasValue && profilesAsync.isLoading) ||
(!assignmentsAsync.hasValue && assignmentsAsync.isLoading) ||
(!profileAsync.hasValue && profileAsync.isLoading);
final effectiveShowSkeleton = showSkeleton;
final canCreate = profileAsync.maybeWhen(
data: (profile) =>
@@ -141,9 +138,24 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
maxWidth: double.infinity,
child: Skeletonizer(
enabled: effectiveShowSkeleton,
child: tasksAsync.when(
data: (tasks) {
if (tasks.isEmpty) {
// Always render the full layout structure using valueOrNull so
// that Skeletonizer has real widget shapes to shimmer. This
// avoids the blank flash caused by the old `.when(loading:
// () => SizedBox.shrink())` approach and keeps previous data
// visible during provider refreshes.
child: Builder(
builder: (context) {
// Show error only when there is genuinely no data.
if (tasksAsync.hasError && !tasksAsync.hasValue) {
return Center(
child: Text('Failed to load tasks: ${tasksAsync.error}'),
);
}
final tasks = tasksAsync.valueOrNull ?? <Task>[];
// True empty state — data loaded but nothing returned.
if (tasks.isEmpty && !effectiveShowSkeleton) {
return const Center(child: Text('No tasks yet.'));
}
final offices = officesAsync.valueOrNull ?? <Office>[];
@@ -527,9 +539,6 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
],
);
},
loading: () => const SizedBox.shrink(),
error: (error, _) =>
Center(child: Text('Failed to load tasks: $error')),
),
),
),