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:
@@ -87,12 +87,27 @@ class _ApprovalsTabState extends ConsumerState<ApprovalsTab> {
|
|||||||
_approving = false;
|
_approving = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
final buf = StringBuffer('Approved ${result.approvedIds.length}');
|
final count = result.approvedIds.length;
|
||||||
if (result.skippedIds.isNotEmpty) {
|
final skipped = result.skippedIds.length;
|
||||||
buf.write(', ${result.skippedIds.length} already reviewed');
|
await showDialog<void>(
|
||||||
}
|
context: context,
|
||||||
ScaffoldMessenger.of(context)
|
builder: (ctx) => AlertDialog(
|
||||||
.showSnackBar(SnackBar(content: Text(buf.toString())));
|
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) {
|
} on DaySheetActionException catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _approving = false);
|
setState(() => _approving = false);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class DaySheetDetailScreen extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _DaySheetDetailScreenState extends ConsumerState<DaySheetDetailScreen> {
|
class _DaySheetDetailScreenState extends ConsumerState<DaySheetDetailScreen> {
|
||||||
Set<String> _selectedTaskIds = {};
|
Set<String> _selectedTaskIds = {};
|
||||||
|
bool _approvingSheet = false;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
@@ -95,16 +96,16 @@ class _DaySheetDetailScreenState extends ConsumerState<DaySheetDetailScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onJustify(String sheetId) async {
|
Future<void> _onJustify(String sheetId) async {
|
||||||
final body = await showJustifyDialog(context);
|
final done = await showJustifyDialog(
|
||||||
if (body == null || !mounted) return;
|
context,
|
||||||
try {
|
onConfirm: (body) => ref
|
||||||
await ref
|
|
||||||
.read(daySheetsControllerProvider)
|
.read(daySheetsControllerProvider)
|
||||||
.justify(sheetId: sheetId, body: body);
|
.justify(sheetId: sheetId, body: body),
|
||||||
} on DaySheetActionException catch (e) {
|
);
|
||||||
_showError(e.message);
|
if (done == true && mounted) {
|
||||||
} catch (e) {
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
_showError(e.toString());
|
const SnackBar(content: Text('Justification submitted')),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,20 +119,21 @@ class _DaySheetDetailScreenState extends ConsumerState<DaySheetDetailScreen> {
|
|||||||
) async {
|
) async {
|
||||||
final flaggedRows =
|
final flaggedRows =
|
||||||
rows.where((r) => _selectedTaskIds.contains(r.taskId)).toList();
|
rows.where((r) => _selectedTaskIds.contains(r.taskId)).toList();
|
||||||
final remarks =
|
final done = await showDisapproveDialog(
|
||||||
await showDisapproveDialog(context, flaggedRows: flaggedRows);
|
context,
|
||||||
if (remarks == null || !mounted) return;
|
flaggedRows: flaggedRows,
|
||||||
try {
|
onConfirm: (remarks, flaggedTaskIds) =>
|
||||||
await ref.read(daySheetsControllerProvider).disapprove(
|
ref.read(daySheetsControllerProvider).disapprove(
|
||||||
sheetId: sheetId,
|
sheetId: sheetId,
|
||||||
remarks: remarks,
|
remarks: remarks,
|
||||||
flaggedTaskIds: flaggedRows.map((r) => r.taskId).toList(),
|
flaggedTaskIds: flaggedTaskIds,
|
||||||
);
|
),
|
||||||
|
);
|
||||||
|
if (done == true && mounted) {
|
||||||
setState(() => _selectedTaskIds = {});
|
setState(() => _selectedTaskIds = {});
|
||||||
} on DaySheetActionException catch (e) {
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
_showError(e.message);
|
const SnackBar(content: Text('Sheet disapproved')),
|
||||||
} catch (e) {
|
);
|
||||||
_showError(e.toString());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,15 +180,33 @@ class _DaySheetDetailScreenState extends ConsumerState<DaySheetDetailScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setState(() => _approvingSheet = true);
|
||||||
final snapshot = DaySheetSnapshot.fromRows(rows);
|
final snapshot = DaySheetSnapshot.fromRows(rows);
|
||||||
try {
|
try {
|
||||||
await ref.read(daySheetsControllerProvider).approve(
|
await ref.read(daySheetsControllerProvider).approve(
|
||||||
[(sheetId: sheet.id, snapshot: snapshot)],
|
[(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) {
|
} on DaySheetActionException catch (e) {
|
||||||
|
if (mounted) setState(() => _approvingSheet = false);
|
||||||
_showError(e.message);
|
_showError(e.message);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (mounted) setState(() => _approvingSheet = false);
|
||||||
_showError(e.toString());
|
_showError(e.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -382,15 +402,22 @@ class _DaySheetDetailScreenState extends ConsumerState<DaySheetDetailScreen> {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FilledButton.icon(
|
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'),
|
label: const Text('Approve'),
|
||||||
onPressed: isOnline
|
onPressed: (_approvingSheet || !isOnline)
|
||||||
? () => _onApprove(
|
? null
|
||||||
|
: () => _onApprove(
|
||||||
sheet,
|
sheet,
|
||||||
rows,
|
rows,
|
||||||
programmerName,
|
programmerName,
|
||||||
)
|
),
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -21,24 +21,30 @@ List<DaySheetRow> rowsForSheet(
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Shows an AlertDialog collecting disapproval remarks and optionally
|
/// Shows an AlertDialog collecting disapproval remarks and optionally
|
||||||
/// toggling which rows are flagged.
|
/// toggling which rows are flagged. Executes [onConfirm] while showing a
|
||||||
///
|
/// loading state; handles errors inline. Returns true if the action
|
||||||
/// Returns the non-empty remarks string on confirm, or `null` on cancel.
|
/// completed, or null if cancelled.
|
||||||
Future<String?> showDisapproveDialog(
|
Future<bool?> showDisapproveDialog(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required List<DaySheetRow> flaggedRows,
|
required List<DaySheetRow> flaggedRows,
|
||||||
|
required Future<void> Function(
|
||||||
|
String remarks, List<String> flaggedTaskIds)
|
||||||
|
onConfirm,
|
||||||
}) {
|
}) {
|
||||||
return showDialog<String>(
|
return showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (ctx) => _DisapproveDialog(flaggedRows: flaggedRows),
|
builder: (ctx) =>
|
||||||
|
_DisapproveDialog(flaggedRows: flaggedRows, onConfirm: onConfirm),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DisapproveDialog extends StatefulWidget {
|
class _DisapproveDialog extends StatefulWidget {
|
||||||
const _DisapproveDialog({required this.flaggedRows});
|
const _DisapproveDialog({required this.flaggedRows, required this.onConfirm});
|
||||||
|
|
||||||
final List<DaySheetRow> flaggedRows;
|
final List<DaySheetRow> flaggedRows;
|
||||||
|
final Future<void> Function(String remarks, List<String> flaggedTaskIds)
|
||||||
|
onConfirm;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_DisapproveDialog> createState() => _DisapproveDialogState();
|
State<_DisapproveDialog> createState() => _DisapproveDialogState();
|
||||||
@@ -47,6 +53,7 @@ class _DisapproveDialog extends StatefulWidget {
|
|||||||
class _DisapproveDialogState extends State<_DisapproveDialog> {
|
class _DisapproveDialogState extends State<_DisapproveDialog> {
|
||||||
final _remarksCtrl = TextEditingController();
|
final _remarksCtrl = TextEditingController();
|
||||||
String? _error;
|
String? _error;
|
||||||
|
bool _loading = false;
|
||||||
late Set<String> _selected;
|
late Set<String> _selected;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -61,13 +68,19 @@ class _DisapproveDialogState extends State<_DisapproveDialog> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _submit() {
|
Future<void> _submit() async {
|
||||||
final remarks = _remarksCtrl.text.trim();
|
final remarks = _remarksCtrl.text.trim();
|
||||||
if (remarks.isEmpty) {
|
if (remarks.isEmpty) {
|
||||||
setState(() => _error = 'Remarks are required');
|
setState(() => _error = 'Remarks are required');
|
||||||
return;
|
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
|
@override
|
||||||
@@ -130,12 +143,18 @@ class _DisapproveDialogState extends State<_DisapproveDialog> {
|
|||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(null),
|
onPressed: _loading ? null : () => Navigator.of(context).pop(null),
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: _submit,
|
onPressed: _loading ? null : _submit,
|
||||||
child: const Text('Disapprove'),
|
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
|
/// Shows an AlertDialog for the programmer to write a justification before
|
||||||
/// resubmitting.
|
/// resubmitting. Executes [onConfirm] while showing a loading state; handles
|
||||||
///
|
/// errors inline. Returns true if the action completed, or null if cancelled.
|
||||||
/// Returns the non-empty justification string on confirm, or `null` on cancel.
|
Future<bool?> showJustifyDialog(
|
||||||
Future<String?> showJustifyDialog(BuildContext context) {
|
BuildContext context, {
|
||||||
return showDialog<String>(
|
required Future<void> Function(String body) onConfirm,
|
||||||
|
}) {
|
||||||
|
return showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (ctx) => const _JustifyDialog(),
|
builder: (ctx) => _JustifyDialog(onConfirm: onConfirm),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _JustifyDialog extends StatefulWidget {
|
class _JustifyDialog extends StatefulWidget {
|
||||||
const _JustifyDialog();
|
const _JustifyDialog({required this.onConfirm});
|
||||||
|
|
||||||
|
final Future<void> Function(String body) onConfirm;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_JustifyDialog> createState() => _JustifyDialogState();
|
State<_JustifyDialog> createState() => _JustifyDialogState();
|
||||||
@@ -168,6 +191,7 @@ class _JustifyDialog extends StatefulWidget {
|
|||||||
class _JustifyDialogState extends State<_JustifyDialog> {
|
class _JustifyDialogState extends State<_JustifyDialog> {
|
||||||
final _bodyCtrl = TextEditingController();
|
final _bodyCtrl = TextEditingController();
|
||||||
String? _error;
|
String? _error;
|
||||||
|
bool _loading = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -175,13 +199,19 @@ class _JustifyDialogState extends State<_JustifyDialog> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _submit() {
|
Future<void> _submit() async {
|
||||||
final body = _bodyCtrl.text.trim();
|
final body = _bodyCtrl.text.trim();
|
||||||
if (body.isEmpty) {
|
if (body.isEmpty) {
|
||||||
setState(() => _error = 'Please write a justification');
|
setState(() => _error = 'Please write a justification');
|
||||||
return;
|
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
|
@override
|
||||||
@@ -193,6 +223,7 @@ class _JustifyDialogState extends State<_JustifyDialog> {
|
|||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _bodyCtrl,
|
controller: _bodyCtrl,
|
||||||
maxLines: 4,
|
maxLines: 4,
|
||||||
|
enabled: !_loading,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Justification',
|
labelText: 'Justification',
|
||||||
hintText: 'Explain what changed or why the sheet is correct',
|
hintText: 'Explain what changed or why the sheet is correct',
|
||||||
@@ -206,12 +237,18 @@ class _JustifyDialogState extends State<_JustifyDialog> {
|
|||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(null),
|
onPressed: _loading ? null : () => Navigator.of(context).pop(null),
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: _submit,
|
onPressed: _loading ? null : _submit,
|
||||||
child: const Text('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 {
|
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);
|
setState(() => _submitting = true);
|
||||||
try {
|
try {
|
||||||
await ref.read(daySheetsControllerProvider).submit(today);
|
await ref.read(daySheetsControllerProvider).submit(today);
|
||||||
|
|||||||
Reference in New Issue
Block a user