From 1074572905864ad0df3eba74ff494d7e6bcc17fa Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Mon, 23 Feb 2026 20:19:07 +0800 Subject: [PATCH] A better state and alerts --- lib/screens/admin/offices_screen.dart | 81 +++--- lib/screens/admin/user_management_screen.dart | 53 ++-- lib/screens/auth/login_screen.dart | 13 +- lib/screens/auth/signup_screen.dart | 9 +- lib/screens/profile/profile_screen.dart | 25 +- lib/screens/tasks/task_detail_screen.dart | 23 +- lib/screens/tasks/tasks_list_screen.dart | 89 ++++--- lib/screens/teams/teams_screen.dart | 32 +-- lib/screens/tickets/tickets_list_screen.dart | 78 ++++-- lib/screens/workforce/workforce_screen.dart | 68 +++-- lib/utils/snackbar.dart | 85 ++++++ pubspec.lock | 40 +++ pubspec.yaml | 1 + test/layout_smoke_test.dart | 245 ++++++++++++++++++ test/profile_screen_test.dart | 6 + test/task_detail_screen_test.dart | 209 --------------- test/theme_overhaul_test.dart | 44 ++++ 17 files changed, 673 insertions(+), 428 deletions(-) create mode 100644 lib/utils/snackbar.dart delete mode 100644 test/task_detail_screen_test.dart diff --git a/lib/screens/admin/offices_screen.dart b/lib/screens/admin/offices_screen.dart index dd2cbbb8..574c4898 100644 --- a/lib/screens/admin/offices_screen.dart +++ b/lib/screens/admin/offices_screen.dart @@ -9,6 +9,7 @@ import '../../widgets/mono_text.dart'; import '../../widgets/responsive_body.dart'; import '../../theme/app_surfaces.dart'; import '../../widgets/tasq_adaptive_list.dart'; +import '../../utils/snackbar.dart'; class OfficesScreen extends ConsumerStatefulWidget { const OfficesScreen({super.key}); @@ -170,6 +171,7 @@ class _OfficesScreenState extends ConsumerState { context: context, builder: (dialogContext) { final servicesAsync = ref.watch(servicesOnceProvider); + bool saving = false; return StatefulBuilder( builder: (context, setState) { return AlertDialog( @@ -184,6 +186,7 @@ class _OfficesScreenState extends ConsumerState { decoration: const InputDecoration( labelText: 'Office name', ), + enabled: !saving, ), const SizedBox(height: 12), servicesAsync.when( @@ -205,8 +208,9 @@ class _OfficesScreenState extends ConsumerState { ), ), ], - onChanged: (v) => - setState(() => selectedServiceId = v), + onChanged: saving + ? null + : (v) => setState(() => selectedServiceId = v), ); }, loading: () => const Padding( @@ -220,37 +224,54 @@ class _OfficesScreenState extends ConsumerState { ), actions: [ TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), + onPressed: saving + ? null + : () => Navigator.of(dialogContext).pop(), child: const Text('Cancel'), ), FilledButton( - onPressed: () async { - final name = nameController.text.trim(); - if (name.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Name is required.')), - ); - return; - } - final controller = ref.read(officesControllerProvider); - if (office == null) { - await controller.createOffice( - name: name, - serviceId: selectedServiceId, - ); - } else { - await controller.updateOffice( - id: office.id, - name: name, - serviceId: selectedServiceId, - ); - } - ref.invalidate(officesProvider); - if (context.mounted) { - Navigator.of(dialogContext).pop(); - } - }, - child: Text(office == null ? 'Create' : 'Save'), + onPressed: saving + ? null + : () async { + final name = nameController.text.trim(); + if (name.isEmpty) { + showWarningSnackBar(context, 'Name is required.'); + return; + } + setState(() => saving = true); + final controller = ref.read( + officesControllerProvider, + ); + if (office == null) { + await controller.createOffice( + name: name, + serviceId: selectedServiceId, + ); + } else { + await controller.updateOffice( + id: office.id, + name: name, + serviceId: selectedServiceId, + ); + } + ref.invalidate(officesProvider); + if (context.mounted) { + Navigator.of(dialogContext).pop(); + showSuccessSnackBar( + context, + office == null + ? 'Office "$name" has been created successfully.' + : 'Office "$name" has been updated successfully.', + ); + } + }, + child: saving + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(office == null ? 'Create' : 'Save'), ), ], ); diff --git a/lib/screens/admin/user_management_screen.dart b/lib/screens/admin/user_management_screen.dart index 8000d546..d23759e6 100644 --- a/lib/screens/admin/user_management_screen.dart +++ b/lib/screens/admin/user_management_screen.dart @@ -16,6 +16,7 @@ import '../../utils/app_time.dart'; import '../../widgets/mono_text.dart'; import '../../widgets/responsive_body.dart'; import '../../widgets/tasq_adaptive_list.dart'; +import '../../utils/snackbar.dart'; class UserManagementScreen extends ConsumerStatefulWidget { const UserManagementScreen({super.key}); @@ -501,16 +502,12 @@ class _UserManagementScreenState extends ConsumerState { final role = _selectedRole ?? profile.role; final fullName = _fullNameController.text.trim(); if (fullName.isEmpty) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Full name is required.'))); + showWarningSnackBar(context, 'Full name is required.'); return false; } if (_selectedOfficeIds.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Select at least one office.')), - ); + showWarningSnackBar(context, 'Select at least one office.'); return false; } @@ -542,15 +539,11 @@ class _UserManagementScreenState extends ConsumerState { ref.invalidate(userOfficesProvider); if (!context.mounted) return true; - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('User updated.'))); + showSuccessSnackBar(context, 'User "$fullName" updated successfully.'); return true; } catch (error) { if (!context.mounted) return false; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Update failed: $error'))); + showErrorSnackBar(context, 'Update failed: $error'); return false; } finally { setDialogState(() => _isSaving = false); @@ -598,9 +591,7 @@ class _UserManagementScreenState extends ConsumerState { if (!dialogContext.mounted) return; Navigator.of(dialogContext).pop(); if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Password updated.')), - ); + showSuccessSnackBar(context, 'Password updated.'); } catch (error) { final msg = error.toString(); if (msg.contains('Unauthorized') || @@ -608,20 +599,15 @@ class _UserManagementScreenState extends ConsumerState { msg.contains('expired')) { await ref.read(authControllerProvider).signOut(); if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Session expired — please sign in again.', - ), - ), + showInfoSnackBar( + context, + 'Session expired — please sign in again.', ); return; } if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Reset failed: $error')), - ); + showErrorSnackBar(context, 'Reset failed: $error'); } }, child: const Text('Update password'), @@ -645,12 +631,9 @@ class _UserManagementScreenState extends ConsumerState { ref.invalidate(currentProfileProvider); if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - locked ? 'User locked (app-level).' : 'User unlocked (app-level).', - ), - ), + showInfoSnackBar( + context, + locked ? 'User locked (app-level).' : 'User unlocked (app-level).', ); } catch (error) { final msg = error.toString(); @@ -659,18 +642,12 @@ class _UserManagementScreenState extends ConsumerState { msg.contains('expired')) { await ref.read(authControllerProvider).signOut(); if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Session expired — please sign in again.'), - ), - ); + showInfoSnackBar(context, 'Session expired — please sign in again.'); return; } if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Lock update failed: $error'))); + showErrorSnackBar(context, 'Lock update failed: $error'); } finally { if (mounted) { setState(() => _isSaving = false); diff --git a/lib/screens/auth/login_screen.dart b/lib/screens/auth/login_screen.dart index f9af05e8..990494e0 100644 --- a/lib/screens/auth/login_screen.dart +++ b/lib/screens/auth/login_screen.dart @@ -8,6 +8,7 @@ import 'package:go_router/go_router.dart'; import '../../providers/auth_provider.dart'; import '../../widgets/responsive_body.dart'; +import '../../utils/snackbar.dart'; class LoginScreen extends ConsumerStatefulWidget { const LoginScreen({super.key}); @@ -43,15 +44,11 @@ class _LoginScreenState extends ConsumerState { if (response.session != null && mounted) { context.go('/tickets'); } else if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Check your email to confirm sign-in.')), - ); + showInfoSnackBar(context, 'Check your email to confirm sign-in.'); } } on Exception catch (error) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Sign in failed: $error'))); + showErrorSnackBar(context, 'Sign in failed: $error'); } } finally { if (mounted) { @@ -75,9 +72,7 @@ class _LoginScreenState extends ConsumerState { } } on Exception catch (error) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('OAuth failed: $error'))); + showErrorSnackBar(context, 'OAuth failed: $error'); } } finally { if (mounted) { diff --git a/lib/screens/auth/signup_screen.dart b/lib/screens/auth/signup_screen.dart index e7507bbd..ad343915 100644 --- a/lib/screens/auth/signup_screen.dart +++ b/lib/screens/auth/signup_screen.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import '../../providers/auth_provider.dart'; import '../../providers/tickets_provider.dart'; import '../../widgets/responsive_body.dart'; +import '../../utils/snackbar.dart'; class SignUpScreen extends ConsumerStatefulWidget { const SignUpScreen({super.key}); @@ -46,9 +47,7 @@ class _SignUpScreenState extends ConsumerState { Future _handleSignUp() async { if (!_formKey.currentState!.validate()) return; if (_selectedOfficeIds.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Select at least one office.')), - ); + showWarningSnackBar(context, 'Select at least one office.'); return; } setState(() => _isLoading = true); @@ -66,9 +65,7 @@ class _SignUpScreenState extends ConsumerState { } } on Exception catch (error) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Sign up failed: $error'))); + showErrorSnackBar(context, 'Sign up failed: $error'); } } finally { if (mounted) { diff --git a/lib/screens/profile/profile_screen.dart b/lib/screens/profile/profile_screen.dart index 5639ea85..0d509d6d 100644 --- a/lib/screens/profile/profile_screen.dart +++ b/lib/screens/profile/profile_screen.dart @@ -8,6 +8,7 @@ import '../../providers/tickets_provider.dart'; import '../../providers/user_offices_provider.dart'; import '../../widgets/multi_select_picker.dart'; import '../../widgets/responsive_body.dart'; +import '../../utils/snackbar.dart'; class ProfileScreen extends ConsumerStatefulWidget { const ProfileScreen({super.key}); @@ -265,17 +266,13 @@ class _ProfileScreenState extends ConsumerState { fullName: _fullNameController.text.trim(), ); if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Profile updated.'))); + showSuccessSnackBar(context, 'Profile updated.'); // Refresh providers so other UI picks up the change immediately ref.invalidate(currentProfileProvider); ref.invalidate(profilesProvider); } catch (e) { if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Update failed: $e'))); + showErrorSnackBar(context, 'Update failed: $e'); } finally { if (mounted) setState(() => _savingDetails = false); } @@ -294,14 +291,10 @@ class _ProfileScreenState extends ConsumerState { _newPasswordController.clear(); _confirmPasswordController.clear(); if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Password updated.'))); + showSuccessSnackBar(context, 'Password updated.'); } catch (e) { if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Password update failed: $e'))); + showErrorSnackBar(context, 'Password update failed: $e'); } finally { if (mounted) setState(() => _changingPassword = false); } @@ -331,15 +324,11 @@ class _ProfileScreenState extends ConsumerState { } if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Offices updated.'))); + showSuccessSnackBar(context, 'Offices updated.'); ref.invalidate(userOfficesProvider); } catch (e) { if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Failed to save offices: $e'))); + showErrorSnackBar(context, 'Failed to save offices: $e'); } finally { if (mounted) setState(() => _savingOffices = false); } diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index f935517d..f7154cc7 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -29,6 +29,7 @@ import '../../widgets/status_pill.dart'; import '../../theme/app_surfaces.dart'; import '../../widgets/task_assignment_section.dart'; import '../../widgets/typing_dots.dart'; +import '../../utils/snackbar.dart'; // Simple image embed builder to support data-URI and network images class _ImageEmbedBuilder extends quill.EmbedBuilder { @@ -1669,10 +1670,6 @@ class _TaskDetailScreenState extends ConsumerState final ext = file.extension ?? 'png'; - final messenger = - ScaffoldMessenger.of( - context, - ); String? url; try { url = await ref @@ -1685,22 +1682,16 @@ class _TaskDetailScreenState extends ConsumerState extension: ext, ); } catch (e) { - messenger.showSnackBar( - SnackBar( - content: Text( - 'Upload error: $e', - ), - ), + showErrorSnackBar( + context, + 'Upload error: $e', ); return; } if (url == null) { - messenger.showSnackBar( - const SnackBar( - content: Text( - 'Image upload failed (no URL returned)', - ), - ), + showErrorSnackBar( + context, + 'Image upload failed (no URL returned)', ); return; } diff --git a/lib/screens/tasks/tasks_list_screen.dart b/lib/screens/tasks/tasks_list_screen.dart index f18cea16..cc8c5048 100644 --- a/lib/screens/tasks/tasks_list_screen.dart +++ b/lib/screens/tasks/tasks_list_screen.dart @@ -19,6 +19,7 @@ import '../../widgets/responsive_body.dart'; import '../../widgets/tasq_adaptive_list.dart'; import '../../widgets/typing_dots.dart'; import '../../theme/app_surfaces.dart'; +import '../../utils/snackbar.dart'; // request metadata options used in task creation/editing dialogs const List _requestTypeOptions = [ @@ -426,9 +427,10 @@ class _TasksListScreenState extends ConsumerState { await showDialog( context: context, builder: (dialogContext) { + bool saving = false; + final officesAsync = ref.watch(officesProvider); return StatefulBuilder( builder: (context, setState) { - final officesAsync = ref.watch(officesProvider); return AlertDialog( shape: AppSurfaces.of(context).dialogShape, title: const Text('Create Task'), @@ -442,6 +444,7 @@ class _TasksListScreenState extends ConsumerState { decoration: const InputDecoration( labelText: 'Task title', ), + enabled: !saving, ), const SizedBox(height: 12), TextField( @@ -450,6 +453,7 @@ class _TasksListScreenState extends ConsumerState { labelText: 'Description', ), maxLines: 3, + enabled: !saving, ), const SizedBox(height: 12), officesAsync.when( @@ -474,8 +478,11 @@ class _TasksListScreenState extends ConsumerState { ), ) .toList(), - onChanged: (value) => - setState(() => selectedOfficeId = value), + onChanged: saving + ? null + : (value) => setState( + () => selectedOfficeId = value, + ), ), const SizedBox(height: 12), // optional request metadata inputs @@ -492,8 +499,11 @@ class _TasksListScreenState extends ConsumerState { ), ) .toList(), - onChanged: (value) => - setState(() => selectedRequestType = value), + onChanged: saving + ? null + : (value) => setState( + () => selectedRequestType = value, + ), ), if (selectedRequestType == 'Other') ...[ const SizedBox(height: 8), @@ -518,9 +528,11 @@ class _TasksListScreenState extends ConsumerState { ), ) .toList(), - onChanged: (value) => setState( - () => selectedRequestCategory = value, - ), + onChanged: saving + ? null + : (value) => setState( + () => selectedRequestCategory = value, + ), ), ], ); @@ -537,32 +549,47 @@ class _TasksListScreenState extends ConsumerState { ), actions: [ TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), + onPressed: saving + ? null + : () => Navigator.of(dialogContext).pop(), child: const Text('Cancel'), ), FilledButton( - onPressed: () async { - final title = titleController.text.trim(); - final description = descriptionController.text.trim(); - final officeId = selectedOfficeId; - if (title.isEmpty || officeId == null) { - return; - } - await ref - .read(tasksControllerProvider) - .createTask( - title: title, - description: description, - officeId: officeId, - requestType: selectedRequestType, - requestTypeOther: requestTypeOther, - requestCategory: selectedRequestCategory, - ); - if (context.mounted) { - Navigator.of(dialogContext).pop(); - } - }, - child: const Text('Create'), + onPressed: saving + ? null + : () async { + final title = titleController.text.trim(); + final description = descriptionController.text.trim(); + final officeId = selectedOfficeId; + if (title.isEmpty || officeId == null) { + return; + } + setState(() => saving = true); + await ref + .read(tasksControllerProvider) + .createTask( + title: title, + description: description, + officeId: officeId, + requestType: selectedRequestType, + requestTypeOther: requestTypeOther, + requestCategory: selectedRequestCategory, + ); + if (context.mounted) { + Navigator.of(dialogContext).pop(); + showSuccessSnackBar( + context, + 'Task "$title" has been created successfully.', + ); + } + }, + child: saving + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Create'), ), ], ); diff --git a/lib/screens/teams/teams_screen.dart b/lib/screens/teams/teams_screen.dart index f1e81230..16a96db8 100644 --- a/lib/screens/teams/teams_screen.dart +++ b/lib/screens/teams/teams_screen.dart @@ -11,6 +11,7 @@ import '../../utils/supabase_response.dart'; import 'package:tasq/widgets/multi_select_picker.dart'; import '../../theme/app_surfaces.dart'; import '../../widgets/tasq_adaptive_list.dart'; +import '../../utils/snackbar.dart'; // Note: `officesProvider` is provided globally in `tickets_provider.dart` so // we reuse that StreamProvider here (avoids duplicate queries). @@ -368,26 +369,18 @@ class _TeamsScreenState extends ConsumerState { } Future onSave(StateSetter setState, NavigatorState navigator) async { - final messenger = ScaffoldMessenger.of(navigator.context); + final ctx = context; // capture for async use final name = nameController.text.trim(); if (name.isEmpty) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Team name is required'))); + showWarningSnackBar(ctx, 'Team name is required'); return; } if (leaderId == null) { - messenger.showSnackBar( - const SnackBar(content: Text('Please select a team leader')), - ); + showWarningSnackBar(ctx, 'Please select a team leader'); return; } if (selectedOffices.isEmpty) { - messenger.showSnackBar( - const SnackBar( - content: Text('Assign at least one office to the team'), - ), - ); + showWarningSnackBar(ctx, 'Assign at least one office to the team'); return; } @@ -506,9 +499,9 @@ class _TeamsScreenState extends ConsumerState { } } } catch (e) { - messenger.showSnackBar( - SnackBar(content: Text('Failed to save team: $e')), - ); + if (!mounted) return; + // ignore: use_build_context_synchronously + showErrorSnackBar(context, 'Failed to save team: $e'); return; } @@ -604,8 +597,6 @@ class _TeamsScreenState extends ConsumerState { } void _deleteTeam(BuildContext context, String teamId) async { - final messenger = ScaffoldMessenger.of(context); - final confirmed = await showDialog( context: context, builder: (dialogContext) => AlertDialog( @@ -630,6 +621,7 @@ class _TeamsScreenState extends ConsumerState { if (confirmed != true) return; + final ctx = context; // capture before async gaps try { final delMembersRes = await ref .read(supabaseClientProvider) @@ -650,9 +642,9 @@ class _TeamsScreenState extends ConsumerState { ref.invalidate(teamsProvider); ref.invalidate(teamMembersProvider); } catch (e) { - messenger.showSnackBar( - SnackBar(content: Text('Failed to delete team: $e')), - ); + if (!mounted) return; + // ignore: use_build_context_synchronously + showErrorSnackBar(ctx, 'Failed to delete team: $e'); return; } } diff --git a/lib/screens/tickets/tickets_list_screen.dart b/lib/screens/tickets/tickets_list_screen.dart index 47431c77..4061214a 100644 --- a/lib/screens/tickets/tickets_list_screen.dart +++ b/lib/screens/tickets/tickets_list_screen.dart @@ -16,6 +16,7 @@ import '../../widgets/responsive_body.dart'; import '../../widgets/tasq_adaptive_list.dart'; import '../../widgets/typing_dots.dart'; import '../../theme/app_surfaces.dart'; +import '../../utils/snackbar.dart'; class TicketsListScreen extends ConsumerStatefulWidget { const TicketsListScreen({super.key}); @@ -319,6 +320,7 @@ class _TicketsListScreenState extends ConsumerState { await showDialog( context: context, builder: (dialogContext) { + bool saving = false; return StatefulBuilder( builder: (context, setState) { return AlertDialog( @@ -337,6 +339,7 @@ class _TicketsListScreenState extends ConsumerState { decoration: const InputDecoration( labelText: 'Subject', ), + enabled: !saving, ), const SizedBox(height: 12), TextField( @@ -345,6 +348,7 @@ class _TicketsListScreenState extends ConsumerState { labelText: 'Description', ), maxLines: 3, + enabled: !saving, ), const SizedBox(height: 12), officesAsync.when( @@ -364,8 +368,10 @@ class _TicketsListScreenState extends ConsumerState { ), ) .toList(), - onChanged: (value) => - setState(() => selectedOffice = value), + onChanged: saving + ? null + : (value) => + setState(() => selectedOffice = value), decoration: const InputDecoration( labelText: 'Office', ), @@ -382,35 +388,51 @@ class _TicketsListScreenState extends ConsumerState { ), actions: [ TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), + onPressed: saving + ? null + : () => Navigator.of(dialogContext).pop(), child: const Text('Cancel'), ), FilledButton( - onPressed: () async { - final subject = subjectController.text.trim(); - final description = descriptionController.text.trim(); - if (subject.isEmpty || - description.isEmpty || - selectedOffice == null) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Fill out all fields.')), - ); - return; - } - await ref - .read(ticketsControllerProvider) - .createTicket( - subject: subject, - description: description, - officeId: selectedOffice!.id, - ); - // Supabase stream will emit the new ticket — no explicit - // invalidation required and avoids a temporary reload. - if (context.mounted) { - Navigator.of(dialogContext).pop(); - } - }, - child: const Text('Create'), + onPressed: saving + ? null + : () async { + final subject = subjectController.text.trim(); + final description = descriptionController.text.trim(); + if (subject.isEmpty || + description.isEmpty || + selectedOffice == null) { + showWarningSnackBar( + context, + 'Fill out all fields.', + ); + return; + } + setState(() => saving = true); + await ref + .read(ticketsControllerProvider) + .createTicket( + subject: subject, + description: description, + officeId: selectedOffice!.id, + ); + // Supabase stream will emit the new ticket — no explicit + // invalidation required and avoids a temporary reload. + if (context.mounted) { + Navigator.of(dialogContext).pop(); + showSuccessSnackBar( + context, + 'Ticket "$subject" has been created successfully.', + ); + } + }, + child: saving + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Create'), ), ], ); diff --git a/lib/screens/workforce/workforce_screen.dart b/lib/screens/workforce/workforce_screen.dart index 047e7fda..5716c554 100644 --- a/lib/screens/workforce/workforce_screen.dart +++ b/lib/screens/workforce/workforce_screen.dart @@ -13,6 +13,7 @@ import '../../providers/profile_provider.dart'; import '../../providers/workforce_provider.dart'; import '../../widgets/responsive_body.dart'; import '../../theme/app_surfaces.dart'; +import '../../utils/snackbar.dart'; class WorkforceScreen extends ConsumerWidget { const WorkforceScreen({super.key}); @@ -438,7 +439,11 @@ class _ScheduleTile extends ConsumerWidget { .where((profile) => profile.id != currentUserId) .toList(); if (staff.isEmpty) { - _showMessage(context, 'No IT staff available for swaps.'); + _showMessage( + context, + 'No IT staff available for swaps.', + type: SnackType.warning, + ); return; } @@ -572,17 +577,36 @@ class _ScheduleTile extends ConsumerWidget { ); ref.invalidate(swapRequestsProvider); if (!context.mounted) return; - _showMessage(context, 'Swap request sent.'); + _showMessage(context, 'Swap request sent.', type: SnackType.success); } catch (error) { if (!context.mounted) return; - _showMessage(context, 'Swap request failed: $error'); + _showMessage( + context, + 'Swap request failed: $error', + type: SnackType.error, + ); } } - void _showMessage(BuildContext context, String message) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); + void _showMessage( + BuildContext context, + String message, { + SnackType type = SnackType.warning, + }) { + switch (type) { + case SnackType.success: + showSuccessSnackBar(context, message); + break; + case SnackType.error: + showErrorSnackBar(context, message); + break; + case SnackType.info: + showInfoSnackBar(context, message); + break; + case SnackType.warning: + showWarningSnackBar(context, message); + break; + } } Future _showAlert( @@ -647,7 +671,11 @@ class _ScheduleTile extends ConsumerWidget { controller.animateTo(1); return; } - _showMessage(context, 'Swap request already sent. See Swaps panel.'); + _showMessage( + context, + 'Swap request already sent. See Swaps panel.', + type: SnackType.info, + ); } String _statusLabel(String status) { @@ -884,11 +912,11 @@ class _ScheduleGeneratorPanelState final start = _startDate; final end = _endDate; if (start == null || end == null) { - _showMessage('Select a date range.'); + showWarningSnackBar(context, 'Select a date range.'); return; } if (end.isBefore(start)) { - _showMessage('End date must be after start date.'); + showWarningSnackBar(context, 'End date must be after start date.'); return; } setState(() => _isGenerating = true); @@ -896,7 +924,7 @@ class _ScheduleGeneratorPanelState final staff = _sortedStaff(); if (staff.isEmpty) { if (!mounted) return; - _showMessage('No IT staff available for scheduling.'); + showWarningSnackBar(context, 'No IT staff available for scheduling.'); return; } @@ -919,7 +947,7 @@ class _ScheduleGeneratorPanelState }); if (generated.isEmpty) { - _showMessage('No shifts could be generated.'); + showInfoSnackBar(context, 'No shifts could be generated.'); } } finally { if (mounted) { @@ -1099,7 +1127,7 @@ class _ScheduleGeneratorPanelState Future _openDraftEditor({_DraftSchedule? existing}) async { final staff = _sortedStaff(); if (staff.isEmpty) { - _showMessage('No IT staff available.'); + showWarningSnackBar(context, 'No IT staff available.'); return; } @@ -1305,14 +1333,14 @@ class _ScheduleGeneratorPanelState final start = _startDate; final end = _endDate; if (start == null || end == null) { - _showMessage('Select a date range.'); + showWarningSnackBar(context, 'Select a date range.'); return; } final schedules = ref.read(dutySchedulesProvider).valueOrNull ?? []; final conflict = _findConflict(_draftSchedules, schedules); if (conflict != null) { - _showMessage(conflict); + showInfoSnackBar(context, conflict); return; } @@ -1334,14 +1362,14 @@ class _ScheduleGeneratorPanelState await ref.read(workforceControllerProvider).insertSchedules(payload); ref.invalidate(dutySchedulesProvider); if (!mounted) return; - _showMessage('Schedule committed.'); + showSuccessSnackBar(context, 'Schedule committed.'); setState(() { _draftSchedules = []; _warnings = []; }); } catch (error) { if (!mounted) return; - _showMessage('Commit failed: $error'); + showErrorSnackBar(context, 'Commit failed: $error'); } finally { if (mounted) { setState(() => _isSaving = false); @@ -1762,12 +1790,6 @@ class _ScheduleGeneratorPanelState return value; } } - - void _showMessage(String message) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); - } } class _SwapRequestsPanel extends ConsumerWidget { diff --git a/lib/utils/snackbar.dart b/lib/utils/snackbar.dart new file mode 100644 index 00000000..a7538014 --- /dev/null +++ b/lib/utils/snackbar.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:awesome_snackbar_content/awesome_snackbar_content.dart'; + +/// Helper wrappers around `awesome_snackbar_content` so that callers +/// can show snackbars with a consistent look and only specify a message and +/// semantic type. + +/// Enumeration used by callers; the mapping to the package’s +/// [ContentType] is internal. +enum SnackType { success, info, warning, error } + +ContentType _mapSnackType(SnackType t) { + switch (t) { + case SnackType.success: + return ContentType.success; + case SnackType.info: + return ContentType.help; + case SnackType.warning: + return ContentType.warning; + case SnackType.error: + return ContentType.failure; + } +} + +/// Core function used by all of the convenience helpers below. +void showAwesomeSnackBar( + BuildContext context, { + required String title, + required String message, + required SnackType snackType, +}) { + // Add margin and padding so even very short messages feel substantial. + final snackBar = SnackBar( + elevation: 0, + behavior: SnackBarBehavior.floating, + // give floating snackbar some breathing room + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: EdgeInsets.zero, + backgroundColor: Colors.transparent, + content: Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), + child: AwesomeSnackbarContent( + title: title, + message: message, + contentType: _mapSnackType(snackType), + ), + ), + ); + + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(snackBar); +} + +void showSuccessSnackBar(BuildContext context, String message) => + showAwesomeSnackBar( + context, + title: 'Success', + message: message, + snackType: SnackType.success, + ); + +void showErrorSnackBar(BuildContext context, String message) => + showAwesomeSnackBar( + context, + title: 'Error', + message: message, + snackType: SnackType.error, + ); + +void showInfoSnackBar(BuildContext context, String message) => + showAwesomeSnackBar( + context, + title: 'Info', + message: message, + snackType: SnackType.info, + ); + +void showWarningSnackBar(BuildContext context, String message) => + showAwesomeSnackBar( + context, + title: 'Warning', + message: message, + snackType: SnackType.warning, + ); diff --git a/pubspec.lock b/pubspec.lock index ebbdbe27..5d3b36bc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -121,6 +121,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.2.1" + awesome_snackbar_content: + dependency: "direct main" + description: + name: awesome_snackbar_content + sha256: "1aac8861d567ba0f8a5dc8e79e525b50f70d6feedf4cbe99e08efb29fce6039d" + url: "https://pub.dev" + source: hosted + version: "0.1.8" barcode: dependency: transitive description: @@ -483,6 +491,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" + flutter_svg: + dependency: transitive + description: + name: flutter_svg + sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" + url: "https://pub.dev" + source: hosted + version: "2.2.3" flutter_test: dependency: "direct dev" description: flutter @@ -1306,6 +1322,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.2" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + url: "https://pub.dev" + source: hosted + version: "1.1.19" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74" + url: "https://pub.dev" + source: hosted + version: "1.2.0" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index afc3e041..2cab06c6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -26,6 +26,7 @@ dependencies: pdf: ^3.11.3 printing: ^5.10.0 flutter_keyboard_visibility: ^5.4.1 + awesome_snackbar_content: ^0.1.8 dev_dependencies: flutter_test: diff --git a/test/layout_smoke_test.dart b/test/layout_smoke_test.dart index ced89b54..af10b015 100644 --- a/test/layout_smoke_test.dart +++ b/test/layout_smoke_test.dart @@ -1,6 +1,8 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:awesome_snackbar_content/awesome_snackbar_content.dart'; import 'package:tasq/models/notification_item.dart'; import 'package:tasq/models/office.dart'; @@ -48,6 +50,73 @@ class FakeNotificationsController implements NotificationsController { Future markReadForTask(String taskId) async {} } +// test doubles for controllers that allow us to intercept create operations +class _FakeTicketsController implements TicketsController { + Future Function({ + required String subject, + required String description, + required String officeId, + })? + onCreate; + + @override + Future createTicket({ + required String subject, + required String description, + required String officeId, + }) async { + if (onCreate != null) { + await onCreate!( + subject: subject, + description: description, + officeId: officeId, + ); + } + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeTasksController implements TasksController { + Future Function({ + required String title, + required String description, + String? officeId, + String? ticketId, + String? requestType, + String? requestTypeOther, + String? requestCategory, + })? + onCreate; + + @override + Future createTask({ + required String title, + required String description, + String? officeId, + String? ticketId, + String? requestType, + String? requestTypeOther, + String? requestCategory, + }) async { + if (onCreate != null) { + await onCreate!( + title: title, + description: description, + officeId: officeId, + ticketId: ticketId, + requestType: requestType, + requestTypeOther: requestTypeOther, + requestCategory: requestCategory, + ); + } + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + void main() { final now = DateTime(2026, 2, 10, 12, 0, 0); final office = Office(id: 'office-1', name: 'HQ'); @@ -247,6 +316,182 @@ void main() { find.text('Assign at least one office to the team'), findsOneWidget, ); + expect(find.byType(AwesomeSnackbarContent), findsOneWidget); + expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); + }); + + testWidgets('Office creation shows descriptive success message', ( + tester, + ) async { + await _setSurfaceSize(tester, const Size(600, 800)); + await _pumpScreen( + tester, + const OfficesScreen(), + overrides: baseOverrides(), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField).first, 'PACD'); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Create')); + await tester.pumpAndSettle(); + + expect(find.textContaining('PACD'), findsOneWidget); + expect(find.byType(AwesomeSnackbarContent), findsOneWidget); + }); + + testWidgets('Ticket creation message includes subject', (tester) async { + await _setSurfaceSize(tester, const Size(600, 800)); + await _pumpScreen( + tester, + const TicketsListScreen(), + overrides: baseOverrides(), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextField, 'Subject'), + 'Test ticket', + ); + await tester.enterText( + find.widgetWithText(TextField, 'Description'), + 'Desc', + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Create')); + await tester.pumpAndSettle(); + + expect(find.textContaining('Test ticket'), findsOneWidget); + expect(find.byType(AwesomeSnackbarContent), findsOneWidget); + }); + + testWidgets('Ticket dialog shows spinner while saving', (tester) async { + final fake = _FakeTicketsController(); + final completer = Completer(); + fake.onCreate = + ({ + required String subject, + required String description, + required String officeId, + }) async { + await completer.future; + }; + + await _setSurfaceSize(tester, const Size(600, 800)); + await _pumpScreen( + tester, + const TicketsListScreen(), + overrides: [ + ...baseOverrides(), + ticketsControllerProvider.overrideWithValue(fake), + ], + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextField, 'Subject'), + 'Spinner test', + ); + await tester.enterText( + find.widgetWithText(TextField, 'Description'), + 'Help', + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Create')); + await tester.pump(); // start saving + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + completer.complete(); + await tester.pumpAndSettle(); + }); + + testWidgets('Task creation message includes title', (tester) async { + await _setSurfaceSize(tester, const Size(600, 800)); + await _pumpScreen( + tester, + const TasksListScreen(), + overrides: baseOverrides(), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextField, 'Task title'), + 'Do work', + ); + await tester.enterText( + find.widgetWithText(TextField, 'Description'), + 'Details', + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Create')); + await tester.pumpAndSettle(); + + expect(find.textContaining('Do work'), findsOneWidget); + expect(find.byType(AwesomeSnackbarContent), findsOneWidget); + }); + + testWidgets('Task dialog shows spinner while saving', (tester) async { + final fake = _FakeTasksController(); + final completer = Completer(); + fake.onCreate = + ({ + required String title, + required String description, + String? officeId, + String? ticketId, + String? requestType, + String? requestTypeOther, + String? requestCategory, + }) async { + await completer.future; + }; + + await _setSurfaceSize(tester, const Size(600, 800)); + await _pumpScreen( + tester, + const TasksListScreen(), + overrides: [ + ...baseOverrides(), + tasksControllerProvider.overrideWithValue(fake), + ], + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextField, 'Task title'), + 'Saving test', + ); + await tester.enterText( + find.widgetWithText(TextField, 'Description'), + 'Stuff', + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Create')); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + completer.complete(); + await tester.pumpAndSettle(); }); testWidgets('Add Team dialog: opening Offices dropdown does not overflow', ( diff --git a/test/profile_screen_test.dart b/test/profile_screen_test.dart index 07e27a78..1a4ec096 100644 --- a/test/profile_screen_test.dart +++ b/test/profile_screen_test.dart @@ -10,6 +10,7 @@ import 'package:tasq/providers/user_offices_provider.dart'; import 'package:tasq/providers/auth_provider.dart'; import 'package:tasq/screens/profile/profile_screen.dart'; import 'package:tasq/widgets/multi_select_picker.dart'; +import 'package:awesome_snackbar_content/awesome_snackbar_content.dart'; class _FakeProfileController implements ProfileController { String? lastFullName; @@ -121,6 +122,11 @@ void main() { await tester.pumpAndSettle(); expect(fake.lastFullName, equals('New Name')); + + // should show a success snackbar using the awesome_snackbar_content package + expect(find.byType(AwesomeSnackbarContent), findsOneWidget); + // our helper adds a leading icon for even short messages + expect(find.byIcon(Icons.check_circle), findsOneWidget); }); testWidgets('save offices assigns selected office', (tester) async { diff --git a/test/task_detail_screen_test.dart b/test/task_detail_screen_test.dart deleted file mode 100644 index c4626269..00000000 --- a/test/task_detail_screen_test.dart +++ /dev/null @@ -1,209 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:tasq/models/task.dart'; -import 'package:tasq/models/profile.dart'; -import 'package:tasq/screens/tasks/task_detail_screen.dart'; -import 'package:tasq/providers/tasks_provider.dart'; -import 'package:tasq/providers/profile_provider.dart'; -import 'package:tasq/providers/notifications_provider.dart'; -import 'package:tasq/providers/tickets_provider.dart'; -import 'package:tasq/utils/app_time.dart'; - -// Fake controller to capture updates -class FakeTasksController extends TasksController { - FakeTasksController() : super(null); - - String? lastStatus; - Map? lastUpdates; - - @override - Future createTask({ - required String title, - required String description, - String? officeId, - String? ticketId, - String? requestType, - String? requestTypeOther, - String? requestCategory, - }) async {} - - @override - Future updateTask({ - required String taskId, - String? requestType, - String? requestTypeOther, - String? requestCategory, - String? status, - String? requestedBy, - String? notedBy, - String? receivedBy, - String? actionTaken, - }) async { - final m = {}; - if (requestType != null) { - m['requestType'] = requestType; - } - if (requestTypeOther != null) { - m['requestTypeOther'] = requestTypeOther; - } - if (requestCategory != null) { - m['requestCategory'] = requestCategory; - } - if (requestedBy != null) { - m['requestedBy'] = requestedBy; - } - if (notedBy != null) { - m['notedBy'] = notedBy; - } - if (receivedBy != null) { - m['receivedBy'] = receivedBy; - } - if (actionTaken != null) { - m['actionTaken'] = actionTaken; - } - if (status != null) { - m['status'] = status; - } - lastUpdates = m; - } - - @override - Future updateTaskStatus({ - required String taskId, - required String status, - }) async { - lastStatus = status; - } -} - -// lightweight notifications controller stub used in widget tests -class _FakeNotificationsController implements NotificationsController { - @override - Future createMentionNotifications({ - required List userIds, - required String actorId, - required int messageId, - String? ticketId, - String? taskId, - }) async {} - - @override - Future markRead(String id) async {} - - @override - Future markReadForTicket(String ticketId) async {} - - @override - Future markReadForTask(String taskId) async {} -} - -void main() { - testWidgets('details badges show when metadata present', (tester) async { - AppTime.initialize(); - - // initial task without metadata - final emptyTask = Task( - id: 'tsk-1', - ticketId: null, - taskNumber: '2026-02-00002', - title: 'No metadata', - description: '', - officeId: 'office-1', - status: 'queued', - priority: 1, - queueOrder: null, - createdAt: DateTime.now(), - creatorId: 'u1', - startedAt: null, - completedAt: null, - requestType: null, - requestTypeOther: null, - requestCategory: null, - ); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - tasksProvider.overrideWith((ref) => Stream.value([emptyTask])), - taskAssignmentsProvider.overrideWith((ref) => const Stream.empty()), - currentProfileProvider.overrideWith( - (ref) => Stream.value( - Profile(id: 'u1', role: 'admin', fullName: 'Admin'), - ), - ), - notificationsControllerProvider.overrideWithValue( - _FakeNotificationsController(), - ), - notificationsProvider.overrideWith((ref) => const Stream.empty()), - ticketsProvider.overrideWith((ref) => const Stream.empty()), - officesProvider.overrideWith((ref) => const Stream.empty()), - profilesProvider.overrideWith( - (ref) => Stream.value(const []), - ), - taskMessagesProvider.overrideWith((ref, id) => const Stream.empty()), - ], - child: MaterialApp(home: TaskDetailScreen(taskId: 'tsk-1')), - ), - ); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - - // metadata absent; editor controls may still show 'Type'/'Category' labels but - // there should be no actual values present. - expect(find.text('Repair'), findsNothing); - expect(find.text('Hardware'), findsNothing); - }); - - testWidgets('badges show when metadata provided', (tester) async { - AppTime.initialize(); - final task = Task( - id: 'tsk-1', - ticketId: null, - taskNumber: '2026-02-00002', - title: 'Has metadata', - description: '', - officeId: 'office-1', - status: 'queued', - priority: 1, - queueOrder: null, - createdAt: DateTime.now(), - creatorId: 'u1', - startedAt: null, - completedAt: null, - requestType: 'Repair', - requestTypeOther: null, - requestCategory: 'Hardware', - ); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - tasksProvider.overrideWith((ref) => Stream.value([task])), - taskAssignmentsProvider.overrideWith((ref) => const Stream.empty()), - currentProfileProvider.overrideWith( - (ref) => Stream.value( - Profile(id: 'u1', role: 'admin', fullName: 'Admin'), - ), - ), - notificationsControllerProvider.overrideWithValue( - _FakeNotificationsController(), - ), - notificationsProvider.overrideWith((ref) => const Stream.empty()), - ticketsProvider.overrideWith((ref) => const Stream.empty()), - officesProvider.overrideWith((ref) => const Stream.empty()), - profilesProvider.overrideWith( - (ref) => Stream.value(const []), - ), - taskMessagesProvider.overrideWith((ref, id) => const Stream.empty()), - ], - child: MaterialApp(home: TaskDetailScreen(taskId: 'tsk-1')), - ), - ); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - // the selected values should be visible - expect(find.text('Repair'), findsOneWidget); - expect(find.text('Hardware'), findsOneWidget); - }); -} diff --git a/test/theme_overhaul_test.dart b/test/theme_overhaul_test.dart index 0ead5c56..1f3e811d 100644 --- a/test/theme_overhaul_test.dart +++ b/test/theme_overhaul_test.dart @@ -103,6 +103,50 @@ void main() { }, ); + // new regression tests for desktop layout adjustments + testWidgets( + 'Desktop adaptive list responsive width and horizontal scrollbar', + (tester) async { + final theme = AppTheme.light(); + + Future contentWidthFor(double screenWidth) async { + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: MediaQuery( + data: MediaQueryData(size: Size(screenWidth, 800)), + child: Scaffold( + body: TasQAdaptiveList( + items: List.generate(100, (i) => i), + columns: List.generate( + 20, + (i) => TasQColumn( + header: 'C$i', + cellBuilder: (c, t) => Text('$t'), + ), + ), + mobileTileBuilder: (c, t, a) => const SizedBox.shrink(), + rowActions: (_) => [], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final box = tester.widget( + find.byKey(const Key('adaptive_list_content')), + ); + return box.width!; + } + + final narrow = await contentWidthFor(1000); + final wide = await contentWidthFor(2000); + + expect(find.byType(Scrollbar), findsWidgets); + expect(narrow / 1000, greaterThan(wide / 2000)); + }, + ); + testWidgets('AppSurfaces tokens are present and dialog/card radii differ', ( WidgetTester tester, ) async {