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:supabase_flutter/supabase_flutter.dart'; import 'package:tasq/models/attendance_log.dart'; import 'package:tasq/models/notification_item.dart'; import 'package:tasq/models/office.dart'; import 'package:tasq/models/pass_slip.dart'; import 'package:tasq/models/profile.dart'; import 'package:tasq/models/task.dart'; import 'package:tasq/models/task_assignment.dart'; import 'package:tasq/models/ticket.dart'; import 'package:tasq/models/user_office.dart'; import 'package:tasq/models/team.dart'; import 'package:tasq/utils/app_time.dart'; import 'package:go_router/go_router.dart'; import 'package:tasq/models/team_member.dart'; import 'package:tasq/models/announcement.dart'; import 'package:tasq/providers/announcements_provider.dart'; import 'package:tasq/utils/snackbar.dart' show scaffoldMessengerKey; import 'package:tasq/providers/attendance_provider.dart'; import 'package:tasq/providers/notifications_provider.dart'; import 'package:tasq/providers/pass_slip_provider.dart'; import 'package:tasq/providers/profile_provider.dart'; import 'package:tasq/providers/supabase_provider.dart'; import 'package:tasq/providers/tasks_provider.dart'; import 'package:tasq/providers/tickets_provider.dart'; import 'package:tasq/providers/user_offices_provider.dart'; import 'package:tasq/screens/admin/offices_screen.dart'; import 'package:tasq/screens/admin/user_management_screen.dart'; import 'package:tasq/screens/tasks/tasks_list_screen.dart'; import 'package:tasq/screens/tickets/tickets_list_screen.dart'; import 'package:tasq/screens/tickets/ticket_detail_screen.dart'; import 'package:tasq/screens/teams/teams_screen.dart'; import 'package:tasq/screens/shared/permissions_screen.dart'; import 'package:tasq/providers/teams_provider.dart'; import 'package:tasq/widgets/app_shell.dart'; // (Noop typing controller removed — use provider overrides when needed.) // Test double for NotificationsController so widget tests don't initialize // a real Supabase client. class FakeNotificationsController implements NotificationsController { @override Future createMentionNotifications({ required List userIds, required String actorId, required int messageId, String? ticketId, String? taskId, }) async {} @override Future createNotification({ required List userIds, required String type, required String actorId, Map? fields, String? pushTitle, String? pushBody, Map? pushData, }) async {} @override Future markRead(String id) async {} @override Future markReadForTicket(String ticketId) async {} @override Future markReadForTask(String taskId) async {} @override Future registerFcmToken(String token) async {} @override Future unregisterFcmToken(String token) async {} @override Future sendPush({ List? tokens, List? userIds, required String title, required String body, Map? data, }) async {} } // test doubles for controllers that allow us to intercept create operations class _FakeTicketsController implements TicketsController { Future Function({ required String subject, required String description, required String officeId, })? onCreate; @override Future createTicket({ required String subject, required String description, required String officeId, }) async { if (onCreate != null) { await onCreate!( subject: subject, description: description, officeId: officeId, ); } } @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } class _FakeTasksController implements TasksController { Future Function({ required String title, required String description, String? officeId, String? ticketId, String? requestType, String? requestTypeOther, String? requestCategory, })? onCreate; @override Future createTask({ required String title, required String description, String? officeId, String? ticketId, String? requestType, String? requestTypeOther, String? requestCategory, }) async { if (onCreate != null) { await onCreate!( title: title, description: description, officeId: officeId, ticketId: ticketId, requestType: requestType, requestTypeOther: requestTypeOther, requestCategory: requestCategory, ); } } @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } /// Fake OfficesController that succeeds without touching Supabase. class _FakeOfficesController extends OfficesController { _FakeOfficesController() : super( SupabaseClient( 'http://localhost', 'test', authOptions: const AuthClientOptions(autoRefreshToken: false), ), ); @override Future createOffice({required String name, String? serviceId}) async {} @override Future updateOffice({ required String id, required String name, String? serviceId, }) async {} @override Future deleteOffice({required String id}) async {} } void main() { final now = DateTime(2026, 2, 10, 12, 0, 0); final office = Office(id: 'office-1', name: 'HQ'); final admin = Profile(id: 'user-1', role: 'admin', fullName: 'Alex Admin'); final tech = Profile(id: 'user-2', role: 'it_staff', fullName: 'Jamie Tech'); final ticket = Ticket( id: 'TCK-1', subject: 'Printer down', description: 'Paper jam and offline', officeId: 'office-1', status: 'open', createdAt: now, creatorId: 'user-1', respondedAt: null, promotedAt: null, closedAt: null, ); final task = Task( id: 'TSK-1', ticketId: 'TCK-1', taskNumber: '2026-02-00001', title: 'Reboot printer', description: 'Clear queue and reboot', officeId: 'office-1', status: 'queued', priority: 1, queueOrder: 1, createdAt: now, creatorId: 'user-2', startedAt: null, completedAt: null, requestType: null, requestTypeOther: null, requestCategory: null, ); final notification = NotificationItem( id: 'N-1', userId: 'user-1', actorId: 'user-2', ticketId: 'TCK-1', taskId: null, itServiceRequestId: null, messageId: 1, type: 'mention', createdAt: now, readAt: null, ); List baseOverrides() { return [ currentProfileProvider.overrideWith((ref) => Stream.value(admin)), profilesProvider.overrideWith((ref) => Stream.value([admin, tech])), officesProvider.overrideWith((ref) => Stream.value([office])), notificationsProvider.overrideWith((ref) => Stream.value([notification])), ticketsProvider.overrideWith((ref) => Stream.value([ticket])), tasksProvider.overrideWith((ref) => Stream.value([task])), tasksControllerProvider.overrideWith((ref) => _FakeTasksController()), taskAssignmentsProvider.overrideWith( (ref) => Stream.value(const []), ), passSlipsProvider.overrideWith( (ref) => Stream.value(const []), ), attendanceLogsProvider.overrideWith( (ref) => Stream.value(const []), ), userOfficesProvider.overrideWith( (ref) => Stream.value([UserOffice(userId: 'user-1', officeId: 'office-1')]), ), announcementsProvider.overrideWith( (ref) => Stream.value(const []), ), ticketMessagesAllProvider.overrideWith((ref) => const Stream.empty()), ticketMessagesProvider.overrideWith((ref, id) => const Stream.empty()), isAdminProvider.overrideWith((ref) => true), notificationsControllerProvider.overrideWithValue( FakeNotificationsController(), ), // Provide a stub Supabase client so providers that depend on // supabaseClientProvider (RealtimeController, TypingIndicatorController, // etc.) are created without touching Supabase.instance. supabaseClientProvider.overrideWithValue( SupabaseClient('http://localhost', 'test-anon-key', authOptions: const AuthClientOptions(autoRefreshToken: false)), ), ]; } List userManagementOverrides() { return [ currentProfileProvider.overrideWith((ref) => Stream.value(admin)), profilesProvider.overrideWith((ref) => Stream.value(const [])), officesProvider.overrideWith((ref) => Stream.value([office])), userOfficesProvider.overrideWith( (ref) => Stream.value(const []), ), ticketMessagesAllProvider.overrideWith((ref) => const Stream.empty()), ticketMessagesProvider.overrideWith((ref, id) => const Stream.empty()), isAdminProvider.overrideWith((ref) => true), ]; } group('Layout smoke tests', () { testWidgets('Tickets list renders without layout exceptions', ( tester, ) async { await _setSurfaceSize(tester, const Size(1280, 900)); await _pumpScreen( tester, const TicketsListScreen(), overrides: baseOverrides(), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 16)); expect(tester.takeException(), isNull); }); testWidgets('Tasks list renders without layout exceptions', (tester) async { await _setSurfaceSize(tester, const Size(1280, 900)); await _pumpScreen( tester, const TasksListScreen(), overrides: baseOverrides(), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 16)); expect(tester.takeException(), isNull); // tabs should be present expect(find.text('My Tasks'), findsOneWidget); expect(find.text('All Tasks'), findsOneWidget); }); testWidgets('Completed task with missing details shows warning icon', ( tester, ) async { await _setSurfaceSize(tester, const Size(1280, 900)); AppTime.initialize(); // create a finished task with no signatories or actionTaken final now = DateTime.now(); final incompleteTask = Task.fromMap({ 'id': 't1', 'status': 'completed', 'title': 'Incomplete', 'description': '', 'created_at': now.toIso8601String(), 'priority': 1, }); await _pumpScreen( tester, const TasksListScreen(), overrides: [ ...baseOverrides(), tasksProvider.overrideWith((ref) => Stream.value([incompleteTask])), ], ); await tester.pumpAndSettle(); // switch to "All Tasks" tab in case default is "My Tasks" and the // task is unassigned await tester.tap(find.text('All Tasks')); await tester.pumpAndSettle(); // warning icon should appear next to status badge in both desktop and // mobile rows; in this layout smoke test we just ensure at least one is // present. expect(find.byIcon(Icons.warning_amber_rounded), findsWidgets); }); testWidgets('Offices screen renders without layout exceptions', ( tester, ) async { await _setSurfaceSize(tester, const Size(1280, 900)); await _pumpScreen( tester, const OfficesScreen(), overrides: baseOverrides(), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 16)); expect(tester.takeException(), isNull); }); testWidgets('Teams screen renders without layout exceptions', ( tester, ) async { await _setSurfaceSize(tester, const Size(1280, 900)); await _pumpScreen( tester, const TeamsScreen(), overrides: [ ...baseOverrides(), teamsProvider.overrideWith((ref) => Stream.value(const [])), teamMembersProvider.overrideWith( (ref) => Stream.value(const []), ), ], ); await tester.pump(); await tester.pump(const Duration(milliseconds: 16)); expect(tester.takeException(), isNull); }); testWidgets('Permissions screen renders without layout exceptions', ( tester, ) async { await _setSurfaceSize(tester, const Size(1280, 900)); await _pumpScreen( tester, const PermissionsScreen(), overrides: baseOverrides(), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 16)); expect(tester.takeException(), isNull); }); testWidgets('App shell shows admin-only nav items for admin role', ( tester, ) async { // Set logical size large enough for the NavigationRail (desktop breakpoint // is 1200 logical px). Use tester.view so DPR scaling is handled correctly. const logicalWidth = 1280.0; const logicalHeight = 1200.0; tester.view.physicalSize = Size( logicalWidth * tester.view.devicePixelRatio, logicalHeight * tester.view.devicePixelRatio, ); addTearDown(tester.view.resetPhysicalSize); // AppScaffold needs a GoRouter state above it; create a minimal router. await tester.pumpWidget( ProviderScope( overrides: baseOverrides(), child: MaterialApp.router( routerConfig: GoRouter( routes: [ GoRoute( path: '/', builder: (context, state) => const AppScaffold(child: SizedBox()), ), ], ), ), ), ); // Allow enough pump cycles for Riverpod to deliver Stream.value(admin) // and for the NavigationRail to rebuild with admin items. await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); await tester.pump(const Duration(milliseconds: 50)); // 'User Management' is only shown in the Settings section for admins. expect(find.text('User Management'), findsOneWidget); }); testWidgets('Add Team dialog requires at least one office', (tester) async { // Need logical width > 600 for the AlertDialog path (vs bottom sheet). // Use tester.view.physicalSize with DPR so the logical size is correct. tester.view.physicalSize = Size( 800 * tester.view.devicePixelRatio, 700 * tester.view.devicePixelRatio, ); addTearDown(tester.view.resetPhysicalSize); await _pumpScreen( tester, const TeamsScreen(), overrides: [ ...baseOverrides(), teamsProvider.overrideWith((ref) => Stream.value(const [])), teamMembersProvider.overrideWith( (ref) => Stream.value(const []), ), ], ); // Wait for M3Fab entrance animation (starts at scale=0) before tapping. await tester.pumpAndSettle(); // Open Add Team dialog await tester.tap(find.byType(FloatingActionButton)); await tester.pumpAndSettle(); // Enter name final nameField = find.byType(TextField).first; await tester.enterText(nameField, 'Alpha Team'); await tester.pumpAndSettle(); // Select leader (it_staff) final leaderDropdown = find.widgetWithText( DropdownButtonFormField, 'Team Leader', ); await tester.tap(leaderDropdown); await tester.pumpAndSettle(); await tester.tap(find.text('Jamie Tech').last); await tester.pumpAndSettle(); // Try to add without selecting offices -> should show validation SnackBar. // Use bounded pumps instead of pumpAndSettle so the snackbar is still // visible when we assert (pumpAndSettle advances the fake clock far // enough for the snackbar's 4-second auto-dismiss timer to fire). await tester.tap(find.text('Add')); await tester.pump(); // trigger rebuild with snackbar await tester.pump(const Duration(milliseconds: 500)); // allow entrance animation // The snackbar is shown — verify by SnackBar widget type since the text // widget inside AwesomeSnackbarContent is not directly findable while // a dialog is open over the Scaffold (ScaffoldMessenger renders snackbars // in the Scaffold below the dialog overlay). expect(find.byType(SnackBar, skipOffstage: false), findsOneWidget); expect(find.byType(AwesomeSnackbarContent, skipOffstage: false), findsOneWidget); }); testWidgets('Office creation shows descriptive success message', ( tester, ) async { await _setSurfaceSize(tester, const Size(600, 960)); await _pumpScreen( tester, const OfficesScreen(), overrides: [ ...baseOverrides(), officesControllerProvider.overrideWithValue(_FakeOfficesController()), ], ); 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, 960)); await _pumpScreen( tester, const TicketsListScreen(), overrides: [ ...baseOverrides(), ticketsControllerProvider.overrideWithValue(_FakeTicketsController()), ], ); await tester.pumpAndSettle(); await tester.tap(find.byType(FloatingActionButton)); await tester.pumpAndSettle(); await tester.enterText( find.widgetWithText(TextField, 'Subject'), 'Test ticket', ); await tester.enterText( find.widgetWithText(TextField, 'Description'), 'Desc', ); await tester.pumpAndSettle(); await tester.tap(find.text('Create')); await tester.pumpAndSettle(); expect(find.textContaining('Test ticket'), findsOneWidget); expect(find.byType(AwesomeSnackbarContent), findsOneWidget); }); testWidgets('Ticket dialog shows spinner while saving', (tester) async { final fake = _FakeTicketsController(); final completer = Completer(); fake.onCreate = ({ required String subject, required String description, required String officeId, }) async { await completer.future; }; await _setSurfaceSize(tester, const Size(600, 960)); 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, 960)); 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(); // The snackbar title/message Text widgets are not reachable via // find.text/find.textContaining when shown through the global // ScaffoldMessengerKey (same issue as the Add Team validation snackbar). // Verify the SnackBar and AwesomeSnackbarContent are present instead. expect(find.byType(SnackBar, skipOffstage: false), findsOneWidget); expect(find.byType(AwesomeSnackbarContent, skipOffstage: false), findsOneWidget); }); testWidgets('Task dialog shows spinner while saving', (tester) async { final fake = _FakeTasksController(); final completer = Completer(); fake.onCreate = ({ required String title, required String description, String? officeId, String? ticketId, String? requestType, String? requestTypeOther, String? requestCategory, }) async { await completer.future; }; await _setSurfaceSize(tester, const Size(600, 960)); 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', ( tester, ) async { await _setSurfaceSize(tester, const Size(600, 960)); await _pumpScreen( tester, const TeamsScreen(), overrides: [ ...baseOverrides(), teamsProvider.overrideWith((ref) => Stream.value(const [])), teamMembersProvider.overrideWith( (ref) => Stream.value(const []), ), ], ); // Wait for M3Fab entrance animation (starts at scale=0) before tapping. await tester.pumpAndSettle(); // Open Add Team dialog await tester.tap(find.byType(FloatingActionButton)); await tester.pumpAndSettle(); // Find Offices InputDecorator and tap its Select ActionChip final officesField = find.widgetWithText(InputDecorator, 'Offices'); expect(officesField, findsOneWidget); final selectChip = find.descendant( of: officesField, matching: find.widgetWithText(ActionChip, 'Select'), ); expect(selectChip, findsOneWidget); await tester.tap(selectChip); await tester.pumpAndSettle(); // Inner dialog should appear and no layout overflow exceptions should be thrown expect(find.text('Select Offices'), findsOneWidget); expect(tester.takeException(), isNull); // The list should contain the sample office expect(find.widgetWithText(CheckboxListTile, 'HQ'), findsOneWidget); }); testWidgets('User management renders without layout exceptions', ( tester, ) async { await _setSurfaceSize(tester, const Size(1280, 900)); await _pumpScreen( tester, const UserManagementScreen(), overrides: userManagementOverrides(), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 16)); expect(tester.takeException(), isNull); }); testWidgets('Typing indicator: no post-dispose state mutation', ( tester, ) async { await _setSurfaceSize(tester, const Size(600, 960)); // Show TicketDetailScreen with the base overrides (includes typing controller). await _pumpScreen( tester, const TicketDetailScreen(ticketId: 'TCK-1'), overrides: baseOverrides(), ); // Use bounded pumps instead of pumpAndSettle: the TypingIndicatorController // subscribes to a Supabase realtime channel which keeps reconnecting in // tests, preventing pumpAndSettle from ever settling. await tester.pump(); // initial frame await tester.pump(const Duration(milliseconds: 100)); // streams deliver // Find message TextField and simulate typing. final finder = find.byType(TextField); expect(finder, findsWidgets); await tester.enterText(finder.first, 'Hello'); // Immediately remove the screen (navigate away / dispose). await tester.pumpWidget(Container()); // Advance enough fake time to cover the typing-stop timer (600ms) and // remote-timeout timer (3500ms). Do NOT use pumpAndSettle: the Supabase // realtime client schedules reconnection timers that never stop. await tester.pump(const Duration(milliseconds: 500)); await tester.pump(const Duration(seconds: 4)); // No unhandled exceptions should have been thrown. expect(tester.takeException(), isNull); }); }); } Future _pumpScreen( WidgetTester tester, Widget child, { required List overrides, }) async { await tester.pumpWidget( ProviderScope( overrides: overrides, child: MaterialApp( // Wire up the app-level ScaffoldMessengerKey so that helpers that call // showSuccessSnackBarGlobal / showWarningSnackBar after a dialog closes // (when the dialog context is gone) can still show a visible snackbar // that find.text() can locate. scaffoldMessengerKey: scaffoldMessengerKey, home: Scaffold(body: child), ), ), ); } Future _setSurfaceSize(WidgetTester tester, Size size) async { await tester.binding.setSurfaceSize(size); addTearDown(() async { await tester.binding.setSurfaceSize(null); }); }