From 51ab9aa014f5a2ae8f15f42d2ffd1760cd22963d Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Tue, 7 Jul 2026 13:40:42 -0700 Subject: [PATCH] feat(backlog): add summary panel --- .../UI_PLAN_2_IMPLEMENTATION_NOTES.md | 15 + .../theme/backlog_board_visual_tokens.dart | 21 + .../widgets/backlog/backlog_board_screen.dart | 188 +---- .../backlog/backlog_summary_panel.dart | 657 ++++++++++++++++++ .../widgets/backlog_summary_panel_test.dart | 182 +++++ 5 files changed, 877 insertions(+), 186 deletions(-) create mode 100644 apps/focus_flow_flutter/lib/widgets/backlog/backlog_summary_panel.dart create mode 100644 apps/focus_flow_flutter/test/widgets/backlog_summary_panel_test.dart diff --git a/Codex Documentation/Current Software Plan/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_IMPLEMENTATION_NOTES.md b/Codex Documentation/Current Software Plan/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_IMPLEMENTATION_NOTES.md index 2df906e..f134617 100644 --- a/Codex Documentation/Current Software Plan/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_IMPLEMENTATION_NOTES.md +++ b/Codex Documentation/Current Software Plan/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_IMPLEMENTATION_NOTES.md @@ -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 request object, sort/group menus update controller state, and Compact, 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. diff --git a/apps/focus_flow_flutter/lib/theme/backlog_board_visual_tokens.dart b/apps/focus_flow_flutter/lib/theme/backlog_board_visual_tokens.dart index 8fc2a65..51440d9 100644 --- a/apps/focus_flow_flutter/lib/theme/backlog_board_visual_tokens.dart +++ b/apps/focus_flow_flutter/lib/theme/backlog_board_visual_tokens.dart @@ -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. static String priorityLabel(PriorityLevel? 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. static int rewardCount(RewardLevel reward) { return switch (reward) { diff --git a/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_screen.dart b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_screen.dart index 3b5e520..3925a9a 100644 --- a/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_screen.dart +++ b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_screen.dart @@ -14,6 +14,7 @@ import '../../models/backlog/backlog_board_screen_data.dart'; import '../../theme/focus_flow_tokens.dart'; import 'backlog_board_column.dart'; import 'backlog_board_controls.dart'; +import 'backlog_summary_panel.dart'; /// Backlog board screen backed by a public scheduler read controller. class BacklogBoardScreen extends StatelessWidget { @@ -270,7 +271,7 @@ class _BacklogReadyBody extends StatelessWidget { title: 'Backlog is clear', 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. class _BacklogStatePanel extends StatelessWidget { /// Creates a state panel. diff --git a/apps/focus_flow_flutter/lib/widgets/backlog/backlog_summary_panel.dart b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_summary_panel.dart new file mode 100644 index 0000000..834cb74 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_summary_panel.dart @@ -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 createState() => _BacklogSummaryPanelState(); +} + +/// State for the Backlog summary panel collapse control. +class _BacklogSummaryPanelState extends State { + /// 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 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(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()}%'; +} diff --git a/apps/focus_flow_flutter/test/widgets/backlog_summary_panel_test.dart b/apps/focus_flow_flutter/test/widgets/backlog_summary_panel_test.dart new file mode 100644 index 0000000..e498808 --- /dev/null +++ b/apps/focus_flow_flutter/test/widgets/backlog_summary_panel_test.dart @@ -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 _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, + ); +}