feat(programmer): dashboard stat tiles + badges

Add StatTiles and BadgesStrip widgets wired into ProgrammerDashboard.

StatTiles shows 2 tiles (focus + completed) when showSheetStats is false,
and 5 tiles (+ streak, first-pass approval, help given) when the viewing
user's role is programmer. BadgesStrip renders earned/locked chips in a
horizontal Wrap with tap-to-dialog detail.

Dashboard feeds todayFocus via buildDayRows/totalSeconds, leaveDays via
approvedLeaveDays, and sheets: const [] until Task 20 wires real data.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-09-27 22:13:43 +08:00
parent 0c99ef270d
commit a7fd1e74f1
4 changed files with 331 additions and 1 deletions
@@ -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<BadgeProgress> 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<void>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(badge.title),
content: Text(badge.description),
),
);
},
);
}
}
@@ -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 = <String, Profile>{
for (final p in profilesAsync.valueOrNull ?? <Profile>[]) p.id: p,
};
final profileNames = <String, String>{
for (final p in profilesAsync.valueOrNull ?? <Profile>[]) p.id: p.fullName,
};
final dayData = dayDataAsync.valueOrNull;
final leaveDays = approvedLeaveDays(
ref.watch(leavesProvider).valueOrNull ?? [],
userId,
);
final todayRows = dayData == null
? const <DaySheetRow>[]
: 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 <ProgrammerDaySheet>[],
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,
@@ -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 = <Widget>[
_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,
),
),
);
}
}
+75
View File
@@ -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);
});
}