A better saving indicator for auto save fields

This commit is contained in:
Marc Rejohn Castillano 2026-02-21 15:06:44 +08:00
parent 863f3151b3
commit f8b8723d26

View File

@ -44,7 +44,8 @@ class TaskDetailScreen extends ConsumerStatefulWidget {
ConsumerState<TaskDetailScreen> createState() => _TaskDetailScreenState(); ConsumerState<TaskDetailScreen> createState() => _TaskDetailScreenState();
} }
class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> { class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
with SingleTickerProviderStateMixin {
final _messageController = TextEditingController(); final _messageController = TextEditingController();
// Controllers for editable signatories // Controllers for editable signatories
final _requestedController = TextEditingController(); final _requestedController = TextEditingController();
@ -65,6 +66,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
bool _typeSaved = false; bool _typeSaved = false;
bool _categorySaving = false; bool _categorySaving = false;
bool _categorySaved = false; bool _categorySaved = false;
late final AnimationController _saveAnimController;
late final Animation<double> _savePulse;
static const List<String> _statusOptions = [ static const List<String> _statusOptions = [
'queued', 'queued',
'in_progress', 'in_progress',
@ -82,6 +85,13 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
.read(notificationsControllerProvider) .read(notificationsControllerProvider)
.markReadForTask(widget.taskId), .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 @override
@ -93,9 +103,30 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
_requestedDebounce?.cancel(); _requestedDebounce?.cancel();
_notedDebounce?.cancel(); _notedDebounce?.cancel();
_receivedDebounce?.cancel(); _receivedDebounce?.cancel();
_saveAnimController.dispose();
super.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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tasksAsync = ref.watch(tasksProvider); final tasksAsync = ref.watch(tasksProvider);
@ -145,6 +176,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
ticketId == null ? null : ref.watch(ticketMessagesProvider(ticketId)), ticketId == null ? null : ref.watch(ticketMessagesProvider(ticketId)),
); );
WidgetsBinding.instance.addPostFrameCallback((_) => _updateSaveAnim());
return ResponsiveBody( return ResponsiveBody(
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
@ -264,19 +297,40 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
initialValue: task.requestType, initialValue: task.requestType,
decoration: InputDecoration( decoration: InputDecoration(
suffixIcon: _typeSaving suffixIcon: _typeSaving
? const SizedBox( ? SizedBox(
width: 12, width: 16,
height: 12, height: 16,
child: child: ScaleTransition(
CircularProgressIndicator( scale: _savePulse,
strokeWidth: 1.0, child: const Icon(
Icons.save,
size: 14,
),
), ),
) )
: _typeSaved : _typeSaved
? const Icon( ? SizedBox(
Icons.check, width: 16,
color: Colors.green, height: 16,
child: Stack(
alignment: Alignment.center,
children: const [
Icon(
Icons.save,
size: 14, size: 14,
color: Colors.green,
),
Positioned(
right: -2,
bottom: -2,
child: Icon(
Icons.check,
size: 10,
color: Colors.white,
),
),
],
),
) )
: null, : null,
), ),
@ -332,19 +386,40 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Details', hintText: 'Details',
suffixIcon: _typeSaving suffixIcon: _typeSaving
? const SizedBox( ? SizedBox(
width: 12, width: 16,
height: 12, height: 16,
child: child: ScaleTransition(
CircularProgressIndicator( scale: _savePulse,
strokeWidth: 1.0, child: const Icon(
Icons.save,
size: 14,
),
), ),
) )
: _typeSaved : _typeSaved
? const Icon( ? SizedBox(
Icons.check, width: 16,
color: Colors.green, height: 16,
child: Stack(
alignment: Alignment.center,
children: const [
Icon(
Icons.save,
size: 14, size: 14,
color: Colors.green,
),
Positioned(
right: -2,
bottom: -2,
child: Icon(
Icons.check,
size: 10,
color: Colors.white,
),
),
],
),
) )
: null, : null,
), ),
@ -392,19 +467,40 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
initialValue: task.requestCategory, initialValue: task.requestCategory,
decoration: InputDecoration( decoration: InputDecoration(
suffixIcon: _categorySaving suffixIcon: _categorySaving
? const SizedBox( ? SizedBox(
width: 12, width: 16,
height: 12, height: 16,
child: child: ScaleTransition(
CircularProgressIndicator( scale: _savePulse,
strokeWidth: 1.0, child: const Icon(
Icons.save,
size: 14,
),
), ),
) )
: _categorySaved : _categorySaved
? const Icon( ? SizedBox(
Icons.check, width: 16,
color: Colors.green, height: 16,
child: Stack(
alignment: Alignment.center,
children: const [
Icon(
Icons.save,
size: 14, size: 14,
color: Colors.green,
),
Positioned(
right: -2,
bottom: -2,
child: Icon(
Icons.check,
size: 10,
color: Colors.white,
),
),
],
),
) )
: null, : null,
), ),
@ -483,19 +579,40 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Requester name or id', hintText: 'Requester name or id',
suffixIcon: _requestedSaving suffixIcon: _requestedSaving
? const SizedBox( ? SizedBox(
width: 12, width: 16,
height: 12, height: 16,
child: child: ScaleTransition(
CircularProgressIndicator( scale: _savePulse,
strokeWidth: 1.0, child: const Icon(
Icons.save,
size: 14,
),
), ),
) )
: _requestedSaved : _requestedSaved
? const Icon( ? SizedBox(
Icons.check, width: 16,
color: Colors.green, height: 16,
child: Stack(
alignment: Alignment.center,
children: const [
Icon(
Icons.save,
size: 14, size: 14,
color: Colors.green,
),
Positioned(
right: -2,
bottom: -2,
child: Icon(
Icons.check,
size: 10,
color: Colors.white,
),
),
],
),
) )
: null, : null,
), ),
@ -661,19 +778,40 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Supervisor/Senior', hintText: 'Supervisor/Senior',
suffixIcon: _notedSaving suffixIcon: _notedSaving
? const SizedBox( ? SizedBox(
width: 12, width: 16,
height: 12, height: 16,
child: child: ScaleTransition(
CircularProgressIndicator( scale: _savePulse,
strokeWidth: 1.0, child: const Icon(
Icons.save,
size: 14,
),
), ),
) )
: _notedSaved : _notedSaved
? const Icon( ? SizedBox(
Icons.check, width: 16,
color: Colors.green, height: 16,
child: Stack(
alignment: Alignment.center,
children: const [
Icon(
Icons.save,
size: 14, size: 14,
color: Colors.green,
),
Positioned(
right: -2,
bottom: -2,
child: Icon(
Icons.check,
size: 10,
color: Colors.white,
),
),
],
),
) )
: null, : null,
), ),
@ -837,19 +975,40 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Receiver name or id', hintText: 'Receiver name or id',
suffixIcon: _receivedSaving suffixIcon: _receivedSaving
? const SizedBox( ? SizedBox(
width: 12, width: 16,
height: 12, height: 16,
child: child: ScaleTransition(
CircularProgressIndicator( scale: _savePulse,
strokeWidth: 1.0, child: const Icon(
Icons.save,
size: 14,
),
), ),
) )
: _receivedSaved : _receivedSaved
? const Icon( ? SizedBox(
Icons.check, width: 16,
color: Colors.green, height: 16,
child: Stack(
alignment: Alignment.center,
children: const [
Icon(
Icons.save,
size: 14, size: 14,
color: Colors.green,
),
Positioned(
right: -2,
bottom: -2,
child: Icon(
Icons.check,
size: 10,
color: Colors.white,
),
),
],
),
) )
: null, : null,
), ),
@ -859,6 +1018,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
const Duration(milliseconds: 700), const Duration(milliseconds: 700),
() async { () async {
final name = v.trim(); final name = v.trim();
setState(() {
_receivedSaving = true;
_receivedSaved = false;
});
try {
await ref await ref
.read(tasksControllerProvider) .read(tasksControllerProvider)
.updateTask( .updateTask(
@ -877,6 +1041,30 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
.upsert({'name': name}); .upsert({'name': name});
} catch (_) {} } catch (_) {}
} }
setState(() {
_receivedSaved =
name.isNotEmpty;
});
} catch (_) {
// ignore
} finally {
setState(() {
_receivedSaving = false;
});
if (_receivedSaved) {
Future.delayed(
const Duration(seconds: 2),
() {
if (mounted) {
setState(
() => _receivedSaved =
false,
);
}
},
);
}
}
}, },
); );
}, },