diff --git a/lib/screens/programmer_tasks/dashboard/badges_strip.dart b/lib/screens/programmer_tasks/dashboard/badges_strip.dart new file mode 100644 index 00000000..db72ea68 --- /dev/null +++ b/lib/screens/programmer_tasks/dashboard/badges_strip.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +import '../../../theme/app_status_colors.dart'; +import '../../../utils/programmer_stats.dart'; + +/// A horizontal wrap of badge chips for the programmer dashboard. +/// +/// Earned badges use the success container colour; locked badges show neutral +/// styling and display `current/target` as their label. Tapping any chip opens +/// an [AlertDialog] with the badge title and description. +class BadgesStrip extends StatelessWidget { + const BadgesStrip({ + super.key, + required this.badges, + }); + + final List badges; + + @override + Widget build(BuildContext context) { + if (badges.isEmpty) return const SizedBox.shrink(); + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final badge in badges) _BadgeChip(badge: badge), + ], + ); + } +} + +class _BadgeChip extends StatelessWidget { + const _BadgeChip({required this.badge}); + + final BadgeProgress badge; + + @override + Widget build(BuildContext context) { + final statusColors = AppStatusColors.of(context); + final earned = badge.earned; + + return ActionChip( + avatar: Icon(badge.icon, size: 16), + label: Text(earned ? badge.title : '${badge.current}/${badge.target}'), + backgroundColor: earned ? statusColors.success.container : null, + onPressed: () { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(badge.title), + content: Text(badge.description), + ), + ); + }, + ); + } +} diff --git a/lib/screens/programmer_tasks/dashboard/programmer_dashboard.dart b/lib/screens/programmer_tasks/dashboard/programmer_dashboard.dart index 7214088c..3234e716 100644 --- a/lib/screens/programmer_tasks/dashboard/programmer_dashboard.dart +++ b/lib/screens/programmer_tasks/dashboard/programmer_dashboard.dart @@ -2,12 +2,20 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../models/profile.model.dart'; +import '../../../models/programmer_day_sheet.model.dart'; import '../../../models/programmer_task.model.dart'; +import '../../../providers/leave_provider.dart'; import '../../../providers/profile_provider.dart'; import '../../../providers/programmer_day_data_provider.dart'; import '../../../providers/programmer_tasks_provider.dart'; +import '../../../utils/app_time.dart'; import '../../../utils/programmer_daily_time.dart'; +import '../../../utils/programmer_day_rows.dart'; +import '../../../utils/programmer_stats.dart'; +import '../../../utils/staff_presence.dart'; +import 'badges_strip.dart'; import 'now_working_card.dart'; +import 'stat_tiles.dart'; import 'task_sections.dart'; /// The programmer's own work dashboard — hero card, stat tiles (Task 8), and @@ -33,10 +41,49 @@ class ProgrammerDashboard extends ConsumerWidget { final dayDataAsync = ref.watch(programmerDayDataProvider(todayKey)); final profilesAsync = ref.watch(profilesProvider); + final now = ref.watch(clockTickerProvider).valueOrNull ?? AppTime.now(); + final tasks = tasksAsync.valueOrNull ?? const []; final profileById = { for (final p in profilesAsync.valueOrNull ?? []) p.id: p, }; + final profileNames = { + for (final p in profilesAsync.valueOrNull ?? []) p.id: p.fullName, + }; + + final dayData = dayDataAsync.valueOrNull; + final leaveDays = approvedLeaveDays( + ref.watch(leavesProvider).valueOrNull ?? [], + userId, + ); + + final todayRows = dayData == null + ? const [] + : buildDayRows( + userId: userId, + day: todayKey, + tasks: tasks, + logsByTask: dayData.logsByTask, + workLogs: dayData.workLogs, + projectNames: const {}, + profileNames: profileNames, + now: now, + ); + final todayFocus = Duration(seconds: totalSeconds(todayRows)); + + final stats = computeProgrammerStats( + userId: userId, + now: now, + tasks: tasks, + sheets: const [], + leaveDays: leaveDays, + todayFocus: todayFocus, + ); + + final showSheetStats = + ref.watch(currentProfileProvider).valueOrNull?.role == 'programmer'; + + final badges = computeBadges(stats, includeSheetBadges: showSheetStats); final groups = groupTasksForDashboard(tasks, runStates, userId); final runningTask = @@ -67,7 +114,10 @@ class ProgrammerDashboard extends ConsumerWidget { readOnly: readOnly, ), const SizedBox(height: 16), - // TODO: Task 8 inserts StatTiles + BadgesStrip here + StatTiles(stats: stats, showSheetStats: showSheetStats), + const SizedBox(height: 8), + BadgesStrip(badges: badges), + const SizedBox(height: 16), TaskSections( groups: groups, profileById: profileById, diff --git a/lib/screens/programmer_tasks/dashboard/stat_tiles.dart b/lib/screens/programmer_tasks/dashboard/stat_tiles.dart new file mode 100644 index 00000000..64ef6df9 --- /dev/null +++ b/lib/screens/programmer_tasks/dashboard/stat_tiles.dart @@ -0,0 +1,148 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../../utils/programmer_stats.dart'; +import '../../../utils/programmer_task_ledger.dart'; +import '../../../widgets/app_metric_card.dart'; + +/// A vertical list of KPI stat tiles for the programmer dashboard. +/// +/// When [showSheetStats] is false only the first two tiles (today's focus and +/// completed-this-week) are shown; sheet-based tiles (streak, first-pass +/// approval, help given) are hidden and badges are computed without sheet data. +class StatTiles extends StatelessWidget { + const StatTiles({ + super.key, + required this.stats, + required this.showSheetStats, + }); + + final ProgrammerStats stats; + final bool showSheetStats; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final tiles = [ + _buildFocusTile(context, cs), + const SizedBox(height: 8), + _buildCompletedTile(context), + ]; + if (showSheetStats) { + tiles.addAll([ + const SizedBox(height: 8), + _buildStreakTile(context), + const SizedBox(height: 8), + _buildFirstPassTile(context), + const SizedBox(height: 8), + _buildHelpGivenTile(context), + ]); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: tiles, + ); + } + + Widget _buildFocusTile(BuildContext context, ColorScheme cs) { + final progressValue = + math.min(1.0, stats.todayFocus.inSeconds / (8 * 3600)); + final trailing = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: 40, + height: 40, + child: CircularProgressIndicator( + value: progressValue, + strokeWidth: 6, + backgroundColor: cs.surfaceContainerHighest, + color: cs.primary, + strokeCap: StrokeCap.round, + ), + ), + const SizedBox(height: 4), + Text( + '/ 8h', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: cs.onSurfaceVariant, + ), + ), + ], + ); + return AppMetricCard( + title: "TODAY'S FOCUS", + value: formatDurationHm(stats.todayFocus), + trailing: trailing, + ); + } + + Widget _buildCompletedTile(BuildContext context) { + final diff = stats.completedThisWeek - stats.completedLastWeek; + final String trailingText; + if (diff > 0) { + trailingText = '↑$diff vs last week'; + } else if (diff < 0) { + trailingText = '↓${-diff} vs last week'; + } else { + trailingText = 'Same as last week'; + } + return AppMetricCard( + title: 'COMPLETED THIS WEEK', + value: stats.completedThisWeek.toString(), + trailing: Text( + trailingText, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); + } + + Widget _buildStreakTile(BuildContext context) { + final n = stats.currentStreak; + final dayLabel = n == 1 ? '1 day' : '$n days'; + return AppMetricCard( + title: 'STREAK', + value: dayLabel, + icon: Icons.local_fire_department_outlined, + trailing: Text( + 'Best ${stats.bestStreak}', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); + } + + Widget _buildFirstPassTile(BuildContext context) { + final value = stats.firstPassRate != null + ? '${(stats.firstPassRate! * 100).round()}%' + : '—'; + return AppMetricCard( + title: 'FIRST-PASS APPROVAL', + value: value, + trailing: Text( + 'Last 30 days', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); + } + + Widget _buildHelpGivenTile(BuildContext context) { + return AppMetricCard( + title: 'HELP GIVEN', + value: formatDurationHm(stats.helpGivenThisMonth), + trailing: Text( + 'This month', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); + } +} diff --git a/test/programmer_stat_tiles_test.dart b/test/programmer_stat_tiles_test.dart new file mode 100644 index 00000000..ae177bba --- /dev/null +++ b/test/programmer_stat_tiles_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tasq/screens/programmer_tasks/dashboard/badges_strip.dart'; +import 'package:tasq/screens/programmer_tasks/dashboard/stat_tiles.dart'; +import 'package:tasq/utils/programmer_stats.dart'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +ProgrammerStats _makeStats({ + int currentStreak = 0, + int bestStreak = 0, + int completedThisWeek = 0, + int completedLastWeek = 0, + double? firstPassRate, +}) { + return ProgrammerStats( + todayFocus: Duration.zero, + helpGivenThisMonth: Duration.zero, + approvedTotal: Duration.zero, + helpGivenTotal: Duration.zero, + completedThisWeek: completedThisWeek, + completedLastWeek: completedLastWeek, + currentStreak: currentStreak, + bestStreak: bestStreak, + completedTotal: 0, + bugFixCompleted: 0, + firstPassRate: firstPassRate, + hasCleanWeek: false, + ); +} + +Widget _wrap(Widget child) { + return MaterialApp(home: Scaffold(body: SingleChildScrollView(child: child))); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + testWidgets('sheet stats hidden when showSheetStats is false', (tester) async { + final stats = _makeStats(currentStreak: 6, bestStreak: 9); + await tester.pumpWidget(_wrap( + StatTiles(stats: stats, showSheetStats: false), + )); + expect(find.text('STREAK'), findsNothing); + }); + + testWidgets('streak tile shows days and best', (tester) async { + final stats = _makeStats(currentStreak: 6, bestStreak: 9); + await tester.pumpWidget(_wrap( + StatTiles(stats: stats, showSheetStats: true), + )); + expect(find.text('6 days'), findsOneWidget); + expect(find.text('Best 9'), findsOneWidget); + }); + + testWidgets('locked badge shows progress', (tester) async { + const badge = BadgeProgress( + id: 'finisher', + title: 'Finisher', + description: 'Complete 10 tasks', + icon: Icons.task_alt, + current: 7, + target: 10, + earned: false, + ); + await tester.pumpWidget(_wrap( + const BadgesStrip(badges: [badge]), + )); + expect(find.text('7/10'), findsOneWidget); + }); +}