Fixed Dashboard full page refresh

This commit is contained in:
Marc Rejohn Castillano 2026-02-18 23:37:21 +08:00
parent 5ec57a1cec
commit f9f3509188
7 changed files with 118 additions and 43 deletions

View File

@ -109,6 +109,7 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
.map((rows) => rows.map(Task.fromMap).toList());
return baseStream.map((allTasks) {
debugPrint('[tasksProvider] stream event: ${allTasks.length} rows');
// RBAC (server-side filtering isn't possible via `.range` on stream builder,
// so enforce allowed IDs here).
var list = allTasks;

View File

@ -135,6 +135,7 @@ final ticketsProvider = StreamProvider<List<Ticket>>((ref) {
.map((rows) => rows.map(Ticket.fromMap).toList());
return baseStream.map((allTickets) {
debugPrint('[ticketsProvider] stream event: ${allTickets.length} rows');
var list = allTickets;
if (!isGlobal) {

View File

@ -75,6 +75,16 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
messagesAsync,
];
// Debug: log dependency loading/error states to diagnose full-page refreshes.
debugPrint(
'[dashboardMetricsProvider] recompute: '
'tickets=${ticketsAsync.isLoading ? "loading" : "ready"} '
'tasks=${tasksAsync.isLoading ? "loading" : "ready"} '
'profiles=${profilesAsync.isLoading ? "loading" : "ready"} '
'assignments=${assignmentsAsync.isLoading ? "loading" : "ready"} '
'messages=${messagesAsync.isLoading ? "loading" : "ready"}',
);
if (asyncValues.any((value) => value.hasError)) {
final errorValue = asyncValues.firstWhere((value) => value.hasError);
final error = errorValue.error ?? 'Failed to load dashboard';
@ -82,7 +92,16 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
return AsyncError(error, stack);
}
if (asyncValues.any((value) => value.isLoading)) {
// Avoid returning a loading state for the whole dashboard when *one*
// dependency is temporarily loading (this caused the top linear
// progress banner to appear during tickettask promotion / task
// completion). Only treat the dashboard as loading when *none* of the
// dependencies have any data yet (initial load).
final anyHasData = asyncValues.any((v) => v.valueOrNull != null);
if (!anyHasData) {
debugPrint(
'[dashboardMetricsProvider] returning AsyncLoading (no dep data)',
);
return const AsyncLoading();
}
@ -424,23 +443,39 @@ class _DashboardStatusBanner extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final metricsAsync = ref.watch(dashboardMetricsProvider);
return metricsAsync.when(
data: (_) => const SizedBox.shrink(),
loading: () => const Padding(
// Watch a small derived string state so only the banner rebuilds when
// its visibility/content actually changes.
final bannerState = ref.watch(
dashboardMetricsProvider.select(
(av) => av.when<String>(
data: (_) => 'data',
loading: () => 'loading',
error: (e, _) => 'error:${e.toString()}',
),
),
);
if (bannerState == 'loading') {
return const Padding(
padding: EdgeInsets.only(bottom: 12),
child: LinearProgressIndicator(minHeight: 2),
),
error: (error, _) => Padding(
);
}
if (bannerState.startsWith('error:')) {
final errorText = bannerState.substring(6);
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
'Dashboard data error: $error',
'Dashboard data error: $errorText',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
),
);
);
}
return const SizedBox.shrink();
}
}
@ -452,11 +487,17 @@ class _MetricCard extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final metricsAsync = ref.watch(dashboardMetricsProvider);
final value = metricsAsync.when(
data: (metrics) => valueBuilder(metrics),
loading: () => '',
error: (error, _) => 'Error',
// Only watch the single string value for this card so unrelated metric
// updates don't rebuild the whole card. This makes updates feel much
// smoother and avoids full-page refreshes.
final value = ref.watch(
dashboardMetricsProvider.select(
(av) => av.when<String>(
data: (m) => valueBuilder(m),
loading: () => '',
error: (error, _) => 'Error',
),
),
);
return AnimatedContainer(
@ -477,11 +518,20 @@ class _MetricCard extends ConsumerWidget {
).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 10),
MonoText(
value,
style: Theme.of(
context,
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700),
// Animate only the metric text (not the whole card) for a
// subtle, smooth update.
AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
transitionBuilder: (child, anim) =>
FadeTransition(opacity: anim, child: child),
child: MonoText(
value,
key: ValueKey(value),
style: Theme.of(
context,
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700),
),
),
],
),
@ -537,23 +587,46 @@ class _StaffTableBody extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final metricsAsync = ref.watch(dashboardMetricsProvider);
return metricsAsync.when(
data: (metrics) {
if (metrics.staffRows.isEmpty) {
return Text(
'No IT staff available.',
style: Theme.of(context).textTheme.bodySmall,
);
}
return Column(
children: metrics.staffRows
.map((row) => _StaffRow(row: row))
.toList(),
);
},
loading: () => const Text('Loading staff...'),
error: (error, _) => Text('Failed to load staff: $error'),
// Only listen to the staff rows and the overall provider state to keep
// rebuilds scoped to this small area.
final providerState = ref.watch(
dashboardMetricsProvider.select(
(av) => av.when<String>(
data: (_) => 'data',
loading: () => 'loading',
error: (e, _) => 'error:${e.toString()}',
),
),
);
final staffRows = ref.watch(
dashboardMetricsProvider.select(
(av) => av.when<List<StaffRowMetrics>>(
data: (m) => m.staffRows,
loading: () => const <StaffRowMetrics>[],
error: (_, __) => const <StaffRowMetrics>[],
),
),
);
if (providerState == 'loading') {
return const Text('Loading staff...');
}
if (providerState.startsWith('error:')) {
final err = providerState.substring(6);
return Text('Failed to load staff: $err');
}
if (staffRows.isEmpty) {
return Text(
'No IT staff available.',
style: Theme.of(context).textTheme.bodySmall,
);
}
return Column(
children: staffRows.map((row) => _StaffRow(row: row)).toList(),
);
}
}

View File

@ -1073,10 +1073,12 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
return PopupMenuButton<String>(
onSelected: (value) async {
// Update DB only Supabase realtime stream will emit the
// updated task list, so explicit invalidation here causes a
// visible loading/refresh and is unnecessary.
await ref
.read(tasksControllerProvider)
.updateTaskStatus(taskId: task.id, status: value);
ref.invalidate(tasksProvider);
},
itemBuilder: (context) => _statusOptions
.map(

View File

@ -904,10 +904,10 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
return PopupMenuButton<String>(
onSelected: (value) async {
// Rely on the realtime stream to propagate the status change.
await ref
.read(ticketsControllerProvider)
.updateTicketStatus(ticketId: ticket.id, status: value);
ref.invalidate(ticketsProvider);
},
itemBuilder: (context) => availableStatuses
.map(

View File

@ -404,7 +404,8 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
description: description,
officeId: selectedOffice!.id,
);
ref.invalidate(ticketsProvider);
// Supabase stream will emit the new ticket no explicit
// invalidation required and avoids a temporary reload.
if (context.mounted) {
Navigator.of(dialogContext).pop();
}

View File

@ -1,8 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:tasq/models/office.dart';
import 'package:tasq/models/profile.dart';
import 'package:tasq/models/user_office.dart';
@ -10,7 +8,6 @@ import 'package:tasq/providers/profile_provider.dart';
import 'package:tasq/providers/tickets_provider.dart';
import 'package:tasq/providers/user_offices_provider.dart';
import 'package:tasq/providers/auth_provider.dart';
import 'package:tasq/providers/supabase_provider.dart';
import 'package:tasq/screens/profile/profile_screen.dart';
import 'package:tasq/widgets/multi_select_picker.dart';