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:
@@ -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 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../models/profile.model.dart';
|
import '../../../models/profile.model.dart';
|
||||||
|
import '../../../models/programmer_day_sheet.model.dart';
|
||||||
import '../../../models/programmer_task.model.dart';
|
import '../../../models/programmer_task.model.dart';
|
||||||
|
import '../../../providers/leave_provider.dart';
|
||||||
import '../../../providers/profile_provider.dart';
|
import '../../../providers/profile_provider.dart';
|
||||||
import '../../../providers/programmer_day_data_provider.dart';
|
import '../../../providers/programmer_day_data_provider.dart';
|
||||||
import '../../../providers/programmer_tasks_provider.dart';
|
import '../../../providers/programmer_tasks_provider.dart';
|
||||||
|
import '../../../utils/app_time.dart';
|
||||||
import '../../../utils/programmer_daily_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 'now_working_card.dart';
|
||||||
|
import 'stat_tiles.dart';
|
||||||
import 'task_sections.dart';
|
import 'task_sections.dart';
|
||||||
|
|
||||||
/// The programmer's own work dashboard — hero card, stat tiles (Task 8), and
|
/// 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 dayDataAsync = ref.watch(programmerDayDataProvider(todayKey));
|
||||||
final profilesAsync = ref.watch(profilesProvider);
|
final profilesAsync = ref.watch(profilesProvider);
|
||||||
|
|
||||||
|
final now = ref.watch(clockTickerProvider).valueOrNull ?? AppTime.now();
|
||||||
|
|
||||||
final tasks = tasksAsync.valueOrNull ?? const [];
|
final tasks = tasksAsync.valueOrNull ?? const [];
|
||||||
final profileById = <String, Profile>{
|
final profileById = <String, Profile>{
|
||||||
for (final p in profilesAsync.valueOrNull ?? <Profile>[]) p.id: p,
|
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 groups = groupTasksForDashboard(tasks, runStates, userId);
|
||||||
final runningTask =
|
final runningTask =
|
||||||
@@ -67,7 +114,10 @@ class ProgrammerDashboard extends ConsumerWidget {
|
|||||||
readOnly: readOnly,
|
readOnly: readOnly,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
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(
|
TaskSections(
|
||||||
groups: groups,
|
groups: groups,
|
||||||
profileById: profileById,
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user