Fixed Dashboard full page refresh
This commit is contained in:
parent
5ec57a1cec
commit
f9f3509188
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 ticket→task 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user