Show saving indicator

This commit is contained in:
Marc Rejohn Castillano 2026-02-21 14:53:38 +08:00
parent 8d31a629ac
commit 863f3151b3

View File

@ -53,6 +53,18 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
Timer? _requestedDebounce; Timer? _requestedDebounce;
Timer? _notedDebounce; Timer? _notedDebounce;
Timer? _receivedDebounce; 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<String> _statusOptions = [ static const List<String> _statusOptions = [
'queued', 'queued',
'in_progress', 'in_progress',
@ -100,7 +112,6 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
child: Center(child: Text('Task not found.')), child: Center(child: Text('Task not found.')),
); );
} }
final ticketId = task.ticketId; final ticketId = task.ticketId;
final typingChannelId = task.id; final typingChannelId = task.id;
final ticket = ticketId == null final ticket = ticketId == null
@ -139,6 +150,17 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
builder: (context, constraints) { builder: (context, constraints) {
final isWide = constraints.maxWidth >= AppBreakpoints.desktop; 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( final detailsContent = Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -158,7 +180,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
child: Text( child: Text(
_createdByLabel(profilesAsync, task, ticket), _createdByLabel(profilesAsync, task, ticket),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.labelMedium,
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -240,6 +262,24 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
const SizedBox(height: 6), const SizedBox(height: 6),
DropdownButtonFormField<String?>( DropdownButtonFormField<String?>(
initialValue: task.requestType, 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: [ items: [
const DropdownMenuItem( const DropdownMenuItem(
value: null, value: null,
@ -251,28 +291,98 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
child: Text(t), child: Text(t),
), ),
], ],
onChanged: (v) => ref onChanged: (v) async {
.read(tasksControllerProvider) setState(() {
.updateTask( _typeSaving = true;
taskId: task.id, _typeSaved = false;
requestType: v, });
), 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') ...[ if (task.requestType == 'Other') ...[
const SizedBox(height: 8), const SizedBox(height: 8),
TextFormField( TextFormField(
initialValue: task.requestTypeOther, initialValue: task.requestTypeOther,
decoration: const InputDecoration( decoration: InputDecoration(
hintText: 'Details', 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 onChanged: (text) async {
.read(tasksControllerProvider) setState(() {
.updateTask( _typeSaving = true;
taskId: task.id, _typeSaved = false;
requestTypeOther: text.isEmpty });
? null try {
: text, 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), const SizedBox(height: 8),
@ -280,6 +390,24 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
const SizedBox(height: 6), const SizedBox(height: 6),
DropdownButtonFormField<String?>( DropdownButtonFormField<String?>(
initialValue: task.requestCategory, 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: [ items: [
const DropdownMenuItem( const DropdownMenuItem(
value: null, value: null,
@ -291,12 +419,42 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
child: Text(c), child: Text(c),
), ),
], ],
onChanged: (v) => ref onChanged: (v) async {
.read(tasksControllerProvider) setState(() {
.updateTask( _categorySaving = true;
taskId: task.id, _categorySaved = false;
requestCategory: v, });
), 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), const SizedBox(height: 12),
@ -320,42 +478,83 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
TypeAheadFormField<String>( TypeAheadFormField<String>(
textFieldConfiguration: textFieldConfiguration: TextFieldConfiguration(
TextFieldConfiguration( controller: _requestedController,
controller: _requestedController, decoration: InputDecoration(
decoration: const InputDecoration( hintText: 'Requester name or id',
hintText: 'Requester name or id', suffixIcon: _requestedSaving
), ? const SizedBox(
onChanged: (v) { width: 12,
_requestedDebounce?.cancel(); height: 12,
_requestedDebounce = Timer( child:
const Duration(milliseconds: 700), CircularProgressIndicator(
() async { strokeWidth: 1.0,
final name = v.trim(); ),
await ref )
.read( : _requestedSaved
tasksControllerProvider, ? const Icon(
) Icons.check,
.updateTask( color: Colors.green,
taskId: task.id, size: 14,
requestedBy: name.isEmpty )
? null : null,
: name, ),
); onChanged: (v) {
if (name.isNotEmpty) { _requestedDebounce?.cancel();
try { _requestedDebounce = Timer(
await ref const Duration(milliseconds: 700),
.read( () async {
supabaseClientProvider, final name = v.trim();
) setState(() {
.from('clients') _requestedSaving = true;
.upsert({'name': name}); _requestedSaved = false;
} catch (_) {} });
} 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 { suggestionsCallback: (pattern) async {
final profiles = final profiles =
ref ref
@ -402,22 +601,49 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
onSuggestionSelected: (suggestion) async { onSuggestionSelected: (suggestion) async {
_requestedDebounce?.cancel(); _requestedDebounce?.cancel();
_requestedController.text = suggestion; _requestedController.text = suggestion;
await ref setState(() {
.read(tasksControllerProvider) _requestedSaving = true;
.updateTask( _requestedSaved = false;
taskId: task.id, });
requestedBy: suggestion.isEmpty
? null
: suggestion,
);
try { try {
await ref
.read(tasksControllerProvider)
.updateTask(
taskId: task.id,
requestedBy: suggestion.isEmpty
? null
: suggestion,
);
if (suggestion.isNotEmpty) { if (suggestion.isNotEmpty) {
await ref try {
.read(supabaseClientProvider) await ref
.from('clients') .read(supabaseClientProvider)
.upsert({'name': suggestion}); .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<TaskDetailScreen> {
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
TypeAheadFormField<String>( TypeAheadFormField<String>(
textFieldConfiguration: textFieldConfiguration: TextFieldConfiguration(
TextFieldConfiguration( controller: _notedController,
controller: _notedController, decoration: InputDecoration(
decoration: const InputDecoration( hintText: 'Supervisor/Senior',
hintText: 'Supervisor/Senior', suffixIcon: _notedSaving
), ? const SizedBox(
onChanged: (v) { width: 12,
_notedDebounce?.cancel(); height: 12,
_notedDebounce = Timer( child:
const Duration(milliseconds: 700), CircularProgressIndicator(
() async { strokeWidth: 1.0,
final name = v.trim(); ),
await ref )
.read( : _notedSaved
tasksControllerProvider, ? const Icon(
) Icons.check,
.updateTask( color: Colors.green,
taskId: task.id, size: 14,
notedBy: name.isEmpty )
? null : null,
: name, ),
); onChanged: (v) {
if (name.isNotEmpty) { _notedDebounce?.cancel();
try { _notedDebounce = Timer(
await ref const Duration(milliseconds: 700),
.read( () async {
supabaseClientProvider, final name = v.trim();
) setState(() {
.from('clients') _notedSaving = true;
.upsert({'name': name}); _notedSaved = false;
} catch (_) {} });
} 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 { suggestionsCallback: (pattern) async {
final profiles = final profiles =
ref ref
@ -512,22 +779,47 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
onSuggestionSelected: (suggestion) async { onSuggestionSelected: (suggestion) async {
_notedDebounce?.cancel(); _notedDebounce?.cancel();
_notedController.text = suggestion; _notedController.text = suggestion;
await ref setState(() {
.read(tasksControllerProvider) _notedSaving = true;
.updateTask( _notedSaved = false;
taskId: task.id, });
notedBy: suggestion.isEmpty
? null
: suggestion,
);
try { try {
await ref
.read(tasksControllerProvider)
.updateTask(
taskId: task.id,
notedBy: suggestion.isEmpty
? null
: suggestion,
);
if (suggestion.isNotEmpty) { if (suggestion.isNotEmpty) {
await ref try {
.read(supabaseClientProvider) await ref
.from('clients') .read(supabaseClientProvider)
.upsert({'name': suggestion}); .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<TaskDetailScreen> {
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
TypeAheadFormField<String>( TypeAheadFormField<String>(
textFieldConfiguration: textFieldConfiguration: TextFieldConfiguration(
TextFieldConfiguration( controller: _receivedController,
controller: _receivedController, decoration: InputDecoration(
decoration: const InputDecoration( hintText: 'Receiver name or id',
hintText: 'Receiver name or id', suffixIcon: _receivedSaving
), ? const SizedBox(
onChanged: (v) { width: 12,
_receivedDebounce?.cancel(); height: 12,
_receivedDebounce = Timer( child:
const Duration(milliseconds: 700), CircularProgressIndicator(
() async { strokeWidth: 1.0,
final name = v.trim(); ),
)
: _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 await ref
.read( .read(
tasksControllerProvider, supabaseClientProvider,
) )
.updateTask( .from('clients')
taskId: task.id, .upsert({'name': name});
receivedBy: name.isEmpty } catch (_) {}
? null }
: name,
);
if (name.isNotEmpty) {
try {
await ref
.read(
supabaseClientProvider,
)
.from('clients')
.upsert({'name': name});
} catch (_) {}
}
},
);
}, },
), );
},
),
suggestionsCallback: (pattern) async { suggestionsCallback: (pattern) async {
final profiles = final profiles =
ref ref
@ -622,22 +927,47 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
onSuggestionSelected: (suggestion) async { onSuggestionSelected: (suggestion) async {
_receivedDebounce?.cancel(); _receivedDebounce?.cancel();
_receivedController.text = suggestion; _receivedController.text = suggestion;
await ref setState(() {
.read(tasksControllerProvider) _receivedSaving = true;
.updateTask( _receivedSaved = false;
taskId: task.id, });
receivedBy: suggestion.isEmpty
? null
: suggestion,
);
try { try {
await ref
.read(tasksControllerProvider)
.updateTask(
taskId: task.id,
receivedBy: suggestion.isEmpty
? null
: suggestion,
);
if (suggestion.isNotEmpty) { if (suggestion.isNotEmpty) {
await ref try {
.read(supabaseClientProvider) await ref
.from('clients') .read(supabaseClientProvider)
.upsert({'name': suggestion}); .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,
);
}
},
);
}
}
}, },
), ),
], ],