627645056b
Separate feature for the `programmer` role to record dev work by category, distinct from the IT help-desk `tasks` feature. Modeled on the IT Service Requests pattern (Brick offline-first model -> Riverpod stream/controller -> list/detail screens); seed of a future Project Management workflow. - programmer_tasks + programmer_task_activity_logs (category enum, RLS, realtime) via supabase migration - single assignee, self-logged or lead-assigned; assign/reassign logged - pause-on-switch: starting a task while another runs prompts to pause the running one, keeping one active task per user for accurate time attribution - worked-duration math shared with help-desk tasks via a generic core in task_duration.dart (existing callers unchanged) - list distinguishes running vs paused with a per-task Resume shortcut - accessible dialog labels; route /programmer-tasks + nav gated to admin/programmer - unit/widget tests: controller lifecycle, duration math, pause-switch dialog Co-Authored-By: claude-flow <ruv@ruv.net>
401 lines
14 KiB
Dart
401 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import '../providers/auth_provider.dart';
|
|
import '../providers/profile_provider.dart';
|
|
import '../providers/supabase_provider.dart';
|
|
import '../utils/lock_enforcer.dart';
|
|
import '../screens/auth/login_screen.dart';
|
|
import '../screens/auth/signup_screen.dart';
|
|
import '../screens/admin/offices_screen.dart';
|
|
import '../screens/admin/user_management_screen.dart';
|
|
import '../screens/admin/geofence_test_screen.dart';
|
|
import '../screens/admin/app_update_screen.dart';
|
|
import '../screens/dashboard/dashboard_screen.dart';
|
|
import '../screens/notifications/notifications_screen.dart';
|
|
import '../screens/profile/profile_screen.dart';
|
|
import '../screens/shared/mobile_verification_screen.dart';
|
|
import '../screens/announcements/announcements_screen.dart';
|
|
import '../screens/shared/permissions_screen.dart';
|
|
import '../screens/reports/reports_screen.dart';
|
|
import '../screens/tasks/task_detail_screen.dart';
|
|
import '../screens/tasks/tasks_list_screen.dart';
|
|
import '../screens/tickets/ticket_detail_screen.dart';
|
|
import '../screens/tickets/tickets_list_screen.dart';
|
|
import '../screens/workforce/workforce_screen.dart';
|
|
import '../screens/attendance/attendance_screen.dart';
|
|
import '../screens/whereabouts/whereabouts_screen.dart';
|
|
import '../widgets/app_shell.dart';
|
|
import '../screens/teams/teams_screen.dart';
|
|
import '../screens/it_service_requests/it_service_requests_list_screen.dart';
|
|
import '../screens/it_service_requests/it_service_request_detail_screen.dart';
|
|
import '../screens/programmer_tasks/programmer_tasks_list_screen.dart';
|
|
import '../screens/programmer_tasks/programmer_task_detail_screen.dart';
|
|
import '../screens/network_map/network_map_overview_screen.dart';
|
|
import '../screens/network_map/network_map_site_screen.dart';
|
|
import '../screens/network_map/network_map_device_screen.dart';
|
|
import '../screens/network_map/network_map_device_edit_screen.dart';
|
|
import '../screens/network_map/network_map_import_screen.dart';
|
|
import '../screens/network_map/network_map_import_review_screen.dart';
|
|
import '../screens/network_map/network_map_vlan_screen.dart';
|
|
import '../theme/m3_motion.dart';
|
|
|
|
import '../utils/navigation.dart';
|
|
|
|
Page<dynamic> Function(BuildContext, GoRouterState) _page(Widget child) =>
|
|
(context, state) => M3SharedAxisPage(key: state.pageKey, child: child);
|
|
|
|
String _defaultRouteForRole(String? role) {
|
|
switch (role) {
|
|
case 'it_staff':
|
|
return '/tasks';
|
|
case 'standard':
|
|
return '/tickets';
|
|
default:
|
|
return '/dashboard';
|
|
}
|
|
}
|
|
|
|
final appRouterProvider = Provider<GoRouter>((ref) {
|
|
final notifier = RouterNotifier(ref);
|
|
ref.onDispose(notifier.dispose);
|
|
|
|
return GoRouter(
|
|
navigatorKey: globalNavigatorKey,
|
|
initialLocation: '/dashboard',
|
|
refreshListenable: notifier,
|
|
redirect: (context, state) {
|
|
final authState = ref.read(authStateChangesProvider);
|
|
dynamic session;
|
|
if (authState is AsyncData) {
|
|
final state = authState.value;
|
|
session = state?.session;
|
|
} else {
|
|
session = ref.read(sessionProvider);
|
|
}
|
|
final isAuthRoute =
|
|
state.fullPath == '/login' || state.fullPath == '/signup';
|
|
final isSignedIn = session != null;
|
|
final profileAsync = ref.read(currentProfileProvider);
|
|
final isAdminRoute = state.matchedLocation.startsWith('/settings');
|
|
final role = profileAsync is AsyncData
|
|
? (profileAsync.value)?.role
|
|
: null;
|
|
final isAdmin = role == 'admin' || role == 'programmer';
|
|
final isReportsRoute = state.matchedLocation == '/reports';
|
|
final hasReportsAccess =
|
|
role == 'admin' ||
|
|
role == 'programmer' ||
|
|
role == 'dispatcher' ||
|
|
role == 'it_staff';
|
|
final isNetworkMapRoute = state.matchedLocation.startsWith('/network-map');
|
|
final hasNetworkMapAccess =
|
|
role == 'admin' ||
|
|
role == 'it_staff' ||
|
|
role == 'dispatcher' ||
|
|
role == 'programmer';
|
|
final isProgrammerTasksRoute =
|
|
state.matchedLocation.startsWith('/programmer-tasks');
|
|
final hasProgrammerTasksAccess =
|
|
role == 'admin' || role == 'programmer';
|
|
|
|
if (!isSignedIn && !isAuthRoute) {
|
|
return '/login';
|
|
}
|
|
if (isSignedIn && isAuthRoute) {
|
|
// If role already loaded, redirect directly and clear any pending flag
|
|
if (role != null) notifier._needsRoleRedirect = false;
|
|
return _defaultRouteForRole(role);
|
|
}
|
|
// Deferred post-login redirect: profile loaded after the initial redirect
|
|
// (which fell back to /dashboard because role was null). Consumed once
|
|
// per sign-in; only redirects when the user is still on /dashboard.
|
|
if (isSignedIn && notifier._needsRoleRedirect && role != null) {
|
|
notifier._needsRoleRedirect = false;
|
|
if (state.matchedLocation == '/dashboard') {
|
|
return _defaultRouteForRole(role);
|
|
}
|
|
}
|
|
// Role unknown while the profile loads: hold the deep link instead of
|
|
// bouncing it. RouterNotifier re-runs this once the profile arrives.
|
|
if (isSignedIn && profileAsync.isLoading && role == null) {
|
|
return null;
|
|
}
|
|
if (isAdminRoute && !isAdmin) {
|
|
return '/tickets';
|
|
}
|
|
if (isReportsRoute && !hasReportsAccess) {
|
|
return '/tickets';
|
|
}
|
|
if (isNetworkMapRoute && !hasNetworkMapAccess) {
|
|
return '/tickets';
|
|
}
|
|
if (isProgrammerTasksRoute && !hasProgrammerTasksAccess) {
|
|
return '/tickets';
|
|
}
|
|
// Attendance & Whereabouts: not accessible to standard users
|
|
final isStandardOnly = role == 'standard';
|
|
final isAttendanceRoute =
|
|
state.matchedLocation == '/attendance' ||
|
|
state.matchedLocation == '/whereabouts';
|
|
if (isAttendanceRoute && isStandardOnly) {
|
|
return '/dashboard';
|
|
}
|
|
return null;
|
|
},
|
|
routes: [
|
|
GoRoute(path: '/login', builder: (context, state) => const LoginScreen()),
|
|
GoRoute(
|
|
path: '/signup',
|
|
builder: (context, state) => const SignUpScreen(),
|
|
),
|
|
GoRoute(
|
|
path: '/verify/:sessionId',
|
|
builder: (context, state) => MobileVerificationScreen(
|
|
sessionId: state.pathParameters['sessionId'] ?? '',
|
|
),
|
|
),
|
|
ShellRoute(
|
|
builder: (context, state, child) => AppScaffold(child: child),
|
|
routes: [
|
|
GoRoute(
|
|
path: '/settings/teams',
|
|
pageBuilder: _page(const TeamsScreen()),
|
|
),
|
|
GoRoute(
|
|
path: '/settings/app-update',
|
|
pageBuilder: _page(const AppUpdateScreen()),
|
|
),
|
|
GoRoute(
|
|
path: '/dashboard',
|
|
pageBuilder: _page(const DashboardScreen()),
|
|
),
|
|
GoRoute(
|
|
path: '/tickets',
|
|
pageBuilder: _page(const TicketsListScreen()),
|
|
routes: [
|
|
GoRoute(
|
|
path: ':id',
|
|
pageBuilder: (context, state) => M3ContainerTransformPage(
|
|
key: state.pageKey,
|
|
child: TicketDetailScreen(
|
|
ticketId: state.pathParameters['id'] ?? '',
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
GoRoute(
|
|
path: '/tasks',
|
|
pageBuilder: _page(const TasksListScreen()),
|
|
routes: [
|
|
GoRoute(
|
|
path: ':id',
|
|
pageBuilder: (context, state) => M3ContainerTransformPage(
|
|
key: state.pageKey,
|
|
child: TaskDetailScreen(
|
|
taskId: state.pathParameters['id'] ?? '',
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
GoRoute(
|
|
path: '/it-service-requests',
|
|
pageBuilder: _page(const ItServiceRequestsListScreen()),
|
|
routes: [
|
|
GoRoute(
|
|
path: ':id',
|
|
pageBuilder: (context, state) => M3ContainerTransformPage(
|
|
key: state.pageKey,
|
|
child: ItServiceRequestDetailScreen(
|
|
requestId: state.pathParameters['id'] ?? '',
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
GoRoute(
|
|
path: '/programmer-tasks',
|
|
pageBuilder: _page(const ProgrammerTasksListScreen()),
|
|
routes: [
|
|
GoRoute(
|
|
path: ':id',
|
|
pageBuilder: (context, state) => M3ContainerTransformPage(
|
|
key: state.pageKey,
|
|
child: ProgrammerTaskDetailScreen(
|
|
taskId: state.pathParameters['id'] ?? '',
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
GoRoute(
|
|
path: '/announcements',
|
|
pageBuilder: _page(const AnnouncementsScreen()),
|
|
),
|
|
GoRoute(
|
|
path: '/workforce',
|
|
pageBuilder: _page(const WorkforceScreen()),
|
|
),
|
|
GoRoute(
|
|
path: '/attendance',
|
|
pageBuilder: _page(const AttendanceScreen()),
|
|
),
|
|
GoRoute(
|
|
path: '/whereabouts',
|
|
pageBuilder: _page(const WhereaboutsScreen()),
|
|
),
|
|
GoRoute(path: '/reports', pageBuilder: _page(const ReportsScreen())),
|
|
GoRoute(
|
|
path: '/settings/users',
|
|
pageBuilder: _page(const UserManagementScreen()),
|
|
),
|
|
GoRoute(
|
|
path: '/settings/offices',
|
|
pageBuilder: _page(const OfficesScreen()),
|
|
),
|
|
GoRoute(
|
|
path: '/settings/geofence-test',
|
|
pageBuilder: _page(const GeofenceTestScreen()),
|
|
),
|
|
GoRoute(
|
|
path: '/settings/permissions',
|
|
pageBuilder: _page(const PermissionsScreen()),
|
|
),
|
|
GoRoute(
|
|
path: '/network-map',
|
|
pageBuilder: _page(const NetworkMapOverviewScreen()),
|
|
routes: [
|
|
GoRoute(
|
|
path: 'import',
|
|
pageBuilder: _page(const NetworkMapImportScreen()),
|
|
routes: [
|
|
GoRoute(
|
|
path: ':importId/review',
|
|
pageBuilder: (context, state) => M3SharedAxisPage(
|
|
key: state.pageKey,
|
|
child: NetworkMapImportReviewScreen(
|
|
importId: state.pathParameters['importId'] ?? '',
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
GoRoute(
|
|
path: 'vlans',
|
|
pageBuilder: _page(const NetworkMapVlanScreen()),
|
|
),
|
|
GoRoute(
|
|
path: 'site/:siteId',
|
|
pageBuilder: (context, state) => M3SharedAxisPage(
|
|
key: state.pageKey,
|
|
child: NetworkMapSiteScreen(
|
|
siteId: state.pathParameters['siteId'] ?? '',
|
|
),
|
|
),
|
|
routes: [
|
|
GoRoute(
|
|
path: 'device/new',
|
|
pageBuilder: (context, state) => M3SharedAxisPage(
|
|
key: state.pageKey,
|
|
child: NetworkMapDeviceEditScreen(
|
|
initialSiteId: state.pathParameters['siteId'],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
GoRoute(
|
|
path: 'device/:deviceId',
|
|
pageBuilder: (context, state) => M3SharedAxisPage(
|
|
key: state.pageKey,
|
|
child: NetworkMapDeviceScreen(
|
|
deviceId: state.pathParameters['deviceId'] ?? '',
|
|
),
|
|
),
|
|
routes: [
|
|
GoRoute(
|
|
path: 'edit',
|
|
pageBuilder: (context, state) => M3SharedAxisPage(
|
|
key: state.pageKey,
|
|
child: NetworkMapDeviceEditScreen(
|
|
deviceId: state.pathParameters['deviceId'],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
GoRoute(
|
|
path: '/notifications',
|
|
pageBuilder: _page(const NotificationsScreen()),
|
|
),
|
|
GoRoute(path: '/profile', pageBuilder: _page(const ProfileScreen())),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
});
|
|
|
|
class RouterNotifier extends ChangeNotifier {
|
|
RouterNotifier(this.ref) {
|
|
_authSub = ref.listen(authStateChangesProvider, (previous, next) {
|
|
// Only enforce lock on successful sign-in events, not on every auth state change
|
|
if (next is AsyncData) {
|
|
final authState = next.value;
|
|
final session = authState?.session;
|
|
// Only check for bans when we have a session and the previous state didn't
|
|
final previousSession = previous is AsyncData
|
|
? previous.value?.session
|
|
: null;
|
|
if (session != null && previousSession == null) {
|
|
// User just signed in; enforce lock check and flag for role-based redirect
|
|
_enforceLockAsync();
|
|
_needsRoleRedirect = true;
|
|
}
|
|
}
|
|
notifyListeners();
|
|
});
|
|
_profileSub = ref.listen(currentProfileProvider, (previous, next) {
|
|
notifyListeners();
|
|
});
|
|
}
|
|
|
|
final Ref ref;
|
|
late final ProviderSubscription _authSub;
|
|
late final ProviderSubscription _profileSub;
|
|
bool _lockEnforcementInProgress = false;
|
|
|
|
/// Set on new sign-in. Consumed by the redirect function to send the user
|
|
/// to their role-appropriate landing page once the profile has loaded.
|
|
bool _needsRoleRedirect = false;
|
|
|
|
/// Safely enforce lock in the background, preventing concurrent calls
|
|
void _enforceLockAsync() {
|
|
// Prevent concurrent enforcement calls
|
|
if (_lockEnforcementInProgress) return;
|
|
_lockEnforcementInProgress = true;
|
|
|
|
// Use Future.microtask to defer execution and avoid blocking
|
|
Future.microtask(() async {
|
|
try {
|
|
await enforceLockForCurrentUser(ref.read(supabaseClientProvider));
|
|
} catch (e) {
|
|
debugPrint('RouterNotifier: lock enforcement error: $e');
|
|
} finally {
|
|
_lockEnforcementInProgress = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_authSub.close();
|
|
_profileSub.close();
|
|
super.dispose();
|
|
}
|
|
}
|