diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index 31a42bc5..82c07510 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -44,7 +44,8 @@ class TaskDetailScreen extends ConsumerStatefulWidget { ConsumerState createState() => _TaskDetailScreenState(); } -class _TaskDetailScreenState extends ConsumerState { +class _TaskDetailScreenState extends ConsumerState + with SingleTickerProviderStateMixin { final _messageController = TextEditingController(); // Controllers for editable signatories final _requestedController = TextEditingController(); @@ -65,6 +66,8 @@ class _TaskDetailScreenState extends ConsumerState { bool _typeSaved = false; bool _categorySaving = false; bool _categorySaved = false; + late final AnimationController _saveAnimController; + late final Animation _savePulse; static const List _statusOptions = [ 'queued', 'in_progress', @@ -82,6 +85,13 @@ class _TaskDetailScreenState extends ConsumerState { .read(notificationsControllerProvider) .markReadForTask(widget.taskId), ); + _saveAnimController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 700), + ); + _savePulse = Tween(begin: 1.0, end: 0.78).animate( + CurvedAnimation(parent: _saveAnimController, curve: Curves.easeInOut), + ); } @override @@ -93,9 +103,30 @@ class _TaskDetailScreenState extends ConsumerState { _requestedDebounce?.cancel(); _notedDebounce?.cancel(); _receivedDebounce?.cancel(); + _saveAnimController.dispose(); super.dispose(); } + bool get _anySaving => + _requestedSaving || + _notedSaving || + _receivedSaving || + _typeSaving || + _categorySaving; + + void _updateSaveAnim() { + if (_anySaving) { + if (!_saveAnimController.isAnimating) { + _saveAnimController.repeat(reverse: true); + } + } else { + if (_saveAnimController.isAnimating) { + _saveAnimController.stop(); + _saveAnimController.reset(); + } + } + } + @override Widget build(BuildContext context) { final tasksAsync = ref.watch(tasksProvider); @@ -145,6 +176,8 @@ class _TaskDetailScreenState extends ConsumerState { ticketId == null ? null : ref.watch(ticketMessagesProvider(ticketId)), ); + WidgetsBinding.instance.addPostFrameCallback((_) => _updateSaveAnim()); + return ResponsiveBody( child: LayoutBuilder( builder: (context, constraints) { @@ -264,19 +297,40 @@ class _TaskDetailScreenState extends ConsumerState { initialValue: task.requestType, decoration: InputDecoration( suffixIcon: _typeSaving - ? const SizedBox( - width: 12, - height: 12, - child: - CircularProgressIndicator( - strokeWidth: 1.0, - ), + ? SizedBox( + width: 16, + height: 16, + child: ScaleTransition( + scale: _savePulse, + child: const Icon( + Icons.save, + size: 14, + ), + ), ) : _typeSaved - ? const Icon( - Icons.check, - color: Colors.green, - size: 14, + ? 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, ), @@ -332,19 +386,40 @@ class _TaskDetailScreenState extends ConsumerState { decoration: InputDecoration( hintText: 'Details', suffixIcon: _typeSaving - ? const SizedBox( - width: 12, - height: 12, - child: - CircularProgressIndicator( - strokeWidth: 1.0, - ), + ? SizedBox( + width: 16, + height: 16, + child: ScaleTransition( + scale: _savePulse, + child: const Icon( + Icons.save, + size: 14, + ), + ), ) : _typeSaved - ? const Icon( - Icons.check, - color: Colors.green, - size: 14, + ? 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, ), @@ -392,19 +467,40 @@ class _TaskDetailScreenState extends ConsumerState { initialValue: task.requestCategory, decoration: InputDecoration( suffixIcon: _categorySaving - ? const SizedBox( - width: 12, - height: 12, - child: - CircularProgressIndicator( - strokeWidth: 1.0, - ), + ? SizedBox( + width: 16, + height: 16, + child: ScaleTransition( + scale: _savePulse, + child: const Icon( + Icons.save, + size: 14, + ), + ), ) : _categorySaved - ? const Icon( - Icons.check, - color: Colors.green, - size: 14, + ? 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, ), @@ -483,19 +579,40 @@ class _TaskDetailScreenState extends ConsumerState { decoration: InputDecoration( hintText: 'Requester name or id', suffixIcon: _requestedSaving - ? const SizedBox( - width: 12, - height: 12, - child: - CircularProgressIndicator( - strokeWidth: 1.0, - ), + ? SizedBox( + width: 16, + height: 16, + child: ScaleTransition( + scale: _savePulse, + child: const Icon( + Icons.save, + size: 14, + ), + ), ) : _requestedSaved - ? const Icon( - Icons.check, - color: Colors.green, - size: 14, + ? 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, ), @@ -661,19 +778,40 @@ class _TaskDetailScreenState extends ConsumerState { decoration: InputDecoration( hintText: 'Supervisor/Senior', suffixIcon: _notedSaving - ? const SizedBox( - width: 12, - height: 12, - child: - CircularProgressIndicator( - strokeWidth: 1.0, - ), + ? SizedBox( + width: 16, + height: 16, + child: ScaleTransition( + scale: _savePulse, + child: const Icon( + Icons.save, + size: 14, + ), + ), ) : _notedSaved - ? const Icon( - Icons.check, - color: Colors.green, - size: 14, + ? 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, ), @@ -837,19 +975,40 @@ class _TaskDetailScreenState extends ConsumerState { decoration: InputDecoration( hintText: 'Receiver name or id', suffixIcon: _receivedSaving - ? const SizedBox( - width: 12, - height: 12, - child: - CircularProgressIndicator( - strokeWidth: 1.0, - ), + ? SizedBox( + width: 16, + height: 16, + child: ScaleTransition( + scale: _savePulse, + child: const Icon( + Icons.save, + size: 14, + ), + ), ) : _receivedSaved - ? const Icon( - Icons.check, - color: Colors.green, - size: 14, + ? 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, ), @@ -859,23 +1018,52 @@ class _TaskDetailScreenState extends ConsumerState { const Duration(milliseconds: 700), () async { final name = v.trim(); - await ref - .read(tasksControllerProvider) - .updateTask( - taskId: task.id, - receivedBy: name.isEmpty - ? null - : name, + setState(() { + _receivedSaving = true; + _receivedSaved = false; + }); + try { + await ref + .read(tasksControllerProvider) + .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, + ); + } + }, ); - if (name.isNotEmpty) { - try { - await ref - .read( - supabaseClientProvider, - ) - .from('clients') - .upsert({'name': name}); - } catch (_) {} + } } }, );