diff --git a/lib/providers/tasks_provider.dart b/lib/providers/tasks_provider.dart index ffc4ce0a..8a9d47b1 100644 --- a/lib/providers/tasks_provider.dart +++ b/lib/providers/tasks_provider.dart @@ -109,6 +109,7 @@ final tasksProvider = StreamProvider>((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; diff --git a/lib/providers/tickets_provider.dart b/lib/providers/tickets_provider.dart index 15425190..72e79224 100644 --- a/lib/providers/tickets_provider.dart +++ b/lib/providers/tickets_provider.dart @@ -135,6 +135,7 @@ final ticketsProvider = StreamProvider>((ref) { .map((rows) => rows.map(Ticket.fromMap).toList()); return baseStream.map((allTickets) { + debugPrint('[ticketsProvider] stream event: ${allTickets.length} rows'); var list = allTickets; if (!isGlobal) { diff --git a/lib/screens/dashboard/dashboard_screen.dart b/lib/screens/dashboard/dashboard_screen.dart index 6332365e..5c999d80 100644 --- a/lib/screens/dashboard/dashboard_screen.dart +++ b/lib/screens/dashboard/dashboard_screen.dart @@ -75,6 +75,16 @@ final dashboardMetricsProvider = Provider>((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>((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( + 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( + 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( + data: (_) => 'data', + loading: () => 'loading', + error: (e, _) => 'error:${e.toString()}', + ), + ), + ); + + final staffRows = ref.watch( + dashboardMetricsProvider.select( + (av) => av.when>( + data: (m) => m.staffRows, + loading: () => const [], + error: (_, __) => const [], + ), + ), + ); + + 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(), ); } } diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index 84e55b5d..ade9e562 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -1073,10 +1073,12 @@ class _TaskDetailScreenState extends ConsumerState { return PopupMenuButton( 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( diff --git a/lib/screens/tickets/ticket_detail_screen.dart b/lib/screens/tickets/ticket_detail_screen.dart index e4818a96..9c0c8c80 100644 --- a/lib/screens/tickets/ticket_detail_screen.dart +++ b/lib/screens/tickets/ticket_detail_screen.dart @@ -904,10 +904,10 @@ class _TicketDetailScreenState extends ConsumerState { return PopupMenuButton( 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( diff --git a/lib/screens/tickets/tickets_list_screen.dart b/lib/screens/tickets/tickets_list_screen.dart index 5677ccbd..7f9e788b 100644 --- a/lib/screens/tickets/tickets_list_screen.dart +++ b/lib/screens/tickets/tickets_list_screen.dart @@ -404,7 +404,8 @@ class _TicketsListScreenState extends ConsumerState { 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(); } diff --git a/test/profile_screen_test.dart b/test/profile_screen_test.dart index b67e753d..07e27a78 100644 --- a/test/profile_screen_test.dart +++ b/test/profile_screen_test.dart @@ -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';