Wire Backlog nav, Break up, Settings dialog + merge Backlog Board feature #20
5 changed files with 877 additions and 186 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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()}%';
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue