diff --git a/lib/providers/profile_provider.dart b/lib/providers/profile_provider.dart index f70169e7..953624f8 100644 --- a/lib/providers/profile_provider.dart +++ b/lib/providers/profile_provider.dart @@ -310,3 +310,11 @@ final isAdminProvider = Provider((ref) { orElse: () => false, ); }); + +final isStrictAdminProvider = Provider((ref) { + final profileAsync = ref.watch(currentProfileProvider); + return profileAsync.maybeWhen( + data: (profile) => profile?.role == 'admin', + orElse: () => false, + ); +}); diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index cc9d9bd8..258c29d5 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -30,6 +30,7 @@ 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_drill_down_screen.dart'; import '../screens/programmer_tasks/programmer_tasks_list_screen.dart'; import '../screens/programmer_tasks/programmer_task_detail_screen.dart'; import '../screens/programmer_projects/programmer_projects_list_screen.dart'; @@ -220,8 +221,22 @@ final appRouterProvider = Provider((ref) { ), GoRoute( path: '/programmer-tasks', - pageBuilder: _page(const ProgrammerTasksListScreen()), + pageBuilder: (context, state) => M3SharedAxisPage( + key: state.pageKey, + child: ProgrammerTasksListScreen( + initialTab: state.uri.queryParameters['tab'], + ), + ), routes: [ + GoRoute( + path: 'programmers/:userId', + pageBuilder: (context, state) => M3ContainerTransformPage( + key: state.pageKey, + child: ProgrammerDrillDownScreen( + userId: state.pathParameters['userId'] ?? '', + ), + ), + ), GoRoute( path: ':id', pageBuilder: (context, state) => M3ContainerTransformPage( diff --git a/lib/screens/programmer_tasks/monitor/help_panel.dart b/lib/screens/programmer_tasks/monitor/help_panel.dart new file mode 100644 index 00000000..737a1ee6 --- /dev/null +++ b/lib/screens/programmer_tasks/monitor/help_panel.dart @@ -0,0 +1,159 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import '../../../providers/programmer_day_data_provider.dart'; +import '../../../providers/programmer_tasks_provider.dart'; +import '../../../widgets/app_section_header.dart'; +import 'monitor_model.dart'; + +final _dateFmt = DateFormat('EEE, MMM d'); + +/// Side panel that shows "who's helping whom" for a selected day, with a +/// ← / → stepper that cannot advance past today. +/// +/// Uses a [ValueNotifier] initialized to [todayKeyProvider] so the +/// displayed day is independent of the parent; the family provider is +/// re-watched whenever the day changes. +class HelpPanel extends ConsumerStatefulWidget { + const HelpPanel({super.key}); + + @override + ConsumerState createState() => _HelpPanelState(); +} + +class _HelpPanelState extends ConsumerState { + late final ValueNotifier _day; + + @override + void initState() { + super.initState(); + final today = ref.read(todayKeyProvider); + _day = ValueNotifier(today); + } + + @override + void dispose() { + _day.dispose(); + super.dispose(); + } + + void _stepDay(int delta) { + final today = ref.read(todayKeyProvider); + final next = _day.value.add(Duration(days: delta)); + if (delta > 0 && !next.isBefore(today) && next != today) return; + if (delta > 0 && next.isAfter(today)) return; + setState(() { + _day.value = next; + }); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final tt = Theme.of(context).textTheme; + final today = ref.watch(todayKeyProvider); + final tasksAsync = ref.watch(programmerTasksProvider); + final dayDataAsync = ref.watch(programmerDayDataProvider(_day.value)); + + final isAtToday = !_day.value.isBefore(today); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const AppSectionHeader("Who's helping whom"), + // Day stepper + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: () => _stepDay(-1), + tooltip: 'Previous day', + ), + Text( + _dateFmt.format(_day.value), + style: tt.labelMedium?.copyWith(color: cs.onSurface), + ), + IconButton( + icon: Icon( + Icons.chevron_right, + color: isAtToday ? cs.onSurface.withValues(alpha: 0.3) : null, + ), + onPressed: isAtToday ? null : () => _stepDay(1), + tooltip: 'Next day', + ), + ], + ), + const SizedBox(height: 8), + // Help edges + Expanded( + child: Builder( + builder: (context) { + if (dayDataAsync.isLoading && !dayDataAsync.hasValue) { + return const Center(child: CircularProgressIndicator()); + } + + final dayData = dayDataAsync.valueOrNull; + if (dayData == null) { + return _emptyText( + 'No data for this day.', + cs: cs, + tt: tt, + ); + } + + final tasks = tasksAsync.valueOrNull ?? []; + final taskById = {for (final t in tasks) t.id: t}; + + final edges = buildHelpEdges( + workLogs: dayData.workLogs, + taskById: taskById, + day: _day.value, + ); + + if (edges.isEmpty) { + return _emptyText( + 'No help logged this day.', + cs: cs, + tt: tt, + ); + } + + return ListView.separated( + itemCount: edges.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, i) { + final e = edges[i]; + final helperName = e.helperId; + final assigneeName = e.assigneeId; + final timeStr = TimeOfDay.fromDateTime(e.at).format(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Text( + '$helperName → $assigneeName · ${e.taskTitle} · ${e.minutes}m · $timeStr', + style: tt.bodySmall?.copyWith(color: cs.onSurface), + ), + ); + }, + ); + }, + ), + ), + ], + ); + } + + Widget _emptyText( + String msg, { + required ColorScheme cs, + required TextTheme tt, + }) { + return Center( + child: Text( + msg, + style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), + ); + } +} diff --git a/lib/screens/programmer_tasks/monitor/monitor_tab.dart b/lib/screens/programmer_tasks/monitor/monitor_tab.dart new file mode 100644 index 00000000..18f117a9 --- /dev/null +++ b/lib/screens/programmer_tasks/monitor/monitor_tab.dart @@ -0,0 +1,207 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../providers/leave_provider.dart'; +import '../../../providers/pass_slip_provider.dart'; +import '../../../providers/profile_provider.dart'; +import '../../../providers/programmer_day_data_provider.dart'; +import '../../../utils/app_time.dart'; +import '../../../utils/staff_presence.dart'; +import '../../../widgets/app_status_summary.dart'; +import '../../../theme/app_status_colors.dart'; +import 'help_panel.dart'; +import 'monitor_model.dart'; +import 'programmer_monitor_card.dart'; + +/// Summary row + programmer grid + help panel for the Monitor tab. +class MonitorTab extends ConsumerWidget { + const MonitorTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Tick every 30 s to keep durations fresh. + final now = ref.watch(clockTickerProvider).valueOrNull ?? AppTime.now(); + final todayKey = ref.watch(todayKeyProvider); + final profilesAsync = ref.watch(profilesProvider); + final dayDataAsync = ref.watch(programmerDayDataProvider(todayKey)); + final runStates = ref.watch(programmerRunStatesProvider); + final leavesAsync = ref.watch(leavesProvider); + final passSlipsAsync = ref.watch(passSlipsProvider); + + final profiles = profilesAsync.valueOrNull ?? []; + final programmerProfiles = + profiles.where((p) => p.role == 'programmer').toList(); + final profileNames = { + for (final p in profiles) p.id: p.fullName, + }; + + final leaves = leavesAsync.valueOrNull ?? []; + final passSlips = passSlipsAsync.valueOrNull ?? []; + final activeLeaves = activeLeaveByUser(leaves, now); + final activePassSlips = activePassSlipByUser(passSlips); + + if (dayDataAsync.isLoading && !dayDataAsync.hasValue) { + return const Center(child: CircularProgressIndicator()); + } + + final dayData = dayDataAsync.valueOrNull; + + List entries = []; + if (dayData != null) { + entries = buildMonitorEntries( + programmers: programmerProfiles, + today: dayData, + runStates: runStates, + leaves: activeLeaves, + passSlips: activePassSlips, + sheetStatusByUser: const {}, + projectNames: const {}, + profileNames: profileNames, + now: now, + ); + } + + // Compute summary counts. + var working = 0; + var paused = 0; + var idle = 0; + var away = 0; + for (final e in entries) { + switch (e.state) { + case MonitorState.working: + working++; + case MonitorState.paused: + paused++; + case MonitorState.idle: + idle++; + case MonitorState.onLeave: + case MonitorState.passSlip: + away++; + } + } + + final summaryItems = [ + AppStatusSummaryItem( + label: 'Working', + count: working, + kind: StatusKind.success, + ), + AppStatusSummaryItem( + label: 'Paused', + count: paused, + kind: StatusKind.warning, + ), + AppStatusSummaryItem( + label: 'Idle', + count: idle, + kind: StatusKind.neutral, + ), + AppStatusSummaryItem( + label: 'Away', + count: away, + kind: StatusKind.info, + ), + ]; + + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + // Help panel breakpoint: show side-by-side at >= 900. + final sideBySide = width >= 900; + + final grid = _ProgrammerGrid( + entries: entries, + width: sideBySide ? width - 320 : width, + ); + + final body = sideBySide + ? Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: grid), + const SizedBox(width: 12), + SizedBox( + width: 300, + child: const HelpPanel(), + ), + ], + ) + : Column( + children: [ + grid, + const SizedBox(height: 16), + SizedBox( + height: 340, + child: const HelpPanel(), + ), + ], + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AppStatusSummaryRow( + items: summaryItems, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: body, + ), + ), + ], + ); + }, + ); + } +} + +class _ProgrammerGrid extends StatelessWidget { + const _ProgrammerGrid({ + required this.entries, + required this.width, + }); + + final List entries; + final double width; + + @override + Widget build(BuildContext context) { + final crossAxisCount = width < 700 + ? 1 + : width < 1100 + ? 2 + : 3; + + if (entries.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text('No programmers found.'), + ), + ); + } + + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + childAspectRatio: 2.4, + ), + itemCount: entries.length, + itemBuilder: (context, i) { + final entry = entries[i]; + return ProgrammerMonitorCard( + entry: entry, + onTap: () => + context.go('/programmer-tasks/programmers/${entry.programmer.id}'), + ); + }, + ); + } +} diff --git a/lib/screens/programmer_tasks/monitor/programmer_monitor_card.dart b/lib/screens/programmer_tasks/monitor/programmer_monitor_card.dart new file mode 100644 index 00000000..9fbf6123 --- /dev/null +++ b/lib/screens/programmer_tasks/monitor/programmer_monitor_card.dart @@ -0,0 +1,230 @@ +import 'package:flutter/material.dart'; + +import '../../../theme/app_status_colors.dart'; +import '../../../theme/app_surfaces.dart'; +import '../../../widgets/tech_chip.dart'; +import 'monitor_model.dart'; + +/// State pill label and colour for a [MonitorState]. +({String label, StatusKind kind}) _statePill(MonitorState state) { + switch (state) { + case MonitorState.working: + return (label: 'Working', kind: StatusKind.success); + case MonitorState.paused: + return (label: 'Paused', kind: StatusKind.warning); + case MonitorState.idle: + return (label: 'Idle', kind: StatusKind.neutral); + case MonitorState.onLeave: + return (label: 'On leave', kind: StatusKind.info); + case MonitorState.passSlip: + return (label: 'Pass slip', kind: StatusKind.info); + } +} + +String _fmtDuration(Duration d) { + final h = d.inHours; + final m = d.inMinutes.remainder(60); + if (h > 0) return '${h}h ${m}m'; + return '${m}m'; +} + +/// Card displayed in the Monitor tab for a single programmer. +/// +/// Shows: +/// - programmer name + state pill +/// - current task title (if working) +/// - running time on current task + total today +/// - task counts (in-progress / paused / not-started) +/// - help edges (Helped → / Helped by ←) +class ProgrammerMonitorCard extends StatelessWidget { + const ProgrammerMonitorCard({ + super.key, + required this.entry, + required this.onTap, + }); + + final ProgrammerMonitorEntry entry; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final tt = Theme.of(context).textTheme; + final surfaces = AppSurfaces.of(context); + final pill = _statePill(entry.state); + + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(surfaces.cardRadius), + child: Container( + decoration: BoxDecoration( + color: cs.surfaceContainerLow, + borderRadius: BorderRadius.circular(surfaces.cardRadius), + border: Border.all(color: cs.outlineVariant), + ), + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header: name + state pill + Row( + children: [ + Expanded( + child: Text( + entry.programmer.fullName, + style: tt.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: cs.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + TechChip( + pill.label, + status: pill.kind, + ), + ], + ), + + // Current task + if (entry.currentTask != null) ...[ + const SizedBox(height: 6), + Text( + entry.currentTask!.title, + style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + + const SizedBox(height: 8), + + // Time row + Wrap( + spacing: 12, + runSpacing: 4, + children: [ + if (entry.state == MonitorState.working && + entry.currentTaskToday > Duration.zero) + _InfoChip( + label: + 'running ${_fmtDuration(entry.currentTaskToday)} today', + cs: cs, + tt: tt, + ), + _InfoChip( + label: 'Today ${_fmtDuration(entry.todayTotal)}', + cs: cs, + tt: tt, + ), + ], + ), + + // Task counts + const SizedBox(height: 6), + Wrap( + spacing: 8, + runSpacing: 4, + children: [ + if (entry.inProgress > 0) + _InfoChip( + label: '${entry.inProgress} in-progress', + cs: cs, + tt: tt, + ), + if (entry.paused > 0) + _InfoChip( + label: '${entry.paused} paused', + cs: cs, + tt: tt, + ), + if (entry.notStarted > 0) + _InfoChip( + label: '${entry.notStarted} not started', + cs: cs, + tt: tt, + ), + ], + ), + + // Help edges + if (entry.helped.isNotEmpty || entry.helpedBy.isNotEmpty) ...[ + const SizedBox(height: 8), + for (final e in entry.helped) + _HelpRow( + prefix: 'Helped →', + name: e.assigneeId, + task: e.taskTitle, + minutes: e.minutes, + cs: cs, + tt: tt, + ), + for (final e in entry.helpedBy) + _HelpRow( + prefix: 'Helped by ←', + name: e.helperId, + task: e.taskTitle, + minutes: e.minutes, + cs: cs, + tt: tt, + ), + ], + ], + ), + ), + ); + } +} + +class _InfoChip extends StatelessWidget { + const _InfoChip({ + required this.label, + required this.cs, + required this.tt, + }); + + final String label; + final ColorScheme cs; + final TextTheme tt; + + @override + Widget build(BuildContext context) { + return Text( + label, + style: tt.labelSmall?.copyWith(color: cs.onSurfaceVariant), + ); + } +} + +class _HelpRow extends StatelessWidget { + const _HelpRow({ + required this.prefix, + required this.name, + required this.task, + required this.minutes, + required this.cs, + required this.tt, + }); + + final String prefix; + final String name; + final String task; + final int minutes; + final ColorScheme cs; + final TextTheme tt; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + '$prefix $name · $task · ${minutes}m', + style: tt.labelSmall?.copyWith(color: cs.onSurfaceVariant), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ); + } +} diff --git a/lib/screens/programmer_tasks/programmer_drill_down_screen.dart b/lib/screens/programmer_tasks/programmer_drill_down_screen.dart new file mode 100644 index 00000000..2508ed48 --- /dev/null +++ b/lib/screens/programmer_tasks/programmer_drill_down_screen.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../models/profile.model.dart'; +import '../../providers/profile_provider.dart'; +import '../../widgets/app_state_view.dart'; +import 'dashboard/programmer_dashboard.dart'; + +/// Admin-only drill-down screen that shows [ProgrammerDashboard] for a +/// specific user in read-only mode. +/// +/// Route: /programmer-tasks/programmers/:userId +class ProgrammerDrillDownScreen extends ConsumerWidget { + const ProgrammerDrillDownScreen({ + super.key, + required this.userId, + }); + + final String userId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isStrictAdmin = ref.watch(isStrictAdminProvider); + final profilesAsync = ref.watch(profilesProvider); + + if (!isStrictAdmin) { + return Scaffold( + appBar: AppBar(title: const Text('Programmer')), + body: const AppEmptyView( + icon: Icons.lock_outline, + title: 'Admins only', + ), + ); + } + + // Resolve the programmer's name for the AppBar title. + final profiles = profilesAsync.valueOrNull ?? []; + Profile? profile; + for (final p in profiles) { + if (p.id == userId) { + profile = p; + break; + } + } + final name = profile?.fullName ?? 'Programmer'; + + return Scaffold( + appBar: AppBar(title: Text(name)), + body: ProgrammerDashboard(userId: userId, readOnly: true), + ); + } +} diff --git a/lib/screens/programmer_tasks/programmer_tasks_list_screen.dart b/lib/screens/programmer_tasks/programmer_tasks_list_screen.dart index e8dffe77..1144d3e1 100644 --- a/lib/screens/programmer_tasks/programmer_tasks_list_screen.dart +++ b/lib/screens/programmer_tasks/programmer_tasks_list_screen.dart @@ -10,11 +10,15 @@ import '../../widgets/app_state_view.dart'; import '../../widgets/reconnect_overlay.dart'; import '../../widgets/responsive_body.dart'; import 'dashboard/programmer_dashboard.dart'; +import 'monitor/monitor_tab.dart'; import 'programmer_task_create.dart'; +import 'programmer_tasks_tabs.dart'; import 'widgets/programmer_task_card.dart'; class ProgrammerTasksListScreen extends ConsumerStatefulWidget { - const ProgrammerTasksListScreen({super.key}); + const ProgrammerTasksListScreen({super.key, this.initialTab}); + + final String? initialTab; @override ConsumerState createState() => @@ -24,25 +28,53 @@ class ProgrammerTasksListScreen extends ConsumerStatefulWidget { class _ProgrammerTasksListScreenState extends ConsumerState with SingleTickerProviderStateMixin { - late final TabController _tabController; + late TabController _tabController; + late List _tabs; + bool _tabsBuilt = false; - @override - void initState() { - super.initState(); - _tabController = TabController(length: 2, vsync: this); + void _buildTabs(bool isStrictAdmin) { + final tabs = programmerTasksTabsFor(isStrictAdmin: isStrictAdmin); + + // Determine initial tab index from widget.initialTab. + int initialIndex = 0; + final parsed = parseProgrammerTasksTab(widget.initialTab); + if (parsed != null) { + final idx = tabs.indexOf(parsed); + if (idx >= 0) initialIndex = idx; + } + + if (_tabsBuilt && + _tabs == tabs && + _tabController.length == tabs.length) { + return; + } + + final oldController = _tabsBuilt ? _tabController : null; + + _tabs = tabs; + _tabController = TabController( + length: tabs.length, + initialIndex: initialIndex, + vsync: this, + ); _tabController.addListener(() { if (mounted) setState(() {}); }); + _tabsBuilt = true; + oldController?.dispose(); } @override void dispose() { - _tabController.dispose(); + if (_tabsBuilt) _tabController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { + final isStrictAdmin = ref.watch(isStrictAdminProvider); + _buildTabs(isStrictAdmin); + final tasksAsync = ref.watch(programmerTasksProvider); final userId = ref.watch(currentUserIdProvider); final profilesAsync = ref.watch(profilesProvider); @@ -51,6 +83,8 @@ class _ProgrammerTasksListScreenState for (final p in profilesAsync.valueOrNull ?? []) p.id: p, }; + final currentTab = _tabs[_tabController.index]; + return Stack( children: [ ResponsiveBody( @@ -64,16 +98,22 @@ class _ProgrammerTasksListScreenState ), TabBar( controller: _tabController, - tabs: const [Tab(text: 'Mine'), Tab(text: 'All')], + tabs: [ + for (final t in _tabs) + Tab(text: programmerTasksTabLabel(t)), + ], ), const SizedBox(height: 8), Expanded( child: Builder( builder: (context) { - final showMine = _tabController.index == 0; + // Monitor tab — show the monitor overview. + if (currentTab == ProgrammerTasksTab.monitor) { + return const MonitorTab(); + } // Mine tab — delegate entirely to ProgrammerDashboard. - if (showMine) { + if (currentTab == ProgrammerTasksTab.mine) { if (userId == null) { return const Center(child: CircularProgressIndicator()); } @@ -137,5 +177,4 @@ class _ProgrammerTasksListScreenState ], ); } - } diff --git a/lib/screens/programmer_tasks/programmer_tasks_tabs.dart b/lib/screens/programmer_tasks/programmer_tasks_tabs.dart new file mode 100644 index 00000000..3bf98e46 --- /dev/null +++ b/lib/screens/programmer_tasks/programmer_tasks_tabs.dart @@ -0,0 +1,57 @@ +/// Tab enumeration and helpers for the Programmer Tasks list screen. +/// +/// This-slice tabs (Task 10): +/// Admin: [monitor, mine, all] +/// Non-admin: [mine, all] +/// +/// approvals and daySheets are deferred to later slices. +enum ProgrammerTasksTab { monitor, approvals, mine, all, daySheets } + +/// Returns the ordered tab list for the current role in this slice. +List programmerTasksTabsFor({required bool isStrictAdmin}) { + if (isStrictAdmin) { + return [ + ProgrammerTasksTab.monitor, + ProgrammerTasksTab.mine, + ProgrammerTasksTab.all, + ]; + } + return [ProgrammerTasksTab.mine, ProgrammerTasksTab.all]; +} + +/// Parses a URL query-parameter value to a [ProgrammerTasksTab]. +/// +/// Valid values: 'monitor', 'approvals', 'mine', 'all', 'day-sheets'. +/// Returns null for unrecognised or null input. +ProgrammerTasksTab? parseProgrammerTasksTab(String? q) { + switch (q) { + case 'monitor': + return ProgrammerTasksTab.monitor; + case 'approvals': + return ProgrammerTasksTab.approvals; + case 'mine': + return ProgrammerTasksTab.mine; + case 'all': + return ProgrammerTasksTab.all; + case 'day-sheets': + return ProgrammerTasksTab.daySheets; + default: + return null; + } +} + +/// Human-readable tab label for display in [TabBar]. +String programmerTasksTabLabel(ProgrammerTasksTab t) { + switch (t) { + case ProgrammerTasksTab.monitor: + return 'Monitor'; + case ProgrammerTasksTab.approvals: + return 'Approvals'; + case ProgrammerTasksTab.mine: + return 'Mine'; + case ProgrammerTasksTab.all: + return 'All'; + case ProgrammerTasksTab.daySheets: + return 'Day Sheets'; + } +} diff --git a/test/programmer_tabs_test.dart b/test/programmer_tabs_test.dart new file mode 100644 index 00000000..802f9951 --- /dev/null +++ b/test/programmer_tabs_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tasq/models/profile.model.dart'; +import 'package:tasq/screens/programmer_tasks/monitor/monitor_model.dart'; +import 'package:tasq/screens/programmer_tasks/monitor/programmer_monitor_card.dart'; +import 'package:tasq/screens/programmer_tasks/programmer_tasks_tabs.dart'; +import 'package:tasq/utils/app_time.dart'; + +Profile _profile({String id = 'u1', String name = 'Alice'}) => Profile( + id: id, + role: 'programmer', + fullName: name, + ); + +ProgrammerMonitorEntry _entry({ + required MonitorState state, + String id = 'u1', + String name = 'Alice', +}) => + ProgrammerMonitorEntry( + programmer: _profile(id: id, name: name), + state: state, + currentTask: null, + currentTaskToday: Duration.zero, + todayTotal: Duration.zero, + inProgress: 0, + paused: 0, + notStarted: 0, + sheetStatus: null, + helped: [], + helpedBy: [], + ); + +Widget _wrapCard(ProgrammerMonitorEntry entry) { + return ProviderScope( + child: MaterialApp( + home: Scaffold( + body: ProgrammerMonitorCard( + entry: entry, + onTap: () {}, + ), + ), + ), + ); +} + +void main() { + setUpAll(AppTime.initialize); + + group('programmerTasksTabsFor', () { + test('tabs in this slice', () { + expect( + programmerTasksTabsFor(isStrictAdmin: true), + [ + ProgrammerTasksTab.monitor, + ProgrammerTasksTab.mine, + ProgrammerTasksTab.all, + ], + ); + expect( + programmerTasksTabsFor(isStrictAdmin: false), + [ProgrammerTasksTab.mine, ProgrammerTasksTab.all], + ); + }); + }); + + group('parseProgrammerTasksTab', () { + test('parse tab query', () { + expect(parseProgrammerTasksTab('day-sheets'), ProgrammerTasksTab.daySheets); + expect(parseProgrammerTasksTab('x'), isNull); + expect(parseProgrammerTasksTab(null), isNull); + expect(parseProgrammerTasksTab('monitor'), ProgrammerTasksTab.monitor); + expect(parseProgrammerTasksTab('mine'), ProgrammerTasksTab.mine); + expect(parseProgrammerTasksTab('all'), ProgrammerTasksTab.all); + expect(parseProgrammerTasksTab('approvals'), ProgrammerTasksTab.approvals); + }); + }); + + group('monitor card pill labels', () { + testWidgets('onLeave -> On leave', (tester) async { + await tester.pumpWidget(_wrapCard(_entry(state: MonitorState.onLeave))); + await tester.pump(); + expect(find.text('On leave'), findsOneWidget); + }); + + testWidgets('passSlip -> Pass slip', (tester) async { + await tester.pumpWidget(_wrapCard(_entry(state: MonitorState.passSlip))); + await tester.pump(); + expect(find.text('Pass slip'), findsOneWidget); + }); + + testWidgets('working -> Working', (tester) async { + await tester.pumpWidget(_wrapCard(_entry(state: MonitorState.working))); + await tester.pump(); + expect(find.text('Working'), findsOneWidget); + }); + + testWidgets('paused -> Paused', (tester) async { + await tester.pumpWidget(_wrapCard(_entry(state: MonitorState.paused))); + await tester.pump(); + expect(find.text('Paused'), findsOneWidget); + }); + + testWidgets('idle -> Idle', (tester) async { + await tester.pumpWidget(_wrapCard(_entry(state: MonitorState.idle))); + await tester.pump(); + expect(find.text('Idle'), findsOneWidget); + }); + }); +}