A better state and alerts
This commit is contained in:
parent
0b900d3480
commit
1074572905
|
|
@ -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<OfficesScreen> {
|
|||
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<OfficesScreen> {
|
|||
decoration: const InputDecoration(
|
||||
labelText: 'Office name',
|
||||
),
|
||||
enabled: !saving,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
servicesAsync.when(
|
||||
|
|
@ -205,8 +208,9 @@ class _OfficesScreenState extends ConsumerState<OfficesScreen> {
|
|||
),
|
||||
),
|
||||
],
|
||||
onChanged: (v) =>
|
||||
setState(() => selectedServiceId = v),
|
||||
onChanged: saving
|
||||
? null
|
||||
: (v) => setState(() => selectedServiceId = v),
|
||||
);
|
||||
},
|
||||
loading: () => const Padding(
|
||||
|
|
@ -220,37 +224,54 @@ class _OfficesScreenState extends ConsumerState<OfficesScreen> {
|
|||
),
|
||||
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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<UserManagementScreen> {
|
|||
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<UserManagementScreen> {
|
|||
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<UserManagementScreen> {
|
|||
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<UserManagementScreen> {
|
|||
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<UserManagementScreen> {
|
|||
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<UserManagementScreen> {
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -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<LoginScreen> {
|
|||
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<LoginScreen> {
|
|||
}
|
||||
} on Exception catch (error) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('OAuth failed: $error')));
|
||||
showErrorSnackBar(context, 'OAuth failed: $error');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
|
|
|
|||
|
|
@ -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<SignUpScreen> {
|
|||
Future<void> _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<SignUpScreen> {
|
|||
}
|
||||
} 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) {
|
||||
|
|
|
|||
|
|
@ -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<ProfileScreen> {
|
|||
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<ProfileScreen> {
|
|||
_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<ProfileScreen> {
|
|||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TaskDetailScreen>
|
|||
final ext =
|
||||
file.extension ??
|
||||
'png';
|
||||
final messenger =
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
);
|
||||
String? url;
|
||||
try {
|
||||
url = await ref
|
||||
|
|
@ -1685,22 +1682,16 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> _requestTypeOptions = [
|
||||
|
|
@ -426,9 +427,10 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
|||
await showDialog<void>(
|
||||
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<TasksListScreen> {
|
|||
decoration: const InputDecoration(
|
||||
labelText: 'Task title',
|
||||
),
|
||||
enabled: !saving,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
|
|
@ -450,6 +453,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
|||
labelText: 'Description',
|
||||
),
|
||||
maxLines: 3,
|
||||
enabled: !saving,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
officesAsync.when(
|
||||
|
|
@ -474,8 +478,11 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
|||
),
|
||||
)
|
||||
.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<TasksListScreen> {
|
|||
),
|
||||
)
|
||||
.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<TasksListScreen> {
|
|||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (value) => setState(
|
||||
() => selectedRequestCategory = value,
|
||||
),
|
||||
onChanged: saving
|
||||
? null
|
||||
: (value) => setState(
|
||||
() => selectedRequestCategory = value,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
@ -537,32 +549,47 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
|||
),
|
||||
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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<TeamsScreen> {
|
|||
}
|
||||
|
||||
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();
|
||||
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<TeamsScreen> {
|
|||
}
|
||||
}
|
||||
} 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<TeamsScreen> {
|
|||
}
|
||||
|
||||
void _deleteTeam(BuildContext context, String teamId) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
||||
final confirmed = await showDialog<bool?>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
|
|
@ -630,6 +621,7 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
|
|||
|
||||
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<TeamsScreen> {
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TicketsListScreen> {
|
|||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
bool saving = false;
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return AlertDialog(
|
||||
|
|
@ -337,6 +339,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
|||
decoration: const InputDecoration(
|
||||
labelText: 'Subject',
|
||||
),
|
||||
enabled: !saving,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
|
|
@ -345,6 +348,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
|||
labelText: 'Description',
|
||||
),
|
||||
maxLines: 3,
|
||||
enabled: !saving,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
officesAsync.when(
|
||||
|
|
@ -364,8 +368,10 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
|||
),
|
||||
)
|
||||
.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<TicketsListScreen> {
|
|||
),
|
||||
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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<void> _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<void> _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 {
|
||||
|
|
|
|||
85
lib/utils/snackbar.dart
Normal file
85
lib/utils/snackbar.dart
Normal 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 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,
|
||||
);
|
||||
40
pubspec.lock
40
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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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<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() {
|
||||
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<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', (
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user