diff --git a/lib/providers/realtime_controller.dart b/lib/providers/realtime_controller.dart index 74ab8df2..0938041c 100644 --- a/lib/providers/realtime_controller.dart +++ b/lib/providers/realtime_controller.dart @@ -20,15 +20,19 @@ final realtimeControllerProvider = ChangeNotifierProvider(( /// connection when the app returns to the foreground or when auth tokens /// are refreshed. class RealtimeController extends ChangeNotifier { - RealtimeController(this._client) { - _init(); - } - final SupabaseClient _client; bool isConnecting = false; + bool isFailed = false; + String? lastError; + int attempts = 0; + final int maxAttempts; bool _disposed = false; + RealtimeController(this._client, {this.maxAttempts = 4}) { + _init(); + } + void _init() { try { // Listen for auth changes and try to recover the realtime connection @@ -39,7 +43,9 @@ class RealtimeController extends ChangeNotifier { recoverConnection(); } }); - } catch (_) {} + } catch (e) { + debugPrint('RealtimeController._init error: $e'); + } } /// Try to reconnect the realtime client using a small exponential backoff. @@ -47,15 +53,15 @@ class RealtimeController extends ChangeNotifier { if (_disposed) return; if (isConnecting) return; + isFailed = false; + lastError = null; isConnecting = true; notifyListeners(); try { - int attempt = 0; - int maxAttempts = 4; int delaySeconds = 1; - while (attempt < maxAttempts && !_disposed) { - attempt++; + while (attempts < maxAttempts && !_disposed) { + attempts++; try { // Best-effort disconnect then connect so the realtime client picks // up any refreshed tokens. @@ -82,11 +88,17 @@ class RealtimeController extends ChangeNotifier { // Give the socket a moment to stabilise. await Future.delayed(const Duration(seconds: 1)); - // Exit early; we don't have a reliable sync API for connection - // state across all platforms, so treat this as a best-effort - // resurrection. + // Success (best-effort). Reset attempt counter and clear failure. + attempts = 0; + isFailed = false; + lastError = null; break; - } catch (_) { + } catch (e) { + lastError = e.toString(); + if (attempts >= maxAttempts) { + isFailed = true; + break; + } await Future.delayed(Duration(seconds: delaySeconds)); delaySeconds = delaySeconds * 2; } @@ -99,6 +111,16 @@ class RealtimeController extends ChangeNotifier { } } + /// Retry a failed recovery attempt. + Future retry() async { + if (_disposed) return; + attempts = 0; + isFailed = false; + lastError = null; + notifyListeners(); + await recoverConnection(); + } + @override void dispose() { _disposed = true; diff --git a/lib/screens/dashboard/dashboard_screen.dart b/lib/screens/dashboard/dashboard_screen.dart index fc91f5f3..ae723ebf 100644 --- a/lib/screens/dashboard/dashboard_screen.dart +++ b/lib/screens/dashboard/dashboard_screen.dart @@ -10,6 +10,8 @@ import '../../providers/profile_provider.dart'; import '../../providers/tasks_provider.dart'; import '../../providers/tickets_provider.dart'; import '../../widgets/responsive_body.dart'; +import '../../providers/realtime_controller.dart'; +import 'package:skeletonizer/skeletonizer.dart'; import '../../theme/app_surfaces.dart'; import '../../widgets/mono_text.dart'; import '../../widgets/status_pill.dart'; @@ -294,97 +296,150 @@ class DashboardScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return ResponsiveBody( - child: LayoutBuilder( - builder: (context, constraints) { - final sections = [ - const SizedBox(height: 16), - _sectionTitle(context, 'IT Staff Pulse'), - const _StaffTable(), - const SizedBox(height: 20), - _sectionTitle(context, 'Core Daily KPIs'), - _cardGrid(context, [ - _MetricCard( - title: 'New tickets today', - valueBuilder: (metrics) => metrics.newTicketsToday.toString(), - ), - _MetricCard( - title: 'Closed today', - valueBuilder: (metrics) => metrics.closedToday.toString(), - ), - _MetricCard( - title: 'Open tickets', - valueBuilder: (metrics) => metrics.openTickets.toString(), - ), - ]), - const SizedBox(height: 20), - _sectionTitle(context, 'Task Flow'), - _cardGrid(context, [ - _MetricCard( - title: 'Tasks created', - valueBuilder: (metrics) => metrics.tasksCreatedToday.toString(), - ), - _MetricCard( - title: 'Tasks completed', - valueBuilder: (metrics) => - metrics.tasksCompletedToday.toString(), - ), - _MetricCard( - title: 'Open tasks', - valueBuilder: (metrics) => metrics.openTasks.toString(), - ), - ]), - const SizedBox(height: 20), - _sectionTitle(context, 'TAT / Response'), - _cardGrid(context, [ - _MetricCard( - title: 'Avg response', - valueBuilder: (metrics) => _formatDuration(metrics.avgResponse), - ), - _MetricCard( - title: 'Avg triage', - valueBuilder: (metrics) => _formatDuration(metrics.avgTriage), - ), - _MetricCard( - title: 'Longest response', - valueBuilder: (metrics) => - _formatDuration(metrics.longestResponse), - ), - ]), - ]; + final realtime = ProviderScope.containerOf( + context, + ).read(realtimeControllerProvider); - final content = Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(top: 16, bottom: 8), - child: Align( - alignment: Alignment.center, - child: Text( - 'Dashboard', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, + return ResponsiveBody( + child: Skeletonizer( + enabled: realtime.isConnecting, + child: LayoutBuilder( + builder: (context, constraints) { + final sections = [ + const SizedBox(height: 16), + _sectionTitle(context, 'IT Staff Pulse'), + const _StaffTable(), + const SizedBox(height: 20), + _sectionTitle(context, 'Core Daily KPIs'), + _cardGrid(context, [ + _MetricCard( + title: 'New tickets today', + valueBuilder: (metrics) => metrics.newTicketsToday.toString(), + ), + _MetricCard( + title: 'Closed today', + valueBuilder: (metrics) => metrics.closedToday.toString(), + ), + _MetricCard( + title: 'Open tickets', + valueBuilder: (metrics) => metrics.openTickets.toString(), + ), + ]), + const SizedBox(height: 20), + _sectionTitle(context, 'Task Flow'), + _cardGrid(context, [ + _MetricCard( + title: 'Tasks created', + valueBuilder: (metrics) => + metrics.tasksCreatedToday.toString(), + ), + _MetricCard( + title: 'Tasks completed', + valueBuilder: (metrics) => + metrics.tasksCompletedToday.toString(), + ), + _MetricCard( + title: 'Open tasks', + valueBuilder: (metrics) => metrics.openTasks.toString(), + ), + ]), + const SizedBox(height: 20), + _sectionTitle(context, 'TAT / Response'), + _cardGrid(context, [ + _MetricCard( + title: 'Avg response', + valueBuilder: (metrics) => + _formatDuration(metrics.avgResponse), + ), + _MetricCard( + title: 'Avg triage', + valueBuilder: (metrics) => _formatDuration(metrics.avgTriage), + ), + _MetricCard( + title: 'Longest response', + valueBuilder: (metrics) => + _formatDuration(metrics.longestResponse), + ), + ]), + ]; + + final content = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 16, bottom: 8), + child: Align( + alignment: Alignment.center, + child: Text( + 'Dashboard', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), ), ), ), - ), - const _DashboardStatusBanner(), - ...sections, - ], - ); + const _DashboardStatusBanner(), + ...sections, + ], + ); - return SingleChildScrollView( - padding: const EdgeInsets.only(bottom: 24), - child: Center( - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: content, - ), - ), - ); - }, + return Stack( + children: [ + SingleChildScrollView( + padding: const EdgeInsets.only(bottom: 24), + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: content, + ), + ), + ), + if (realtime.isConnecting) + Positioned.fill( + child: AbsorbPointer( + absorbing: true, + child: Container( + color: Theme.of( + context, + ).colorScheme.surface.withAlpha((0.35 * 255).round()), + alignment: Alignment.topCenter, + padding: const EdgeInsets.only(top: 36), + child: SizedBox( + width: 280, + child: Card( + elevation: 4, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ), + SizedBox(width: 12), + Expanded( + child: Text('Reconnecting realtime…'), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ], + ); + }, + ), ), ); } diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index 87189706..db089a8a 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -22,6 +22,8 @@ import '../../providers/profile_provider.dart'; import '../../providers/tasks_provider.dart'; import '../../providers/tickets_provider.dart'; import '../../providers/typing_provider.dart'; +import '../../providers/realtime_controller.dart'; +import 'package:skeletonizer/skeletonizer.dart'; import '../../utils/app_time.dart'; import '../../utils/snackbar.dart'; import '../../widgets/app_breakpoints.dart'; @@ -235,398 +237,326 @@ class _TaskDetailScreenState extends ConsumerState WidgetsBinding.instance.addPostFrameCallback((_) => _updateSaveAnim()); - return ResponsiveBody( - child: LayoutBuilder( - builder: (context, constraints) { - final isWide = constraints.maxWidth >= AppBreakpoints.desktop; + final realtime = ref.watch(realtimeControllerProvider); + final isRetrieving = + realtime.isConnecting || + tasksAsync.isLoading || + ticketsAsync.isLoading || + officesAsync.isLoading || + profileAsync.isLoading || + assignmentsAsync.isLoading || + taskMessagesAsync.isLoading; - // Seed controllers once per task to reflect persisted values - if (_seededTaskId != task.id) { - _seededTaskId = task.id; - _requestedController.text = task.requestedBy ?? ''; - _notedController.text = task.notedBy ?? ''; - _receivedController.text = task.receivedBy ?? ''; - _requestedSaved = _requestedController.text.isNotEmpty; - _notedSaved = _notedController.text.isNotEmpty; - _receivedSaved = _receivedController.text.isNotEmpty; + return Skeletonizer( + enabled: isRetrieving, + child: ResponsiveBody( + child: LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth >= AppBreakpoints.desktop; - // Seed action taken plain text controller from persisted JSON or raw text - try { - _actionDebounce?.cancel(); - _actionController?.dispose(); - if (task.actionTaken != null && task.actionTaken!.isNotEmpty) { - try { - final docJson = - jsonDecode(task.actionTaken!) as List; - final doc = quill.Document.fromJson(docJson); - _actionController = quill.QuillController( - document: doc, - selection: const TextSelection.collapsed(offset: 0), - ); - } catch (_) { + // Seed controllers once per task to reflect persisted values + if (_seededTaskId != task.id) { + _seededTaskId = task.id; + _requestedController.text = task.requestedBy ?? ''; + _notedController.text = task.notedBy ?? ''; + _receivedController.text = task.receivedBy ?? ''; + _requestedSaved = _requestedController.text.isNotEmpty; + _notedSaved = _notedController.text.isNotEmpty; + _receivedSaved = _receivedController.text.isNotEmpty; + + // Seed action taken plain text controller from persisted JSON or raw text + try { + _actionDebounce?.cancel(); + _actionController?.dispose(); + if (task.actionTaken != null && task.actionTaken!.isNotEmpty) { + try { + final docJson = + jsonDecode(task.actionTaken!) as List; + final doc = quill.Document.fromJson(docJson); + _actionController = quill.QuillController( + document: doc, + selection: const TextSelection.collapsed(offset: 0), + ); + } catch (_) { + _actionController = quill.QuillController.basic(); + } + } else { _actionController = quill.QuillController.basic(); } - } else { + } catch (_) { _actionController = quill.QuillController.basic(); } - } catch (_) { - _actionController = quill.QuillController.basic(); + + // Attach auto-save listener for action taken (debounced) + _actionController?.addListener(() { + _actionDebounce?.cancel(); + _actionDebounce = Timer( + const Duration(milliseconds: 700), + () async { + final plain = + _actionController?.document.toPlainText().trim() ?? ''; + setState(() { + _actionSaving = true; + _actionSaved = false; + }); + try { + final deltaJson = jsonEncode( + _actionController?.document.toDelta().toJson(), + ); + await ref + .read(tasksControllerProvider) + .updateTask(taskId: task.id, actionTaken: deltaJson); + setState(() { + _actionSaved = plain.isNotEmpty; + }); + } catch (_) { + // ignore + } finally { + setState(() { + _actionSaving = false; + }); + if (_actionSaved) { + Future.delayed(const Duration(seconds: 2), () { + if (mounted) { + setState(() => _actionSaved = false); + } + }); + } + } + }, + ); + }); } - // Attach auto-save listener for action taken (debounced) - _actionController?.addListener(() { - _actionDebounce?.cancel(); - _actionDebounce = Timer( - const Duration(milliseconds: 700), - () async { - final plain = - _actionController?.document.toPlainText().trim() ?? ''; - setState(() { - _actionSaving = true; - _actionSaved = false; - }); - try { - final deltaJson = jsonEncode( - _actionController?.document.toDelta().toJson(), - ); - await ref - .read(tasksControllerProvider) - .updateTask(taskId: task.id, actionTaken: deltaJson); - setState(() { - _actionSaved = plain.isNotEmpty; - }); - } catch (_) { - // ignore - } finally { - setState(() { - _actionSaving = false; - }); - if (_actionSaved) { - Future.delayed(const Duration(seconds: 2), () { - if (mounted) { - setState(() => _actionSaved = false); - } - }); - } - } - }, - ); - }); - } - - final detailsContent = Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - task.title.isNotEmpty - ? task.title - : 'Task ${task.taskNumber ?? task.id}', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(width: 8), - Builder( - builder: (ctx) { - final profile = profileAsync.maybeWhen( - data: (p) => p, - orElse: () => null, - ); - final canEdit = - profile != null && - (profile.role == 'admin' || - profile.role == 'dispatcher' || - profile.role == 'it_staff' || - profile.id == task.creatorId); - if (!canEdit) return const SizedBox.shrink(); - return IconButton( - tooltip: 'Edit task', - onPressed: () => _showEditTaskDialog(ctx, ref, task), - icon: const Icon(Icons.edit), - ); - }, - ), - ], - ), - ), - const SizedBox(height: 6), - Align( - alignment: Alignment.center, - child: Text( - _createdByLabel(profilesAsync, task, ticket), - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.labelMedium, - ), - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: Wrap( - spacing: 12, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - _buildStatusChip(context, task, canUpdateStatus), - _MetaBadge(label: 'Office', value: officeName), - _MetaBadge( - label: 'Task #', - value: task.taskNumber ?? task.id, - isMono: true, + final detailsContent = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + task.title.isNotEmpty + ? task.title + : 'Task ${task.taskNumber ?? task.id}', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, ), - ], - ), - ), - IconButton( - tooltip: 'Preview/print task', - onPressed: () async { - try { - final logsAsync = ref.read( - taskActivityLogsProvider(task.id), - ); - final logs = - logsAsync.valueOrNull ?? []; - final assignmentList = assignments; - final profilesList = - profilesAsync.valueOrNull ?? []; - - // Read the services stream; if the office is linked to a service - // but the stream hasn't yielded yet, fetch once and await it - final servicesAsync = ref.read(servicesProvider); - final servicesById = { - for (final s in servicesAsync.valueOrNull ?? []) - s.id: s, - }; - - final officeServiceId = officeId == null - ? null - : officeById[officeId]?.serviceId; - - if (officeServiceId != null && - (servicesAsync.valueOrNull == null || - (servicesAsync.valueOrNull?.isEmpty ?? true))) { - final servicesOnce = await ref.read( - servicesOnceProvider.future, + ), + const SizedBox(width: 8), + Builder( + builder: (ctx) { + final profile = profileAsync.maybeWhen( + data: (p) => p, + orElse: () => null, ); - for (final s in servicesOnce) { - servicesById[s.id] = s; - } - } - - final serviceName = officeServiceId == null - ? '' - : (servicesById[officeServiceId]?.name ?? ''); - - await showTaskPdfPreview( - context, - task, - ticket, - officeName, - serviceName, - logs, - assignmentList, - profilesList, - ); - } catch (_) {} - }, - icon: const Icon(Icons.print), + final canEdit = + profile != null && + (profile.role == 'admin' || + profile.role == 'dispatcher' || + profile.role == 'it_staff' || + profile.id == task.creatorId); + if (!canEdit) return const SizedBox.shrink(); + return IconButton( + tooltip: 'Edit task', + onPressed: () => + _showEditTaskDialog(ctx, ref, task), + icon: const Icon(Icons.edit), + ); + }, + ), + ], ), - ], - ), - if (description.isNotEmpty) ...[ - const SizedBox(height: 12), - Text(description), - ], - - // warning banner for completed tasks with missing metadata - if (task.status == 'completed' && task.hasIncompleteDetails) ...[ + ), + const SizedBox(height: 6), + Align( + alignment: Alignment.center, + child: Text( + _createdByLabel(profilesAsync, task, ticket), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelMedium, + ), + ), const SizedBox(height: 12), Row( children: [ - const Icon( - Icons.warning_amber_rounded, - color: Colors.orange, - ), - const SizedBox(width: 6), Expanded( - child: Text( - 'Task completed but some details are still empty.', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + child: Wrap( + spacing: 12, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _buildStatusChip(context, task, canUpdateStatus), + _MetaBadge(label: 'Office', value: officeName), + _MetaBadge( + label: 'Task #', + value: task.taskNumber ?? task.id, + isMono: true, + ), + ], ), ), + IconButton( + tooltip: 'Preview/print task', + onPressed: () async { + try { + final logsAsync = ref.read( + taskActivityLogsProvider(task.id), + ); + final logs = + logsAsync.valueOrNull ?? []; + final assignmentList = assignments; + final profilesList = + profilesAsync.valueOrNull ?? []; + + // Read the services stream; if the office is linked to a service + // but the stream hasn't yielded yet, fetch once and await it + final servicesAsync = ref.read(servicesProvider); + final servicesById = { + for (final s in servicesAsync.valueOrNull ?? []) + s.id: s, + }; + + final officeServiceId = officeId == null + ? null + : officeById[officeId]?.serviceId; + + if (officeServiceId != null && + (servicesAsync.valueOrNull == null || + (servicesAsync.valueOrNull?.isEmpty ?? + true))) { + final servicesOnce = await ref.read( + servicesOnceProvider.future, + ); + for (final s in servicesOnce) { + servicesById[s.id] = s; + } + } + + final serviceName = officeServiceId == null + ? '' + : (servicesById[officeServiceId]?.name ?? ''); + + await showTaskPdfPreview( + context, + task, + ticket, + officeName, + serviceName, + logs, + assignmentList, + profilesList, + ); + } catch (_) {} + }, + icon: const Icon(Icons.print), + ), ], ), - ], - const SizedBox(height: 16), - // Collapsible tabbed details: Assignees / Type & Category / Signatories - ExpansionTile( - title: const Text('Details'), - initiallyExpanded: isWide, - childrenPadding: const EdgeInsets.symmetric(horizontal: 0), - children: [ - DefaultTabController( - length: 4, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TabBar( - labelColor: Theme.of(context).colorScheme.onSurface, - indicatorColor: Theme.of(context).colorScheme.primary, - tabs: const [ - Tab(text: 'Assignees'), - Tab(text: 'Type & Category'), - Tab(text: 'Signatories'), - Tab(text: 'Action taken'), - ], + if (description.isNotEmpty) ...[ + const SizedBox(height: 12), + Text(description), + ], + + // warning banner for completed tasks with missing metadata + if (task.status == 'completed' && + task.hasIncompleteDetails) ...[ + const SizedBox(height: 12), + Row( + children: [ + const Icon( + Icons.warning_amber_rounded, + color: Colors.orange, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Task completed but some details are still empty.', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), ), - const SizedBox(height: 8), - SizedBox( - height: isWide ? 360 : 300, - child: TabBarView( - children: [ - // Assignees - SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - TaskAssignmentSection( - taskId: task.id, - canAssign: showAssign, - ), - const SizedBox(height: 12), - const SizedBox.shrink(), - ], + ), + ], + ), + ], + const SizedBox(height: 16), + // Collapsible tabbed details: Assignees / Type & Category / Signatories + ExpansionTile( + title: const Text('Details'), + initiallyExpanded: isWide, + childrenPadding: const EdgeInsets.symmetric(horizontal: 0), + children: [ + DefaultTabController( + length: 4, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TabBar( + labelColor: Theme.of(context).colorScheme.onSurface, + indicatorColor: Theme.of( + context, + ).colorScheme.primary, + tabs: const [ + Tab(text: 'Assignees'), + Tab(text: 'Type & Category'), + Tab(text: 'Signatories'), + Tab(text: 'Action taken'), + ], + ), + const SizedBox(height: 8), + SizedBox( + height: isWide ? 360 : 300, + child: TabBarView( + children: [ + // Assignees + SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + TaskAssignmentSection( + taskId: task.id, + canAssign: showAssign, + ), + const SizedBox(height: 12), + const SizedBox.shrink(), + ], + ), ), ), - ), - // Type & Category - SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - if (!canUpdateStatus) ...[ - _MetaBadge( - label: 'Type', - value: task.requestType ?? 'None', - ), - const SizedBox(height: 8), - _MetaBadge( - label: 'Category', - value: task.requestCategory ?? 'None', - ), - ] else ...[ - const Text('Type'), - const SizedBox(height: 6), - DropdownButtonFormField( - initialValue: task.requestType, - decoration: InputDecoration( - suffixIcon: _typeSaving - ? SizedBox( - width: 16, - height: 16, - child: ScaleTransition( - scale: _savePulse, - child: const Icon( - Icons.save, - size: 14, - ), - ), - ) - : _typeSaved - ? SizedBox( - width: 16, - height: 16, - child: Stack( - alignment: - Alignment.center, - children: const [ - Icon( - Icons.save, - size: 14, - color: Colors.green, - ), - Positioned( - right: -2, - bottom: -2, - child: Icon( - Icons.check, - size: 10, - color: Colors.white, - ), - ), - ], - ), - ) - : null, + // Type & Category + SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + if (!canUpdateStatus) ...[ + _MetaBadge( + label: 'Type', + value: task.requestType ?? 'None', ), - items: [ - const DropdownMenuItem( - value: null, - child: Text('None'), - ), - for (final t in requestTypeOptions) - DropdownMenuItem( - value: t, - child: Text(t), - ), - ], - onChanged: (v) async { - setState(() { - _typeSaving = true; - _typeSaved = false; - }); - try { - await ref - .read(tasksControllerProvider) - .updateTask( - taskId: task.id, - requestType: v, - ); - setState( - () => _typeSaved = - v != null && v.isNotEmpty, - ); - } catch (_) { - } finally { - setState( - () => _typeSaving = false, - ); - if (_typeSaved) { - Future.delayed( - const Duration(seconds: 2), - () { - if (mounted) { - setState( - () => - _typeSaved = false, - ); - } - }, - ); - } - } - }, - ), - if (task.requestType == 'Other') ...[ const SizedBox(height: 8), - TextFormField( - initialValue: task.requestTypeOther, + _MetaBadge( + label: 'Category', + value: + task.requestCategory ?? 'None', + ), + ] else ...[ + const Text('Type'), + const SizedBox(height: 6), + DropdownButtonFormField( + initialValue: task.requestType, decoration: InputDecoration( - hintText: 'Details', suffixIcon: _typeSaving ? SizedBox( width: 16, @@ -667,7 +597,19 @@ class _TaskDetailScreenState extends ConsumerState ) : null, ), - onChanged: (text) async { + items: [ + const DropdownMenuItem( + value: null, + child: Text('None'), + ), + for (final t + in requestTypeOptions) + DropdownMenuItem( + value: t, + child: Text(t), + ), + ], + onChanged: (v) async { setState(() { _typeSaving = true; _typeSaved = false; @@ -679,14 +621,11 @@ class _TaskDetailScreenState extends ConsumerState ) .updateTask( taskId: task.id, - requestTypeOther: - text.isEmpty - ? null - : text, + requestType: v, ); setState( () => _typeSaved = - text.isNotEmpty, + v != null && v.isNotEmpty, ); } catch (_) { } finally { @@ -709,92 +648,420 @@ class _TaskDetailScreenState extends ConsumerState } }, ), - ], - const SizedBox(height: 8), - const Text('Category'), - const SizedBox(height: 6), - DropdownButtonFormField( - initialValue: task.requestCategory, - decoration: InputDecoration( - suffixIcon: _categorySaving - ? SizedBox( - width: 16, - height: 16, - child: ScaleTransition( - scale: _savePulse, - child: const Icon( - Icons.save, - size: 14, - ), - ), - ) - : _categorySaved - ? SizedBox( - width: 16, - height: 16, - child: Stack( - alignment: - Alignment.center, - children: const [ - Icon( - Icons.save, - size: 14, - color: Colors.green, - ), - Positioned( - right: -2, - bottom: -2, - child: Icon( - Icons.check, - size: 10, - color: Colors.white, + if (task.requestType == 'Other') ...[ + const SizedBox(height: 8), + TextFormField( + initialValue: + task.requestTypeOther, + decoration: InputDecoration( + hintText: 'Details', + suffixIcon: _typeSaving + ? SizedBox( + width: 16, + height: 16, + child: ScaleTransition( + scale: _savePulse, + child: const Icon( + Icons.save, + size: 14, ), ), - ], - ), - ) - : null, - ), - items: [ - const DropdownMenuItem( - value: null, - child: Text('None'), - ), - for (final c - in requestCategoryOptions) - DropdownMenuItem( - value: c, - child: Text(c), + ) + : _typeSaved + ? SizedBox( + width: 16, + height: 16, + child: Stack( + alignment: + Alignment.center, + children: const [ + Icon( + Icons.save, + size: 14, + color: + Colors.green, + ), + Positioned( + right: -2, + bottom: -2, + child: Icon( + Icons.check, + size: 10, + color: Colors + .white, + ), + ), + ], + ), + ) + : null, ), + onChanged: (text) async { + setState(() { + _typeSaving = true; + _typeSaved = false; + }); + try { + await ref + .read( + tasksControllerProvider, + ) + .updateTask( + taskId: task.id, + requestTypeOther: + text.isEmpty + ? null + : text, + ); + setState( + () => _typeSaved = + text.isNotEmpty, + ); + } catch (_) { + } finally { + setState( + () => _typeSaving = false, + ); + if (_typeSaved) { + Future.delayed( + const Duration( + seconds: 2, + ), + () { + if (mounted) { + setState( + () => _typeSaved = + false, + ); + } + }, + ); + } + } + }, + ), ], - onChanged: (v) async { + const SizedBox(height: 8), + const Text('Category'), + const SizedBox(height: 6), + DropdownButtonFormField( + initialValue: task.requestCategory, + decoration: InputDecoration( + suffixIcon: _categorySaving + ? SizedBox( + width: 16, + height: 16, + child: ScaleTransition( + scale: _savePulse, + child: const Icon( + Icons.save, + size: 14, + ), + ), + ) + : _categorySaved + ? SizedBox( + width: 16, + height: 16, + child: Stack( + alignment: + Alignment.center, + children: const [ + Icon( + Icons.save, + size: 14, + color: Colors.green, + ), + Positioned( + right: -2, + bottom: -2, + child: Icon( + Icons.check, + size: 10, + color: + Colors.white, + ), + ), + ], + ), + ) + : null, + ), + items: [ + const DropdownMenuItem( + value: null, + child: Text('None'), + ), + for (final c + in requestCategoryOptions) + DropdownMenuItem( + value: c, + child: Text(c), + ), + ], + onChanged: (v) async { + setState(() { + _categorySaving = true; + _categorySaved = false; + }); + try { + await ref + .read( + tasksControllerProvider, + ) + .updateTask( + taskId: task.id, + requestCategory: v, + ); + setState( + () => _categorySaved = + v != null && v.isNotEmpty, + ); + } catch (_) { + } finally { + setState( + () => _categorySaving = false, + ); + if (_categorySaved) { + Future.delayed( + const Duration(seconds: 2), + () { + if (mounted) { + setState( + () => _categorySaved = + false, + ); + } + }, + ); + } + } + }, + ), + ], + const SizedBox(height: 12), + ], + ), + ), + ), + + // Signatories (editable) + SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + 'Requested by', + style: Theme.of( + context, + ).textTheme.bodySmall, + ), + const SizedBox(height: 6), + TypeAheadFormField( + textFieldConfiguration: TextFieldConfiguration( + controller: _requestedController, + decoration: InputDecoration( + hintText: 'Requester name or id', + suffixIcon: _requestedSaving + ? SizedBox( + width: 16, + height: 16, + child: ScaleTransition( + scale: _savePulse, + child: const Icon( + Icons.save, + size: 14, + ), + ), + ) + : _requestedSaved + ? SizedBox( + width: 16, + height: 16, + child: Stack( + alignment: + Alignment.center, + children: const [ + Icon( + Icons.save, + size: 14, + color: Colors.green, + ), + Positioned( + right: -2, + bottom: -2, + child: Icon( + Icons.check, + size: 10, + color: + Colors.white, + ), + ), + ], + ), + ) + : null, + ), + onChanged: (v) { + _requestedDebounce?.cancel(); + _requestedDebounce = Timer( + const Duration( + milliseconds: 700, + ), + () async { + final name = v.trim(); + setState(() { + _requestedSaving = true; + _requestedSaved = false; + }); + try { + await ref + .read( + tasksControllerProvider, + ) + .updateTask( + taskId: task.id, + requestedBy: + name.isEmpty + ? null + : name, + ); + if (name.isNotEmpty) { + try { + await ref + .read( + supabaseClientProvider, + ) + .from('clients') + .upsert({ + 'name': name, + }); + } catch (_) {} + } + setState(() { + _requestedSaved = + name.isNotEmpty; + }); + } catch (_) { + } finally { + setState(() { + _requestedSaving = false; + }); + if (_requestedSaved) { + Future.delayed( + const Duration( + seconds: 2, + ), + () { + if (mounted) { + setState( + () => + _requestedSaved = + false, + ); + } + }, + ); + } + } + }, + ); + }, + ), + suggestionsCallback: (pattern) async { + final profiles = + ref + .watch(profilesProvider) + .valueOrNull ?? + []; + final fromProfiles = profiles + .map( + (p) => p.fullName.isEmpty + ? p.id + : p.fullName, + ) + .where( + (n) => + n.toLowerCase().contains( + pattern.toLowerCase(), + ), + ) + .toList(); + try { + final clientRows = await ref + .read(supabaseClientProvider) + .from('clients') + .select('name') + .ilike('name', '%$pattern%'); + final clientNames = + (clientRows as List?) + ?.map( + (r) => + r['name'] as String, + ) + .whereType() + .toList() ?? + []; + final merged = { + ...fromProfiles, + ...clientNames, + }.toList(); + return merged; + } catch (_) { + return fromProfiles; + } + }, + itemBuilder: (context, suggestion) => + ListTile(title: Text(suggestion)), + onSuggestionSelected: (suggestion) async { + _requestedDebounce?.cancel(); + _requestedController.text = + suggestion; setState(() { - _categorySaving = true; - _categorySaved = false; + _requestedSaving = true; + _requestedSaved = false; }); try { await ref .read(tasksControllerProvider) .updateTask( taskId: task.id, - requestCategory: v, + requestedBy: + suggestion.isEmpty + ? null + : suggestion, ); + if (suggestion.isNotEmpty) { + try { + await ref + .read( + supabaseClientProvider, + ) + .from('clients') + .upsert({ + 'name': suggestion, + }); + } catch (_) {} + } setState( - () => _categorySaved = - v != null && v.isNotEmpty, + () => _requestedSaved = + suggestion.isNotEmpty, ); } catch (_) { } finally { setState( - () => _categorySaving = false, + () => _requestedSaving = false, ); - if (_categorySaved) { + if (_requestedSaved) { Future.delayed( const Duration(seconds: 2), () { if (mounted) { setState( - () => _categorySaved = + () => _requestedSaved = false, ); } @@ -804,299 +1071,176 @@ class _TaskDetailScreenState extends ConsumerState } }, ), - ], - const SizedBox(height: 12), - ], - ), - ), - ), - // Signatories (editable) - SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - 'Requested by', - style: Theme.of( - context, - ).textTheme.bodySmall, - ), - const SizedBox(height: 6), - TypeAheadFormField( - textFieldConfiguration: TextFieldConfiguration( - controller: _requestedController, - decoration: InputDecoration( - hintText: 'Requester name or id', - suffixIcon: _requestedSaving - ? SizedBox( - width: 16, - height: 16, - child: ScaleTransition( - scale: _savePulse, - child: const Icon( - Icons.save, - size: 14, - ), - ), - ) - : _requestedSaved - ? SizedBox( - width: 16, - height: 16, - child: Stack( - alignment: - Alignment.center, - children: const [ - Icon( + const SizedBox(height: 12), + Text( + 'Noted by (Supervisor/Senior)', + style: Theme.of( + context, + ).textTheme.bodySmall, + ), + const SizedBox(height: 6), + TypeAheadFormField( + textFieldConfiguration: TextFieldConfiguration( + controller: _notedController, + decoration: InputDecoration( + hintText: 'Supervisor/Senior', + suffixIcon: _notedSaving + ? SizedBox( + width: 16, + height: 16, + child: ScaleTransition( + scale: _savePulse, + child: const Icon( Icons.save, size: 14, - color: Colors.green, ), - Positioned( - right: -2, - bottom: -2, - child: Icon( - Icons.check, - size: 10, - color: Colors.white, - ), - ), - ], - ), - ) - : null, - ), - onChanged: (v) { - _requestedDebounce?.cancel(); - _requestedDebounce = Timer( - const Duration(milliseconds: 700), - () async { - final name = v.trim(); - setState(() { - _requestedSaving = true; - _requestedSaved = false; - }); - try { - await ref - .read( - tasksControllerProvider, - ) - .updateTask( - taskId: task.id, - requestedBy: - name.isEmpty - ? null - : name, - ); - if (name.isNotEmpty) { - try { - await ref - .read( - supabaseClientProvider, - ) - .from('clients') - .upsert({ - 'name': name, - }); - } catch (_) {} - } - setState(() { - _requestedSaved = - name.isNotEmpty; - }); - } catch (_) { - } finally { - setState(() { - _requestedSaving = false; - }); - if (_requestedSaved) { - Future.delayed( - const Duration( - seconds: 2, ), - () { - if (mounted) { - setState( - () => - _requestedSaved = - false, - ); - } - }, - ); - } - } - }, - ); - }, - ), - suggestionsCallback: (pattern) async { - final profiles = - ref - .watch(profilesProvider) - .valueOrNull ?? - []; - final fromProfiles = profiles - .map( - (p) => p.fullName.isEmpty - ? p.id - : p.fullName, - ) - .where( - (n) => n.toLowerCase().contains( - pattern.toLowerCase(), + ) + : _notedSaved + ? SizedBox( + width: 16, + height: 16, + child: Stack( + alignment: + Alignment.center, + children: const [ + Icon( + Icons.save, + size: 14, + color: Colors.green, + ), + Positioned( + right: -2, + bottom: -2, + child: Icon( + Icons.check, + size: 10, + color: + Colors.white, + ), + ), + ], + ), + ) + : null, + ), + onChanged: (v) { + _notedDebounce?.cancel(); + _notedDebounce = Timer( + const Duration( + milliseconds: 700, ), - ) - .toList(); - try { - final clientRows = await ref - .read(supabaseClientProvider) - .from('clients') - .select('name') - .ilike('name', '%$pattern%'); - final clientNames = - (clientRows as List?) - ?.map( - (r) => - r['name'] as String, - ) - .whereType() - .toList() ?? - []; - final merged = { - ...fromProfiles, - ...clientNames, - }.toList(); - return merged; - } catch (_) { - return fromProfiles; - } - }, - itemBuilder: (context, suggestion) => - ListTile(title: Text(suggestion)), - onSuggestionSelected: - (suggestion) async { - _requestedDebounce?.cancel(); - _requestedController.text = - suggestion; - setState(() { - _requestedSaving = true; - _requestedSaved = false; - }); - try { - await ref - .read( - tasksControllerProvider, - ) - .updateTask( - taskId: task.id, - requestedBy: - suggestion.isEmpty - ? null - : suggestion, - ); - if (suggestion.isNotEmpty) { + () async { + final name = v.trim(); + setState(() { + _notedSaving = true; + _notedSaved = false; + }); try { await ref .read( - supabaseClientProvider, + tasksControllerProvider, ) - .from('clients') - .upsert({ - 'name': suggestion, - }); - } catch (_) {} - } - setState( - () => _requestedSaved = - suggestion.isNotEmpty, - ); - } catch (_) { - } finally { - setState( - () => - _requestedSaving = false, - ); - if (_requestedSaved) { - Future.delayed( - const Duration(seconds: 2), - () { - if (mounted) { - setState( - () => - _requestedSaved = - false, + .updateTask( + taskId: task.id, + notedBy: name.isEmpty + ? null + : name, ); - } - }, - ); - } - } + if (name.isNotEmpty) { + try { + await ref + .read( + supabaseClientProvider, + ) + .from('clients') + .upsert({ + 'name': name, + }); + } catch (_) {} + } + setState(() { + _notedSaved = + name.isNotEmpty; + }); + } catch (_) { + // ignore + } finally { + setState(() { + _notedSaving = false; + }); + if (_notedSaved) { + Future.delayed( + const Duration( + seconds: 2, + ), + () { + if (mounted) { + setState( + () => + _notedSaved = + false, + ); + } + }, + ); + } + } + }, + ); }, - ), - - const SizedBox(height: 12), - Text( - 'Noted by (Supervisor/Senior)', - style: Theme.of( - context, - ).textTheme.bodySmall, - ), - const SizedBox(height: 6), - TypeAheadFormField( - textFieldConfiguration: TextFieldConfiguration( - controller: _notedController, - decoration: InputDecoration( - hintText: 'Supervisor/Senior', - suffixIcon: _notedSaving - ? SizedBox( - width: 16, - height: 16, - child: ScaleTransition( - scale: _savePulse, - child: const Icon( - Icons.save, - size: 14, - ), - ), - ) - : _notedSaved - ? SizedBox( - width: 16, - height: 16, - child: Stack( - alignment: - Alignment.center, - children: const [ - Icon( - Icons.save, - size: 14, - color: Colors.green, - ), - Positioned( - right: -2, - bottom: -2, - child: Icon( - Icons.check, - size: 10, - color: Colors.white, - ), - ), - ], - ), - ) - : null, ), - onChanged: (v) { - _notedDebounce?.cancel(); - _notedDebounce = Timer( - const Duration(milliseconds: 700), - () async { - final name = v.trim(); + suggestionsCallback: (pattern) async { + final profiles = + ref + .watch(profilesProvider) + .valueOrNull ?? + []; + final fromProfiles = profiles + .map( + (p) => p.fullName.isEmpty + ? p.id + : p.fullName, + ) + .where( + (n) => + n.toLowerCase().contains( + pattern.toLowerCase(), + ), + ) + .toList(); + try { + final clientRows = await ref + .read(supabaseClientProvider) + .from('clients') + .select('name') + .ilike('name', '%$pattern%'); + final clientNames = + (clientRows as List?) + ?.map( + (r) => + r['name'] as String, + ) + .whereType() + .toList() ?? + []; + final merged = { + ...fromProfiles, + ...clientNames, + }.toList(); + return merged; + } catch (_) { + return fromProfiles; + } + }, + itemBuilder: (context, suggestion) => + ListTile(title: Text(suggestion)), + onSuggestionSelected: + (suggestion) async { + _notedDebounce?.cancel(); + _notedController.text = + suggestion; setState(() { _notedSaving = true; _notedSaved = false; @@ -1108,11 +1252,12 @@ class _TaskDetailScreenState extends ConsumerState ) .updateTask( taskId: task.id, - notedBy: name.isEmpty + notedBy: + suggestion.isEmpty ? null - : name, + : suggestion, ); - if (name.isNotEmpty) { + if (suggestion.isNotEmpty) { try { await ref .read( @@ -1120,20 +1265,19 @@ class _TaskDetailScreenState extends ConsumerState ) .from('clients') .upsert({ - 'name': name, + 'name': suggestion, }); } catch (_) {} } - setState(() { - _notedSaved = - name.isNotEmpty; - }); + setState( + () => _notedSaved = + suggestion.isNotEmpty, + ); } catch (_) { - // ignore } finally { - setState(() { - _notedSaving = false; - }); + setState( + () => _notedSaving = false, + ); if (_notedSaved) { Future.delayed( const Duration( @@ -1151,170 +1295,178 @@ class _TaskDetailScreenState extends ConsumerState } } }, - ); - }, ), - suggestionsCallback: (pattern) async { - final profiles = - ref - .watch(profilesProvider) - .valueOrNull ?? - []; - final fromProfiles = profiles - .map( - (p) => p.fullName.isEmpty - ? p.id - : p.fullName, - ) - .where( - (n) => n.toLowerCase().contains( - pattern.toLowerCase(), + + const SizedBox(height: 12), + Text( + 'Received by', + style: Theme.of( + context, + ).textTheme.bodySmall, + ), + const SizedBox(height: 6), + TypeAheadFormField( + textFieldConfiguration: TextFieldConfiguration( + controller: _receivedController, + decoration: InputDecoration( + hintText: 'Receiver name or id', + suffixIcon: _receivedSaving + ? SizedBox( + width: 16, + height: 16, + child: ScaleTransition( + scale: _savePulse, + child: const Icon( + Icons.save, + size: 14, + ), + ), + ) + : _receivedSaved + ? SizedBox( + width: 16, + height: 16, + child: Stack( + alignment: + Alignment.center, + children: const [ + Icon( + Icons.save, + size: 14, + color: Colors.green, + ), + Positioned( + right: -2, + bottom: -2, + child: Icon( + Icons.check, + size: 10, + color: + Colors.white, + ), + ), + ], + ), + ) + : null, + ), + onChanged: (v) { + _receivedDebounce?.cancel(); + _receivedDebounce = Timer( + const Duration( + milliseconds: 700, ), - ) - .toList(); - try { - final clientRows = await ref - .read(supabaseClientProvider) - .from('clients') - .select('name') - .ilike('name', '%$pattern%'); - final clientNames = - (clientRows as List?) - ?.map( - (r) => - r['name'] as String, - ) - .whereType() - .toList() ?? - []; - final merged = { - ...fromProfiles, - ...clientNames, - }.toList(); - return merged; - } catch (_) { - return fromProfiles; - } - }, - itemBuilder: (context, suggestion) => - ListTile(title: Text(suggestion)), - onSuggestionSelected: - (suggestion) async { - _notedDebounce?.cancel(); - _notedController.text = - suggestion; - setState(() { - _notedSaving = true; - _notedSaved = false; - }); - try { - await ref - .read( - tasksControllerProvider, - ) - .updateTask( - taskId: task.id, - notedBy: - suggestion.isEmpty - ? null - : suggestion, - ); - if (suggestion.isNotEmpty) { + () async { + final name = v.trim(); + setState(() { + _receivedSaving = true; + _receivedSaved = false; + }); try { await ref .read( - supabaseClientProvider, + tasksControllerProvider, ) - .from('clients') - .upsert({ - 'name': suggestion, - }); - } catch (_) {} - } - setState( - () => _notedSaved = - suggestion.isNotEmpty, - ); - } catch (_) { - } finally { - setState( - () => _notedSaving = false, - ); - if (_notedSaved) { - Future.delayed( - const Duration(seconds: 2), - () { - if (mounted) { - setState( - () => _notedSaved = - false, + .updateTask( + taskId: task.id, + receivedBy: + name.isEmpty + ? null + : name, ); - } - }, - ); - } - } + if (name.isNotEmpty) { + try { + await ref + .read( + supabaseClientProvider, + ) + .from('clients') + .upsert({ + 'name': name, + }); + } catch (_) {} + } + setState(() { + _receivedSaved = + name.isNotEmpty; + }); + } catch (_) { + // ignore + } finally { + setState(() { + _receivedSaving = false; + }); + if (_receivedSaved) { + Future.delayed( + const Duration( + seconds: 2, + ), + () { + if (mounted) { + setState( + () => + _receivedSaved = + false, + ); + } + }, + ); + } + } + }, + ); }, - ), - - const SizedBox(height: 12), - Text( - 'Received by', - style: Theme.of( - context, - ).textTheme.bodySmall, - ), - const SizedBox(height: 6), - TypeAheadFormField( - textFieldConfiguration: TextFieldConfiguration( - controller: _receivedController, - decoration: InputDecoration( - hintText: 'Receiver name or id', - suffixIcon: _receivedSaving - ? SizedBox( - width: 16, - height: 16, - child: ScaleTransition( - scale: _savePulse, - child: const Icon( - Icons.save, - size: 14, - ), - ), - ) - : _receivedSaved - ? SizedBox( - width: 16, - height: 16, - child: Stack( - alignment: - Alignment.center, - children: const [ - Icon( - Icons.save, - size: 14, - color: Colors.green, - ), - Positioned( - right: -2, - bottom: -2, - child: Icon( - Icons.check, - size: 10, - color: Colors.white, - ), - ), - ], - ), - ) - : null, ), - onChanged: (v) { - _receivedDebounce?.cancel(); - _receivedDebounce = Timer( - const Duration(milliseconds: 700), - () async { - final name = v.trim(); + suggestionsCallback: (pattern) async { + final profiles = + ref + .watch(profilesProvider) + .valueOrNull ?? + []; + final fromProfiles = profiles + .map( + (p) => p.fullName.isEmpty + ? p.id + : p.fullName, + ) + .where( + (n) => + n.toLowerCase().contains( + pattern.toLowerCase(), + ), + ) + .toList(); + try { + final clientRows = await ref + .read(supabaseClientProvider) + .from('clients') + .select('name') + .ilike('name', '%$pattern%'); + final clientNames = + (clientRows as List?) + ?.map( + (r) => + r['name'] as String, + ) + .whereType() + .toList() ?? + []; + final merged = { + ...fromProfiles, + ...clientNames, + }.toList(); + return merged; + } catch (_) { + return fromProfiles; + } + }, + itemBuilder: (context, suggestion) => + ListTile(title: Text(suggestion)), + onSuggestionSelected: + (suggestion) async { + _receivedDebounce?.cancel(); + _receivedController.text = + suggestion; setState(() { _receivedSaving = true; _receivedSaved = false; @@ -1326,11 +1478,12 @@ class _TaskDetailScreenState extends ConsumerState ) .updateTask( taskId: task.id, - receivedBy: name.isEmpty + receivedBy: + suggestion.isEmpty ? null - : name, + : suggestion, ); - if (name.isNotEmpty) { + if (suggestion.isNotEmpty) { try { await ref .read( @@ -1338,20 +1491,20 @@ class _TaskDetailScreenState extends ConsumerState ) .from('clients') .upsert({ - 'name': name, + 'name': suggestion, }); } catch (_) {} } - setState(() { - _receivedSaved = - name.isNotEmpty; - }); + setState( + () => _receivedSaved = + suggestion.isNotEmpty, + ); } catch (_) { - // ignore } finally { - setState(() { - _receivedSaving = false; - }); + setState( + () => + _receivedSaving = false, + ); if (_receivedSaved) { Future.delayed( const Duration( @@ -1370,726 +1523,685 @@ class _TaskDetailScreenState extends ConsumerState } } }, - ); - }, ), - suggestionsCallback: (pattern) async { - final profiles = - ref - .watch(profilesProvider) - .valueOrNull ?? - []; - final fromProfiles = profiles - .map( - (p) => p.fullName.isEmpty - ? p.id - : p.fullName, - ) - .where( - (n) => n.toLowerCase().contains( - pattern.toLowerCase(), - ), - ) - .toList(); - try { - final clientRows = await ref - .read(supabaseClientProvider) - .from('clients') - .select('name') - .ilike('name', '%$pattern%'); - final clientNames = - (clientRows as List?) - ?.map( - (r) => - r['name'] as String, - ) - .whereType() - .toList() ?? - []; - final merged = { - ...fromProfiles, - ...clientNames, - }.toList(); - return merged; - } catch (_) { - return fromProfiles; - } - }, - itemBuilder: (context, suggestion) => - ListTile(title: Text(suggestion)), - onSuggestionSelected: - (suggestion) async { - _receivedDebounce?.cancel(); - _receivedController.text = - suggestion; - setState(() { - _receivedSaving = true; - _receivedSaved = false; - }); - try { - await ref - .read( - tasksControllerProvider, - ) - .updateTask( - taskId: task.id, - receivedBy: - suggestion.isEmpty - ? null - : suggestion, - ); - if (suggestion.isNotEmpty) { - try { - await ref - .read( - supabaseClientProvider, - ) - .from('clients') - .upsert({ - 'name': suggestion, - }); - } catch (_) {} - } - setState( - () => _receivedSaved = - suggestion.isNotEmpty, - ); - } catch (_) { - } finally { - setState( - () => _receivedSaving = false, - ); - if (_receivedSaved) { - Future.delayed( - const Duration(seconds: 2), - () { - if (mounted) { - setState( - () => _receivedSaved = - false, - ); - } - }, - ); - } - } - }, - ), - ], - ), - ), - ), - - // Action taken (rich text) - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Action taken'), - const SizedBox(height: 6), - // Toolbar + editor with inline save indicator - Container( - height: isWide ? 260 : 220, - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - border: Border.all( - color: Theme.of( - context, - ).colorScheme.outline, - ), - borderRadius: BorderRadius.circular(8), - ), - child: Stack( - children: [ - Column( - children: [ - Row( - children: [ - IconButton( - tooltip: 'Bold', - icon: const Icon( - Icons.format_bold, - ), - onPressed: () => - _actionController - ?.formatSelection( - quill - .Attribute - .bold, - ), - ), - IconButton( - tooltip: 'Italic', - icon: const Icon( - Icons.format_italic, - ), - onPressed: () => - _actionController - ?.formatSelection( - quill - .Attribute - .italic, - ), - ), - IconButton( - tooltip: 'Underline', - icon: const Icon( - Icons.format_underlined, - ), - onPressed: () => - _actionController - ?.formatSelection( - quill - .Attribute - .underline, - ), - ), - IconButton( - tooltip: 'Bullet list', - icon: const Icon( - Icons - .format_list_bulleted, - ), - onPressed: () => - _actionController - ?.formatSelection( - quill - .Attribute - .ul, - ), - ), - IconButton( - tooltip: 'Numbered list', - icon: const Icon( - Icons - .format_list_numbered, - ), - onPressed: () => - _actionController - ?.formatSelection( - quill - .Attribute - .ol, - ), - ), - const SizedBox(width: 8), - IconButton( - tooltip: 'Heading 2', - icon: const Icon( - Icons.format_size, - ), - onPressed: () => - _actionController - ?.formatSelection( - quill - .Attribute - .h2, - ), - ), - IconButton( - tooltip: 'Heading 3', - icon: const Icon( - Icons.format_size, - size: 18, - ), - onPressed: () => - _actionController - ?.formatSelection( - quill - .Attribute - .h3, - ), - ), - IconButton( - tooltip: 'Undo', - icon: const Icon( - Icons.undo, - ), - onPressed: () => - _actionController - ?.undo(), - ), - IconButton( - tooltip: 'Redo', - icon: const Icon( - Icons.redo, - ), - onPressed: () => - _actionController - ?.redo(), - ), - IconButton( - tooltip: 'Insert link', - icon: const Icon( - Icons.link, - ), - onPressed: () async { - final urlCtrl = - TextEditingController(); - final res = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text( - 'Insert link', - ), - content: TextField( - controller: urlCtrl, - decoration: - const InputDecoration( - hintText: - 'https://', - ), - ), - actions: [ - TextButton( - onPressed: () => - Navigator.of( - ctx, - ).pop(), - child: const Text( - 'Cancel', - ), - ), - TextButton( - onPressed: () => - Navigator.of( - ctx, - ).pop( - urlCtrl.text - .trim(), - ), - child: const Text( - 'Insert', - ), - ), - ], - ), - ); - if (res == null || - res.isEmpty) { - return; - } - final sel = - _actionController - ?.selection ?? - const TextSelection.collapsed( - offset: 0, - ); - final start = - sel.baseOffset; - final end = - sel.extentOffset; - if (!sel.isCollapsed && - end > start) { - final len = end - start; - try { - _actionController - ?.document - .delete( - start, - len, - ); - } catch (_) {} - _actionController - ?.document - .insert(start, res); - } else { - _actionController - ?.document - .insert(start, res); - } - }, - ), - IconButton( - tooltip: 'Insert image', - icon: const Icon( - Icons.image, - ), - onPressed: () async { - try { - final r = - await FilePicker - .platform - .pickFiles( - withData: - true, - type: FileType - .image, - ); - if (r == null || - r.files.isEmpty) { - return; - } - final file = - r.files.first; - final bytes = - file.bytes; - if (bytes == null) { - return; - } - final ext = - file.extension ?? - 'png'; - String? url; - try { - url = await ref - .read( - tasksControllerProvider, - ) - .uploadActionImage( - taskId: task.id, - bytes: bytes, - extension: ext, - ); - } catch (e) { - showErrorSnackBar( - context, - 'Upload error: $e', - ); - return; - } - if (url == null) { - showErrorSnackBar( - context, - 'Image upload failed (no URL returned)', - ); - return; - } - final trimmedUrl = url - .trim(); - final idx = - _actionController - ?.selection - .baseOffset ?? - 0; - // ignore: avoid_print - print( - 'inserting image embed idx=$idx url=$trimmedUrl', - ); - _actionController - ?.document - .insert( - idx, - quill - .BlockEmbed.image( - trimmedUrl, - ), - ); - } catch (_) {} - }, - ), - ], - ), - const SizedBox(height: 6), - Expanded( - child: MouseRegion( - cursor: - SystemMouseCursors.text, - child: quill.QuillEditor.basic( - controller: - _actionController!, - focusNode: _actionFocusNode, - scrollController: - _actionScrollController, - config: - quill.QuillEditorConfig( - embedBuilders: const [ - _ImageEmbedBuilder(), - ], - scrollable: true, - padding: - EdgeInsets.zero, - ), - ), - ), - ), - ], - ), - Positioned( - right: 6, - bottom: 6, - child: _actionSaving - ? SizedBox( - width: 20, - height: 20, - child: ScaleTransition( - scale: _savePulse, - child: const Icon( - Icons.save, - size: 16, - ), - ), - ) - : _actionSaved - ? SizedBox( - width: 20, - height: 20, - child: Stack( - alignment: - Alignment.center, - children: const [ - Icon( - Icons.save, - size: 16, - color: Colors.green, - ), - Positioned( - right: -2, - bottom: -2, - child: Icon( - Icons.check, - size: 10, - color: Colors.white, - ), - ), - ], - ), - ) - : const SizedBox.shrink(), - ), - ], - ), - ), - ], - ), - ), - ], - ), - ), - ], - ), - ), - ], - ), - ], - ); - - final detailsCard = Card( - child: Padding( - padding: const EdgeInsets.all(20), - child: SingleChildScrollView(child: detailsContent), - ), - ); - - // Tabbed area: Chat + Activity - final tabbedCard = Card( - child: DefaultTabController( - length: 2, - child: Column( - children: [ - Material( - color: Theme.of(context).colorScheme.surface, - child: TabBar( - labelColor: Theme.of(context).colorScheme.onSurface, - indicatorColor: Theme.of(context).colorScheme.primary, - tabs: const [ - Tab(text: 'Chat'), - Tab(text: 'Activity'), - ], - ), - ), - SizedBox(height: 8), - Expanded( - child: TabBarView( - children: [ - // Chat tab (existing messages UI) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), - child: Column( - children: [ - Expanded( - child: messagesAsync.when( - data: (messages) => _buildMessages( - context, - messages, - profilesAsync.valueOrNull ?? [], - ), - loading: () => const Center( - child: CircularProgressIndicator(), - ), - error: (error, _) => Center( - child: Text( - 'Failed to load messages: $error', + ], ), ), ), - ), - SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.fromLTRB( - 0, - 8, - 0, - 12, - ), + + // Action taken (rich text) + Padding( + padding: const EdgeInsets.only(top: 8.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (typingState.userIds.isNotEmpty) - Padding( - padding: const EdgeInsets.only( - bottom: 6, + const Text('Action taken'), + const SizedBox(height: 6), + // Toolbar + editor with inline save indicator + Container( + height: isWide ? 260 : 220, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of( + context, + ).colorScheme.outline, ), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest, - borderRadius: - BorderRadius.circular(16), - ), - child: Row( - mainAxisSize: MainAxisSize.min, + borderRadius: BorderRadius.circular( + 8, + ), + ), + child: Stack( + children: [ + Column( children: [ - Text( - _typingLabel( - typingState.userIds, - profilesAsync, - ), - style: Theme.of( - context, - ).textTheme.labelSmall, + Row( + children: [ + IconButton( + tooltip: 'Bold', + icon: const Icon( + Icons.format_bold, + ), + onPressed: () => + _actionController + ?.formatSelection( + quill + .Attribute + .bold, + ), + ), + IconButton( + tooltip: 'Italic', + icon: const Icon( + Icons.format_italic, + ), + onPressed: () => + _actionController + ?.formatSelection( + quill + .Attribute + .italic, + ), + ), + IconButton( + tooltip: 'Underline', + icon: const Icon( + Icons.format_underlined, + ), + onPressed: () => + _actionController + ?.formatSelection( + quill + .Attribute + .underline, + ), + ), + IconButton( + tooltip: 'Bullet list', + icon: const Icon( + Icons + .format_list_bulleted, + ), + onPressed: () => + _actionController + ?.formatSelection( + quill + .Attribute + .ul, + ), + ), + IconButton( + tooltip: 'Numbered list', + icon: const Icon( + Icons + .format_list_numbered, + ), + onPressed: () => + _actionController + ?.formatSelection( + quill + .Attribute + .ol, + ), + ), + const SizedBox(width: 8), + IconButton( + tooltip: 'Heading 2', + icon: const Icon( + Icons.format_size, + ), + onPressed: () => + _actionController + ?.formatSelection( + quill + .Attribute + .h2, + ), + ), + IconButton( + tooltip: 'Heading 3', + icon: const Icon( + Icons.format_size, + size: 18, + ), + onPressed: () => + _actionController + ?.formatSelection( + quill + .Attribute + .h3, + ), + ), + IconButton( + tooltip: 'Undo', + icon: const Icon( + Icons.undo, + ), + onPressed: () => + _actionController + ?.undo(), + ), + IconButton( + tooltip: 'Redo', + icon: const Icon( + Icons.redo, + ), + onPressed: () => + _actionController + ?.redo(), + ), + IconButton( + tooltip: 'Insert link', + icon: const Icon( + Icons.link, + ), + onPressed: () async { + final urlCtrl = + TextEditingController(); + final res = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text( + 'Insert link', + ), + content: TextField( + controller: + urlCtrl, + decoration: + const InputDecoration( + hintText: + 'https://', + ), + ), + actions: [ + TextButton( + onPressed: () => + Navigator.of( + ctx, + ).pop(), + child: + const Text( + 'Cancel', + ), + ), + TextButton( + onPressed: () => + Navigator.of( + ctx, + ).pop( + urlCtrl + .text + .trim(), + ), + child: + const Text( + 'Insert', + ), + ), + ], + ), + ); + if (res == null || + res.isEmpty) { + return; + } + final sel = + _actionController + ?.selection ?? + const TextSelection.collapsed( + offset: 0, + ); + final start = + sel.baseOffset; + final end = + sel.extentOffset; + if (!sel.isCollapsed && + end > start) { + final len = + end - start; + try { + _actionController + ?.document + .delete( + start, + len, + ); + } catch (_) {} + _actionController + ?.document + .insert( + start, + res, + ); + } else { + _actionController + ?.document + .insert( + start, + res, + ); + } + }, + ), + IconButton( + tooltip: 'Insert image', + icon: const Icon( + Icons.image, + ), + onPressed: () async { + try { + final r = + await FilePicker + .platform + .pickFiles( + withData: + true, + type: FileType + .image, + ); + if (r == null || + r.files.isEmpty) { + return; + } + final file = + r.files.first; + final bytes = + file.bytes; + if (bytes == null) { + return; + } + final ext = + file.extension ?? + 'png'; + String? url; + try { + url = await ref + .read( + tasksControllerProvider, + ) + .uploadActionImage( + taskId: + task.id, + bytes: bytes, + extension: + ext, + ); + } catch (e) { + showErrorSnackBar( + context, + 'Upload error: $e', + ); + return; + } + if (url == null) { + showErrorSnackBar( + context, + 'Image upload failed (no URL returned)', + ); + return; + } + final trimmedUrl = url + .trim(); + final idx = + _actionController + ?.selection + .baseOffset ?? + 0; + // ignore: avoid_print + print( + 'inserting image embed idx=$idx url=$trimmedUrl', + ); + _actionController + ?.document + .insert( + idx, + quill + .BlockEmbed.image( + trimmedUrl, + ), + ); + } catch (_) {} + }, + ), + ], ), - const SizedBox(width: 8), - TypingDots( - size: 8, - color: Theme.of( - context, - ).colorScheme.primary, + const SizedBox(height: 6), + Expanded( + child: MouseRegion( + cursor: + SystemMouseCursors.text, + child: quill.QuillEditor.basic( + controller: + _actionController!, + focusNode: + _actionFocusNode, + scrollController: + _actionScrollController, + config: quill.QuillEditorConfig( + embedBuilders: const [ + _ImageEmbedBuilder(), + ], + scrollable: true, + padding: + EdgeInsets.zero, + ), + ), + ), ), ], ), - ), - ), - if (_mentionQuery != null) - Padding( - padding: const EdgeInsets.only( - bottom: 8, - ), - child: _buildMentionList( - profilesAsync, - ), - ), - if (!canSendMessages) - Padding( - padding: const EdgeInsets.only( - bottom: 8, - ), - child: Text( - 'Messaging is disabled for completed tasks.', - style: Theme.of( - context, - ).textTheme.labelMedium, - ), - ), - Row( - children: [ - Expanded( - child: TextField( - controller: _messageController, - decoration: const InputDecoration( - hintText: 'Message...', - ), - textInputAction: - TextInputAction.send, - enabled: canSendMessages, - onChanged: (_) => - _handleComposerChanged( - profilesAsync.valueOrNull ?? - [], - ref.read( - currentUserIdProvider, - ), - canSendMessages, - typingChannelId, - ), - onSubmitted: (_) => - _handleSendMessage( - task, - profilesAsync.valueOrNull ?? - [], - ref.read( - currentUserIdProvider, - ), - canSendMessages, - typingChannelId, - ), + Positioned( + right: 6, + bottom: 6, + child: _actionSaving + ? SizedBox( + width: 20, + height: 20, + child: ScaleTransition( + scale: _savePulse, + child: const Icon( + Icons.save, + size: 16, + ), + ), + ) + : _actionSaved + ? SizedBox( + width: 20, + height: 20, + child: Stack( + alignment: + Alignment.center, + children: const [ + Icon( + Icons.save, + size: 16, + color: Colors.green, + ), + Positioned( + right: -2, + bottom: -2, + child: Icon( + Icons.check, + size: 10, + color: + Colors.white, + ), + ), + ], + ), + ) + : const SizedBox.shrink(), ), - ), - const SizedBox(width: 12), - IconButton( - tooltip: 'Send', - onPressed: canSendMessages - ? () => _handleSendMessage( - task, - profilesAsync.valueOrNull ?? - [], - ref.read( - currentUserIdProvider, - ), - canSendMessages, - typingChannelId, - ) - : null, - icon: const Icon(Icons.send), - ), - ], + ], + ), ), ], ), ), - ), - ], + ], + ), ), - ), - - // Activity tab - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), - child: _buildActivityTab( - task, - assignments, - messagesAsync, - profilesAsync.valueOrNull ?? [], - ), - ), - ], + ], + ), ), - ), - ], - ), - ), - ); - - if (isWide) { - return Row( - children: [ - Expanded(flex: 2, child: detailsCard), - const SizedBox(width: 16), - Expanded(flex: 3, child: tabbedCard), + ], + ), ], ); - } - // Mobile/tablet: allow vertical scrolling of detail card while - // keeping the chat/activity panel filling the remaining viewport - // (and scrolling internally). Use a CustomScrollView to provide a - // bounded height for the tabbed card via SliverFillRemaining. - return CustomScrollView( - slivers: [ - SliverToBoxAdapter(child: detailsCard), - const SliverToBoxAdapter(child: SizedBox(height: 12)), - SliverFillRemaining(hasScrollBody: true, child: tabbedCard), - ], - ); - }, + final detailsCard = Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: SingleChildScrollView(child: detailsContent), + ), + ); + + // Tabbed area: Chat + Activity + final tabbedCard = Card( + child: DefaultTabController( + length: 2, + child: Column( + children: [ + Material( + color: Theme.of(context).colorScheme.surface, + child: TabBar( + labelColor: Theme.of(context).colorScheme.onSurface, + indicatorColor: Theme.of(context).colorScheme.primary, + tabs: const [ + Tab(text: 'Chat'), + Tab(text: 'Activity'), + ], + ), + ), + SizedBox(height: 8), + Expanded( + child: TabBarView( + children: [ + // Chat tab (existing messages UI) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Column( + children: [ + Expanded( + child: messagesAsync.when( + data: (messages) => _buildMessages( + context, + messages, + profilesAsync.valueOrNull ?? [], + ), + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (error, _) => Center( + child: Text( + 'Failed to load messages: $error', + ), + ), + ), + ), + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB( + 0, + 8, + 0, + 12, + ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + if (typingState.userIds.isNotEmpty) + Padding( + padding: const EdgeInsets.only( + bottom: 6, + ), + child: Container( + padding: + const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + borderRadius: + BorderRadius.circular(16), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _typingLabel( + typingState.userIds, + profilesAsync, + ), + style: Theme.of( + context, + ).textTheme.labelSmall, + ), + const SizedBox(width: 8), + TypingDots( + size: 8, + color: Theme.of( + context, + ).colorScheme.primary, + ), + ], + ), + ), + ), + if (_mentionQuery != null) + Padding( + padding: const EdgeInsets.only( + bottom: 8, + ), + child: _buildMentionList( + profilesAsync, + ), + ), + if (!canSendMessages) + Padding( + padding: const EdgeInsets.only( + bottom: 8, + ), + child: Text( + 'Messaging is disabled for completed tasks.', + style: Theme.of( + context, + ).textTheme.labelMedium, + ), + ), + Row( + children: [ + Expanded( + child: TextField( + controller: _messageController, + decoration: + const InputDecoration( + hintText: 'Message...', + ), + textInputAction: + TextInputAction.send, + enabled: canSendMessages, + onChanged: (_) => + _handleComposerChanged( + profilesAsync + .valueOrNull ?? + [], + ref.read( + currentUserIdProvider, + ), + canSendMessages, + typingChannelId, + ), + onSubmitted: (_) => + _handleSendMessage( + task, + profilesAsync + .valueOrNull ?? + [], + ref.read( + currentUserIdProvider, + ), + canSendMessages, + typingChannelId, + ), + ), + ), + const SizedBox(width: 12), + IconButton( + tooltip: 'Send', + onPressed: canSendMessages + ? () => _handleSendMessage( + task, + profilesAsync + .valueOrNull ?? + [], + ref.read( + currentUserIdProvider, + ), + canSendMessages, + typingChannelId, + ) + : null, + icon: const Icon(Icons.send), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + + // Activity tab + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: _buildActivityTab( + task, + assignments, + messagesAsync, + profilesAsync.valueOrNull ?? [], + ), + ), + ], + ), + ), + ], + ), + ), + ); + + if (isWide) { + return Row( + children: [ + Expanded(flex: 2, child: detailsCard), + const SizedBox(width: 16), + Expanded(flex: 3, child: tabbedCard), + ], + ); + } + + // Mobile/tablet: allow vertical scrolling of detail card while + // keeping the chat/activity panel filling the remaining viewport + // (and scrolling internally). Use a CustomScrollView to provide a + // bounded height for the tabbed card via SliverFillRemaining. + return Stack( + children: [ + CustomScrollView( + slivers: [ + SliverToBoxAdapter(child: detailsCard), + const SliverToBoxAdapter(child: SizedBox(height: 12)), + SliverFillRemaining(hasScrollBody: true, child: tabbedCard), + ], + ), + if (isRetrieving) + Positioned.fill( + child: AbsorbPointer( + absorbing: true, + child: Container( + color: Theme.of( + context, + ).colorScheme.surface.withAlpha((0.35 * 255).round()), + alignment: Alignment.topCenter, + padding: const EdgeInsets.only(top: 36), + child: SizedBox( + width: 280, + child: Card( + elevation: 4, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ), + SizedBox(width: 12), + Expanded(child: Text('Retrieving updates…')), + ], + ), + ), + ), + ), + ), + ), + ), + ], + ); + }, + ), ), ); } diff --git a/lib/screens/tasks/tasks_list_screen.dart b/lib/screens/tasks/tasks_list_screen.dart index 0e686e9b..b19b1c56 100644 --- a/lib/screens/tasks/tasks_list_screen.dart +++ b/lib/screens/tasks/tasks_list_screen.dart @@ -13,8 +13,11 @@ import '../../providers/notifications_provider.dart'; import '../../providers/profile_provider.dart'; import '../../providers/tasks_provider.dart'; import '../../providers/tickets_provider.dart'; +import '../../providers/realtime_controller.dart'; import '../../providers/typing_provider.dart'; import '../../widgets/mono_text.dart'; +import '../../widgets/reconnect_overlay.dart'; +import 'package:skeletonizer/skeletonizer.dart'; import '../../widgets/responsive_body.dart'; import '../../widgets/tasq_adaptive_list.dart'; import '../../widgets/typing_dots.dart'; @@ -52,6 +55,7 @@ class _TasksListScreenState extends ConsumerState String? _selectedAssigneeId; DateTimeRange? _selectedDateRange; late final TabController _tabController; + bool _isSwitchingTab = false; @override void dispose() { @@ -66,8 +70,15 @@ class _TasksListScreenState extends ConsumerState super.initState(); _tabController = TabController(length: 2, vsync: this); _tabController.addListener(() { - // rebuild when tab changes so filters shown/hidden update - setState(() {}); + // 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); + }); + } }); } @@ -90,6 +101,17 @@ class _TasksListScreenState extends ConsumerState final notificationsAsync = ref.watch(notificationsProvider); final profilesAsync = ref.watch(profilesProvider); final assignmentsAsync = ref.watch(taskAssignmentsProvider); + final realtime = ref.watch(realtimeControllerProvider); + + final showSkeleton = + realtime.isConnecting || + 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; final canCreate = profileAsync.maybeWhen( data: (profile) => @@ -117,289 +139,226 @@ class _TasksListScreenState extends ConsumerState children: [ ResponsiveBody( maxWidth: double.infinity, - child: tasksAsync.when( - data: (tasks) { - if (tasks.isEmpty) { - return const Center(child: Text('No tasks yet.')); - } - final offices = officesAsync.valueOrNull ?? []; - final officesSorted = List.from(offices) - ..sort( - (a, b) => - a.name.toLowerCase().compareTo(b.name.toLowerCase()), - ); - final officeOptions = >[ - const DropdownMenuItem( - value: null, - child: Text('All offices'), - ), - ...officesSorted.map( - (office) => DropdownMenuItem( - value: office.id, - child: Text(office.name), - ), - ), - ]; - final staffOptions = _staffOptions(profilesAsync.valueOrNull); - final statusOptions = _taskStatusOptions(tasks); - - // derive latest assignee per task from task assignments stream - final assignments = - assignmentsAsync.valueOrNull ?? []; - final assignmentsByTask = {}; - for (final a in assignments) { - final current = assignmentsByTask[a.taskId]; - if (current == null || a.createdAt.isAfter(current.createdAt)) { - assignmentsByTask[a.taskId] = a; + child: Skeletonizer( + enabled: effectiveShowSkeleton, + child: tasksAsync.when( + data: (tasks) { + if (tasks.isEmpty) { + return const Center(child: Text('No tasks yet.')); } - } - final latestAssigneeByTaskId = {}; - for (final entry in assignmentsByTask.entries) { - latestAssigneeByTaskId[entry.key] = entry.value.userId; - } + final offices = officesAsync.valueOrNull ?? []; + final officesSorted = List.from(offices) + ..sort( + (a, b) => + a.name.toLowerCase().compareTo(b.name.toLowerCase()), + ); + final officeOptions = >[ + const DropdownMenuItem( + value: null, + child: Text('All offices'), + ), + ...officesSorted.map( + (office) => DropdownMenuItem( + value: office.id, + child: Text(office.name), + ), + ), + ]; + final staffOptions = _staffOptions(profilesAsync.valueOrNull); + final statusOptions = _taskStatusOptions(tasks); - final filteredTasks = _applyTaskFilters( - tasks, - ticketById: ticketById, - subjectQuery: _subjectController.text, - taskNumber: _taskNumberController.text, - officeId: _selectedOfficeId, - status: _selectedStatus, - assigneeId: _selectedAssigneeId, - dateRange: _selectedDateRange, - latestAssigneeByTaskId: latestAssigneeByTaskId, - ); + // derive latest assignee per task from task assignments stream + final assignments = + assignmentsAsync.valueOrNull ?? []; + final assignmentsByTask = {}; + for (final a in assignments) { + final current = assignmentsByTask[a.taskId]; + if (current == null || + a.createdAt.isAfter(current.createdAt)) { + assignmentsByTask[a.taskId] = a; + } + } + final latestAssigneeByTaskId = {}; + for (final entry in assignmentsByTask.entries) { + latestAssigneeByTaskId[entry.key] = entry.value.userId; + } - final filterHeader = Wrap( - spacing: 12, - runSpacing: 12, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - SizedBox( - width: 220, - child: TextField( - controller: _subjectController, - onChanged: (_) => setState(() {}), - decoration: const InputDecoration( - labelText: 'Subject', - prefixIcon: Icon(Icons.search), - ), - ), - ), - SizedBox( - width: 200, - child: DropdownButtonFormField( - isExpanded: true, - key: ValueKey(_selectedOfficeId), - initialValue: _selectedOfficeId, - items: officeOptions, - onChanged: (value) => - setState(() => _selectedOfficeId = value), - decoration: const InputDecoration(labelText: 'Office'), - ), - ), - SizedBox( - width: 160, - child: TextField( - controller: _taskNumberController, - onChanged: (_) => setState(() {}), - decoration: const InputDecoration( - labelText: 'Task #', - prefixIcon: Icon(Icons.filter_alt), - ), - ), - ), - if (_tabController.index == 1) + final filteredTasks = _applyTaskFilters( + tasks, + ticketById: ticketById, + subjectQuery: _subjectController.text, + taskNumber: _taskNumberController.text, + officeId: _selectedOfficeId, + status: _selectedStatus, + assigneeId: _selectedAssigneeId, + dateRange: _selectedDateRange, + latestAssigneeByTaskId: latestAssigneeByTaskId, + ); + + final filterHeader = Wrap( + spacing: 12, + runSpacing: 12, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ SizedBox( width: 220, + child: TextField( + controller: _subjectController, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + labelText: 'Subject', + prefixIcon: Icon(Icons.search), + ), + ), + ), + SizedBox( + width: 200, child: DropdownButtonFormField( isExpanded: true, - key: ValueKey(_selectedAssigneeId), - initialValue: _selectedAssigneeId, - items: staffOptions, + key: ValueKey(_selectedOfficeId), + initialValue: _selectedOfficeId, + items: officeOptions, onChanged: (value) => - setState(() => _selectedAssigneeId = value), + setState(() => _selectedOfficeId = value), + decoration: const InputDecoration(labelText: 'Office'), + ), + ), + SizedBox( + width: 160, + child: TextField( + controller: _taskNumberController, + onChanged: (_) => setState(() {}), decoration: const InputDecoration( - labelText: 'Assigned staff', + labelText: 'Task #', + prefixIcon: Icon(Icons.filter_alt), ), ), ), - SizedBox( - width: 180, - child: DropdownButtonFormField( - isExpanded: true, - key: ValueKey(_selectedStatus), - initialValue: _selectedStatus, - items: statusOptions, - onChanged: (value) => - setState(() => _selectedStatus = value), - decoration: const InputDecoration(labelText: 'Status'), - ), - ), - OutlinedButton.icon( - onPressed: () async { - final next = await showDateRangePicker( - context: context, - firstDate: DateTime(2020), - lastDate: AppTime.now().add(const Duration(days: 365)), - currentDate: AppTime.now(), - initialDateRange: _selectedDateRange, - ); - if (!mounted) return; - setState(() => _selectedDateRange = next); - }, - icon: const Icon(Icons.date_range), - label: Text( - _selectedDateRange == null - ? 'Date range' - : AppTime.formatDateRange(_selectedDateRange!), - ), - ), - if (_hasTaskFilters) - TextButton.icon( - onPressed: () => setState(() { - _subjectController.clear(); - _selectedOfficeId = null; - _selectedStatus = null; - _selectedAssigneeId = null; - _selectedDateRange = null; - }), - icon: const Icon(Icons.close), - label: const Text('Clear'), - ), - ], - ); - - // reusable helper for rendering a list given a subset of tasks - Widget makeList(List tasksList) { - final summary = _StatusSummaryRow( - counts: _taskStatusCounts(tasksList), - ); - return TasQAdaptiveList( - items: tasksList, - onRowTap: (task) => context.go('/tasks/${task.id}'), - summaryDashboard: summary, - filterHeader: filterHeader, - onRequestRefresh: () { - // For server-side pagination, update the query provider - ref.read(tasksQueryProvider.notifier).state = - const TaskQuery(offset: 0, limit: 50); - }, - onPageChanged: null, - isLoading: false, - columns: [ - TasQColumn( - header: 'Task #', - technical: true, - cellBuilder: (context, task) => - Text(task.taskNumber ?? task.id), - ), - TasQColumn( - header: 'Subject', - cellBuilder: (context, task) { - final ticket = task.ticketId == null - ? null - : ticketById[task.ticketId]; - return Text( - task.title.isNotEmpty - ? task.title - : (ticket?.subject ?? 'Task ${task.id}'), - ); - }, - ), - TasQColumn( - header: 'Office', - cellBuilder: (context, task) { - final ticket = task.ticketId == null - ? null - : ticketById[task.ticketId]; - final officeId = ticket?.officeId ?? task.officeId; - return Text( - officeId == null - ? 'Unassigned office' - : (officeById[officeId]?.name ?? officeId), - ); - }, - ), - TasQColumn( - header: 'Assigned Agent', - cellBuilder: (context, task) { - final assigneeId = latestAssigneeByTaskId[task.id]; - return Text(_assignedAgent(profileById, assigneeId)); - }, - ), - TasQColumn( - header: 'Status', - cellBuilder: (context, task) => Row( - mainAxisSize: MainAxisSize.min, - children: [ - _StatusBadge(status: task.status), - if (task.status == 'completed' && - task.hasIncompleteDetails) ...[ - const SizedBox(width: 4), - const Icon( - Icons.warning_amber_rounded, - size: 16, - color: Colors.orange, - ), - ], - ], + if (_tabController.index == 1) + SizedBox( + width: 220, + child: DropdownButtonFormField( + isExpanded: true, + key: ValueKey(_selectedAssigneeId), + initialValue: _selectedAssigneeId, + items: staffOptions, + onChanged: (value) => + setState(() => _selectedAssigneeId = value), + decoration: const InputDecoration( + labelText: 'Assigned staff', + ), + ), + ), + SizedBox( + width: 180, + child: DropdownButtonFormField( + isExpanded: true, + key: ValueKey(_selectedStatus), + initialValue: _selectedStatus, + items: statusOptions, + onChanged: (value) => + setState(() => _selectedStatus = value), + decoration: const InputDecoration(labelText: 'Status'), ), ), - TasQColumn( - header: 'Timestamp', - technical: true, - cellBuilder: (context, task) => - Text(_formatTimestamp(task.createdAt)), + OutlinedButton.icon( + onPressed: () async { + final next = await showDateRangePicker( + context: context, + firstDate: DateTime(2020), + lastDate: AppTime.now().add( + const Duration(days: 365), + ), + currentDate: AppTime.now(), + initialDateRange: _selectedDateRange, + ); + if (!mounted) return; + setState(() => _selectedDateRange = next); + }, + icon: const Icon(Icons.date_range), + label: Text( + _selectedDateRange == null + ? 'Date range' + : AppTime.formatDateRange(_selectedDateRange!), + ), ), + if (_hasTaskFilters) + TextButton.icon( + onPressed: () => setState(() { + _subjectController.clear(); + _selectedOfficeId = null; + _selectedStatus = null; + _selectedAssigneeId = null; + _selectedDateRange = null; + }), + icon: const Icon(Icons.close), + label: const Text('Clear'), + ), ], - mobileTileBuilder: (context, task, actions) { - final ticketId = task.ticketId; - final ticket = ticketId == null - ? null - : ticketById[ticketId]; - final officeId = ticket?.officeId ?? task.officeId; - final officeName = officeId == null - ? 'Unassigned office' - : (officeById[officeId]?.name ?? officeId); - final assigned = _assignedAgent( - profileById, - latestAssigneeByTaskId[task.id], - ); - final subtitle = _buildSubtitle(officeName, task.status); - final hasMention = _hasTaskMention( - notificationsAsync, - task, - ); - final typingState = ref.watch( - typingIndicatorProvider(task.id), - ); - final showTyping = typingState.userIds.isNotEmpty; + ); - return Card( - child: ListTile( - leading: _buildQueueBadge(context, task), - dense: true, - visualDensity: VisualDensity.compact, - title: Text( - task.title.isNotEmpty - ? task.title - : (ticket?.subject ?? - 'Task ${task.taskNumber ?? task.id}'), - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(subtitle), - const SizedBox(height: 2), - Text('Assigned: $assigned'), - const SizedBox(height: 4), - MonoText('ID ${task.taskNumber ?? task.id}'), - const SizedBox(height: 2), - Text(_formatTimestamp(task.createdAt)), - ], - ), - trailing: Row( + // reusable helper for rendering a list given a subset of tasks + Widget makeList(List tasksList) { + final summary = _StatusSummaryRow( + counts: _taskStatusCounts(tasksList), + ); + return TasQAdaptiveList( + items: tasksList, + onRowTap: (task) => context.go('/tasks/${task.id}'), + summaryDashboard: summary, + filterHeader: filterHeader, + skeletonMode: effectiveShowSkeleton, + onRequestRefresh: () { + // For server-side pagination, update the query provider + ref.read(tasksQueryProvider.notifier).state = + const TaskQuery(offset: 0, limit: 50); + }, + onPageChanged: null, + isLoading: false, + columns: [ + TasQColumn( + header: 'Task #', + technical: true, + cellBuilder: (context, task) => + Text(task.taskNumber ?? task.id), + ), + TasQColumn( + header: 'Subject', + cellBuilder: (context, task) { + final ticket = task.ticketId == null + ? null + : ticketById[task.ticketId]; + return Text( + task.title.isNotEmpty + ? task.title + : (ticket?.subject ?? 'Task ${task.id}'), + ); + }, + ), + TasQColumn( + header: 'Office', + cellBuilder: (context, task) { + final ticket = task.ticketId == null + ? null + : ticketById[task.ticketId]; + final officeId = ticket?.officeId ?? task.officeId; + return Text( + officeId == null + ? 'Unassigned office' + : (officeById[officeId]?.name ?? officeId), + ); + }, + ), + TasQColumn( + header: 'Assigned Agent', + cellBuilder: (context, task) { + final assigneeId = latestAssigneeByTaskId[task.id]; + return Text(_assignedAgent(profileById, assigneeId)); + }, + ), + TasQColumn( + header: 'Status', + cellBuilder: (context, task) => Row( mainAxisSize: MainAxisSize.min, children: [ _StatusBadge(status: task.status), @@ -412,85 +371,155 @@ class _TasksListScreenState extends ConsumerState color: Colors.orange, ), ], - if (showTyping) ...[ - const SizedBox(width: 6), - TypingDots( - size: 6, - color: Theme.of(context).colorScheme.primary, - ), - ], - if (hasMention) - const Padding( - padding: EdgeInsets.only(left: 8), - child: Icon( - Icons.circle, - size: 10, - color: Colors.red, - ), - ), ], ), - onTap: () => context.go('/tasks/${task.id}'), ), - ); - }, - ); - } - - final currentUserId = profileAsync.valueOrNull?.id; - final myTasks = currentUserId == null - ? [] - : filteredTasks - .where( - (t) => latestAssigneeByTaskId[t.id] == currentUserId, - ) - .toList(); - - return Column( - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.only(top: 16, bottom: 8), - child: Align( - alignment: Alignment.center, - child: Text( - 'Tasks', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - ), + TasQColumn( + header: 'Timestamp', + technical: true, + cellBuilder: (context, task) => + Text(_formatTimestamp(task.createdAt)), ), - ), - ), - Expanded( - child: Column( - children: [ - TabBar( - controller: _tabController, - tabs: const [ - Tab(text: 'My Tasks'), - Tab(text: 'All Tasks'), - ], - ), - Expanded( - child: TabBarView( - controller: _tabController, + ], + mobileTileBuilder: (context, task, actions) { + final ticketId = task.ticketId; + final ticket = ticketId == null + ? null + : ticketById[ticketId]; + final officeId = ticket?.officeId ?? task.officeId; + final officeName = officeId == null + ? 'Unassigned office' + : (officeById[officeId]?.name ?? officeId); + final assigned = _assignedAgent( + profileById, + latestAssigneeByTaskId[task.id], + ); + final subtitle = _buildSubtitle(officeName, task.status); + final hasMention = _hasTaskMention( + notificationsAsync, + task, + ); + final typingState = ref.watch( + typingIndicatorProvider(task.id), + ); + final showTyping = typingState.userIds.isNotEmpty; + + return Card( + child: ListTile( + leading: _buildQueueBadge(context, task), + dense: true, + visualDensity: VisualDensity.compact, + title: Text( + task.title.isNotEmpty + ? task.title + : (ticket?.subject ?? + 'Task ${task.taskNumber ?? task.id}'), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - makeList(myTasks), - makeList(filteredTasks), + Text(subtitle), + const SizedBox(height: 2), + Text('Assigned: $assigned'), + const SizedBox(height: 4), + MonoText('ID ${task.taskNumber ?? task.id}'), + const SizedBox(height: 2), + Text(_formatTimestamp(task.createdAt)), ], ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _StatusBadge(status: task.status), + if (task.status == 'completed' && + task.hasIncompleteDetails) ...[ + const SizedBox(width: 4), + const Icon( + Icons.warning_amber_rounded, + size: 16, + color: Colors.orange, + ), + ], + if (showTyping) ...[ + const SizedBox(width: 6), + TypingDots( + size: 6, + color: Theme.of(context).colorScheme.primary, + ), + ], + if (hasMention) + const Padding( + padding: EdgeInsets.only(left: 8), + child: Icon( + Icons.circle, + size: 10, + color: Colors.red, + ), + ), + ], + ), + onTap: () => context.go('/tasks/${task.id}'), ), - ], + ); + }, + ); + } + + final currentUserId = profileAsync.valueOrNull?.id; + final myTasks = currentUserId == null + ? [] + : filteredTasks + .where( + (t) => + latestAssigneeByTaskId[t.id] == currentUserId, + ) + .toList(); + + return Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.only(top: 16, bottom: 8), + child: Align( + alignment: Alignment.center, + child: Text( + 'Tasks', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.w700), + ), + ), ), - ), - ], - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, _) => - Center(child: Text('Failed to load tasks: $error')), + Expanded( + child: Column( + children: [ + TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'My Tasks'), + Tab(text: 'All Tasks'), + ], + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + makeList(myTasks), + makeList(filteredTasks), + ], + ), + ), + ], + ), + ), + ], + ); + }, + loading: () => const SizedBox.shrink(), + error: (error, _) => + Center(child: Text('Failed to load tasks: $error')), + ), ), ), if (canCreate) @@ -505,6 +534,7 @@ class _TasksListScreenState extends ConsumerState ), ), ), + const ReconnectOverlay(), ], ); } diff --git a/lib/screens/tickets/tickets_list_screen.dart b/lib/screens/tickets/tickets_list_screen.dart index c6d0d3b4..67d1ca7b 100644 --- a/lib/screens/tickets/tickets_list_screen.dart +++ b/lib/screens/tickets/tickets_list_screen.dart @@ -10,8 +10,11 @@ import '../../models/ticket.dart'; import '../../providers/notifications_provider.dart'; import '../../providers/profile_provider.dart'; import '../../providers/tickets_provider.dart'; +import '../../providers/realtime_controller.dart'; import '../../providers/typing_provider.dart'; import '../../widgets/mono_text.dart'; +import '../../widgets/reconnect_overlay.dart'; +import 'package:skeletonizer/skeletonizer.dart'; import '../../widgets/responsive_body.dart'; import '../../widgets/tasq_adaptive_list.dart'; import '../../widgets/typing_dots.dart'; @@ -30,6 +33,7 @@ class _TicketsListScreenState extends ConsumerState { String? _selectedOfficeId; String? _selectedStatus; DateTimeRange? _selectedDateRange; + bool _isInitial = true; @override void dispose() { @@ -50,258 +54,280 @@ class _TicketsListScreenState extends ConsumerState { final officesAsync = ref.watch(officesProvider); final notificationsAsync = ref.watch(notificationsProvider); final profilesAsync = ref.watch(profilesProvider); + final realtime = ref.watch(realtimeControllerProvider); + + final showSkeleton = + realtime.isConnecting || + ticketsAsync.maybeWhen(loading: () => true, orElse: () => false) || + officesAsync.maybeWhen(loading: () => true, orElse: () => false) || + profilesAsync.maybeWhen(loading: () => true, orElse: () => false) || + notificationsAsync.maybeWhen(loading: () => true, orElse: () => false); + + if (_isInitial) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() => _isInitial = false); + }); + } + final effectiveShowSkeleton = showSkeleton || _isInitial; return Stack( children: [ ResponsiveBody( maxWidth: double.infinity, - child: ticketsAsync.when( - data: (tickets) { - if (tickets.isEmpty) { - return const Center(child: Text('No tickets yet.')); - } - final officeById = { - for (final office in officesAsync.valueOrNull ?? []) - office.id: office, - }; - final profileById = { - for (final profile in profilesAsync.valueOrNull ?? []) - profile.id: profile, - }; - final unreadByTicketId = _unreadByTicketId(notificationsAsync); - final offices = officesAsync.valueOrNull ?? []; - final officesSorted = List.from(offices) - ..sort( - (a, b) => - a.name.toLowerCase().compareTo(b.name.toLowerCase()), + child: Skeletonizer( + enabled: effectiveShowSkeleton, + child: ticketsAsync.when( + data: (tickets) { + if (tickets.isEmpty) { + return const Center(child: Text('No tickets yet.')); + } + final officeById = { + for (final office in officesAsync.valueOrNull ?? []) + office.id: office, + }; + final profileById = { + for (final profile + in profilesAsync.valueOrNull ?? []) + profile.id: profile, + }; + final unreadByTicketId = _unreadByTicketId(notificationsAsync); + final offices = officesAsync.valueOrNull ?? []; + final officesSorted = List.from(offices) + ..sort( + (a, b) => + a.name.toLowerCase().compareTo(b.name.toLowerCase()), + ); + final officeOptions = >[ + const DropdownMenuItem( + value: null, + child: Text('All offices'), + ), + ...officesSorted.map( + (office) => DropdownMenuItem( + value: office.id, + child: Text(office.name), + ), + ), + ]; + final statusOptions = _ticketStatusOptions(tickets); + final filteredTickets = _applyTicketFilters( + tickets, + subjectQuery: _subjectController.text, + officeId: _selectedOfficeId, + status: _selectedStatus, + dateRange: _selectedDateRange, ); - final officeOptions = >[ - const DropdownMenuItem( - value: null, - child: Text('All offices'), - ), - ...officesSorted.map( - (office) => DropdownMenuItem( - value: office.id, - child: Text(office.name), - ), - ), - ]; - final statusOptions = _ticketStatusOptions(tickets); - final filteredTickets = _applyTicketFilters( - tickets, - subjectQuery: _subjectController.text, - officeId: _selectedOfficeId, - status: _selectedStatus, - dateRange: _selectedDateRange, - ); - final summaryDashboard = _StatusSummaryRow( - counts: _statusCounts(filteredTickets), - ); - final filterHeader = Wrap( - spacing: 12, - runSpacing: 12, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - SizedBox( - width: 220, - child: TextField( - controller: _subjectController, - onChanged: (_) => setState(() {}), - decoration: const InputDecoration( - labelText: 'Subject', - prefixIcon: Icon(Icons.search), - ), - ), - ), - SizedBox( - width: 200, - child: DropdownButtonFormField( - isExpanded: true, - key: ValueKey(_selectedOfficeId), - initialValue: _selectedOfficeId, - items: officeOptions, - onChanged: (value) => - setState(() => _selectedOfficeId = value), - decoration: const InputDecoration(labelText: 'Office'), - ), - ), - SizedBox( - width: 180, - child: DropdownButtonFormField( - isExpanded: true, - key: ValueKey(_selectedStatus), - initialValue: _selectedStatus, - items: statusOptions, - onChanged: (value) => - setState(() => _selectedStatus = value), - decoration: const InputDecoration(labelText: 'Status'), - ), - ), - OutlinedButton.icon( - onPressed: () async { - final next = await showDateRangePicker( - context: context, - firstDate: DateTime(2020), - lastDate: AppTime.now().add(const Duration(days: 365)), - currentDate: AppTime.now(), - initialDateRange: _selectedDateRange, - ); - if (!mounted) return; - setState(() => _selectedDateRange = next); - }, - icon: const Icon(Icons.date_range), - label: Text( - _selectedDateRange == null - ? 'Date range' - : AppTime.formatDateRange(_selectedDateRange!), - ), - ), - if (_hasTicketFilters) - TextButton.icon( - onPressed: () => setState(() { - _subjectController.clear(); - _selectedOfficeId = null; - _selectedStatus = null; - _selectedDateRange = null; - }), - icon: const Icon(Icons.close), - label: const Text('Clear'), - ), - ], - ); - final listBody = TasQAdaptiveList( - items: filteredTickets, - onRowTap: (ticket) => context.go('/tickets/${ticket.id}'), - summaryDashboard: summaryDashboard, - filterHeader: filterHeader, - onRequestRefresh: () { - // For server-side pagination, update the query provider - // This will trigger a reload with new pagination parameters - ref.read(ticketsQueryProvider.notifier).state = - const TicketQuery(offset: 0, limit: 50); - }, - onPageChanged: (firstRow) { - ref - .read(ticketsQueryProvider.notifier) - .update((q) => q.copyWith(offset: firstRow)); - }, - isLoading: false, - columns: [ - TasQColumn( - header: 'Ticket ID', - technical: true, - cellBuilder: (context, ticket) => Text(ticket.id), - ), - TasQColumn( - header: 'Subject', - cellBuilder: (context, ticket) => Text(ticket.subject), - ), - TasQColumn( - header: 'Office', - cellBuilder: (context, ticket) => Text( - officeById[ticket.officeId]?.name ?? ticket.officeId, - ), - ), - TasQColumn( - header: 'Filed by', - cellBuilder: (context, ticket) => - Text(_assignedAgent(profileById, ticket.creatorId)), - ), - TasQColumn( - header: 'Status', - cellBuilder: (context, ticket) => - _StatusBadge(status: ticket.status), - ), - TasQColumn( - header: 'Timestamp', - technical: true, - cellBuilder: (context, ticket) => - Text(_formatTimestamp(ticket.createdAt)), - ), - ], - mobileTileBuilder: (context, ticket, actions) { - final officeName = - officeById[ticket.officeId]?.name ?? ticket.officeId; - final assigned = _assignedAgent( - profileById, - ticket.creatorId, - ); - final hasMention = unreadByTicketId[ticket.id] == true; - final typingState = ref.watch( - typingIndicatorProvider(ticket.id), - ); - final showTyping = typingState.userIds.isNotEmpty; - return Card( - child: ListTile( - leading: const Icon(Icons.confirmation_number_outlined), - dense: true, - visualDensity: VisualDensity.compact, - title: Text(ticket.subject), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(officeName), - const SizedBox(height: 2), - Text('Filed by: $assigned'), - const SizedBox(height: 4), - MonoText('ID ${ticket.id}'), - const SizedBox(height: 2), - Text(_formatTimestamp(ticket.createdAt)), - ], - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _StatusBadge(status: ticket.status), - if (showTyping) ...[ - const SizedBox(width: 6), - TypingDots( - size: 6, - color: Theme.of(context).colorScheme.primary, - ), - ], - if (hasMention) - const Padding( - padding: EdgeInsets.only(left: 8), - child: Icon( - Icons.circle, - size: 10, - color: Colors.red, - ), - ), - ], - ), - onTap: () => context.go('/tickets/${ticket.id}'), - ), - ); - }, - ); - - return Column( - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.only(top: 16, bottom: 8), - child: Align( - alignment: Alignment.center, - child: Text( - 'Tickets', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, + final summaryDashboard = _StatusSummaryRow( + counts: _statusCounts(filteredTickets), + ); + final filterHeader = Wrap( + spacing: 12, + runSpacing: 12, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + SizedBox( + width: 220, + child: TextField( + controller: _subjectController, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + labelText: 'Subject', + prefixIcon: Icon(Icons.search), ), ), ), - ), - Expanded(child: listBody), - ], - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, _) => - Center(child: Text('Failed to load tickets: $error')), + SizedBox( + width: 200, + child: DropdownButtonFormField( + isExpanded: true, + key: ValueKey(_selectedOfficeId), + initialValue: _selectedOfficeId, + items: officeOptions, + onChanged: (value) => + setState(() => _selectedOfficeId = value), + decoration: const InputDecoration(labelText: 'Office'), + ), + ), + SizedBox( + width: 180, + child: DropdownButtonFormField( + isExpanded: true, + key: ValueKey(_selectedStatus), + initialValue: _selectedStatus, + items: statusOptions, + onChanged: (value) => + setState(() => _selectedStatus = value), + decoration: const InputDecoration(labelText: 'Status'), + ), + ), + OutlinedButton.icon( + onPressed: () async { + final next = await showDateRangePicker( + context: context, + firstDate: DateTime(2020), + lastDate: AppTime.now().add( + const Duration(days: 365), + ), + currentDate: AppTime.now(), + initialDateRange: _selectedDateRange, + ); + if (!mounted) return; + setState(() => _selectedDateRange = next); + }, + icon: const Icon(Icons.date_range), + label: Text( + _selectedDateRange == null + ? 'Date range' + : AppTime.formatDateRange(_selectedDateRange!), + ), + ), + if (_hasTicketFilters) + TextButton.icon( + onPressed: () => setState(() { + _subjectController.clear(); + _selectedOfficeId = null; + _selectedStatus = null; + _selectedDateRange = null; + }), + icon: const Icon(Icons.close), + label: const Text('Clear'), + ), + ], + ); + final listBody = TasQAdaptiveList( + items: filteredTickets, + onRowTap: (ticket) => context.go('/tickets/${ticket.id}'), + summaryDashboard: summaryDashboard, + filterHeader: filterHeader, + skeletonMode: effectiveShowSkeleton, + onRequestRefresh: () { + // For server-side pagination, update the query provider + // This will trigger a reload with new pagination parameters + ref.read(ticketsQueryProvider.notifier).state = + const TicketQuery(offset: 0, limit: 50); + }, + onPageChanged: (firstRow) { + ref + .read(ticketsQueryProvider.notifier) + .update((q) => q.copyWith(offset: firstRow)); + }, + isLoading: false, + columns: [ + TasQColumn( + header: 'Ticket ID', + technical: true, + cellBuilder: (context, ticket) => Text(ticket.id), + ), + TasQColumn( + header: 'Subject', + cellBuilder: (context, ticket) => Text(ticket.subject), + ), + TasQColumn( + header: 'Office', + cellBuilder: (context, ticket) => Text( + officeById[ticket.officeId]?.name ?? ticket.officeId, + ), + ), + TasQColumn( + header: 'Filed by', + cellBuilder: (context, ticket) => + Text(_assignedAgent(profileById, ticket.creatorId)), + ), + TasQColumn( + header: 'Status', + cellBuilder: (context, ticket) => + _StatusBadge(status: ticket.status), + ), + TasQColumn( + header: 'Timestamp', + technical: true, + cellBuilder: (context, ticket) => + Text(_formatTimestamp(ticket.createdAt)), + ), + ], + mobileTileBuilder: (context, ticket, actions) { + final officeName = + officeById[ticket.officeId]?.name ?? ticket.officeId; + final assigned = _assignedAgent( + profileById, + ticket.creatorId, + ); + final hasMention = unreadByTicketId[ticket.id] == true; + final typingState = ref.watch( + typingIndicatorProvider(ticket.id), + ); + final showTyping = typingState.userIds.isNotEmpty; + return Card( + child: ListTile( + leading: const Icon(Icons.confirmation_number_outlined), + dense: true, + visualDensity: VisualDensity.compact, + title: Text(ticket.subject), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(officeName), + const SizedBox(height: 2), + Text('Filed by: $assigned'), + const SizedBox(height: 4), + MonoText('ID ${ticket.id}'), + const SizedBox(height: 2), + Text(_formatTimestamp(ticket.createdAt)), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _StatusBadge(status: ticket.status), + if (showTyping) ...[ + const SizedBox(width: 6), + TypingDots( + size: 6, + color: Theme.of(context).colorScheme.primary, + ), + ], + if (hasMention) + const Padding( + padding: EdgeInsets.only(left: 8), + child: Icon( + Icons.circle, + size: 10, + color: Colors.red, + ), + ), + ], + ), + onTap: () => context.go('/tickets/${ticket.id}'), + ), + ); + }, + ); + + return Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.only(top: 16, bottom: 8), + child: Align( + alignment: Alignment.center, + child: Text( + 'Tickets', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.w700), + ), + ), + ), + Expanded(child: listBody), + ], + ); + }, + loading: () => const SizedBox.shrink(), + error: (error, _) => + Center(child: Text('Failed to load tickets: $error')), + ), ), ), Positioned( @@ -315,6 +341,7 @@ class _TicketsListScreenState extends ConsumerState { ), ), ), + const ReconnectOverlay(), ], ); } diff --git a/lib/widgets/reconnect_overlay.dart b/lib/widgets/reconnect_overlay.dart new file mode 100644 index 00000000..5b275aba --- /dev/null +++ b/lib/widgets/reconnect_overlay.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../providers/realtime_controller.dart'; + +class ReconnectOverlay extends ConsumerWidget { + const ReconnectOverlay({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ctrl = ref.watch(realtimeControllerProvider); + if (!ctrl.isConnecting && !ctrl.isFailed) return const SizedBox.shrink(); + + if (ctrl.isFailed) { + return Positioned.fill( + child: AbsorbPointer( + absorbing: true, + child: Center( + child: SizedBox( + width: 420, + child: Card( + elevation: 6, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Realtime connection failed', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + ctrl.lastError ?? + 'Unable to reconnect after multiple attempts.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => ctrl.retry(), + child: const Text('Retry'), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ); + } + + // isConnecting: show richer skeleton-like placeholders + return Positioned.fill( + child: AbsorbPointer( + absorbing: true, + child: Container( + color: Theme.of( + context, + ).colorScheme.surface.withAlpha((0.35 * 255).round()), + child: Center( + child: SizedBox( + width: 640, + child: Card( + elevation: 4, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + Container( + height: 20, + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + ), + const SizedBox(height: 12), + // chips row + Row( + children: [ + for (var i = 0; i < 3; i++) + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Container( + width: 100, + height: 14, + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + // lines representing content + for (var i = 0; i < 4; i++) ...[ + Container( + height: 12, + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + ), + ], + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/tasq_adaptive_list.dart b/lib/widgets/tasq_adaptive_list.dart index 0dba733d..7076eff5 100644 --- a/lib/widgets/tasq_adaptive_list.dart +++ b/lib/widgets/tasq_adaptive_list.dart @@ -1,6 +1,8 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; +// skeleton rendering is controlled by the caller's `Skeletonizer` wrapper +// so this widget doesn't import `skeletonizer` directly. import '../theme/app_typography.dart'; import '../theme/app_surfaces.dart'; @@ -59,6 +61,7 @@ class TasQAdaptiveList extends StatelessWidget { this.onRequestRefresh, this.onPageChanged, this.isLoading = false, + this.skeletonMode = false, }); /// The list of items to display. @@ -117,6 +120,13 @@ class TasQAdaptiveList extends StatelessWidget { /// If true, shows a loading indicator for server-side pagination. final bool isLoading; + /// When true the widget renders skeleton placeholders for the + /// dashboard, filter panel and list items instead of the real content. + /// This is intended to provide a single-source skeleton UI for screens + /// that wrap the whole body in a `Skeletonizer` and want consistent + /// sectioned placeholders. + final bool skeletonMode; + @override Widget build(BuildContext context) { return LayoutBuilder( @@ -135,6 +145,54 @@ class TasQAdaptiveList extends StatelessWidget { Widget _buildMobile(BuildContext context, BoxConstraints constraints) { final hasBoundedHeight = constraints.hasBoundedHeight; + if (skeletonMode) { + // Render structured skeleton sections: summary, filters, and list. + final summary = summaryDashboard == null + ? const SizedBox.shrink() + : Column( + children: [ + SizedBox(width: double.infinity, child: summaryDashboard!), + const SizedBox(height: 12), + ], + ); + final filter = filterHeader == null + ? const SizedBox.shrink() + : Column( + children: [ + ExpansionTile( + title: const Text('Filters'), + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: filterHeader!, + ), + ], + ), + const SizedBox(height: 8), + ], + ); + + final skeletonList = ListView.separated( + padding: const EdgeInsets.only(bottom: 24), + itemCount: 6, + separatorBuilder: (context, index) => const SizedBox(height: 12), + itemBuilder: (context, index) => _loadingTile(context), + ); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (summaryDashboard != null) ...[summary], + if (filterHeader != null) ...[filter], + Expanded(child: _buildInfiniteScrollListener(skeletonList)), + ], + ), + ); + } + // Mobile: Single-column with infinite scroll listeners final listView = ListView.separated( padding: const EdgeInsets.only(bottom: 24), @@ -142,14 +200,8 @@ class TasQAdaptiveList extends StatelessWidget { separatorBuilder: (context, index) => const SizedBox(height: 12), itemBuilder: (context, index) { if (index >= items.length) { - // Loading indicator for infinite scroll - return Padding( - padding: const EdgeInsets.only(top: 8), - child: SizedBox( - height: 24, - child: Center(child: CircularProgressIndicator(strokeWidth: 2)), - ), - ); + // Loading skeleton for infinite scroll (non-blocking shimmer) + return _loadingTile(context); } final item = items[index]; final actions = rowActions?.call(item) ?? const []; @@ -169,13 +221,7 @@ class TasQAdaptiveList extends StatelessWidget { separatorBuilder: (context, index) => const SizedBox(height: 12), itemBuilder: (context, index) { if (index >= items.length) { - return Padding( - padding: const EdgeInsets.only(top: 8), - child: SizedBox( - height: 24, - child: Center(child: CircularProgressIndicator(strokeWidth: 2)), - ), - ); + return _loadingTile(context); } final item = items[index]; final actions = rowActions?.call(item) ?? const []; @@ -245,6 +291,44 @@ class TasQAdaptiveList extends StatelessWidget { ); } + Widget _loadingTile(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 8), + child: SizedBox( + height: 72, + child: Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(6), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container(height: 12, color: Colors.white), + const SizedBox(height: 8), + Container(height: 10, width: 150, color: Colors.white), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } + Widget _buildDesktop(BuildContext context, BoxConstraints constraints) { final dataSource = _TasQTableSource( context: context, diff --git a/pubspec.lock b/pubspec.lock index c82acad7..9cb155c2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1301,6 +1301,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" + skeletonizer: + dependency: "direct main" + description: + name: skeletonizer + sha256: "9f38f9b47ec3cf2235a6a4f154a88a95432bc55ba98b3e2eb6ced5c1974bc122" + url: "https://pub.dev" + source: hosted + version: "2.1.3" sky_engine: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index f5275cba..5567b9e4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,6 +34,7 @@ dependencies: firebase_messaging: ^16.1.1 shared_preferences: ^2.2.0 uuid: ^4.1.0 + skeletonizer: ^2.1.3 dev_dependencies: flutter_test: