feat(programmer-tasks): loading states, confirmation & success dialogs

Day sheet dialogs (disapprove, justify): moved the async controller call
inside each dialog so loading state is owned there — button shows a
spinner and is disabled while the RPC is in flight; errors display inline
instead of via a snackbar after dialog close.

Detail screen (approve): added _approvingSheet bool; Approve button shows
CircularProgressIndicator while in flight. On success shows an AlertDialog
"Sheet Approved" with a check icon instead of silently updating state.

Approvals tab (bulk approve): upgraded post-approve feedback from a
snackbar to a success AlertDialog showing the count of approved sheets
and any skipped ones.

Day sheets tab (submit): added confirmation AlertDialog before the
existing submit flow so programmers don't accidentally submit mid-day.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-09-28 22:31:50 +08:00
parent 2946945ce3
commit 88848138d9
4 changed files with 157 additions and 58 deletions
@@ -87,12 +87,27 @@ class _ApprovalsTabState extends ConsumerState<ApprovalsTab> {
_approving = false;
});
final buf = StringBuffer('Approved ${result.approvedIds.length}');
if (result.skippedIds.isNotEmpty) {
buf.write(', ${result.skippedIds.length} already reviewed');
}
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(buf.toString())));
final count = result.approvedIds.length;
final skipped = result.skippedIds.length;
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
icon: const Icon(Icons.check_circle_outline, size: 48),
title: Text('Approved $count ${count == 1 ? 'Sheet' : 'Sheets'}'),
content: skipped > 0
? Text(
'$skipped ${skipped == 1 ? 'sheet was' : 'sheets were'} '
'already reviewed and skipped.',
)
: null,
actions: [
FilledButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Done'),
),
],
),
);
} on DaySheetActionException catch (e) {
if (!mounted) return;
setState(() => _approving = false);
@@ -35,6 +35,7 @@ class DaySheetDetailScreen extends ConsumerStatefulWidget {
class _DaySheetDetailScreenState extends ConsumerState<DaySheetDetailScreen> {
Set<String> _selectedTaskIds = {};
bool _approvingSheet = false;
// ---------------------------------------------------------------------------
// Helpers
@@ -95,16 +96,16 @@ class _DaySheetDetailScreenState extends ConsumerState<DaySheetDetailScreen> {
}
Future<void> _onJustify(String sheetId) async {
final body = await showJustifyDialog(context);
if (body == null || !mounted) return;
try {
await ref
final done = await showJustifyDialog(
context,
onConfirm: (body) => ref
.read(daySheetsControllerProvider)
.justify(sheetId: sheetId, body: body);
} on DaySheetActionException catch (e) {
_showError(e.message);
} catch (e) {
_showError(e.toString());
.justify(sheetId: sheetId, body: body),
);
if (done == true && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Justification submitted')),
);
}
}
@@ -118,20 +119,21 @@ class _DaySheetDetailScreenState extends ConsumerState<DaySheetDetailScreen> {
) async {
final flaggedRows =
rows.where((r) => _selectedTaskIds.contains(r.taskId)).toList();
final remarks =
await showDisapproveDialog(context, flaggedRows: flaggedRows);
if (remarks == null || !mounted) return;
try {
await ref.read(daySheetsControllerProvider).disapprove(
sheetId: sheetId,
remarks: remarks,
flaggedTaskIds: flaggedRows.map((r) => r.taskId).toList(),
);
final done = await showDisapproveDialog(
context,
flaggedRows: flaggedRows,
onConfirm: (remarks, flaggedTaskIds) =>
ref.read(daySheetsControllerProvider).disapprove(
sheetId: sheetId,
remarks: remarks,
flaggedTaskIds: flaggedTaskIds,
),
);
if (done == true && mounted) {
setState(() => _selectedTaskIds = {});
} on DaySheetActionException catch (e) {
_showError(e.message);
} catch (e) {
_showError(e.toString());
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Sheet disapproved')),
);
}
}
@@ -178,15 +180,33 @@ class _DaySheetDetailScreenState extends ConsumerState<DaySheetDetailScreen> {
}
}
setState(() => _approvingSheet = true);
final snapshot = DaySheetSnapshot.fromRows(rows);
try {
await ref.read(daySheetsControllerProvider).approve(
[(sheetId: sheet.id, snapshot: snapshot)],
);
setState(() => _selectedTaskIds = {});
if (!mounted) return;
setState(() { _approvingSheet = false; _selectedTaskIds = {}; });
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
icon: const Icon(Icons.check_circle_outline, size: 48),
title: const Text('Sheet Approved'),
content: Text("$programmerName's day sheet has been approved."),
actions: [
FilledButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Done'),
),
],
),
);
} on DaySheetActionException catch (e) {
if (mounted) setState(() => _approvingSheet = false);
_showError(e.message);
} catch (e) {
if (mounted) setState(() => _approvingSheet = false);
_showError(e.toString());
}
}
@@ -382,15 +402,22 @@ class _DaySheetDetailScreenState extends ConsumerState<DaySheetDetailScreen> {
const SizedBox(width: 12),
Expanded(
child: FilledButton.icon(
icon: const Icon(Icons.check_circle_outline),
icon: _approvingSheet
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2),
)
: const Icon(Icons.check_circle_outline),
label: const Text('Approve'),
onPressed: isOnline
? () => _onApprove(
onPressed: (_approvingSheet || !isOnline)
? null
: () => _onApprove(
sheet,
rows,
programmerName,
)
: null,
),
),
),
],
@@ -21,24 +21,30 @@ List<DaySheetRow> rowsForSheet(
// ---------------------------------------------------------------------------
/// Shows an AlertDialog collecting disapproval remarks and optionally
/// toggling which rows are flagged.
///
/// Returns the non-empty remarks string on confirm, or `null` on cancel.
Future<String?> showDisapproveDialog(
/// toggling which rows are flagged. Executes [onConfirm] while showing a
/// loading state; handles errors inline. Returns true if the action
/// completed, or null if cancelled.
Future<bool?> showDisapproveDialog(
BuildContext context, {
required List<DaySheetRow> flaggedRows,
required Future<void> Function(
String remarks, List<String> flaggedTaskIds)
onConfirm,
}) {
return showDialog<String>(
return showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (ctx) => _DisapproveDialog(flaggedRows: flaggedRows),
builder: (ctx) =>
_DisapproveDialog(flaggedRows: flaggedRows, onConfirm: onConfirm),
);
}
class _DisapproveDialog extends StatefulWidget {
const _DisapproveDialog({required this.flaggedRows});
const _DisapproveDialog({required this.flaggedRows, required this.onConfirm});
final List<DaySheetRow> flaggedRows;
final Future<void> Function(String remarks, List<String> flaggedTaskIds)
onConfirm;
@override
State<_DisapproveDialog> createState() => _DisapproveDialogState();
@@ -47,6 +53,7 @@ class _DisapproveDialog extends StatefulWidget {
class _DisapproveDialogState extends State<_DisapproveDialog> {
final _remarksCtrl = TextEditingController();
String? _error;
bool _loading = false;
late Set<String> _selected;
@override
@@ -61,13 +68,19 @@ class _DisapproveDialogState extends State<_DisapproveDialog> {
super.dispose();
}
void _submit() {
Future<void> _submit() async {
final remarks = _remarksCtrl.text.trim();
if (remarks.isEmpty) {
setState(() => _error = 'Remarks are required');
return;
}
Navigator.of(context).pop(remarks);
setState(() { _loading = true; _error = null; });
try {
await widget.onConfirm(remarks, _selected.toList());
if (mounted) Navigator.of(context).pop(true);
} catch (e) {
if (mounted) setState(() { _loading = false; _error = e.toString(); });
}
}
@override
@@ -130,12 +143,18 @@ class _DisapproveDialogState extends State<_DisapproveDialog> {
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(null),
onPressed: _loading ? null : () => Navigator.of(context).pop(null),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _submit,
child: const Text('Disapprove'),
onPressed: _loading ? null : _submit,
child: _loading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Disapprove'),
),
],
);
@@ -147,19 +166,23 @@ class _DisapproveDialogState extends State<_DisapproveDialog> {
// ---------------------------------------------------------------------------
/// Shows an AlertDialog for the programmer to write a justification before
/// resubmitting.
///
/// Returns the non-empty justification string on confirm, or `null` on cancel.
Future<String?> showJustifyDialog(BuildContext context) {
return showDialog<String>(
/// resubmitting. Executes [onConfirm] while showing a loading state; handles
/// errors inline. Returns true if the action completed, or null if cancelled.
Future<bool?> showJustifyDialog(
BuildContext context, {
required Future<void> Function(String body) onConfirm,
}) {
return showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (ctx) => const _JustifyDialog(),
builder: (ctx) => _JustifyDialog(onConfirm: onConfirm),
);
}
class _JustifyDialog extends StatefulWidget {
const _JustifyDialog();
const _JustifyDialog({required this.onConfirm});
final Future<void> Function(String body) onConfirm;
@override
State<_JustifyDialog> createState() => _JustifyDialogState();
@@ -168,6 +191,7 @@ class _JustifyDialog extends StatefulWidget {
class _JustifyDialogState extends State<_JustifyDialog> {
final _bodyCtrl = TextEditingController();
String? _error;
bool _loading = false;
@override
void dispose() {
@@ -175,13 +199,19 @@ class _JustifyDialogState extends State<_JustifyDialog> {
super.dispose();
}
void _submit() {
Future<void> _submit() async {
final body = _bodyCtrl.text.trim();
if (body.isEmpty) {
setState(() => _error = 'Please write a justification');
return;
}
Navigator.of(context).pop(body);
setState(() { _loading = true; _error = null; });
try {
await widget.onConfirm(body);
if (mounted) Navigator.of(context).pop(true);
} catch (e) {
if (mounted) setState(() { _loading = false; _error = e.toString(); });
}
}
@override
@@ -193,6 +223,7 @@ class _JustifyDialogState extends State<_JustifyDialog> {
child: TextField(
controller: _bodyCtrl,
maxLines: 4,
enabled: !_loading,
decoration: InputDecoration(
labelText: 'Justification',
hintText: 'Explain what changed or why the sheet is correct',
@@ -206,12 +237,18 @@ class _JustifyDialogState extends State<_JustifyDialog> {
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(null),
onPressed: _loading ? null : () => Navigator.of(context).pop(null),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _submit,
child: const Text('Submit'),
onPressed: _loading ? null : _submit,
child: _loading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Submit'),
),
],
);
@@ -43,6 +43,26 @@ class _DaySheetsTabState extends ConsumerState<DaySheetsTab> {
// ---------------------------------------------------------------------------
Future<void> _submit(DateTime today) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Submit today's work?"),
content: const Text(
"This will send your day sheet to admin for review."),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Submit'),
),
],
),
);
if (ok != true) return;
setState(() => _submitting = true);
try {
await ref.read(daySheetsControllerProvider).submit(today);