From 863f3151b32067a30cc74f6623a0a71005b6c049 Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Sat, 21 Feb 2026 14:53:38 +0800 Subject: [PATCH] Show saving indicator --- lib/screens/tasks/task_detail_screen.dart | 658 ++++++++++++++++------ 1 file changed, 494 insertions(+), 164 deletions(-) diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index 9ad1cdff..31a42bc5 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -53,6 +53,18 @@ class _TaskDetailScreenState extends ConsumerState { Timer? _requestedDebounce; Timer? _notedDebounce; Timer? _receivedDebounce; + // Seeding/state tracking for signatory fields + String? _seededTaskId; + bool _requestedSaving = false; + bool _requestedSaved = false; + bool _notedSaving = false; + bool _notedSaved = false; + bool _receivedSaving = false; + bool _receivedSaved = false; + bool _typeSaving = false; + bool _typeSaved = false; + bool _categorySaving = false; + bool _categorySaved = false; static const List _statusOptions = [ 'queued', 'in_progress', @@ -100,7 +112,6 @@ class _TaskDetailScreenState extends ConsumerState { child: Center(child: Text('Task not found.')), ); } - final ticketId = task.ticketId; final typingChannelId = task.id; final ticket = ticketId == null @@ -139,6 +150,17 @@ class _TaskDetailScreenState extends ConsumerState { builder: (context, constraints) { final isWide = constraints.maxWidth >= AppBreakpoints.desktop; + // 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; + } + final detailsContent = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -158,7 +180,7 @@ class _TaskDetailScreenState extends ConsumerState { child: Text( _createdByLabel(profilesAsync, task, ticket), textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall, + style: Theme.of(context).textTheme.labelMedium, ), ), const SizedBox(height: 12), @@ -240,6 +262,24 @@ class _TaskDetailScreenState extends ConsumerState { const SizedBox(height: 6), DropdownButtonFormField( initialValue: task.requestType, + decoration: InputDecoration( + suffixIcon: _typeSaving + ? const SizedBox( + width: 12, + height: 12, + child: + CircularProgressIndicator( + strokeWidth: 1.0, + ), + ) + : _typeSaved + ? const Icon( + Icons.check, + color: Colors.green, + size: 14, + ) + : null, + ), items: [ const DropdownMenuItem( value: null, @@ -251,28 +291,98 @@ class _TaskDetailScreenState extends ConsumerState { child: Text(t), ), ], - onChanged: (v) => ref - .read(tasksControllerProvider) - .updateTask( - taskId: task.id, - requestType: v, - ), + 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, - decoration: const InputDecoration( + decoration: InputDecoration( hintText: 'Details', + suffixIcon: _typeSaving + ? const SizedBox( + width: 12, + height: 12, + child: + CircularProgressIndicator( + strokeWidth: 1.0, + ), + ) + : _typeSaved + ? const Icon( + Icons.check, + color: Colors.green, + size: 14, + ) + : null, ), - onChanged: (text) => ref - .read(tasksControllerProvider) - .updateTask( - taskId: task.id, - requestTypeOther: text.isEmpty - ? null - : text, - ), + 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, + ); + } + }, + ); + } + } + }, ), ], const SizedBox(height: 8), @@ -280,6 +390,24 @@ class _TaskDetailScreenState extends ConsumerState { const SizedBox(height: 6), DropdownButtonFormField( initialValue: task.requestCategory, + decoration: InputDecoration( + suffixIcon: _categorySaving + ? const SizedBox( + width: 12, + height: 12, + child: + CircularProgressIndicator( + strokeWidth: 1.0, + ), + ) + : _categorySaved + ? const Icon( + Icons.check, + color: Colors.green, + size: 14, + ) + : null, + ), items: [ const DropdownMenuItem( value: null, @@ -291,12 +419,42 @@ class _TaskDetailScreenState extends ConsumerState { child: Text(c), ), ], - onChanged: (v) => ref - .read(tasksControllerProvider) - .updateTask( - taskId: task.id, - requestCategory: v, - ), + 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), @@ -320,42 +478,83 @@ class _TaskDetailScreenState extends ConsumerState { ), const SizedBox(height: 6), TypeAheadFormField( - textFieldConfiguration: - TextFieldConfiguration( - controller: _requestedController, - decoration: const InputDecoration( - hintText: 'Requester name or id', - ), - onChanged: (v) { - _requestedDebounce?.cancel(); - _requestedDebounce = Timer( - const Duration(milliseconds: 700), - () async { - final name = v.trim(); - 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 (_) {} - } - }, - ); + textFieldConfiguration: TextFieldConfiguration( + controller: _requestedController, + decoration: InputDecoration( + hintText: 'Requester name or id', + suffixIcon: _requestedSaving + ? const SizedBox( + width: 12, + height: 12, + child: + CircularProgressIndicator( + strokeWidth: 1.0, + ), + ) + : _requestedSaved + ? const Icon( + Icons.check, + color: Colors.green, + size: 14, + ) + : 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 @@ -402,22 +601,49 @@ class _TaskDetailScreenState extends ConsumerState { onSuggestionSelected: (suggestion) async { _requestedDebounce?.cancel(); _requestedController.text = suggestion; - await ref - .read(tasksControllerProvider) - .updateTask( - taskId: task.id, - requestedBy: suggestion.isEmpty - ? null - : suggestion, - ); + setState(() { + _requestedSaving = true; + _requestedSaved = false; + }); try { + await ref + .read(tasksControllerProvider) + .updateTask( + taskId: task.id, + requestedBy: suggestion.isEmpty + ? null + : suggestion, + ); if (suggestion.isNotEmpty) { - await ref - .read(supabaseClientProvider) - .from('clients') - .upsert({'name': suggestion}); + try { + await ref + .read(supabaseClientProvider) + .from('clients') + .upsert({'name': suggestion}); + } catch (_) {} } - } catch (_) {} + setState( + () => _requestedSaved = + suggestion.isNotEmpty, + ); + } catch (_) { + } finally { + setState( + () => _requestedSaving = false, + ); + if (_requestedSaved) { + Future.delayed( + const Duration(seconds: 2), + () { + if (mounted) { + setState( + () => _requestedSaved = false, + ); + } + }, + ); + } + } }, ), @@ -430,42 +656,83 @@ class _TaskDetailScreenState extends ConsumerState { ), const SizedBox(height: 6), TypeAheadFormField( - textFieldConfiguration: - TextFieldConfiguration( - controller: _notedController, - decoration: const InputDecoration( - hintText: 'Supervisor/Senior', - ), - onChanged: (v) { - _notedDebounce?.cancel(); - _notedDebounce = Timer( - const Duration(milliseconds: 700), - () async { - final name = v.trim(); - await ref - .read( - tasksControllerProvider, - ) - .updateTask( - taskId: task.id, - notedBy: name.isEmpty - ? null - : name, - ); - if (name.isNotEmpty) { - try { - await ref - .read( - supabaseClientProvider, - ) - .from('clients') - .upsert({'name': name}); - } catch (_) {} - } - }, - ); + textFieldConfiguration: TextFieldConfiguration( + controller: _notedController, + decoration: InputDecoration( + hintText: 'Supervisor/Senior', + suffixIcon: _notedSaving + ? const SizedBox( + width: 12, + height: 12, + child: + CircularProgressIndicator( + strokeWidth: 1.0, + ), + ) + : _notedSaved + ? const Icon( + Icons.check, + color: Colors.green, + size: 14, + ) + : null, + ), + onChanged: (v) { + _notedDebounce?.cancel(); + _notedDebounce = Timer( + const Duration(milliseconds: 700), + () async { + final name = v.trim(); + setState(() { + _notedSaving = true; + _notedSaved = false; + }); + try { + await ref + .read(tasksControllerProvider) + .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, + ); + } + }, + ); + } + } }, - ), + ); + }, + ), suggestionsCallback: (pattern) async { final profiles = ref @@ -512,22 +779,47 @@ class _TaskDetailScreenState extends ConsumerState { onSuggestionSelected: (suggestion) async { _notedDebounce?.cancel(); _notedController.text = suggestion; - await ref - .read(tasksControllerProvider) - .updateTask( - taskId: task.id, - notedBy: suggestion.isEmpty - ? null - : suggestion, - ); + setState(() { + _notedSaving = true; + _notedSaved = false; + }); try { + await ref + .read(tasksControllerProvider) + .updateTask( + taskId: task.id, + notedBy: suggestion.isEmpty + ? null + : suggestion, + ); if (suggestion.isNotEmpty) { - await ref - .read(supabaseClientProvider) - .from('clients') - .upsert({'name': suggestion}); + try { + await ref + .read(supabaseClientProvider) + .from('clients') + .upsert({'name': suggestion}); + } catch (_) {} } - } catch (_) {} + setState( + () => _notedSaved = + suggestion.isNotEmpty, + ); + } catch (_) { + } finally { + setState(() => _notedSaving = false); + if (_notedSaved) { + Future.delayed( + const Duration(seconds: 2), + () { + if (mounted) { + setState( + () => _notedSaved = false, + ); + } + }, + ); + } + } }, ), @@ -540,42 +832,55 @@ class _TaskDetailScreenState extends ConsumerState { ), const SizedBox(height: 6), TypeAheadFormField( - textFieldConfiguration: - TextFieldConfiguration( - controller: _receivedController, - decoration: const InputDecoration( - hintText: 'Receiver name or id', - ), - onChanged: (v) { - _receivedDebounce?.cancel(); - _receivedDebounce = Timer( - const Duration(milliseconds: 700), - () async { - final name = v.trim(); + textFieldConfiguration: TextFieldConfiguration( + controller: _receivedController, + decoration: InputDecoration( + hintText: 'Receiver name or id', + suffixIcon: _receivedSaving + ? const SizedBox( + width: 12, + height: 12, + child: + CircularProgressIndicator( + strokeWidth: 1.0, + ), + ) + : _receivedSaved + ? const Icon( + Icons.check, + color: Colors.green, + size: 14, + ) + : null, + ), + onChanged: (v) { + _receivedDebounce?.cancel(); + _receivedDebounce = Timer( + const Duration(milliseconds: 700), + () async { + final name = v.trim(); + await ref + .read(tasksControllerProvider) + .updateTask( + taskId: task.id, + receivedBy: name.isEmpty + ? null + : name, + ); + if (name.isNotEmpty) { + try { await ref .read( - tasksControllerProvider, + supabaseClientProvider, ) - .updateTask( - taskId: task.id, - receivedBy: name.isEmpty - ? null - : name, - ); - if (name.isNotEmpty) { - try { - await ref - .read( - supabaseClientProvider, - ) - .from('clients') - .upsert({'name': name}); - } catch (_) {} - } - }, - ); + .from('clients') + .upsert({'name': name}); + } catch (_) {} + } }, - ), + ); + }, + ), suggestionsCallback: (pattern) async { final profiles = ref @@ -622,22 +927,47 @@ class _TaskDetailScreenState extends ConsumerState { onSuggestionSelected: (suggestion) async { _receivedDebounce?.cancel(); _receivedController.text = suggestion; - await ref - .read(tasksControllerProvider) - .updateTask( - taskId: task.id, - receivedBy: suggestion.isEmpty - ? null - : suggestion, - ); + setState(() { + _receivedSaving = true; + _receivedSaved = false; + }); try { + await ref + .read(tasksControllerProvider) + .updateTask( + taskId: task.id, + receivedBy: suggestion.isEmpty + ? null + : suggestion, + ); if (suggestion.isNotEmpty) { - await ref - .read(supabaseClientProvider) - .from('clients') - .upsert({'name': suggestion}); + try { + await ref + .read(supabaseClientProvider) + .from('clients') + .upsert({'name': suggestion}); + } catch (_) {} } - } catch (_) {} + setState( + () => _receivedSaved = + suggestion.isNotEmpty, + ); + } catch (_) { + } finally { + setState(() => _receivedSaving = false); + if (_receivedSaved) { + Future.delayed( + const Duration(seconds: 2), + () { + if (mounted) { + setState( + () => _receivedSaved = false, + ); + } + }, + ); + } + } }, ), ],