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());
|
.map((rows) => rows.map(Task.fromMap).toList());
|
||||||
|
|
||||||
return baseStream.map((allTasks) {
|
return baseStream.map((allTasks) {
|
||||||
|
debugPrint('[tasksProvider] stream event: ${allTasks.length} rows');
|
||||||
// RBAC (server-side filtering isn't possible via `.range` on stream builder,
|
// RBAC (server-side filtering isn't possible via `.range` on stream builder,
|
||||||
// so enforce allowed IDs here).
|
// so enforce allowed IDs here).
|
||||||
var list = allTasks;
|
var list = allTasks;
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,7 @@ final ticketsProvider = StreamProvider<List<Ticket>>((ref) {
|
||||||
.map((rows) => rows.map(Ticket.fromMap).toList());
|
.map((rows) => rows.map(Ticket.fromMap).toList());
|
||||||
|
|
||||||
return baseStream.map((allTickets) {
|
return baseStream.map((allTickets) {
|
||||||
|
debugPrint('[ticketsProvider] stream event: ${allTickets.length} rows');
|
||||||
var list = allTickets;
|
var list = allTickets;
|
||||||
|
|
||||||
if (!isGlobal) {
|
if (!isGlobal) {
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,16 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
|
||||||
messagesAsync,
|
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)) {
|
if (asyncValues.any((value) => value.hasError)) {
|
||||||
final errorValue = asyncValues.firstWhere((value) => value.hasError);
|
final errorValue = asyncValues.firstWhere((value) => value.hasError);
|
||||||
final error = errorValue.error ?? 'Failed to load dashboard';
|
final error = errorValue.error ?? 'Failed to load dashboard';
|
||||||
|
|
@ -82,7 +92,16 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
|
||||||
return AsyncError(error, stack);
|
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();
|
return const AsyncLoading();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -424,24 +443,40 @@ class _DashboardStatusBanner extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final metricsAsync = ref.watch(dashboardMetricsProvider);
|
// Watch a small derived string state so only the banner rebuilds when
|
||||||
return metricsAsync.when(
|
// its visibility/content actually changes.
|
||||||
data: (_) => const SizedBox.shrink(),
|
final bannerState = ref.watch(
|
||||||
loading: () => const Padding(
|
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),
|
padding: EdgeInsets.only(bottom: 12),
|
||||||
child: LinearProgressIndicator(minHeight: 2),
|
child: LinearProgressIndicator(minHeight: 2),
|
||||||
),
|
);
|
||||||
error: (error, _) => Padding(
|
}
|
||||||
|
|
||||||
|
if (bannerState.startsWith('error:')) {
|
||||||
|
final errorText = bannerState.substring(6);
|
||||||
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Dashboard data error: $error',
|
'Dashboard data error: $errorText',
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: Theme.of(context).colorScheme.error,
|
color: Theme.of(context).colorScheme.error,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MetricCard extends ConsumerWidget {
|
class _MetricCard extends ConsumerWidget {
|
||||||
|
|
@ -452,11 +487,17 @@ class _MetricCard extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final metricsAsync = ref.watch(dashboardMetricsProvider);
|
// Only watch the single string value for this card so unrelated metric
|
||||||
final value = metricsAsync.when(
|
// updates don't rebuild the whole card. This makes updates feel much
|
||||||
data: (metrics) => valueBuilder(metrics),
|
// smoother and avoids full-page refreshes.
|
||||||
|
final value = ref.watch(
|
||||||
|
dashboardMetricsProvider.select(
|
||||||
|
(av) => av.when<String>(
|
||||||
|
data: (m) => valueBuilder(m),
|
||||||
loading: () => '—',
|
loading: () => '—',
|
||||||
error: (error, _) => 'Error',
|
error: (error, _) => 'Error',
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return AnimatedContainer(
|
return AnimatedContainer(
|
||||||
|
|
@ -477,12 +518,21 @@ class _MetricCard extends ConsumerWidget {
|
||||||
).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600),
|
).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
MonoText(
|
|
||||||
|
// 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,
|
value,
|
||||||
|
key: ValueKey(value),
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700),
|
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -537,23 +587,46 @@ class _StaffTableBody extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final metricsAsync = ref.watch(dashboardMetricsProvider);
|
// Only listen to the staff rows and the overall provider state to keep
|
||||||
return metricsAsync.when(
|
// rebuilds scoped to this small area.
|
||||||
data: (metrics) {
|
final providerState = ref.watch(
|
||||||
if (metrics.staffRows.isEmpty) {
|
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(
|
return Text(
|
||||||
'No IT staff available.',
|
'No IT staff available.',
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: metrics.staffRows
|
children: staffRows.map((row) => _StaffRow(row: row)).toList(),
|
||||||
.map((row) => _StaffRow(row: row))
|
|
||||||
.toList(),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
loading: () => const Text('Loading staff...'),
|
|
||||||
error: (error, _) => Text('Failed to load staff: $error'),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1073,10 +1073,12 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
||||||
|
|
||||||
return PopupMenuButton<String>(
|
return PopupMenuButton<String>(
|
||||||
onSelected: (value) async {
|
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
|
await ref
|
||||||
.read(tasksControllerProvider)
|
.read(tasksControllerProvider)
|
||||||
.updateTaskStatus(taskId: task.id, status: value);
|
.updateTaskStatus(taskId: task.id, status: value);
|
||||||
ref.invalidate(tasksProvider);
|
|
||||||
},
|
},
|
||||||
itemBuilder: (context) => _statusOptions
|
itemBuilder: (context) => _statusOptions
|
||||||
.map(
|
.map(
|
||||||
|
|
|
||||||
|
|
@ -904,10 +904,10 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||||
|
|
||||||
return PopupMenuButton<String>(
|
return PopupMenuButton<String>(
|
||||||
onSelected: (value) async {
|
onSelected: (value) async {
|
||||||
|
// Rely on the realtime stream to propagate the status change.
|
||||||
await ref
|
await ref
|
||||||
.read(ticketsControllerProvider)
|
.read(ticketsControllerProvider)
|
||||||
.updateTicketStatus(ticketId: ticket.id, status: value);
|
.updateTicketStatus(ticketId: ticket.id, status: value);
|
||||||
ref.invalidate(ticketsProvider);
|
|
||||||
},
|
},
|
||||||
itemBuilder: (context) => availableStatuses
|
itemBuilder: (context) => availableStatuses
|
||||||
.map(
|
.map(
|
||||||
|
|
|
||||||
|
|
@ -404,7 +404,8 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||||
description: description,
|
description: description,
|
||||||
officeId: selectedOffice!.id,
|
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) {
|
if (context.mounted) {
|
||||||
Navigator.of(dialogContext).pop();
|
Navigator.of(dialogContext).pop();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.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/office.dart';
|
||||||
import 'package:tasq/models/profile.dart';
|
import 'package:tasq/models/profile.dart';
|
||||||
import 'package:tasq/models/user_office.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/tickets_provider.dart';
|
||||||
import 'package:tasq/providers/user_offices_provider.dart';
|
import 'package:tasq/providers/user_offices_provider.dart';
|
||||||
import 'package:tasq/providers/auth_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/screens/profile/profile_screen.dart';
|
||||||
import 'package:tasq/widgets/multi_select_picker.dart';
|
import 'package:tasq/widgets/multi_select_picker.dart';
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user