feat(backlog): add summary panel

This commit is contained in:
Ashley Venn 2026-07-07 13:40:42 -07:00
parent dd25bdfaae
commit 51ab9aa014
5 changed files with 877 additions and 186 deletions

View file

@ -155,3 +155,18 @@ Freeform Tags Metadata.md` for a later migration-backed feature.
Search reloads through the board query, filters apply through the public Search reloads through the board query, filters apply through the public
request object, sort/group menus update controller state, and Compact, request object, sort/group menus update controller state, and Compact,
Settings, and New Task remain safe inert shells until later blocks. Settings, and New Task remain safe inert shells until later blocks.
## Block 06 Summary Panel
1. `BacklogSummaryPanel` now owns the right-side summary surface and consumes
only `BacklogBoardSummaryReadModel` data from the public scheduler API.
2. The panel renders total tasks, total estimated time, priority distribution,
project distribution, a custom-painted duration donut with legend rows, and
the planning tip card without hardcoded summary values.
3. Summary panel color decisions use
`apps/focus_flow_flutter/lib/theme/backlog_board_visual_tokens.dart` for
priority, project, and duration colors so board cards and summary rows share
the same visual mapping.
4. Collapse and expand state is local to the Flutter widget. It hides body
sections while leaving the summary header visible and does not write any
scheduler state.

View file

@ -98,6 +98,16 @@ abstract final class BacklogBoardVisualTokens {
}; };
} }
/// Resolves a priority distribution color token to a flag color.
static Color priorityColorToken(String? token) {
return switch (token) {
'priority-veryHigh' || 'priority-high' => priorityHigh,
'priority-medium' => priorityMedium,
'priority-low' || 'priority-veryLow' => priorityLow,
_ => FocusFlowTokens.textFaint,
};
}
/// Resolves a task priority to a compact card label. /// Resolves a task priority to a compact card label.
static String priorityLabel(PriorityLevel? priority) { static String priorityLabel(PriorityLevel? priority) {
return switch (priority) { return switch (priority) {
@ -120,6 +130,17 @@ abstract final class BacklogBoardVisualTokens {
}; };
} }
/// Resolves a duration distribution id to a chart segment color.
static Color durationColor(String id) {
return switch (id) {
'0-30' => priorityLow,
'30-60' => notNowAccent,
'60-120' => breakUpFirstAccent,
'120+' => FocusFlowTokens.accentMagenta,
_ => FocusFlowTokens.textFaint,
};
}
/// Resolves a reward level to the number of solid reward marks to render. /// Resolves a reward level to the number of solid reward marks to render.
static int rewardCount(RewardLevel reward) { static int rewardCount(RewardLevel reward) {
return switch (reward) { return switch (reward) {

View file

@ -14,6 +14,7 @@ import '../../models/backlog/backlog_board_screen_data.dart';
import '../../theme/focus_flow_tokens.dart'; import '../../theme/focus_flow_tokens.dart';
import 'backlog_board_column.dart'; import 'backlog_board_column.dart';
import 'backlog_board_controls.dart'; import 'backlog_board_controls.dart';
import 'backlog_summary_panel.dart';
/// Backlog board screen backed by a public scheduler read controller. /// Backlog board screen backed by a public scheduler read controller.
class BacklogBoardScreen extends StatelessWidget { class BacklogBoardScreen extends StatelessWidget {
@ -270,7 +271,7 @@ class _BacklogReadyBody extends StatelessWidget {
title: 'Backlog is clear', title: 'Backlog is clear',
message: 'New tasks will appear here.', message: 'New tasks will appear here.',
) )
: _BacklogSummaryShell(summary: data.summary), : BacklogSummaryPanel(summary: data.summary),
), ),
], ],
); );
@ -351,191 +352,6 @@ class _BacklogBoardColumns extends StatelessWidget {
} }
} }
/// Summary panel shell for loaded board data.
class _BacklogSummaryShell extends StatelessWidget {
/// Creates a summary shell.
const _BacklogSummaryShell({required this.summary});
/// Summary read model.
final BacklogBoardSummaryReadModel summary;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: FocusFlowTokens.panelBackground,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: FocusFlowTokens.subtleBorder),
),
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.auto_awesome, color: FocusFlowTokens.accentMagenta),
SizedBox(width: 10),
Expanded(
child: Text(
'Backlog summary',
style: TextStyle(
color: FocusFlowTokens.textPrimary,
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
),
Icon(Icons.expand_less, color: FocusFlowTokens.textMuted),
],
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: _SummaryMetric(
value: '${summary.totalTaskCount}',
label: 'Total tasks',
),
),
Expanded(
child: _SummaryMetric(
value: _estimatedTimeText(summary.totalEstimatedMinutes),
label: 'Estimated time',
),
),
],
),
const SizedBox(height: 26),
_SummaryDistribution(distribution: summary.priorityDistribution),
const SizedBox(height: 20),
_SummaryDistribution(distribution: summary.projectDistribution),
const Spacer(),
DecoratedBox(
decoration: BoxDecoration(
color: FocusFlowTokens.elevatedPanel,
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(14),
child: Text(
summary.planningTip,
style: const TextStyle(
color: FocusFlowTokens.textMuted,
height: 1.4,
),
),
),
),
],
),
),
);
}
/// Formats [minutes] as compact hour/minute text.
String _estimatedTimeText(int minutes) {
final hours = minutes ~/ 60;
final remainder = minutes % 60;
if (hours == 0) {
return '${remainder}m';
}
if (remainder == 0) {
return '${hours}h';
}
return '${hours}h ${remainder}m';
}
}
/// One numeric metric in the summary shell.
class _SummaryMetric extends StatelessWidget {
/// Creates a summary metric.
const _SummaryMetric({required this.value, required this.label});
/// Metric value.
final String value;
/// Metric label.
final String label;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
value,
style: const TextStyle(
color: FocusFlowTokens.textPrimary,
fontSize: 24,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 4),
Text(
label,
style: const TextStyle(
color: FocusFlowTokens.textFaint,
fontSize: 12,
),
),
],
);
}
}
/// Compact list form of a summary distribution.
class _SummaryDistribution extends StatelessWidget {
/// Creates a summary distribution.
const _SummaryDistribution({required this.distribution});
/// Distribution read model.
final BacklogDistributionReadModel distribution;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
distribution.title,
style: const TextStyle(
color: FocusFlowTokens.textPrimary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 10),
for (final entry in distribution.entries.take(5))
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Expanded(
child: Text(
entry.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: FocusFlowTokens.textMuted),
),
),
Text(
'${entry.count}',
style: const TextStyle(color: FocusFlowTokens.textPrimary),
),
],
),
),
],
);
}
}
/// Reusable centered state panel. /// Reusable centered state panel.
class _BacklogStatePanel extends StatelessWidget { class _BacklogStatePanel extends StatelessWidget {
/// Creates a state panel. /// Creates a state panel.

View file

@ -0,0 +1,657 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Renders the Backlog board summary panel for the FocusFlow Flutter UI.
library;
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:scheduler_core/scheduler_core.dart';
import '../../theme/backlog_board_visual_tokens.dart';
import '../../theme/focus_flow_tokens.dart';
/// Right-side Backlog summary panel driven by board summary read models.
class BacklogSummaryPanel extends StatefulWidget {
/// Creates a Backlog summary panel.
const BacklogSummaryPanel({required this.summary, super.key});
/// Summary read model to display.
final BacklogBoardSummaryReadModel summary;
/// Creates the mutable state object used by this stateful widget.
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
@override
State<BacklogSummaryPanel> createState() => _BacklogSummaryPanelState();
}
/// State for the Backlog summary panel collapse control.
class _BacklogSummaryPanelState extends State<BacklogSummaryPanel> {
/// Whether summary body sections are visible.
var _expanded = true;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: FocusFlowTokens.panelBackground,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: FocusFlowTokens.subtleBorder),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.18),
blurRadius: 28,
offset: const Offset(0, 10),
),
],
),
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_SummaryHeader(
expanded: _expanded,
onToggle: () {
setState(() {
_expanded = !_expanded;
});
},
),
if (_expanded) ...[
const SizedBox(height: 22),
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_SummaryTotals(summary: widget.summary),
const SizedBox(height: 22),
_PriorityDistribution(
distribution: widget.summary.priorityDistribution,
),
const SizedBox(height: 18),
_ProjectDistribution(
distribution: widget.summary.projectDistribution,
),
const SizedBox(height: 18),
_DurationDistribution(
distribution: widget.summary.durationDistribution,
),
const SizedBox(height: 18),
_PlanningTip(text: widget.summary.planningTip),
],
),
),
),
],
],
),
),
);
}
}
/// Header row for the summary panel.
class _SummaryHeader extends StatelessWidget {
/// Creates a summary header.
const _SummaryHeader({required this.expanded, required this.onToggle});
/// Whether body sections are visible.
final bool expanded;
/// Callback invoked by the collapse button.
final VoidCallback onToggle;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return Row(
children: [
const Icon(Icons.auto_awesome, color: FocusFlowTokens.accentMagenta),
const SizedBox(width: 10),
const Expanded(
child: Text(
'Backlog summary',
style: TextStyle(
color: FocusFlowTokens.textPrimary,
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
),
IconButton(
key: const ValueKey('backlog-summary-collapse-button'),
tooltip: expanded ? 'Collapse summary' : 'Expand summary',
onPressed: onToggle,
icon: Icon(expanded ? Icons.expand_less : Icons.expand_more),
color: FocusFlowTokens.textMuted,
visualDensity: VisualDensity.compact,
),
],
);
}
}
/// Total task and estimated-time metrics.
class _SummaryTotals extends StatelessWidget {
/// Creates summary totals.
const _SummaryTotals({required this.summary});
/// Summary data.
final BacklogBoardSummaryReadModel summary;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: _SummaryMetric(
value: '${summary.totalTaskCount}',
label: 'Total tasks',
),
),
Expanded(
child: _SummaryMetric(
value: _estimatedTimeText(summary.totalEstimatedMinutes),
label: 'Total estimated time',
),
),
],
);
}
}
/// One numeric metric in the summary panel.
class _SummaryMetric extends StatelessWidget {
/// Creates a summary metric.
const _SummaryMetric({required this.value, required this.label});
/// Metric value.
final String value;
/// Metric label.
final String label;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
value,
style: const TextStyle(
color: FocusFlowTokens.textPrimary,
fontSize: 24,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 4),
Text(
label,
style: const TextStyle(
color: FocusFlowTokens.textFaint,
fontSize: 11.5,
),
),
],
);
}
}
/// Priority distribution section.
class _PriorityDistribution extends StatelessWidget {
/// Creates a priority distribution section.
const _PriorityDistribution({required this.distribution});
/// Distribution read model.
final BacklogDistributionReadModel distribution;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return _DistributionSection(
title: 'By priority',
children: [
for (final entry in distribution.entries)
_DistributionRow(
leading: Icon(
Icons.flag,
color: BacklogBoardVisualTokens.priorityColorToken(
entry.colorToken,
),
size: 15,
),
label: entry.label,
count: entry.count,
),
],
);
}
}
/// Project distribution section.
class _ProjectDistribution extends StatelessWidget {
/// Creates a project distribution section.
const _ProjectDistribution({required this.distribution});
/// Distribution read model.
final BacklogDistributionReadModel distribution;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return _DistributionSection(
title: 'By project',
children: [
for (final entry in distribution.entries)
_DistributionRow(
leading: _ColorDot(
color: BacklogBoardVisualTokens.projectColor(
entry.colorToken ?? entry.id,
),
),
label: entry.label,
count: entry.count,
),
],
);
}
}
/// Shared distribution section shell.
class _DistributionSection extends StatelessWidget {
/// Creates a distribution section.
const _DistributionSection({required this.title, required this.children});
/// Section title.
final String title;
/// Distribution row widgets.
final List<Widget> children;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
title,
style: const TextStyle(
color: FocusFlowTokens.textPrimary,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 9),
if (children.isEmpty)
const Text(
'No tasks',
style: TextStyle(color: FocusFlowTokens.textFaint, fontSize: 12),
)
else
...children,
],
);
}
}
/// One compact distribution row.
class _DistributionRow extends StatelessWidget {
/// Creates a distribution row.
const _DistributionRow({
required this.leading,
required this.label,
required this.count,
});
/// Leading icon or dot.
final Widget leading;
/// Row label.
final String label;
/// Row count.
final int count;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
SizedBox(width: 18, child: Center(child: leading)),
const SizedBox(width: 8),
Expanded(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: FocusFlowTokens.textMuted,
fontSize: 13,
),
),
),
Text(
'$count',
style: const TextStyle(
color: FocusFlowTokens.textPrimary,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
}
/// Colored project dot.
class _ColorDot extends StatelessWidget {
/// Creates a color dot.
const _ColorDot({required this.color});
/// Dot color.
final Color color;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return Container(
width: 9,
height: 9,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
);
}
}
/// Duration donut chart and legend section.
class _DurationDistribution extends StatelessWidget {
/// Creates a duration distribution section.
const _DurationDistribution({required this.distribution});
/// Distribution read model.
final BacklogDistributionReadModel distribution;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
final entries = _durationEntries(distribution);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Duration distribution',
style: TextStyle(
color: FocusFlowTokens.textPrimary,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
const Text(
'Estimated time',
style: TextStyle(color: FocusFlowTokens.textFaint, fontSize: 11.5),
),
const SizedBox(height: 12),
Row(
children: [
SizedBox(
width: 88,
height: 88,
child: CustomPaint(
painter: _DurationDonutPainter(entries: entries),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
children: [
for (final entry in entries) _DurationLegendRow(entry: entry),
],
),
),
],
),
],
);
}
}
/// One normalized duration chart entry.
class _DurationChartEntry {
/// Creates a duration chart entry.
const _DurationChartEntry({
required this.id,
required this.label,
required this.count,
required this.percentage,
required this.color,
});
/// Stable entry id.
final String id;
/// Display label.
final String label;
/// Task count in this duration bucket.
final int count;
/// Share of total distribution.
final double percentage;
/// Chart color.
final Color color;
}
/// Legend row for one duration chart entry.
class _DurationLegendRow extends StatelessWidget {
/// Creates a duration legend row.
const _DurationLegendRow({required this.entry});
/// Chart entry to render.
final _DurationChartEntry entry;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 7),
child: Row(
children: [
_ColorDot(color: entry.color),
const SizedBox(width: 7),
Expanded(
child: Text(
entry.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: FocusFlowTokens.textMuted,
fontSize: 11.5,
),
),
),
Text(
'${entry.count} (${_percentageLabel(entry.percentage)})',
style: const TextStyle(
color: FocusFlowTokens.textPrimary,
fontSize: 11.5,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
}
/// Painter for the duration donut chart.
class _DurationDonutPainter extends CustomPainter {
/// Creates a duration donut painter.
const _DurationDonutPainter({required this.entries});
/// Entries to paint.
final List<_DurationChartEntry> entries;
/// Performs the paint behavior for this scheduler component.
/// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved.
@override
void paint(Canvas canvas, Size size) {
final strokeWidth = size.shortestSide * 0.18;
final rect = Offset.zero & size;
final arcRect = rect.deflate(strokeWidth / 2);
final basePaint = Paint()
..color = FocusFlowTokens.subtleBorder.withValues(alpha: 0.62)
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth;
final radius = math.min(arcRect.width, arcRect.height) / 2;
canvas.drawCircle(rect.center, radius, basePaint);
final total = entries.fold<int>(0, (sum, entry) => sum + entry.count);
if (total == 0) {
return;
}
var startAngle = -math.pi / 2;
for (final entry in entries) {
if (entry.count == 0) {
continue;
}
final sweep = math.pi * 2 * entry.percentage;
final paint = Paint()
..color = entry.color
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.butt;
canvas.drawArc(arcRect, startAngle, sweep, false, paint);
startAngle += sweep;
}
}
/// Performs the shouldRepaint behavior for this scheduler component.
/// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved.
@override
bool shouldRepaint(covariant _DurationDonutPainter oldDelegate) {
return oldDelegate.entries != entries;
}
}
/// Planning tip card.
class _PlanningTip extends StatelessWidget {
/// Creates a planning tip card.
const _PlanningTip({required this.text});
/// Tip body text.
final String text;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: FocusFlowTokens.elevatedPanel,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: FocusFlowTokens.subtleBorder.withValues(alpha: 0.38),
),
),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(
Icons.auto_awesome,
color: FocusFlowTokens.accentMagenta,
size: 18,
),
SizedBox(width: 8),
Text(
'Planning tip',
style: TextStyle(
color: FocusFlowTokens.accentMagenta,
fontWeight: FontWeight.w800,
),
),
],
),
const SizedBox(height: 10),
Text(
text,
style: const TextStyle(
color: FocusFlowTokens.textMuted,
height: 1.38,
),
),
],
),
),
);
}
}
/// Builds normalized duration chart entries from [distribution].
List<_DurationChartEntry> _durationEntries(
BacklogDistributionReadModel distribution,
) {
final byId = {for (final entry in distribution.entries) entry.id: entry};
return [
for (final id in const ['0-30', '30-60', '60-120', '120+'])
_DurationChartEntry(
id: id,
label: _durationLabel(id, byId[id]?.label ?? id),
count: byId[id]?.count ?? 0,
percentage: byId[id]?.percentage ?? 0,
color: BacklogBoardVisualTokens.durationColor(id),
),
];
}
/// Resolves a display label for a duration distribution [id].
String _durationLabel(String id, String fallback) {
return switch (id) {
'0-30' => '0-30 min',
'30-60' => '30-60 min',
'60-120' => '60-120 min',
'120+' => '120+ min',
_ => fallback,
};
}
/// Formats [minutes] as compact hour/minute text.
String _estimatedTimeText(int minutes) {
final hours = minutes ~/ 60;
final remainder = minutes % 60;
if (hours == 0) {
return '${remainder}m';
}
if (remainder == 0) {
return '${hours}h';
}
return '${hours}h ${remainder}m';
}
/// Formats [percentage] for legend display.
String _percentageLabel(double percentage) {
return '${(percentage * 100).round()}%';
}

View file

@ -0,0 +1,182 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Tests Backlog summary panel rendering and collapse behavior.
library;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:scheduler_core/scheduler_core.dart';
import 'package:focus_flow_flutter/theme/focus_flow_theme.dart';
import 'package:focus_flow_flutter/widgets/backlog/backlog_summary_panel.dart';
/// Runs Backlog summary panel widget tests.
void main() {
testWidgets('renders summary sections and toggles collapse state', (
tester,
) async {
await _pumpSummaryPanel(tester, _summary());
expect(find.text('Backlog summary'), findsOneWidget);
expect(find.text('24'), findsOneWidget);
expect(find.text('12h 15m'), findsOneWidget);
expect(find.text('Total tasks'), findsOneWidget);
expect(find.text('Total estimated time'), findsOneWidget);
expect(find.text('By priority'), findsOneWidget);
expect(find.text('High'), findsOneWidget);
expect(find.text('Medium'), findsOneWidget);
expect(find.text('Low'), findsOneWidget);
expect(find.text('By project'), findsOneWidget);
expect(find.text('Home'), findsOneWidget);
expect(find.text('Work'), findsOneWidget);
expect(find.text('Duration distribution'), findsOneWidget);
expect(find.text('0-30 min'), findsOneWidget);
expect(find.text('7 (29%)'), findsOneWidget);
expect(find.text('30-60 min'), findsOneWidget);
expect(find.text('9 (38%)'), findsOneWidget);
expect(find.text('60-120 min'), findsOneWidget);
expect(find.text('6 (25%)'), findsOneWidget);
expect(find.text('120+ min'), findsOneWidget);
expect(find.text('2 (8%)'), findsOneWidget);
expect(find.text('Planning tip'), findsOneWidget);
expect(find.text(_planningTip), findsOneWidget);
await tester.tap(
find.byKey(const ValueKey('backlog-summary-collapse-button')),
);
await tester.pumpAndSettle();
expect(find.text('Backlog summary'), findsOneWidget);
expect(find.text('By priority'), findsNothing);
expect(find.text('Duration distribution'), findsNothing);
expect(find.text(_planningTip), findsNothing);
await tester.tap(
find.byKey(const ValueKey('backlog-summary-collapse-button')),
);
await tester.pumpAndSettle();
expect(find.text('By priority'), findsOneWidget);
expect(find.text('Duration distribution'), findsOneWidget);
expect(find.text(_planningTip), findsOneWidget);
});
}
/// Planning guidance text used by the panel fixture.
const _planningTip =
'Start with the small high-priority work, then reserve a block for the longer items.';
/// Pumps [summary] in a fixed-width dark FocusFlow test harness.
Future<void> _pumpSummaryPanel(
WidgetTester tester,
BacklogBoardSummaryReadModel summary,
) async {
await tester.binding.setSurfaceSize(const Size(420, 760));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
MaterialApp(
theme: FocusFlowTheme.dark(),
home: Scaffold(
body: Center(
child: SizedBox(
width: 320,
height: 720,
child: BacklogSummaryPanel(summary: summary),
),
),
),
),
);
await tester.pumpAndSettle();
}
/// Builds a representative summary read model for panel tests.
BacklogBoardSummaryReadModel _summary() {
return BacklogBoardSummaryReadModel(
totalTaskCount: 24,
totalEstimatedMinutes: 735,
priorityDistribution: BacklogDistributionReadModel(
title: 'Priority',
entries: const [
BacklogDistributionEntryReadModel(
id: 'high',
label: 'High',
count: 7,
percentage: 0.29,
colorToken: 'priority-high',
),
BacklogDistributionEntryReadModel(
id: 'medium',
label: 'Medium',
count: 9,
percentage: 0.38,
colorToken: 'priority-medium',
),
BacklogDistributionEntryReadModel(
id: 'low',
label: 'Low',
count: 8,
percentage: 0.33,
colorToken: 'priority-low',
),
],
),
projectDistribution: BacklogDistributionReadModel(
title: 'Project',
entries: const [
BacklogDistributionEntryReadModel(
id: 'home',
label: 'Home',
count: 10,
percentage: 0.42,
colorToken: 'project-home',
),
BacklogDistributionEntryReadModel(
id: 'work',
label: 'Work',
count: 8,
percentage: 0.33,
colorToken: 'project-work',
),
BacklogDistributionEntryReadModel(
id: 'personal',
label: 'Personal',
count: 6,
percentage: 0.25,
colorToken: 'project-personal',
),
],
),
durationDistribution: BacklogDistributionReadModel(
title: 'Duration',
entries: const [
BacklogDistributionEntryReadModel(
id: '0-30',
label: '0-30 min',
count: 7,
percentage: 0.29,
),
BacklogDistributionEntryReadModel(
id: '30-60',
label: '30-60 min',
count: 9,
percentage: 0.38,
),
BacklogDistributionEntryReadModel(
id: '60-120',
label: '60-120 min',
count: 6,
percentage: 0.25,
),
BacklogDistributionEntryReadModel(
id: '120+',
label: '120+ min',
count: 2,
percentage: 0.08,
),
],
),
planningTip: _planningTip,
);
}