Added programmer role and fixed snackbar not showing

This commit is contained in:
2026-03-16 07:23:20 +08:00
parent 9f7791e56f
commit 81853c4367
23 changed files with 362 additions and 65 deletions
+2
View File
@@ -8,6 +8,7 @@ import 'models/profile.dart';
import 'providers/profile_provider.dart';
import 'services/background_location_service.dart';
import 'theme/app_theme.dart';
import 'utils/snackbar.dart';
class TasqApp extends ConsumerWidget {
const TasqApp({super.key});
@@ -36,6 +37,7 @@ class TasqApp extends ConsumerWidget {
return MaterialApp.router(
title: 'TasQ',
routerConfig: router,
scaffoldMessengerKey: scaffoldMessengerKey,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
themeMode: ThemeMode.system,
+1
View File
@@ -31,6 +31,7 @@ final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
final hasFullAccess =
profile.role == 'admin' ||
profile.role == 'programmer' ||
profile.role == 'dispatcher' ||
profile.role == 'it_staff';
+1
View File
@@ -23,6 +23,7 @@ final leavesProvider = StreamProvider<List<LeaveOfAbsence>>((ref) {
final hasFullAccess =
profile.role == 'admin' ||
profile.role == 'programmer' ||
profile.role == 'dispatcher' ||
profile.role == 'it_staff';
+3 -2
View File
@@ -16,9 +16,10 @@ final passSlipsProvider = StreamProvider<List<PassSlip>>((ref) {
if (profile == null) return Stream.value(const <PassSlip>[]);
final isAdmin = profile.role == 'admin' || profile.role == 'dispatcher';
final hasFullAccess = isAdmin || profile.role == 'programmer';
final wrapper = StreamRecoveryWrapper<PassSlip>(
stream: isAdmin
stream: hasFullAccess
? client
.from('pass_slips')
.stream(primaryKey: ['id'])
@@ -30,7 +31,7 @@ final passSlipsProvider = StreamProvider<List<PassSlip>>((ref) {
.order('requested_at', ascending: false),
onPollData: () async {
final query = client.from('pass_slips').select();
final data = isAdmin
final data = hasFullAccess
? await query.order('requested_at', ascending: false)
: await query
.eq('user_id', profile.id)
+2 -1
View File
@@ -156,7 +156,8 @@ class ProfileController {
final isAdminProvider = Provider<bool>((ref) {
final profileAsync = ref.watch(currentProfileProvider);
return profileAsync.maybeWhen(
data: (profile) => profile?.role == 'admin',
data: (profile) =>
profile?.role == 'admin' || profile?.role == 'programmer',
orElse: () => false,
);
});
+1
View File
@@ -266,6 +266,7 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
final isGlobal =
profile.role == 'admin' ||
profile.role == 'programmer' ||
profile.role == 'dispatcher' ||
profile.role == 'it_staff';
+1
View File
@@ -176,6 +176,7 @@ final ticketsProvider = StreamProvider<List<Ticket>>((ref) {
final isGlobal =
profile.role == 'admin' ||
profile.role == 'programmer' ||
profile.role == 'dispatcher' ||
profile.role == 'it_staff';
+4 -1
View File
@@ -101,7 +101,10 @@ final swapRequestsProvider = StreamProvider<List<SwapRequest>>((ref) {
return Stream.value(const <SwapRequest>[]);
}
final isAdmin = profile.role == 'admin' || profile.role == 'dispatcher';
final isAdmin =
profile.role == 'admin' ||
profile.role == 'programmer' ||
profile.role == 'dispatcher';
final wrapper = StreamRecoveryWrapper<SwapRequest>(
stream: isAdmin
+5 -2
View File
@@ -59,10 +59,13 @@ final appRouterProvider = Provider<GoRouter>((ref) {
final role = profileAsync is AsyncData
? (profileAsync.value)?.role
: null;
final isAdmin = role == 'admin';
final isAdmin = role == 'admin' || role == 'programmer';
final isReportsRoute = state.matchedLocation == '/reports';
final hasReportsAccess =
role == 'admin' || role == 'dispatcher' || role == 'it_staff';
role == 'admin' ||
role == 'programmer' ||
role == 'dispatcher' ||
role == 'it_staff';
if (!isSignedIn && !isAuthRoute) {
return '/login';
@@ -32,6 +32,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
'standard',
'dispatcher',
'it_staff',
'programmer',
'admin',
];
@@ -132,9 +132,10 @@ class _AttendanceScreenState extends ConsumerState<AttendanceScreen>
ColorScheme colors,
Profile profile,
) {
final isAdmin = profile.role == 'admin';
final isAdmin = profile.role == 'admin' || profile.role == 'programmer';
final canFileLeave =
profile.role == 'admin' ||
profile.role == 'programmer' ||
profile.role == 'dispatcher' ||
profile.role == 'it_staff';
@@ -209,7 +210,7 @@ class _AttendanceScreenState extends ConsumerState<AttendanceScreen>
}
void _showPassSlipDialog(BuildContext context, Profile profile) {
final isAdmin = profile.role == 'admin';
final isAdmin = profile.role == 'admin' || profile.role == 'programmer';
if (isAdmin) {
showWarningSnackBar(context, 'Admins cannot file pass slips.');
return;
@@ -177,6 +177,7 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
.where(
(profile) =>
profile.role == 'admin' ||
profile.role == 'programmer' ||
profile.role == 'dispatcher' ||
profile.role == 'it_staff',
)
@@ -241,9 +241,15 @@ class _ItServiceRequestDetailScreenState
if (profile == null) return false;
final request = ref.read(itServiceRequestByIdProvider(widget.requestId));
if (request == null) return false;
// Admin, dispatcher can always edit; IT Staff and creator can edit in certain statuses
if (profile.role == 'admin' || profile.role == 'dispatcher') return true;
if (profile.role == 'it_staff') return true;
// Admin, programmer, dispatcher can always edit; IT Staff and creator can edit in certain statuses
if (profile.role == 'admin' ||
profile.role == 'programmer' ||
profile.role == 'dispatcher') {
return true;
}
if (profile.role == 'it_staff') {
return true;
}
if (request.creatorId == profile.id &&
(request.status == 'draft' || request.status == 'pending_approval')) {
return true;
@@ -259,7 +265,11 @@ class _ItServiceRequestDetailScreenState
bool get _canChangeStatus {
final profile = ref.read(currentProfileProvider).valueOrNull;
if (profile == null) return false;
if (profile.role == 'admin' || profile.role == 'dispatcher') return true;
if (profile.role == 'admin' ||
profile.role == 'programmer' ||
profile.role == 'dispatcher') {
return true;
}
// Assigned IT staff can change status
final assignments =
ref.read(itServiceRequestAssignmentsProvider).valueOrNull ?? [];
@@ -140,6 +140,7 @@ class _ItServiceRequestsListScreenState
final isPrivileged =
currentProfile != null &&
(currentProfile.role == 'admin' ||
currentProfile.role == 'programmer' ||
currentProfile.role == 'dispatcher' ||
currentProfile.role == 'it_staff');
+14 -17
View File
@@ -663,8 +663,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
),
);
if (mounted) {
showSuccessSnackBar(
context,
showSuccessSnackBarGlobal(
'Task resumed',
);
}
@@ -682,16 +681,14 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
),
);
if (mounted) {
showInfoSnackBar(
context,
showInfoSnackBarGlobal(
'Task paused',
);
}
}
} catch (e) {
if (mounted) {
showErrorSnackBar(
context,
showErrorSnackBarGlobal(
e.toString(),
);
}
@@ -4310,8 +4307,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
// Validate IT staff assignment before starting or completing
if ((value == 'in_progress' || value == 'completed') &&
!hasAssignedItStaff) {
showWarningSnackBar(
context,
showWarningSnackBarGlobal(
'Please assign at least one IT Staff member before ${value == 'in_progress' ? 'starting' : 'completing'} this task.',
);
return;
@@ -4327,7 +4323,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
} catch (e) {
// surface validation or other errors to user
if (mounted) {
showErrorSnackBar(context, e.toString());
showErrorSnackBarGlobal(e.toString());
}
}
},
@@ -4362,6 +4358,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
}
final isGlobal =
profile.role == 'admin' ||
profile.role == 'programmer' ||
profile.role == 'dispatcher' ||
profile.role == 'it_staff';
if (isGlobal) {
@@ -4390,7 +4387,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
if (bytes == null) {
if (mounted) {
showErrorSnackBar(context, 'Failed to read file');
showErrorSnackBarGlobal('Failed to read file');
}
return;
}
@@ -4399,7 +4396,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
const maxSizeBytes = 25 * 1024 * 1024;
if (bytes.length > maxSizeBytes) {
if (mounted) {
showErrorSnackBar(context, 'File size exceeds 25MB limit');
showErrorSnackBarGlobal('File size exceeds 25MB limit');
}
return;
}
@@ -4457,12 +4454,12 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
if (uploadSuccess) {
debugPrint('Showing success message and reloading attachments');
showSuccessSnackBar(context, 'File uploaded successfully');
showSuccessSnackBarGlobal('File uploaded successfully');
// Reload attachments list (non-blocking)
_loadAttachments(taskId);
debugPrint('Attachment reload triggered');
} else {
showErrorSnackBar(context, 'Upload failed: $errorMessage');
showErrorSnackBarGlobal('Upload failed: $errorMessage');
}
} catch (e) {
if (mounted) {
@@ -4536,15 +4533,15 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
if (mounted) {
if (savePath != null && savePath.isNotEmpty) {
showSuccessSnackBar(context, 'File saved to: $savePath');
showSuccessSnackBarGlobal('File saved to: $savePath');
} else {
showInfoSnackBar(context, 'Download cancelled');
showInfoSnackBarGlobal('Download cancelled');
}
}
} catch (e) {
debugPrint('Download error: $e');
if (mounted) {
showErrorSnackBar(context, 'Download error: $e');
showErrorSnackBarGlobal('Download error: $e');
}
}
}
@@ -4576,7 +4573,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
]);
if (mounted) {
showSuccessSnackBar(context, 'Attachment deleted');
showSuccessSnackBarGlobal('Attachment deleted');
// Reload attachments list
await _loadAttachments(taskId);
}
+2 -2
View File
@@ -122,6 +122,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
data: (profile) =>
profile != null &&
(profile.role == 'admin' ||
profile.role == 'programmer' ||
profile.role == 'dispatcher' ||
profile.role == 'it_staff'),
orElse: () => false,
@@ -862,8 +863,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
);
if (context.mounted) {
Navigator.of(dialogContext).pop();
showSuccessSnackBar(
context,
showSuccessSnackBarGlobal(
'Task "$title" has been created successfully.',
);
}
@@ -120,6 +120,7 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
final canEdit =
profile != null &&
(profile.role == 'admin' ||
profile.role == 'programmer' ||
profile.role == 'dispatcher' ||
profile.role == 'it_staff' ||
profile.id == ticket.creatorId);
+3 -1
View File
@@ -26,7 +26,8 @@ class WorkforceScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final profileAsync = ref.watch(currentProfileProvider);
final role = profileAsync.valueOrNull?.role ?? 'standard';
final isAdmin = role == 'admin' || role == 'dispatcher';
final isAdmin =
role == 'admin' || role == 'programmer' || role == 'dispatcher';
return ResponsiveBody(
child: LayoutBuilder(
@@ -1486,6 +1487,7 @@ class _ScheduleGeneratorPanelState
(profile) =>
profile.role == 'it_staff' ||
profile.role == 'admin' ||
profile.role == 'programmer' ||
profile.role == 'dispatcher',
)
.toList();
+146 -31
View File
@@ -1,5 +1,11 @@
import 'package:flutter/material.dart';
import 'package:awesome_snackbar_content/awesome_snackbar_content.dart';
import 'package:flutter/foundation.dart';
/// A global messenger key used to show snackbars from contexts without a
/// Scaffold (e.g. dialogs, background callbacks, or tests).
final GlobalKey<ScaffoldMessengerState> scaffoldMessengerKey =
GlobalKey<ScaffoldMessengerState>();
/// Helper wrappers around `awesome_snackbar_content` so that callers
/// can show snackbars with a consistent look and only specify a message and
@@ -28,6 +34,7 @@ void showAwesomeSnackBar(
required String title,
required String message,
required SnackType snackType,
bool retry = true,
}) {
// Add margin and padding so even very short messages feel substantial.
final snackBar = SnackBar(
@@ -47,39 +54,147 @@ void showAwesomeSnackBar(
),
);
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(snackBar);
// Prefer a local ScaffoldMessenger when available, but prefer the global
// `scaffoldMessengerKey` if it's already ready. This increases reliability
// when callers are inside dialogs or other overlays.
final globalMessenger = scaffoldMessengerKey.currentState;
final localMessenger = ScaffoldMessenger.maybeOf(context);
final messenger = localMessenger ?? globalMessenger;
if (messenger == null) {
if (kDebugMode) {
debugPrint(
'showAwesomeSnackBar: no messenger available; scheduling fallback',
);
}
// If the scaffold messenger is not available yet, schedule a post-frame
// callback that uses the global messenger (if it becomes available).
if (retry) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final gm = scaffoldMessengerKey.currentState;
if (gm != null) {
try {
gm
..hideCurrentSnackBar()
..showSnackBar(snackBar);
if (kDebugMode) {
debugPrint(
'showAwesomeSnackBar: shown via global messenger (post-frame)',
);
}
} catch (e) {
if (kDebugMode) {
debugPrint('showAwesomeSnackBar: post-frame show failed: $e');
}
}
} else if (kDebugMode) {
debugPrint(
'showAwesomeSnackBar: global messenger still null after frame',
);
}
});
}
return;
}
try {
messenger
..hideCurrentSnackBar()
..showSnackBar(snackBar);
if (kDebugMode) {
debugPrint(
'showAwesomeSnackBar: shown via ${identical(messenger, globalMessenger) ? 'global' : 'local'} messenger',
);
}
} catch (e) {
if (kDebugMode) debugPrint('showAwesomeSnackBar: show failed: $e');
}
}
void showSuccessSnackBar(BuildContext context, String message) =>
showAwesomeSnackBar(
context,
title: 'Success',
message: message,
snackType: SnackType.success,
);
void showSuccessSnackBar(BuildContext context, String message) {
showSuccessSnackBarGlobal(message);
}
void showErrorSnackBar(BuildContext context, String message) =>
showAwesomeSnackBar(
context,
title: 'Error',
message: message,
snackType: SnackType.error,
);
void showErrorSnackBar(BuildContext context, String message) {
showErrorSnackBarGlobal(message);
}
void showInfoSnackBar(BuildContext context, String message) =>
showAwesomeSnackBar(
context,
title: 'Info',
message: message,
snackType: SnackType.info,
);
void showInfoSnackBar(BuildContext context, String message) {
showInfoSnackBarGlobal(message);
}
void showWarningSnackBar(BuildContext context, String message) =>
showAwesomeSnackBar(
context,
title: 'Warning',
message: message,
snackType: SnackType.warning,
);
void showWarningSnackBar(BuildContext context, String message) {
showWarningSnackBarGlobal(message);
}
/// Global helpers that use the app-level `scaffoldMessengerKey` directly.
/// Use these from places where a valid `BuildContext` with a Scaffold may
/// not be available (e.g. dialog builders or background callbacks).
void showAwesomeSnackBarGlobal({
required String title,
required String message,
required SnackType snackType,
}) {
final gm = scaffoldMessengerKey.currentState;
if (gm == null) {
if (kDebugMode) {
debugPrint('showAwesomeSnackBarGlobal: global messenger null');
}
return;
}
final snackBar = SnackBar(
elevation: 0,
behavior: SnackBarBehavior.floating,
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),
),
),
);
try {
gm
..hideCurrentSnackBar()
..showSnackBar(snackBar);
if (kDebugMode) {
debugPrint('showAwesomeSnackBarGlobal: shown');
}
} catch (e) {
if (kDebugMode) {
debugPrint('showAwesomeSnackBarGlobal: show failed: $e');
}
}
}
void showSuccessSnackBarGlobal(String message) => showAwesomeSnackBarGlobal(
title: 'Success',
message: message,
snackType: SnackType.success,
);
void showErrorSnackBarGlobal(String message) => showAwesomeSnackBarGlobal(
title: 'Error',
message: message,
snackType: SnackType.error,
);
void showInfoSnackBarGlobal(String message) => showAwesomeSnackBarGlobal(
title: 'Info',
message: message,
snackType: SnackType.info,
);
void showWarningSnackBarGlobal(String message) => showAwesomeSnackBarGlobal(
title: 'Warning',
message: message,
snackType: SnackType.warning,
);
+2 -2
View File
@@ -404,7 +404,7 @@ List<NavSection> _buildSections(String role) {
),
];
if (role == 'admin' || role == 'dispatcher') {
if (role == 'admin' || role == 'programmer' || role == 'dispatcher') {
return [
NavSection(label: 'Operations', items: mainItems),
NavSection(
@@ -508,7 +508,7 @@ List<NavItem> _standardNavItems() {
}
List<NavItem> _primaryItemsForRole(String role) {
if (role == 'admin') {
if (role == 'admin' || role == 'programmer') {
return [
NavItem(
label: 'Dashboard',