A better state and alerts

This commit is contained in:
Marc Rejohn Castillano 2026-02-23 20:19:07 +08:00
parent 0b900d3480
commit 1074572905
17 changed files with 673 additions and 428 deletions

View File

@ -9,6 +9,7 @@ import '../../widgets/mono_text.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
import '../../theme/app_surfaces.dart'; import '../../theme/app_surfaces.dart';
import '../../widgets/tasq_adaptive_list.dart'; import '../../widgets/tasq_adaptive_list.dart';
import '../../utils/snackbar.dart';
class OfficesScreen extends ConsumerStatefulWidget { class OfficesScreen extends ConsumerStatefulWidget {
const OfficesScreen({super.key}); const OfficesScreen({super.key});
@ -170,6 +171,7 @@ class _OfficesScreenState extends ConsumerState<OfficesScreen> {
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
final servicesAsync = ref.watch(servicesOnceProvider); final servicesAsync = ref.watch(servicesOnceProvider);
bool saving = false;
return StatefulBuilder( return StatefulBuilder(
builder: (context, setState) { builder: (context, setState) {
return AlertDialog( return AlertDialog(
@ -184,6 +186,7 @@ class _OfficesScreenState extends ConsumerState<OfficesScreen> {
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Office name', labelText: 'Office name',
), ),
enabled: !saving,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
servicesAsync.when( servicesAsync.when(
@ -205,8 +208,9 @@ class _OfficesScreenState extends ConsumerState<OfficesScreen> {
), ),
), ),
], ],
onChanged: (v) => onChanged: saving
setState(() => selectedServiceId = v), ? null
: (v) => setState(() => selectedServiceId = v),
); );
}, },
loading: () => const Padding( loading: () => const Padding(
@ -220,37 +224,54 @@ class _OfficesScreenState extends ConsumerState<OfficesScreen> {
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(dialogContext).pop(), onPressed: saving
? null
: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
FilledButton( FilledButton(
onPressed: () async { onPressed: saving
final name = nameController.text.trim(); ? null
if (name.isEmpty) { : () async {
ScaffoldMessenger.of(context).showSnackBar( final name = nameController.text.trim();
const SnackBar(content: Text('Name is required.')), if (name.isEmpty) {
); showWarningSnackBar(context, 'Name is required.');
return; return;
} }
final controller = ref.read(officesControllerProvider); setState(() => saving = true);
if (office == null) { final controller = ref.read(
await controller.createOffice( officesControllerProvider,
name: name, );
serviceId: selectedServiceId, if (office == null) {
); await controller.createOffice(
} else { name: name,
await controller.updateOffice( serviceId: selectedServiceId,
id: office.id, );
name: name, } else {
serviceId: selectedServiceId, await controller.updateOffice(
); id: office.id,
} name: name,
ref.invalidate(officesProvider); serviceId: selectedServiceId,
if (context.mounted) { );
Navigator.of(dialogContext).pop(); }
} ref.invalidate(officesProvider);
}, if (context.mounted) {
child: Text(office == null ? 'Create' : 'Save'), 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'),
), ),
], ],
); );

View File

@ -16,6 +16,7 @@ import '../../utils/app_time.dart';
import '../../widgets/mono_text.dart'; import '../../widgets/mono_text.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
import '../../widgets/tasq_adaptive_list.dart'; import '../../widgets/tasq_adaptive_list.dart';
import '../../utils/snackbar.dart';
class UserManagementScreen extends ConsumerStatefulWidget { class UserManagementScreen extends ConsumerStatefulWidget {
const UserManagementScreen({super.key}); const UserManagementScreen({super.key});
@ -501,16 +502,12 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
final role = _selectedRole ?? profile.role; final role = _selectedRole ?? profile.role;
final fullName = _fullNameController.text.trim(); final fullName = _fullNameController.text.trim();
if (fullName.isEmpty) { if (fullName.isEmpty) {
ScaffoldMessenger.of( showWarningSnackBar(context, 'Full name is required.');
context,
).showSnackBar(const SnackBar(content: Text('Full name is required.')));
return false; return false;
} }
if (_selectedOfficeIds.isEmpty) { if (_selectedOfficeIds.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( showWarningSnackBar(context, 'Select at least one office.');
const SnackBar(content: Text('Select at least one office.')),
);
return false; return false;
} }
@ -542,15 +539,11 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
ref.invalidate(userOfficesProvider); ref.invalidate(userOfficesProvider);
if (!context.mounted) return true; if (!context.mounted) return true;
ScaffoldMessenger.of( showSuccessSnackBar(context, 'User "$fullName" updated successfully.');
context,
).showSnackBar(const SnackBar(content: Text('User updated.')));
return true; return true;
} catch (error) { } catch (error) {
if (!context.mounted) return false; if (!context.mounted) return false;
ScaffoldMessenger.of( showErrorSnackBar(context, 'Update failed: $error');
context,
).showSnackBar(SnackBar(content: Text('Update failed: $error')));
return false; return false;
} finally { } finally {
setDialogState(() => _isSaving = false); setDialogState(() => _isSaving = false);
@ -598,9 +591,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
if (!dialogContext.mounted) return; if (!dialogContext.mounted) return;
Navigator.of(dialogContext).pop(); Navigator.of(dialogContext).pop();
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( showSuccessSnackBar(context, 'Password updated.');
const SnackBar(content: Text('Password updated.')),
);
} catch (error) { } catch (error) {
final msg = error.toString(); final msg = error.toString();
if (msg.contains('Unauthorized') || if (msg.contains('Unauthorized') ||
@ -608,20 +599,15 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
msg.contains('expired')) { msg.contains('expired')) {
await ref.read(authControllerProvider).signOut(); await ref.read(authControllerProvider).signOut();
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( showInfoSnackBar(
const SnackBar( context,
content: Text( 'Session expired — please sign in again.',
'Session expired — please sign in again.',
),
),
); );
return; return;
} }
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( showErrorSnackBar(context, 'Reset failed: $error');
SnackBar(content: Text('Reset failed: $error')),
);
} }
}, },
child: const Text('Update password'), child: const Text('Update password'),
@ -645,12 +631,9 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
ref.invalidate(currentProfileProvider); ref.invalidate(currentProfileProvider);
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( showInfoSnackBar(
SnackBar( context,
content: Text( locked ? 'User locked (app-level).' : 'User unlocked (app-level).',
locked ? 'User locked (app-level).' : 'User unlocked (app-level).',
),
),
); );
} catch (error) { } catch (error) {
final msg = error.toString(); final msg = error.toString();
@ -659,18 +642,12 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
msg.contains('expired')) { msg.contains('expired')) {
await ref.read(authControllerProvider).signOut(); await ref.read(authControllerProvider).signOut();
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( showInfoSnackBar(context, 'Session expired — please sign in again.');
const SnackBar(
content: Text('Session expired — please sign in again.'),
),
);
return; return;
} }
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( showErrorSnackBar(context, 'Lock update failed: $error');
context,
).showSnackBar(SnackBar(content: Text('Lock update failed: $error')));
} finally { } finally {
if (mounted) { if (mounted) {
setState(() => _isSaving = false); setState(() => _isSaving = false);

View File

@ -8,6 +8,7 @@ import 'package:go_router/go_router.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
import '../../utils/snackbar.dart';
class LoginScreen extends ConsumerStatefulWidget { class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key}); const LoginScreen({super.key});
@ -43,15 +44,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
if (response.session != null && mounted) { if (response.session != null && mounted) {
context.go('/tickets'); context.go('/tickets');
} else if (mounted) { } else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( showInfoSnackBar(context, 'Check your email to confirm sign-in.');
const SnackBar(content: Text('Check your email to confirm sign-in.')),
);
} }
} on Exception catch (error) { } on Exception catch (error) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of( showErrorSnackBar(context, 'Sign in failed: $error');
context,
).showSnackBar(SnackBar(content: Text('Sign in failed: $error')));
} }
} finally { } finally {
if (mounted) { if (mounted) {
@ -75,9 +72,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
} }
} on Exception catch (error) { } on Exception catch (error) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of( showErrorSnackBar(context, 'OAuth failed: $error');
context,
).showSnackBar(SnackBar(content: Text('OAuth failed: $error')));
} }
} finally { } finally {
if (mounted) { if (mounted) {

View File

@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../providers/tickets_provider.dart'; import '../../providers/tickets_provider.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
import '../../utils/snackbar.dart';
class SignUpScreen extends ConsumerStatefulWidget { class SignUpScreen extends ConsumerStatefulWidget {
const SignUpScreen({super.key}); const SignUpScreen({super.key});
@ -46,9 +47,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
Future<void> _handleSignUp() async { Future<void> _handleSignUp() async {
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
if (_selectedOfficeIds.isEmpty) { if (_selectedOfficeIds.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( showWarningSnackBar(context, 'Select at least one office.');
const SnackBar(content: Text('Select at least one office.')),
);
return; return;
} }
setState(() => _isLoading = true); setState(() => _isLoading = true);
@ -66,9 +65,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
} }
} on Exception catch (error) { } on Exception catch (error) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of( showErrorSnackBar(context, 'Sign up failed: $error');
context,
).showSnackBar(SnackBar(content: Text('Sign up failed: $error')));
} }
} finally { } finally {
if (mounted) { if (mounted) {

View File

@ -8,6 +8,7 @@ import '../../providers/tickets_provider.dart';
import '../../providers/user_offices_provider.dart'; import '../../providers/user_offices_provider.dart';
import '../../widgets/multi_select_picker.dart'; import '../../widgets/multi_select_picker.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
import '../../utils/snackbar.dart';
class ProfileScreen extends ConsumerStatefulWidget { class ProfileScreen extends ConsumerStatefulWidget {
const ProfileScreen({super.key}); const ProfileScreen({super.key});
@ -265,17 +266,13 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
fullName: _fullNameController.text.trim(), fullName: _fullNameController.text.trim(),
); );
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( showSuccessSnackBar(context, 'Profile updated.');
context,
).showSnackBar(const SnackBar(content: Text('Profile updated.')));
// Refresh providers so other UI picks up the change immediately // Refresh providers so other UI picks up the change immediately
ref.invalidate(currentProfileProvider); ref.invalidate(currentProfileProvider);
ref.invalidate(profilesProvider); ref.invalidate(profilesProvider);
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( showErrorSnackBar(context, 'Update failed: $e');
context,
).showSnackBar(SnackBar(content: Text('Update failed: $e')));
} finally { } finally {
if (mounted) setState(() => _savingDetails = false); if (mounted) setState(() => _savingDetails = false);
} }
@ -294,14 +291,10 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
_newPasswordController.clear(); _newPasswordController.clear();
_confirmPasswordController.clear(); _confirmPasswordController.clear();
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( showSuccessSnackBar(context, 'Password updated.');
context,
).showSnackBar(const SnackBar(content: Text('Password updated.')));
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( showErrorSnackBar(context, 'Password update failed: $e');
context,
).showSnackBar(SnackBar(content: Text('Password update failed: $e')));
} finally { } finally {
if (mounted) setState(() => _changingPassword = false); if (mounted) setState(() => _changingPassword = false);
} }
@ -331,15 +324,11 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
} }
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( showSuccessSnackBar(context, 'Offices updated.');
context,
).showSnackBar(const SnackBar(content: Text('Offices updated.')));
ref.invalidate(userOfficesProvider); ref.invalidate(userOfficesProvider);
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( showErrorSnackBar(context, 'Failed to save offices: $e');
context,
).showSnackBar(SnackBar(content: Text('Failed to save offices: $e')));
} finally { } finally {
if (mounted) setState(() => _savingOffices = false); if (mounted) setState(() => _savingOffices = false);
} }

View File

@ -29,6 +29,7 @@ import '../../widgets/status_pill.dart';
import '../../theme/app_surfaces.dart'; import '../../theme/app_surfaces.dart';
import '../../widgets/task_assignment_section.dart'; import '../../widgets/task_assignment_section.dart';
import '../../widgets/typing_dots.dart'; import '../../widgets/typing_dots.dart';
import '../../utils/snackbar.dart';
// Simple image embed builder to support data-URI and network images // Simple image embed builder to support data-URI and network images
class _ImageEmbedBuilder extends quill.EmbedBuilder { class _ImageEmbedBuilder extends quill.EmbedBuilder {
@ -1669,10 +1670,6 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
final ext = final ext =
file.extension ?? file.extension ??
'png'; 'png';
final messenger =
ScaffoldMessenger.of(
context,
);
String? url; String? url;
try { try {
url = await ref url = await ref
@ -1685,22 +1682,16 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
extension: ext, extension: ext,
); );
} catch (e) { } catch (e) {
messenger.showSnackBar( showErrorSnackBar(
SnackBar( context,
content: Text( 'Upload error: $e',
'Upload error: $e',
),
),
); );
return; return;
} }
if (url == null) { if (url == null) {
messenger.showSnackBar( showErrorSnackBar(
const SnackBar( context,
content: Text( 'Image upload failed (no URL returned)',
'Image upload failed (no URL returned)',
),
),
); );
return; return;
} }

View File

@ -19,6 +19,7 @@ import '../../widgets/responsive_body.dart';
import '../../widgets/tasq_adaptive_list.dart'; import '../../widgets/tasq_adaptive_list.dart';
import '../../widgets/typing_dots.dart'; import '../../widgets/typing_dots.dart';
import '../../theme/app_surfaces.dart'; import '../../theme/app_surfaces.dart';
import '../../utils/snackbar.dart';
// request metadata options used in task creation/editing dialogs // request metadata options used in task creation/editing dialogs
const List<String> _requestTypeOptions = [ const List<String> _requestTypeOptions = [
@ -426,9 +427,10 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
await showDialog<void>( await showDialog<void>(
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
bool saving = false;
final officesAsync = ref.watch(officesProvider);
return StatefulBuilder( return StatefulBuilder(
builder: (context, setState) { builder: (context, setState) {
final officesAsync = ref.watch(officesProvider);
return AlertDialog( return AlertDialog(
shape: AppSurfaces.of(context).dialogShape, shape: AppSurfaces.of(context).dialogShape,
title: const Text('Create Task'), title: const Text('Create Task'),
@ -442,6 +444,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Task title', labelText: 'Task title',
), ),
enabled: !saving,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( TextField(
@ -450,6 +453,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
labelText: 'Description', labelText: 'Description',
), ),
maxLines: 3, maxLines: 3,
enabled: !saving,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
officesAsync.when( officesAsync.when(
@ -474,8 +478,11 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
), ),
) )
.toList(), .toList(),
onChanged: (value) => onChanged: saving
setState(() => selectedOfficeId = value), ? null
: (value) => setState(
() => selectedOfficeId = value,
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// optional request metadata inputs // optional request metadata inputs
@ -492,8 +499,11 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
), ),
) )
.toList(), .toList(),
onChanged: (value) => onChanged: saving
setState(() => selectedRequestType = value), ? null
: (value) => setState(
() => selectedRequestType = value,
),
), ),
if (selectedRequestType == 'Other') ...[ if (selectedRequestType == 'Other') ...[
const SizedBox(height: 8), const SizedBox(height: 8),
@ -518,9 +528,11 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
), ),
) )
.toList(), .toList(),
onChanged: (value) => setState( onChanged: saving
() => selectedRequestCategory = value, ? null
), : (value) => setState(
() => selectedRequestCategory = value,
),
), ),
], ],
); );
@ -537,32 +549,47 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(dialogContext).pop(), onPressed: saving
? null
: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
FilledButton( FilledButton(
onPressed: () async { onPressed: saving
final title = titleController.text.trim(); ? null
final description = descriptionController.text.trim(); : () async {
final officeId = selectedOfficeId; final title = titleController.text.trim();
if (title.isEmpty || officeId == null) { final description = descriptionController.text.trim();
return; final officeId = selectedOfficeId;
} if (title.isEmpty || officeId == null) {
await ref return;
.read(tasksControllerProvider) }
.createTask( setState(() => saving = true);
title: title, await ref
description: description, .read(tasksControllerProvider)
officeId: officeId, .createTask(
requestType: selectedRequestType, title: title,
requestTypeOther: requestTypeOther, description: description,
requestCategory: selectedRequestCategory, officeId: officeId,
); requestType: selectedRequestType,
if (context.mounted) { requestTypeOther: requestTypeOther,
Navigator.of(dialogContext).pop(); requestCategory: selectedRequestCategory,
} );
}, if (context.mounted) {
child: const Text('Create'), 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'),
), ),
], ],
); );

View File

@ -11,6 +11,7 @@ import '../../utils/supabase_response.dart';
import 'package:tasq/widgets/multi_select_picker.dart'; import 'package:tasq/widgets/multi_select_picker.dart';
import '../../theme/app_surfaces.dart'; import '../../theme/app_surfaces.dart';
import '../../widgets/tasq_adaptive_list.dart'; import '../../widgets/tasq_adaptive_list.dart';
import '../../utils/snackbar.dart';
// Note: `officesProvider` is provided globally in `tickets_provider.dart` so // Note: `officesProvider` is provided globally in `tickets_provider.dart` so
// we reuse that StreamProvider here (avoids duplicate queries). // we reuse that StreamProvider here (avoids duplicate queries).
@ -368,26 +369,18 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
} }
Future<void> onSave(StateSetter setState, NavigatorState navigator) async { Future<void> onSave(StateSetter setState, NavigatorState navigator) async {
final messenger = ScaffoldMessenger.of(navigator.context); final ctx = context; // capture for async use
final name = nameController.text.trim(); final name = nameController.text.trim();
if (name.isEmpty) { if (name.isEmpty) {
ScaffoldMessenger.of( showWarningSnackBar(ctx, 'Team name is required');
context,
).showSnackBar(const SnackBar(content: Text('Team name is required')));
return; return;
} }
if (leaderId == null) { if (leaderId == null) {
messenger.showSnackBar( showWarningSnackBar(ctx, 'Please select a team leader');
const SnackBar(content: Text('Please select a team leader')),
);
return; return;
} }
if (selectedOffices.isEmpty) { if (selectedOffices.isEmpty) {
messenger.showSnackBar( showWarningSnackBar(ctx, 'Assign at least one office to the team');
const SnackBar(
content: Text('Assign at least one office to the team'),
),
);
return; return;
} }
@ -506,9 +499,9 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
} }
} }
} catch (e) { } catch (e) {
messenger.showSnackBar( if (!mounted) return;
SnackBar(content: Text('Failed to save team: $e')), // ignore: use_build_context_synchronously
); showErrorSnackBar(context, 'Failed to save team: $e');
return; return;
} }
@ -604,8 +597,6 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
} }
void _deleteTeam(BuildContext context, String teamId) async { void _deleteTeam(BuildContext context, String teamId) async {
final messenger = ScaffoldMessenger.of(context);
final confirmed = await showDialog<bool?>( final confirmed = await showDialog<bool?>(
context: context, context: context,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AlertDialog(
@ -630,6 +621,7 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
if (confirmed != true) return; if (confirmed != true) return;
final ctx = context; // capture before async gaps
try { try {
final delMembersRes = await ref final delMembersRes = await ref
.read(supabaseClientProvider) .read(supabaseClientProvider)
@ -650,9 +642,9 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
ref.invalidate(teamsProvider); ref.invalidate(teamsProvider);
ref.invalidate(teamMembersProvider); ref.invalidate(teamMembersProvider);
} catch (e) { } catch (e) {
messenger.showSnackBar( if (!mounted) return;
SnackBar(content: Text('Failed to delete team: $e')), // ignore: use_build_context_synchronously
); showErrorSnackBar(ctx, 'Failed to delete team: $e');
return; return;
} }
} }

View File

@ -16,6 +16,7 @@ import '../../widgets/responsive_body.dart';
import '../../widgets/tasq_adaptive_list.dart'; import '../../widgets/tasq_adaptive_list.dart';
import '../../widgets/typing_dots.dart'; import '../../widgets/typing_dots.dart';
import '../../theme/app_surfaces.dart'; import '../../theme/app_surfaces.dart';
import '../../utils/snackbar.dart';
class TicketsListScreen extends ConsumerStatefulWidget { class TicketsListScreen extends ConsumerStatefulWidget {
const TicketsListScreen({super.key}); const TicketsListScreen({super.key});
@ -319,6 +320,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
await showDialog<void>( await showDialog<void>(
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
bool saving = false;
return StatefulBuilder( return StatefulBuilder(
builder: (context, setState) { builder: (context, setState) {
return AlertDialog( return AlertDialog(
@ -337,6 +339,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Subject', labelText: 'Subject',
), ),
enabled: !saving,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( TextField(
@ -345,6 +348,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
labelText: 'Description', labelText: 'Description',
), ),
maxLines: 3, maxLines: 3,
enabled: !saving,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
officesAsync.when( officesAsync.when(
@ -364,8 +368,10 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
), ),
) )
.toList(), .toList(),
onChanged: (value) => onChanged: saving
setState(() => selectedOffice = value), ? null
: (value) =>
setState(() => selectedOffice = value),
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Office', labelText: 'Office',
), ),
@ -382,35 +388,51 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(dialogContext).pop(), onPressed: saving
? null
: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
FilledButton( FilledButton(
onPressed: () async { onPressed: saving
final subject = subjectController.text.trim(); ? null
final description = descriptionController.text.trim(); : () async {
if (subject.isEmpty || final subject = subjectController.text.trim();
description.isEmpty || final description = descriptionController.text.trim();
selectedOffice == null) { if (subject.isEmpty ||
ScaffoldMessenger.of(context).showSnackBar( description.isEmpty ||
const SnackBar(content: Text('Fill out all fields.')), selectedOffice == null) {
); showWarningSnackBar(
return; context,
} 'Fill out all fields.',
await ref );
.read(ticketsControllerProvider) return;
.createTicket( }
subject: subject, setState(() => saving = true);
description: description, await ref
officeId: selectedOffice!.id, .read(ticketsControllerProvider)
); .createTicket(
// Supabase stream will emit the new ticket no explicit subject: subject,
// invalidation required and avoids a temporary reload. description: description,
if (context.mounted) { officeId: selectedOffice!.id,
Navigator.of(dialogContext).pop(); );
} // Supabase stream will emit the new ticket no explicit
}, // invalidation required and avoids a temporary reload.
child: const Text('Create'), 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'),
), ),
], ],
); );

View File

@ -13,6 +13,7 @@ import '../../providers/profile_provider.dart';
import '../../providers/workforce_provider.dart'; import '../../providers/workforce_provider.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
import '../../theme/app_surfaces.dart'; import '../../theme/app_surfaces.dart';
import '../../utils/snackbar.dart';
class WorkforceScreen extends ConsumerWidget { class WorkforceScreen extends ConsumerWidget {
const WorkforceScreen({super.key}); const WorkforceScreen({super.key});
@ -438,7 +439,11 @@ class _ScheduleTile extends ConsumerWidget {
.where((profile) => profile.id != currentUserId) .where((profile) => profile.id != currentUserId)
.toList(); .toList();
if (staff.isEmpty) { if (staff.isEmpty) {
_showMessage(context, 'No IT staff available for swaps.'); _showMessage(
context,
'No IT staff available for swaps.',
type: SnackType.warning,
);
return; return;
} }
@ -572,17 +577,36 @@ class _ScheduleTile extends ConsumerWidget {
); );
ref.invalidate(swapRequestsProvider); ref.invalidate(swapRequestsProvider);
if (!context.mounted) return; if (!context.mounted) return;
_showMessage(context, 'Swap request sent.'); _showMessage(context, 'Swap request sent.', type: SnackType.success);
} catch (error) { } catch (error) {
if (!context.mounted) return; 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) { void _showMessage(
ScaffoldMessenger.of( BuildContext context,
context, String message, {
).showSnackBar(SnackBar(content: Text(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<void> _showAlert( Future<void> _showAlert(
@ -647,7 +671,11 @@ class _ScheduleTile extends ConsumerWidget {
controller.animateTo(1); controller.animateTo(1);
return; 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) { String _statusLabel(String status) {
@ -884,11 +912,11 @@ class _ScheduleGeneratorPanelState
final start = _startDate; final start = _startDate;
final end = _endDate; final end = _endDate;
if (start == null || end == null) { if (start == null || end == null) {
_showMessage('Select a date range.'); showWarningSnackBar(context, 'Select a date range.');
return; return;
} }
if (end.isBefore(start)) { if (end.isBefore(start)) {
_showMessage('End date must be after start date.'); showWarningSnackBar(context, 'End date must be after start date.');
return; return;
} }
setState(() => _isGenerating = true); setState(() => _isGenerating = true);
@ -896,7 +924,7 @@ class _ScheduleGeneratorPanelState
final staff = _sortedStaff(); final staff = _sortedStaff();
if (staff.isEmpty) { if (staff.isEmpty) {
if (!mounted) return; if (!mounted) return;
_showMessage('No IT staff available for scheduling.'); showWarningSnackBar(context, 'No IT staff available for scheduling.');
return; return;
} }
@ -919,7 +947,7 @@ class _ScheduleGeneratorPanelState
}); });
if (generated.isEmpty) { if (generated.isEmpty) {
_showMessage('No shifts could be generated.'); showInfoSnackBar(context, 'No shifts could be generated.');
} }
} finally { } finally {
if (mounted) { if (mounted) {
@ -1099,7 +1127,7 @@ class _ScheduleGeneratorPanelState
Future<void> _openDraftEditor({_DraftSchedule? existing}) async { Future<void> _openDraftEditor({_DraftSchedule? existing}) async {
final staff = _sortedStaff(); final staff = _sortedStaff();
if (staff.isEmpty) { if (staff.isEmpty) {
_showMessage('No IT staff available.'); showWarningSnackBar(context, 'No IT staff available.');
return; return;
} }
@ -1305,14 +1333,14 @@ class _ScheduleGeneratorPanelState
final start = _startDate; final start = _startDate;
final end = _endDate; final end = _endDate;
if (start == null || end == null) { if (start == null || end == null) {
_showMessage('Select a date range.'); showWarningSnackBar(context, 'Select a date range.');
return; return;
} }
final schedules = ref.read(dutySchedulesProvider).valueOrNull ?? []; final schedules = ref.read(dutySchedulesProvider).valueOrNull ?? [];
final conflict = _findConflict(_draftSchedules, schedules); final conflict = _findConflict(_draftSchedules, schedules);
if (conflict != null) { if (conflict != null) {
_showMessage(conflict); showInfoSnackBar(context, conflict);
return; return;
} }
@ -1334,14 +1362,14 @@ class _ScheduleGeneratorPanelState
await ref.read(workforceControllerProvider).insertSchedules(payload); await ref.read(workforceControllerProvider).insertSchedules(payload);
ref.invalidate(dutySchedulesProvider); ref.invalidate(dutySchedulesProvider);
if (!mounted) return; if (!mounted) return;
_showMessage('Schedule committed.'); showSuccessSnackBar(context, 'Schedule committed.');
setState(() { setState(() {
_draftSchedules = []; _draftSchedules = [];
_warnings = []; _warnings = [];
}); });
} catch (error) { } catch (error) {
if (!mounted) return; if (!mounted) return;
_showMessage('Commit failed: $error'); showErrorSnackBar(context, 'Commit failed: $error');
} finally { } finally {
if (mounted) { if (mounted) {
setState(() => _isSaving = false); setState(() => _isSaving = false);
@ -1762,12 +1790,6 @@ class _ScheduleGeneratorPanelState
return value; return value;
} }
} }
void _showMessage(String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
} }
class _SwapRequestsPanel extends ConsumerWidget { class _SwapRequestsPanel extends ConsumerWidget {

85
lib/utils/snackbar.dart Normal file
View File

@ -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 packages
/// [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,
);

View File

@ -121,6 +121,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.2.1" 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: barcode:
dependency: transitive dependency: transitive
description: description:
@ -483,6 +491,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.6.1" 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: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@ -1306,6 +1322,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.5.2" 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: vector_math:
dependency: transitive dependency: transitive
description: description:

View File

@ -26,6 +26,7 @@ dependencies:
pdf: ^3.11.3 pdf: ^3.11.3
printing: ^5.10.0 printing: ^5.10.0
flutter_keyboard_visibility: ^5.4.1 flutter_keyboard_visibility: ^5.4.1
awesome_snackbar_content: ^0.1.8
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@ -1,6 +1,8 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.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/notification_item.dart';
import 'package:tasq/models/office.dart'; import 'package:tasq/models/office.dart';
@ -48,6 +50,73 @@ class FakeNotificationsController implements NotificationsController {
Future<void> markReadForTask(String taskId) async {} Future<void> markReadForTask(String taskId) async {}
} }
// test doubles for controllers that allow us to intercept create operations
class _FakeTicketsController implements TicketsController {
Future<void> Function({
required String subject,
required String description,
required String officeId,
})?
onCreate;
@override
Future<void> 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<void> Function({
required String title,
required String description,
String? officeId,
String? ticketId,
String? requestType,
String? requestTypeOther,
String? requestCategory,
})?
onCreate;
@override
Future<void> 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() { void main() {
final now = DateTime(2026, 2, 10, 12, 0, 0); final now = DateTime(2026, 2, 10, 12, 0, 0);
final office = Office(id: 'office-1', name: 'HQ'); final office = Office(id: 'office-1', name: 'HQ');
@ -247,6 +316,182 @@ void main() {
find.text('Assign at least one office to the team'), find.text('Assign at least one office to the team'),
findsOneWidget, 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<void>();
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<void>();
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', ( testWidgets('Add Team dialog: opening Offices dropdown does not overflow', (

View File

@ -10,6 +10,7 @@ import 'package:tasq/providers/user_offices_provider.dart';
import 'package:tasq/providers/auth_provider.dart'; import 'package:tasq/providers/auth_provider.dart';
import 'package:tasq/screens/profile/profile_screen.dart'; import 'package:tasq/screens/profile/profile_screen.dart';
import 'package:tasq/widgets/multi_select_picker.dart'; import 'package:tasq/widgets/multi_select_picker.dart';
import 'package:awesome_snackbar_content/awesome_snackbar_content.dart';
class _FakeProfileController implements ProfileController { class _FakeProfileController implements ProfileController {
String? lastFullName; String? lastFullName;
@ -121,6 +122,11 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(fake.lastFullName, equals('New Name')); 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 { testWidgets('save offices assigns selected office', (tester) async {

View File

@ -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<String, dynamic>? lastUpdates;
@override
Future<void> createTask({
required String title,
required String description,
String? officeId,
String? ticketId,
String? requestType,
String? requestTypeOther,
String? requestCategory,
}) async {}
@override
Future<void> updateTask({
required String taskId,
String? requestType,
String? requestTypeOther,
String? requestCategory,
String? status,
String? requestedBy,
String? notedBy,
String? receivedBy,
String? actionTaken,
}) async {
final m = <String, dynamic>{};
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<void> updateTaskStatus({
required String taskId,
required String status,
}) async {
lastStatus = status;
}
}
// lightweight notifications controller stub used in widget tests
class _FakeNotificationsController implements NotificationsController {
@override
Future<void> createMentionNotifications({
required List<String> userIds,
required String actorId,
required int messageId,
String? ticketId,
String? taskId,
}) async {}
@override
Future<void> markRead(String id) async {}
@override
Future<void> markReadForTicket(String ticketId) async {}
@override
Future<void> 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 <Profile>[]),
),
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 <Profile>[]),
),
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);
});
}

View File

@ -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<double> contentWidthFor(double screenWidth) async {
await tester.pumpWidget(
MaterialApp(
theme: theme,
home: MediaQuery(
data: MediaQueryData(size: Size(screenWidth, 800)),
child: Scaffold(
body: TasQAdaptiveList<int>(
items: List.generate(100, (i) => i),
columns: List.generate(
20,
(i) => TasQColumn<int>(
header: 'C$i',
cellBuilder: (c, t) => Text('$t'),
),
),
mobileTileBuilder: (c, t, a) => const SizedBox.shrink(),
rowActions: (_) => [],
),
),
),
),
);
await tester.pumpAndSettle();
final box = tester.widget<SizedBox>(
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', ( testWidgets('AppSurfaces tokens are present and dialog/card radii differ', (
WidgetTester tester, WidgetTester tester,
) async { ) async {