feat(backlog): add board controls
This commit is contained in:
parent
2bcbba46b6
commit
dd25bdfaae
17 changed files with 1559 additions and 231 deletions
|
|
@ -137,3 +137,21 @@ Freeform Tags Metadata.md` for a later migration-backed feature.
|
||||||
tasks rather than parent-child relationships. Child preview rendering is
|
tasks rather than parent-child relationships. Child preview rendering is
|
||||||
covered by a focused widget test using handcrafted public read models so the
|
covered by a focused widget test using handcrafted public read models so the
|
||||||
seed scope does not expand in this block.
|
seed scope does not expand in this block.
|
||||||
|
|
||||||
|
## Block 05 Search, Filter, Sort, and Group Controls
|
||||||
|
|
||||||
|
1. The board request now has `BacklogBoardFilterSelection` for board-only
|
||||||
|
filters: priority group, project, duration bucket, board bucket, and missing
|
||||||
|
duration. Legacy `BacklogFilter.noRewardSet` remains the cleanup filter for
|
||||||
|
no-reward tasks.
|
||||||
|
2. `BacklogSortKey.custom` preserves source/read-model order for the board
|
||||||
|
default, and `BacklogSortKey.duration` supports duration ordering. The simple
|
||||||
|
backlog list still keeps its existing priority default.
|
||||||
|
3. `BacklogBoardGroupMode` now supports project, priority, and duration
|
||||||
|
grouping as stable ordering inside the existing four bucket columns. The UI
|
||||||
|
does not add section dividers in this block.
|
||||||
|
4. The Backlog control row lives in
|
||||||
|
`apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_controls.dart`.
|
||||||
|
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.
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,7 @@ class DemoSchedulerComposition implements SchedulerAppComposition {
|
||||||
localDate: request.localDate,
|
localDate: request.localDate,
|
||||||
searchText: request.searchText,
|
searchText: request.searchText,
|
||||||
filters: request.filters,
|
filters: request.filters,
|
||||||
|
boardFilters: request.boardFilters,
|
||||||
sortKey: request.sortKey,
|
sortKey: request.sortKey,
|
||||||
groupMode: request.groupMode,
|
groupMode: request.groupMode,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,7 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
||||||
localDate: request.localDate,
|
localDate: request.localDate,
|
||||||
searchText: request.searchText,
|
searchText: request.searchText,
|
||||||
filters: request.filters,
|
filters: request.filters,
|
||||||
|
boardFilters: request.boardFilters,
|
||||||
sortKey: request.sortKey,
|
sortKey: request.sortKey,
|
||||||
groupMode: request.groupMode,
|
groupMode: request.groupMode,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,8 @@ class BacklogBoardReadRequest {
|
||||||
required this.localDate,
|
required this.localDate,
|
||||||
this.searchText,
|
this.searchText,
|
||||||
List<BacklogFilter> filters = const <BacklogFilter>[],
|
List<BacklogFilter> filters = const <BacklogFilter>[],
|
||||||
this.sortKey = BacklogSortKey.priority,
|
this.boardFilters = const BacklogBoardFilterSelection.empty(),
|
||||||
|
this.sortKey = BacklogSortKey.custom,
|
||||||
this.groupMode = BacklogBoardGroupMode.none,
|
this.groupMode = BacklogBoardGroupMode.none,
|
||||||
}) : filters = List<BacklogFilter>.unmodifiable(filters);
|
}) : filters = List<BacklogFilter>.unmodifiable(filters);
|
||||||
|
|
||||||
|
|
@ -48,6 +49,9 @@ class BacklogBoardReadRequest {
|
||||||
/// User-selected backlog filters.
|
/// User-selected backlog filters.
|
||||||
final List<BacklogFilter> filters;
|
final List<BacklogFilter> filters;
|
||||||
|
|
||||||
|
/// User-selected board-specific filters.
|
||||||
|
final BacklogBoardFilterSelection boardFilters;
|
||||||
|
|
||||||
/// User-selected task ordering.
|
/// User-selected task ordering.
|
||||||
final BacklogSortKey sortKey;
|
final BacklogSortKey sortKey;
|
||||||
|
|
||||||
|
|
@ -144,9 +148,14 @@ class BacklogBoardController extends ChangeNotifier {
|
||||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||||
List<BacklogFilter> _filters = const <BacklogFilter>[];
|
List<BacklogFilter> _filters = const <BacklogFilter>[];
|
||||||
|
|
||||||
|
/// Private state stored as `_boardFilters` for this implementation.
|
||||||
|
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||||
|
BacklogBoardFilterSelection _boardFilters =
|
||||||
|
const BacklogBoardFilterSelection.empty();
|
||||||
|
|
||||||
/// Private state stored as `_sortKey` for this implementation.
|
/// Private state stored as `_sortKey` for this implementation.
|
||||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||||
BacklogSortKey _sortKey = BacklogSortKey.priority;
|
BacklogSortKey _sortKey = BacklogSortKey.custom;
|
||||||
|
|
||||||
/// Private state stored as `_groupMode` for this implementation.
|
/// Private state stored as `_groupMode` for this implementation.
|
||||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||||
|
|
@ -176,6 +185,9 @@ class BacklogBoardController extends ChangeNotifier {
|
||||||
/// Current filters applied to board reads.
|
/// Current filters applied to board reads.
|
||||||
List<BacklogFilter> get filters => _filters;
|
List<BacklogFilter> get filters => _filters;
|
||||||
|
|
||||||
|
/// Current board-specific filters applied to board reads.
|
||||||
|
BacklogBoardFilterSelection get boardFilters => _boardFilters;
|
||||||
|
|
||||||
/// Current sort key applied to board reads.
|
/// Current sort key applied to board reads.
|
||||||
BacklogSortKey get sortKey => _sortKey;
|
BacklogSortKey get sortKey => _sortKey;
|
||||||
|
|
||||||
|
|
@ -198,6 +210,7 @@ class BacklogBoardController extends ChangeNotifier {
|
||||||
localDate: date,
|
localDate: date,
|
||||||
searchText: _searchText,
|
searchText: _searchText,
|
||||||
filters: _filters,
|
filters: _filters,
|
||||||
|
boardFilters: _boardFilters,
|
||||||
sortKey: _sortKey,
|
sortKey: _sortKey,
|
||||||
groupMode: _groupMode,
|
groupMode: _groupMode,
|
||||||
),
|
),
|
||||||
|
|
@ -333,6 +346,22 @@ class BacklogBoardController extends ChangeNotifier {
|
||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Updates legacy and board-specific filters together and reloads once.
|
||||||
|
Future<void> updateFilterState({
|
||||||
|
required List<BacklogFilter> filters,
|
||||||
|
required BacklogBoardFilterSelection boardFilters,
|
||||||
|
}) async {
|
||||||
|
_filters = List<BacklogFilter>.unmodifiable(filters);
|
||||||
|
_boardFilters = boardFilters;
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates board-specific filters and reloads the board.
|
||||||
|
Future<void> updateBoardFilters(BacklogBoardFilterSelection value) async {
|
||||||
|
_boardFilters = value;
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
/// Updates sort key and reloads the board.
|
/// Updates sort key and reloads the board.
|
||||||
Future<void> updateSortKey(BacklogSortKey value) async {
|
Future<void> updateSortKey(BacklogSortKey value) async {
|
||||||
if (value == _sortKey) {
|
if (value == _sortKey) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,954 @@
|
||||||
|
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
/// Renders Backlog board controls for the FocusFlow Flutter UI.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:scheduler_core/scheduler_core.dart';
|
||||||
|
|
||||||
|
import '../../controllers/backlog_board_controller.dart';
|
||||||
|
import '../../theme/backlog_board_visual_tokens.dart';
|
||||||
|
import '../../theme/focus_flow_tokens.dart';
|
||||||
|
|
||||||
|
/// Header controls for Compact/Board mode and settings.
|
||||||
|
class BacklogModeAndSettingsControls extends StatelessWidget {
|
||||||
|
/// Creates mode and settings controls.
|
||||||
|
const BacklogModeAndSettingsControls({super.key});
|
||||||
|
|
||||||
|
/// 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(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 42,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: FocusFlowTokens.subtleBorder),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: const [
|
||||||
|
_ModeSegment(label: 'Compact', active: false),
|
||||||
|
_ModeSegment(label: 'Board', active: true),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 18),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () {},
|
||||||
|
icon: const Icon(Icons.wb_sunny_outlined, size: 18),
|
||||||
|
label: const Text('Settings'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
fixedSize: const Size(122, 42),
|
||||||
|
foregroundColor: FocusFlowTokens.textPrimary,
|
||||||
|
side: const BorderSide(color: FocusFlowTokens.subtleBorder),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search, filter, sort, group, and new-task controls.
|
||||||
|
class BacklogBoardTopControls extends StatefulWidget {
|
||||||
|
/// Creates Backlog board top controls.
|
||||||
|
const BacklogBoardTopControls({required this.controller, super.key});
|
||||||
|
|
||||||
|
/// Controller updated by shell controls.
|
||||||
|
final BacklogBoardController controller;
|
||||||
|
|
||||||
|
/// 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<BacklogBoardTopControls> createState() =>
|
||||||
|
_BacklogBoardTopControlsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State for Backlog board top controls.
|
||||||
|
class _BacklogBoardTopControlsState extends State<BacklogBoardTopControls> {
|
||||||
|
/// Text controller for the search field.
|
||||||
|
late final TextEditingController _searchController;
|
||||||
|
|
||||||
|
/// Initializes text controller state from the current board controller.
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_searchController = TextEditingController(
|
||||||
|
text: widget.controller.searchText ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Releases text controller resources.
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 activeFilterCount =
|
||||||
|
widget.controller.filters.length +
|
||||||
|
widget.controller.boardFilters.activeFilterCount;
|
||||||
|
final searchText = widget.controller.searchText ?? '';
|
||||||
|
if (searchText.isEmpty && _searchController.text.isNotEmpty) {
|
||||||
|
_searchController.clear();
|
||||||
|
}
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 44,
|
||||||
|
child: TextField(
|
||||||
|
key: const ValueKey('backlog-search-field'),
|
||||||
|
controller: _searchController,
|
||||||
|
onChanged: (value) {
|
||||||
|
unawaited(widget.controller.updateSearchText(value));
|
||||||
|
},
|
||||||
|
style: const TextStyle(color: FocusFlowTokens.textPrimary),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
prefixIcon: const Icon(
|
||||||
|
Icons.search,
|
||||||
|
color: FocusFlowTokens.textMuted,
|
||||||
|
),
|
||||||
|
suffixIcon: searchText.isEmpty
|
||||||
|
? null
|
||||||
|
: IconButton(
|
||||||
|
key: const ValueKey('backlog-search-clear'),
|
||||||
|
tooltip: 'Clear search',
|
||||||
|
onPressed: () {
|
||||||
|
_searchController.clear();
|
||||||
|
unawaited(widget.controller.updateSearchText(null));
|
||||||
|
},
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.close,
|
||||||
|
color: FocusFlowTokens.textMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
hintText: 'Search backlog...',
|
||||||
|
hintStyle: const TextStyle(color: FocusFlowTokens.textFaint),
|
||||||
|
filled: true,
|
||||||
|
fillColor: FocusFlowTokens.panelBackground,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: const BorderSide(
|
||||||
|
color: FocusFlowTokens.subtleBorder,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: const BorderSide(
|
||||||
|
color: FocusFlowTokens.accentMagenta,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
_BacklogControlButton(
|
||||||
|
key: const ValueKey('backlog-filter-button'),
|
||||||
|
icon: Icons.filter_alt_outlined,
|
||||||
|
label: activeFilterCount == 0
|
||||||
|
? 'Filters'
|
||||||
|
: 'Filters $activeFilterCount',
|
||||||
|
onPressed: () {
|
||||||
|
unawaited(_showFilterDialog(context, widget.controller));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
_BacklogMenuControlButton<BacklogSortKey>(
|
||||||
|
key: const ValueKey('backlog-sort-menu-button'),
|
||||||
|
icon: Icons.tune,
|
||||||
|
label: 'Sort: ${_sortLabel(widget.controller.sortKey)}',
|
||||||
|
width: 158,
|
||||||
|
selectedValue: widget.controller.sortKey,
|
||||||
|
options: _sortOptions,
|
||||||
|
onSelected: (value) {
|
||||||
|
unawaited(widget.controller.updateSortKey(value));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
_BacklogMenuControlButton<BacklogBoardGroupMode>(
|
||||||
|
key: const ValueKey('backlog-group-menu-button'),
|
||||||
|
icon: Icons.dashboard_customize_outlined,
|
||||||
|
label: 'Group: ${_groupLabel(widget.controller.groupMode)}',
|
||||||
|
width: 156,
|
||||||
|
selectedValue: widget.controller.groupMode,
|
||||||
|
options: _groupOptions,
|
||||||
|
onSelected: (value) {
|
||||||
|
unawaited(widget.controller.updateGroupMode(value));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 18),
|
||||||
|
FilledButton(
|
||||||
|
key: const ValueKey('backlog-new-task-button'),
|
||||||
|
onPressed: () {},
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
fixedSize: const Size(146, 44),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
backgroundColor: FocusFlowTokens.accentMagenta,
|
||||||
|
foregroundColor: FocusFlowTokens.textPrimary,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.add, size: 18),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('New Task'),
|
||||||
|
SizedBox(width: 4),
|
||||||
|
Icon(Icons.keyboard_arrow_down, size: 18),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One segment in the Backlog mode toggle.
|
||||||
|
class _ModeSegment extends StatelessWidget {
|
||||||
|
/// Creates a mode segment.
|
||||||
|
const _ModeSegment({required this.label, required this.active});
|
||||||
|
|
||||||
|
/// Segment label.
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// Whether this segment is selected.
|
||||||
|
final bool active;
|
||||||
|
|
||||||
|
/// 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: 78,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: active ? FocusFlowTokens.glassPanelStrong : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: active
|
||||||
|
? Border.all(color: FocusFlowTokens.navBorder)
|
||||||
|
: Border.all(color: Colors.transparent),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: active
|
||||||
|
? FocusFlowTokens.accentMagenta
|
||||||
|
: FocusFlowTokens.textPrimary,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact outlined control button used by the Backlog shell.
|
||||||
|
class _BacklogControlButton extends StatelessWidget {
|
||||||
|
/// Creates a Backlog control button.
|
||||||
|
const _BacklogControlButton({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.onPressed,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Leading icon for the control.
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
/// Control label.
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// Callback invoked when the control is pressed.
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
|
/// 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 OutlinedButton.icon(
|
||||||
|
onPressed: onPressed,
|
||||||
|
icon: Icon(icon, size: 18),
|
||||||
|
label: Text(label),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
fixedSize: const Size(132, 44),
|
||||||
|
foregroundColor: FocusFlowTokens.textPrimary,
|
||||||
|
side: const BorderSide(color: FocusFlowTokens.subtleBorder),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic popup menu button styled like the Backlog control row.
|
||||||
|
class _BacklogMenuControlButton<T> extends StatelessWidget {
|
||||||
|
/// Creates a popup menu control.
|
||||||
|
const _BacklogMenuControlButton({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.width,
|
||||||
|
required this.selectedValue,
|
||||||
|
required this.options,
|
||||||
|
required this.onSelected,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Leading icon for the control.
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
/// Button label.
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// Fixed button width.
|
||||||
|
final double width;
|
||||||
|
|
||||||
|
/// Currently selected value.
|
||||||
|
final T selectedValue;
|
||||||
|
|
||||||
|
/// Menu options.
|
||||||
|
final List<_MenuOption<T>> options;
|
||||||
|
|
||||||
|
/// Selection callback.
|
||||||
|
final ValueChanged<T> onSelected;
|
||||||
|
|
||||||
|
/// 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 PopupMenuButton<T>(
|
||||||
|
color: FocusFlowTokens.elevatedPanel,
|
||||||
|
onSelected: onSelected,
|
||||||
|
itemBuilder: (context) {
|
||||||
|
return [
|
||||||
|
for (final option in options)
|
||||||
|
PopupMenuItem<T>(
|
||||||
|
value: option.value,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
option.value == selectedValue
|
||||||
|
? Icons.check
|
||||||
|
: Icons.circle_outlined,
|
||||||
|
size: 16,
|
||||||
|
color: option.value == selectedValue
|
||||||
|
? FocusFlowTokens.accentMagenta
|
||||||
|
: FocusFlowTokens.textFaint,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
option.label,
|
||||||
|
style: const TextStyle(color: FocusFlowTokens.textPrimary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
width: width,
|
||||||
|
height: 44,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: FocusFlowTokens.subtleBorder),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: FocusFlowTokens.textPrimary),
|
||||||
|
const SizedBox(width: 9),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: FocusFlowTokens.textPrimary,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
const Icon(
|
||||||
|
Icons.keyboard_arrow_down,
|
||||||
|
color: FocusFlowTokens.textMuted,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Menu option for a Backlog popup control.
|
||||||
|
class _MenuOption<T> {
|
||||||
|
/// Creates a menu option.
|
||||||
|
const _MenuOption({required this.value, required this.label});
|
||||||
|
|
||||||
|
/// Option value.
|
||||||
|
final T value;
|
||||||
|
|
||||||
|
/// Display label.
|
||||||
|
final String label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result returned from the filter dialog.
|
||||||
|
class _FilterDialogResult {
|
||||||
|
/// Creates a filter dialog result.
|
||||||
|
_FilterDialogResult({
|
||||||
|
required List<BacklogFilter> legacyFilters,
|
||||||
|
required this.boardFilters,
|
||||||
|
}) : legacyFilters = List<BacklogFilter>.unmodifiable(legacyFilters);
|
||||||
|
|
||||||
|
/// Legacy backlog filters to apply.
|
||||||
|
final List<BacklogFilter> legacyFilters;
|
||||||
|
|
||||||
|
/// Board-specific filters to apply.
|
||||||
|
final BacklogBoardFilterSelection boardFilters;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dialog for editing Backlog board filters.
|
||||||
|
class _BacklogFilterDialog extends StatefulWidget {
|
||||||
|
/// Creates a Backlog filter dialog.
|
||||||
|
const _BacklogFilterDialog({
|
||||||
|
required this.initialLegacyFilters,
|
||||||
|
required this.initialBoardFilters,
|
||||||
|
required this.projectOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Legacy filters active before the dialog opened.
|
||||||
|
final List<BacklogFilter> initialLegacyFilters;
|
||||||
|
|
||||||
|
/// Board filters active before the dialog opened.
|
||||||
|
final BacklogBoardFilterSelection initialBoardFilters;
|
||||||
|
|
||||||
|
/// Project options available to the board.
|
||||||
|
final List<ProjectOptionReadModel> projectOptions;
|
||||||
|
|
||||||
|
/// 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<_BacklogFilterDialog> createState() => _BacklogFilterDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State for the Backlog filter dialog.
|
||||||
|
class _BacklogFilterDialogState extends State<_BacklogFilterDialog> {
|
||||||
|
/// Selected priority groups.
|
||||||
|
late Set<BacklogBoardPriorityFilter> _priorityGroups;
|
||||||
|
|
||||||
|
/// Selected project ids.
|
||||||
|
late Set<String> _projectIds;
|
||||||
|
|
||||||
|
/// Selected duration buckets.
|
||||||
|
late Set<BacklogBoardDurationFilter> _durationBuckets;
|
||||||
|
|
||||||
|
/// Selected board buckets.
|
||||||
|
late Set<BacklogBoardBucket> _buckets;
|
||||||
|
|
||||||
|
/// Whether missing-duration tasks are selected.
|
||||||
|
late bool _missingDurationOnly;
|
||||||
|
|
||||||
|
/// Whether no-reward tasks are selected.
|
||||||
|
late bool _noRewardSetOnly;
|
||||||
|
|
||||||
|
/// Initializes mutable filter selections.
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_priorityGroups = {...widget.initialBoardFilters.priorityGroups};
|
||||||
|
_projectIds = {...widget.initialBoardFilters.projectIds};
|
||||||
|
_durationBuckets = {...widget.initialBoardFilters.durationBuckets};
|
||||||
|
_buckets = {...widget.initialBoardFilters.buckets};
|
||||||
|
_missingDurationOnly = widget.initialBoardFilters.missingDurationOnly;
|
||||||
|
_noRewardSetOnly = widget.initialLegacyFilters.contains(
|
||||||
|
BacklogFilter.noRewardSet,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 Dialog(
|
||||||
|
backgroundColor: FocusFlowTokens.elevatedPanel,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
side: const BorderSide(color: FocusFlowTokens.subtleBorder),
|
||||||
|
),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 560, maxHeight: 680),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(22, 20, 22, 16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.filter_alt_outlined,
|
||||||
|
color: FocusFlowTokens.accentMagenta,
|
||||||
|
),
|
||||||
|
SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Filters',
|
||||||
|
style: TextStyle(
|
||||||
|
color: FocusFlowTokens.textPrimary,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_FilterSection(
|
||||||
|
title: 'Priority',
|
||||||
|
children: [
|
||||||
|
_checkbox(
|
||||||
|
label: 'High priority',
|
||||||
|
value: _priorityGroups.contains(
|
||||||
|
BacklogBoardPriorityFilter.high,
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
_toggleSet(
|
||||||
|
_priorityGroups,
|
||||||
|
BacklogBoardPriorityFilter.high,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_checkbox(
|
||||||
|
label: 'Medium priority',
|
||||||
|
value: _priorityGroups.contains(
|
||||||
|
BacklogBoardPriorityFilter.medium,
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
_toggleSet(
|
||||||
|
_priorityGroups,
|
||||||
|
BacklogBoardPriorityFilter.medium,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_checkbox(
|
||||||
|
label: 'Low priority',
|
||||||
|
value: _priorityGroups.contains(
|
||||||
|
BacklogBoardPriorityFilter.low,
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
_toggleSet(
|
||||||
|
_priorityGroups,
|
||||||
|
BacklogBoardPriorityFilter.low,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_FilterSection(
|
||||||
|
title: 'Project',
|
||||||
|
children: widget.projectOptions.isEmpty
|
||||||
|
? const [
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Text(
|
||||||
|
'No projects loaded',
|
||||||
|
style: TextStyle(
|
||||||
|
color: FocusFlowTokens.textMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
for (final project in widget.projectOptions)
|
||||||
|
_checkbox(
|
||||||
|
label: project.name,
|
||||||
|
dotColor:
|
||||||
|
BacklogBoardVisualTokens.projectColor(
|
||||||
|
project.colorToken,
|
||||||
|
),
|
||||||
|
value: _projectIds.contains(
|
||||||
|
project.projectId,
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
_toggleSet(
|
||||||
|
_projectIds,
|
||||||
|
project.projectId,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_FilterSection(
|
||||||
|
title: 'Duration',
|
||||||
|
children: [
|
||||||
|
_checkbox(
|
||||||
|
label: '0-30 min',
|
||||||
|
value: _durationBuckets.contains(
|
||||||
|
BacklogBoardDurationFilter.zeroToThirty,
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
_toggleSet(
|
||||||
|
_durationBuckets,
|
||||||
|
BacklogBoardDurationFilter.zeroToThirty,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_checkbox(
|
||||||
|
label: '31-60 min',
|
||||||
|
value: _durationBuckets.contains(
|
||||||
|
BacklogBoardDurationFilter.thirtyToSixty,
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
_toggleSet(
|
||||||
|
_durationBuckets,
|
||||||
|
BacklogBoardDurationFilter.thirtyToSixty,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_checkbox(
|
||||||
|
label: '61-120 min',
|
||||||
|
value: _durationBuckets.contains(
|
||||||
|
BacklogBoardDurationFilter.sixtyToOneTwenty,
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
_toggleSet(
|
||||||
|
_durationBuckets,
|
||||||
|
BacklogBoardDurationFilter.sixtyToOneTwenty,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_checkbox(
|
||||||
|
label: '120+ min',
|
||||||
|
value: _durationBuckets.contains(
|
||||||
|
BacklogBoardDurationFilter.overOneTwenty,
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
_toggleSet(
|
||||||
|
_durationBuckets,
|
||||||
|
BacklogBoardDurationFilter.overOneTwenty,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_checkbox(
|
||||||
|
label: 'Missing duration',
|
||||||
|
value: _missingDurationOnly,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_missingDurationOnly = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_FilterSection(
|
||||||
|
title: 'Column',
|
||||||
|
children: [
|
||||||
|
for (final bucket in BacklogBoardBucket.values)
|
||||||
|
_checkbox(
|
||||||
|
label: _bucketLabel(bucket),
|
||||||
|
dotColor: BacklogBoardVisualTokens.bucketAccent(
|
||||||
|
bucket,
|
||||||
|
),
|
||||||
|
value: _buckets.contains(bucket),
|
||||||
|
onChanged: (value) {
|
||||||
|
_toggleSet(_buckets, bucket, value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_FilterSection(
|
||||||
|
title: 'Cleanup',
|
||||||
|
children: [
|
||||||
|
_checkbox(
|
||||||
|
label: 'No reward set',
|
||||||
|
value: _noRewardSetOnly,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_noRewardSetOnly = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop(
|
||||||
|
_FilterDialogResult(
|
||||||
|
legacyFilters: const [],
|
||||||
|
boardFilters:
|
||||||
|
const BacklogBoardFilterSelection.empty(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Clear'),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop(_result());
|
||||||
|
},
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: FocusFlowTokens.accentMagenta,
|
||||||
|
foregroundColor: FocusFlowTokens.textPrimary,
|
||||||
|
),
|
||||||
|
child: const Text('Apply'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a styled filter checkbox row.
|
||||||
|
Widget _checkbox({
|
||||||
|
required String label,
|
||||||
|
required bool value,
|
||||||
|
required ValueChanged<bool> onChanged,
|
||||||
|
Color? dotColor,
|
||||||
|
}) {
|
||||||
|
return Material(
|
||||||
|
type: MaterialType.transparency,
|
||||||
|
child: CheckboxListTile(
|
||||||
|
dense: true,
|
||||||
|
value: value,
|
||||||
|
onChanged: (next) {
|
||||||
|
onChanged(next ?? false);
|
||||||
|
},
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
activeColor: FocusFlowTokens.accentMagenta,
|
||||||
|
checkColor: FocusFlowTokens.textPrimary,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
if (dotColor != null) ...[
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: dotColor,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(color: FocusFlowTokens.textPrimary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggles [value] in [set] and rebuilds the dialog.
|
||||||
|
void _toggleSet<T>(Set<T> set, T value, bool selected) {
|
||||||
|
setState(() {
|
||||||
|
if (selected) {
|
||||||
|
set.add(value);
|
||||||
|
} else {
|
||||||
|
set.remove(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the immutable result for the current dialog selections.
|
||||||
|
_FilterDialogResult _result() {
|
||||||
|
return _FilterDialogResult(
|
||||||
|
legacyFilters: [if (_noRewardSetOnly) BacklogFilter.noRewardSet],
|
||||||
|
boardFilters: BacklogBoardFilterSelection(
|
||||||
|
priorityGroups: _priorityGroups,
|
||||||
|
projectIds: _projectIds,
|
||||||
|
durationBuckets: _durationBuckets,
|
||||||
|
buckets: _buckets,
|
||||||
|
missingDurationOnly: _missingDurationOnly,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Section wrapper inside the filter dialog.
|
||||||
|
class _FilterSection extends StatelessWidget {
|
||||||
|
/// Creates a filter section.
|
||||||
|
const _FilterSection({required this.title, required this.children});
|
||||||
|
|
||||||
|
/// Section title.
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
/// Section children.
|
||||||
|
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 Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: FocusFlowTokens.panelBackground.withValues(alpha: 0.72),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: FocusFlowTokens.subtleBorder.withValues(alpha: 0.72),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 12, 14, 10),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: FocusFlowTokens.textPrimary,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
...children,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the filter dialog and applies the returned result.
|
||||||
|
Future<void> _showFilterDialog(
|
||||||
|
BuildContext context,
|
||||||
|
BacklogBoardController controller,
|
||||||
|
) async {
|
||||||
|
final result = await showDialog<_FilterDialogResult>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return _BacklogFilterDialog(
|
||||||
|
initialLegacyFilters: controller.filters,
|
||||||
|
initialBoardFilters: controller.boardFilters,
|
||||||
|
projectOptions: _projectOptionsForState(controller.state),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (result == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await controller.updateFilterState(
|
||||||
|
filters: result.legacyFilters,
|
||||||
|
boardFilters: result.boardFilters,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns project options from the current ready or empty board state.
|
||||||
|
List<ProjectOptionReadModel> _projectOptionsForState(
|
||||||
|
BacklogBoardScreenState state,
|
||||||
|
) {
|
||||||
|
return switch (state) {
|
||||||
|
BacklogBoardReady(:final data) => data.board.projectOptions,
|
||||||
|
BacklogBoardEmpty(:final data) => data.board.projectOptions,
|
||||||
|
BacklogBoardLoading() ||
|
||||||
|
BacklogBoardFailure() => const <ProjectOptionReadModel>[],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display label for [sortKey].
|
||||||
|
String _sortLabel(BacklogSortKey sortKey) {
|
||||||
|
return switch (sortKey) {
|
||||||
|
BacklogSortKey.custom => 'Custom',
|
||||||
|
BacklogSortKey.priority => 'Priority',
|
||||||
|
BacklogSortKey.rewardVsEffort => 'Reward',
|
||||||
|
BacklogSortKey.age => 'Age',
|
||||||
|
BacklogSortKey.project => 'Project',
|
||||||
|
BacklogSortKey.timesPushed => 'Pushed',
|
||||||
|
BacklogSortKey.duration => 'Duration',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display label for [groupMode].
|
||||||
|
String _groupLabel(BacklogBoardGroupMode groupMode) {
|
||||||
|
return switch (groupMode) {
|
||||||
|
BacklogBoardGroupMode.none => 'None',
|
||||||
|
BacklogBoardGroupMode.project => 'Project',
|
||||||
|
BacklogBoardGroupMode.priority => 'Priority',
|
||||||
|
BacklogBoardGroupMode.duration => 'Duration',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display label for [bucket].
|
||||||
|
String _bucketLabel(BacklogBoardBucket bucket) {
|
||||||
|
return switch (bucket) {
|
||||||
|
BacklogBoardBucket.doNext => 'Do Next',
|
||||||
|
BacklogBoardBucket.needTimeBlock => 'Need Time Block',
|
||||||
|
BacklogBoardBucket.breakUpFirst => 'Break Up First',
|
||||||
|
BacklogBoardBucket.notNow => 'Not Now',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sort menu options in display order.
|
||||||
|
const _sortOptions = [
|
||||||
|
_MenuOption(value: BacklogSortKey.custom, label: 'Custom'),
|
||||||
|
_MenuOption(value: BacklogSortKey.priority, label: 'Priority'),
|
||||||
|
_MenuOption(value: BacklogSortKey.rewardVsEffort, label: 'Reward vs Effort'),
|
||||||
|
_MenuOption(value: BacklogSortKey.age, label: 'Age'),
|
||||||
|
_MenuOption(value: BacklogSortKey.project, label: 'Project'),
|
||||||
|
_MenuOption(value: BacklogSortKey.timesPushed, label: 'Times Pushed'),
|
||||||
|
_MenuOption(value: BacklogSortKey.duration, label: 'Duration'),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Group menu options in display order.
|
||||||
|
const _groupOptions = [
|
||||||
|
_MenuOption(value: BacklogBoardGroupMode.none, label: 'None'),
|
||||||
|
_MenuOption(value: BacklogBoardGroupMode.project, label: 'Project'),
|
||||||
|
_MenuOption(value: BacklogBoardGroupMode.priority, label: 'Priority'),
|
||||||
|
_MenuOption(value: BacklogBoardGroupMode.duration, label: 'Duration'),
|
||||||
|
];
|
||||||
|
|
@ -13,6 +13,7 @@ import '../../controllers/backlog_board_controller.dart';
|
||||||
import '../../models/backlog/backlog_board_screen_data.dart';
|
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';
|
||||||
|
|
||||||
/// 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 {
|
||||||
|
|
@ -108,7 +109,7 @@ class _BacklogFrame extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
_BacklogHeader(taskCountLabel: taskCountLabel),
|
_BacklogHeader(taskCountLabel: taskCountLabel),
|
||||||
const SizedBox(height: 22),
|
const SizedBox(height: 22),
|
||||||
_BacklogControls(controller: controller),
|
BacklogBoardTopControls(controller: controller),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
Expanded(child: body),
|
Expanded(child: body),
|
||||||
],
|
],
|
||||||
|
|
@ -166,205 +167,12 @@ class _BacklogHeader extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 20),
|
const SizedBox(width: 20),
|
||||||
const _ModeAndSettingsControls(),
|
const BacklogModeAndSettingsControls(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Header controls for Compact/Board mode and settings.
|
|
||||||
class _ModeAndSettingsControls extends StatelessWidget {
|
|
||||||
/// Creates mode and settings controls.
|
|
||||||
const _ModeAndSettingsControls();
|
|
||||||
|
|
||||||
/// 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(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
height: 42,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
border: Border.all(color: FocusFlowTokens.subtleBorder),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: const [
|
|
||||||
_ModeSegment(label: 'Compact', active: false),
|
|
||||||
_ModeSegment(label: 'Board', active: true),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 18),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: () {},
|
|
||||||
icon: const Icon(Icons.wb_sunny_outlined, size: 18),
|
|
||||||
label: const Text('Settings'),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
fixedSize: const Size(122, 42),
|
|
||||||
foregroundColor: FocusFlowTokens.textPrimary,
|
|
||||||
side: const BorderSide(color: FocusFlowTokens.subtleBorder),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One segment in the Backlog mode toggle.
|
|
||||||
class _ModeSegment extends StatelessWidget {
|
|
||||||
/// Creates a mode segment.
|
|
||||||
const _ModeSegment({required this.label, required this.active});
|
|
||||||
|
|
||||||
/// Segment label.
|
|
||||||
final String label;
|
|
||||||
|
|
||||||
/// Whether this segment is selected.
|
|
||||||
final bool active;
|
|
||||||
|
|
||||||
/// 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: 78,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: active ? FocusFlowTokens.glassPanelStrong : Colors.transparent,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
border: active
|
|
||||||
? Border.all(color: FocusFlowTokens.navBorder)
|
|
||||||
: Border.all(color: Colors.transparent),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(
|
|
||||||
color: active
|
|
||||||
? FocusFlowTokens.accentMagenta
|
|
||||||
: FocusFlowTokens.textPrimary,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Search, filter, sort, group, and new-task controls.
|
|
||||||
class _BacklogControls extends StatelessWidget {
|
|
||||||
/// Creates Backlog controls.
|
|
||||||
const _BacklogControls({required this.controller});
|
|
||||||
|
|
||||||
/// Controller updated by shell controls.
|
|
||||||
final BacklogBoardController controller;
|
|
||||||
|
|
||||||
/// 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: SizedBox(
|
|
||||||
height: 44,
|
|
||||||
child: TextField(
|
|
||||||
key: const ValueKey('backlog-search-field'),
|
|
||||||
onSubmitted: (value) {
|
|
||||||
unawaited(controller.updateSearchText(value));
|
|
||||||
},
|
|
||||||
style: const TextStyle(color: FocusFlowTokens.textPrimary),
|
|
||||||
decoration: InputDecoration(
|
|
||||||
prefixIcon: const Icon(
|
|
||||||
Icons.search,
|
|
||||||
color: FocusFlowTokens.textMuted,
|
|
||||||
),
|
|
||||||
hintText: 'Search backlog...',
|
|
||||||
hintStyle: const TextStyle(color: FocusFlowTokens.textFaint),
|
|
||||||
filled: true,
|
|
||||||
fillColor: FocusFlowTokens.panelBackground,
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
enabledBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
borderSide: const BorderSide(
|
|
||||||
color: FocusFlowTokens.subtleBorder,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
focusedBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
borderSide: const BorderSide(
|
|
||||||
color: FocusFlowTokens.accentMagenta,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
const _BacklogControlButton(
|
|
||||||
icon: Icons.filter_alt_outlined,
|
|
||||||
label: 'Filters',
|
|
||||||
),
|
|
||||||
const SizedBox(width: 14),
|
|
||||||
const _BacklogControlButton(icon: Icons.tune, label: 'Sort: Custom'),
|
|
||||||
const SizedBox(width: 14),
|
|
||||||
const _BacklogControlButton(
|
|
||||||
icon: Icons.dashboard_customize_outlined,
|
|
||||||
label: 'Group: None',
|
|
||||||
),
|
|
||||||
const SizedBox(width: 18),
|
|
||||||
FilledButton.icon(
|
|
||||||
key: const ValueKey('backlog-new-task-button'),
|
|
||||||
onPressed: () {},
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
label: const Text('New Task'),
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
fixedSize: const Size(146, 44),
|
|
||||||
backgroundColor: FocusFlowTokens.accentMagenta,
|
|
||||||
foregroundColor: FocusFlowTokens.textPrimary,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compact outlined control button used by the Backlog shell.
|
|
||||||
class _BacklogControlButton extends StatelessWidget {
|
|
||||||
/// Creates a Backlog control button.
|
|
||||||
const _BacklogControlButton({required this.icon, required this.label});
|
|
||||||
|
|
||||||
/// Leading icon for the control.
|
|
||||||
final IconData icon;
|
|
||||||
|
|
||||||
/// Control 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 OutlinedButton.icon(
|
|
||||||
onPressed: () {},
|
|
||||||
icon: Icon(icon, size: 18),
|
|
||||||
label: Text(label),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
fixedSize: const Size(138, 44),
|
|
||||||
foregroundColor: FocusFlowTokens.textPrimary,
|
|
||||||
side: const BorderSide(color: FocusFlowTokens.subtleBorder),
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Body shown while the Backlog board loads.
|
/// Body shown while the Backlog board loads.
|
||||||
class _BacklogLoadingBody extends StatelessWidget {
|
class _BacklogLoadingBody extends StatelessWidget {
|
||||||
/// Creates a loading body.
|
/// Creates a loading body.
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ void main() {
|
||||||
await controller.load();
|
await controller.load();
|
||||||
|
|
||||||
expect(capturedRequest!.localDate, CivilDate(2026, 7, 7));
|
expect(capturedRequest!.localDate, CivilDate(2026, 7, 7));
|
||||||
|
expect(capturedRequest!.sortKey, BacklogSortKey.custom);
|
||||||
|
expect(capturedRequest!.boardFilters.isEmpty, isTrue);
|
||||||
final state = controller.state;
|
final state = controller.state;
|
||||||
expect(state, isA<BacklogBoardReady>());
|
expect(state, isA<BacklogBoardReady>());
|
||||||
expect((state as BacklogBoardReady).data.summary.totalTaskCount, 1);
|
expect((state as BacklogBoardReady).data.summary.totalTaskCount, 1);
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,114 @@ void main() {
|
||||||
final border = decoration.border! as Border;
|
final border = decoration.border! as Border;
|
||||||
expect(border.top.color, FocusFlowTokens.accentMagenta);
|
expect(border.top.color, FocusFlowTokens.accentMagenta);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('controls update board read requests', (tester) async {
|
||||||
|
final requests = <BacklogBoardReadRequest>[];
|
||||||
|
final item = _boardItemWithChildren();
|
||||||
|
final board = _boardWithItem(
|
||||||
|
item,
|
||||||
|
projectOptions: const [
|
||||||
|
ProjectOptionReadModel(
|
||||||
|
projectId: 'work',
|
||||||
|
name: 'Work',
|
||||||
|
colorToken: 'project-work',
|
||||||
|
isArchived: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final selectedDates = SelectedDateController(
|
||||||
|
initialDate: CivilDate(2026, 7, 7),
|
||||||
|
);
|
||||||
|
final controller = BacklogBoardController(
|
||||||
|
readBoard: (request) async {
|
||||||
|
requests.add(request);
|
||||||
|
return ApplicationResult.success(board);
|
||||||
|
},
|
||||||
|
readTaskDetail: (taskId, localDate) async {
|
||||||
|
return ApplicationResult.success(_detailFor(item));
|
||||||
|
},
|
||||||
|
selectedDates: selectedDates,
|
||||||
|
dateLabelFor: (date) => date.toIsoString(),
|
||||||
|
);
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
addTearDown(selectedDates.dispose);
|
||||||
|
|
||||||
|
await controller.load();
|
||||||
|
await _pumpBacklogScreen(tester, controller);
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const ValueKey('backlog-search-field')),
|
||||||
|
'course',
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(requests.last.searchText, 'course');
|
||||||
|
expect(find.byKey(const ValueKey('backlog-search-clear')), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const ValueKey('backlog-search-clear')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(requests.last.searchText, isNull);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const ValueKey('backlog-sort-menu-button')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Reward vs Effort').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(requests.last.sortKey, BacklogSortKey.rewardVsEffort);
|
||||||
|
expect(find.text('Sort: Reward'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const ValueKey('backlog-group-menu-button')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Project').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(requests.last.groupMode, BacklogBoardGroupMode.project);
|
||||||
|
expect(find.text('Group: Project'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const ValueKey('backlog-filter-button')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
Future<void> tapFilterCheckbox(String label) async {
|
||||||
|
final checkbox = find.widgetWithText(CheckboxListTile, label);
|
||||||
|
await tester.ensureVisible(checkbox);
|
||||||
|
await tester.tap(checkbox);
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
await tapFilterCheckbox('High priority');
|
||||||
|
await tapFilterCheckbox('Work');
|
||||||
|
await tapFilterCheckbox('0-30 min');
|
||||||
|
await tapFilterCheckbox('Need Time Block');
|
||||||
|
await tapFilterCheckbox('No reward set');
|
||||||
|
await tester.tap(find.text('Apply'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(requests.last.filters, [BacklogFilter.noRewardSet]);
|
||||||
|
expect(
|
||||||
|
requests.last.boardFilters.priorityGroups,
|
||||||
|
contains(BacklogBoardPriorityFilter.high),
|
||||||
|
);
|
||||||
|
expect(requests.last.boardFilters.projectIds, contains('work'));
|
||||||
|
expect(
|
||||||
|
requests.last.boardFilters.durationBuckets,
|
||||||
|
contains(BacklogBoardDurationFilter.zeroToThirty),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
requests.last.boardFilters.buckets,
|
||||||
|
contains(BacklogBoardBucket.needTimeBlock),
|
||||||
|
);
|
||||||
|
expect(find.text('Filters 5'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const ValueKey('backlog-filter-button')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Clear'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(requests.last.filters, isEmpty);
|
||||||
|
expect(requests.last.boardFilters.isEmpty, isTrue);
|
||||||
|
expect(find.text('Filters'), findsOneWidget);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pumps [controller] inside the dark FocusFlow app shell test harness.
|
/// Pumps [controller] inside the dark FocusFlow app shell test harness.
|
||||||
|
|
@ -141,7 +249,10 @@ BacklogBoardItemReadModel _boardItemWithChildren() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds a Backlog board containing [item] and the other stable empty columns.
|
/// Builds a Backlog board containing [item] and the other stable empty columns.
|
||||||
BacklogBoardQueryResult _boardWithItem(BacklogBoardItemReadModel item) {
|
BacklogBoardQueryResult _boardWithItem(
|
||||||
|
BacklogBoardItemReadModel item, {
|
||||||
|
List<ProjectOptionReadModel> projectOptions = const [],
|
||||||
|
}) {
|
||||||
return BacklogBoardQueryResult(
|
return BacklogBoardQueryResult(
|
||||||
ownerId: 'owner-1',
|
ownerId: 'owner-1',
|
||||||
localDate: CivilDate(2026, 7, 7),
|
localDate: CivilDate(2026, 7, 7),
|
||||||
|
|
@ -208,7 +319,7 @@ BacklogBoardQueryResult _boardWithItem(BacklogBoardItemReadModel item) {
|
||||||
),
|
),
|
||||||
planningTip: 'Pick one task.',
|
planningTip: 'Pick one task.',
|
||||||
),
|
),
|
||||||
projectOptions: const [],
|
projectOptions: projectOptions,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import '../persistence/repositories.dart';
|
||||||
import '../scheduling/scheduling_engine.dart';
|
import '../scheduling/scheduling_engine.dart';
|
||||||
part 'application_management/codes/application_management_command_code.dart';
|
part 'application_management/codes/application_management_command_code.dart';
|
||||||
part 'application_management/codes/owner_settings_warning_code.dart';
|
part 'application_management/codes/owner_settings_warning_code.dart';
|
||||||
|
part 'application_management/requests/backlog_board_filter_selection.dart';
|
||||||
part 'application_management/requests/backlog_board_group_mode.dart';
|
part 'application_management/requests/backlog_board_group_mode.dart';
|
||||||
part 'application_management/requests/get_backlog_board_request.dart';
|
part 'application_management/requests/get_backlog_board_request.dart';
|
||||||
part 'application_management/requests/get_backlog_request.dart';
|
part 'application_management/requests/get_backlog_request.dart';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
part of '../../application_management.dart';
|
||||||
|
|
||||||
|
/// Priority groups exposed by the Backlog board filter UI.
|
||||||
|
enum BacklogBoardPriorityFilter {
|
||||||
|
/// High and very high priority tasks.
|
||||||
|
high,
|
||||||
|
|
||||||
|
/// Medium priority tasks.
|
||||||
|
medium,
|
||||||
|
|
||||||
|
/// Low and very low priority tasks.
|
||||||
|
low,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Duration buckets exposed by the Backlog board filter UI.
|
||||||
|
enum BacklogBoardDurationFilter {
|
||||||
|
/// Tasks estimated at thirty minutes or less.
|
||||||
|
zeroToThirty,
|
||||||
|
|
||||||
|
/// Tasks estimated above thirty minutes and up to sixty minutes.
|
||||||
|
thirtyToSixty,
|
||||||
|
|
||||||
|
/// Tasks estimated above sixty minutes and up to one hundred twenty minutes.
|
||||||
|
sixtyToOneTwenty,
|
||||||
|
|
||||||
|
/// Tasks estimated above one hundred twenty minutes.
|
||||||
|
overOneTwenty,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Board-specific filters that do not belong to the legacy list filter enum.
|
||||||
|
class BacklogBoardFilterSelection {
|
||||||
|
/// Creates a board filter selection from user-facing filter groups.
|
||||||
|
BacklogBoardFilterSelection({
|
||||||
|
Iterable<BacklogBoardPriorityFilter> priorityGroups =
|
||||||
|
const <BacklogBoardPriorityFilter>[],
|
||||||
|
Iterable<String> projectIds = const <String>[],
|
||||||
|
Iterable<BacklogBoardDurationFilter> durationBuckets =
|
||||||
|
const <BacklogBoardDurationFilter>[],
|
||||||
|
Iterable<BacklogBoardBucket> buckets = const <BacklogBoardBucket>[],
|
||||||
|
this.missingDurationOnly = false,
|
||||||
|
}) : priorityGroups = Set<BacklogBoardPriorityFilter>.unmodifiable(
|
||||||
|
priorityGroups,
|
||||||
|
),
|
||||||
|
projectIds = Set<String>.unmodifiable(projectIds),
|
||||||
|
durationBuckets = Set<BacklogBoardDurationFilter>.unmodifiable(
|
||||||
|
durationBuckets,
|
||||||
|
),
|
||||||
|
buckets = Set<BacklogBoardBucket>.unmodifiable(buckets);
|
||||||
|
|
||||||
|
/// Creates an empty board filter selection.
|
||||||
|
const BacklogBoardFilterSelection.empty()
|
||||||
|
: priorityGroups = const <BacklogBoardPriorityFilter>{},
|
||||||
|
projectIds = const <String>{},
|
||||||
|
durationBuckets = const <BacklogBoardDurationFilter>{},
|
||||||
|
buckets = const <BacklogBoardBucket>{},
|
||||||
|
missingDurationOnly = false;
|
||||||
|
|
||||||
|
/// Selected priority groups.
|
||||||
|
final Set<BacklogBoardPriorityFilter> priorityGroups;
|
||||||
|
|
||||||
|
/// Selected project ids.
|
||||||
|
final Set<String> projectIds;
|
||||||
|
|
||||||
|
/// Selected duration buckets.
|
||||||
|
final Set<BacklogBoardDurationFilter> durationBuckets;
|
||||||
|
|
||||||
|
/// Selected board buckets.
|
||||||
|
final Set<BacklogBoardBucket> buckets;
|
||||||
|
|
||||||
|
/// Whether only tasks without a duration estimate should be returned.
|
||||||
|
final bool missingDurationOnly;
|
||||||
|
|
||||||
|
/// Whether this selection contains no active board filters.
|
||||||
|
bool get isEmpty {
|
||||||
|
return priorityGroups.isEmpty &&
|
||||||
|
projectIds.isEmpty &&
|
||||||
|
durationBuckets.isEmpty &&
|
||||||
|
buckets.isEmpty &&
|
||||||
|
!missingDurationOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of active filter groups.
|
||||||
|
int get activeFilterCount {
|
||||||
|
return priorityGroups.length +
|
||||||
|
projectIds.length +
|
||||||
|
durationBuckets.length +
|
||||||
|
buckets.length +
|
||||||
|
(missingDurationOnly ? 1 : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,4 +10,10 @@ enum BacklogBoardGroupMode {
|
||||||
|
|
||||||
/// Preserve board buckets while allowing project-aware ordering inside them.
|
/// Preserve board buckets while allowing project-aware ordering inside them.
|
||||||
project,
|
project,
|
||||||
|
|
||||||
|
/// Preserve board buckets while ordering by priority groups inside them.
|
||||||
|
priority,
|
||||||
|
|
||||||
|
/// Preserve board buckets while ordering by duration groups inside them.
|
||||||
|
duration,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,12 @@ class GetBacklogBoardRequest {
|
||||||
required this.localDate,
|
required this.localDate,
|
||||||
this.searchText,
|
this.searchText,
|
||||||
List<BacklogFilter> filters = const <BacklogFilter>[],
|
List<BacklogFilter> filters = const <BacklogFilter>[],
|
||||||
this.sortKey = BacklogSortKey.priority,
|
BacklogBoardFilterSelection? boardFilters,
|
||||||
|
this.sortKey = BacklogSortKey.custom,
|
||||||
this.groupMode = BacklogBoardGroupMode.none,
|
this.groupMode = BacklogBoardGroupMode.none,
|
||||||
}) : filters = List<BacklogFilter>.unmodifiable(filters);
|
}) : filters = List<BacklogFilter>.unmodifiable(filters),
|
||||||
|
boardFilters =
|
||||||
|
boardFilters ?? const BacklogBoardFilterSelection.empty();
|
||||||
|
|
||||||
/// Read context containing owner scope and read instant.
|
/// Read context containing owner scope and read instant.
|
||||||
final ApplicationOperationContext context;
|
final ApplicationOperationContext context;
|
||||||
|
|
@ -27,6 +30,9 @@ class GetBacklogBoardRequest {
|
||||||
/// Optional user-facing backlog filters.
|
/// Optional user-facing backlog filters.
|
||||||
final List<BacklogFilter> filters;
|
final List<BacklogFilter> filters;
|
||||||
|
|
||||||
|
/// Board-specific filters applied after bucket and card metadata resolution.
|
||||||
|
final BacklogBoardFilterSelection boardFilters;
|
||||||
|
|
||||||
/// User-facing sort order used inside each board bucket.
|
/// User-facing sort order used inside each board bucket.
|
||||||
final BacklogSortKey sortKey;
|
final BacklogSortKey sortKey;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,9 @@ class V1ApplicationManagementUseCases {
|
||||||
final ownerId = context.ownerTimeZone.ownerId;
|
final ownerId = context.ownerTimeZone.ownerId;
|
||||||
logger.debug(() => 'Loading backlog board. ownerId=$ownerId '
|
logger.debug(() => 'Loading backlog board. ownerId=$ownerId '
|
||||||
'localDate=${request.localDate.toIsoString()} '
|
'localDate=${request.localDate.toIsoString()} '
|
||||||
'filterCount=${request.filters.length} sortKey=${request.sortKey.name} '
|
'filterCount=${request.filters.length} '
|
||||||
|
'boardFilterCount=${request.boardFilters.activeFilterCount} '
|
||||||
|
'sortKey=${request.sortKey.name} '
|
||||||
'groupMode=${request.groupMode.name}');
|
'groupMode=${request.groupMode.name}');
|
||||||
final settings = await _ownerSettingsOrDefault(
|
final settings = await _ownerSettingsOrDefault(
|
||||||
repositories,
|
repositories,
|
||||||
|
|
@ -107,13 +109,8 @@ class V1ApplicationManagementUseCases {
|
||||||
request: request,
|
request: request,
|
||||||
settings: settings,
|
settings: settings,
|
||||||
);
|
);
|
||||||
final searched = _searchBacklogTasks(
|
|
||||||
tasks: filtered,
|
|
||||||
searchText: request.searchText,
|
|
||||||
projectById: projectById,
|
|
||||||
);
|
|
||||||
final sorted = _sortBoardTasks(
|
final sorted = _sortBoardTasks(
|
||||||
tasks: searched,
|
tasks: filtered,
|
||||||
request: request,
|
request: request,
|
||||||
settings: settings,
|
settings: settings,
|
||||||
projectById: projectById,
|
projectById: projectById,
|
||||||
|
|
@ -131,15 +128,24 @@ class V1ApplicationManagementUseCases {
|
||||||
childTasks: childrenByParentId[task.id] ?? const <Task>[],
|
childTasks: childrenByParentId[task.id] ?? const <Task>[],
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
final searched = _searchBoardItems(
|
||||||
|
items: items,
|
||||||
|
searchText: request.searchText,
|
||||||
|
);
|
||||||
|
final boardFiltered = _filterBoardItems(
|
||||||
|
items: searched,
|
||||||
|
filters: request.boardFilters,
|
||||||
|
);
|
||||||
|
|
||||||
logger.debug(() => 'Backlog board loaded. ownerId=$ownerId '
|
logger.debug(() => 'Backlog board loaded. ownerId=$ownerId '
|
||||||
'candidateCount=${candidates.length} itemCount=${items.length}');
|
'candidateCount=${candidates.length} '
|
||||||
|
'itemCount=${boardFiltered.length}');
|
||||||
return ApplicationResult.success(
|
return ApplicationResult.success(
|
||||||
BacklogBoardQueryResult(
|
BacklogBoardQueryResult(
|
||||||
ownerId: ownerId,
|
ownerId: ownerId,
|
||||||
localDate: request.localDate,
|
localDate: request.localDate,
|
||||||
summary: _summaryForBoardItems(items),
|
summary: _summaryForBoardItems(boardFiltered),
|
||||||
columns: _columnsForBoardItems(items),
|
columns: _columnsForBoardItems(boardFiltered),
|
||||||
projectOptions: _projectOptionsFor(projects),
|
projectOptions: _projectOptionsFor(projects),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -266,30 +272,120 @@ class V1ApplicationManagementUseCases {
|
||||||
return List<Task>.unmodifiable(filtered);
|
return List<Task>.unmodifiable(filtered);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Applies case-insensitive text search over task title, project, and derived tags.
|
/// Applies case-insensitive text search over board card text.
|
||||||
List<Task> _searchBacklogTasks({
|
List<BacklogBoardItemReadModel> _searchBoardItems({
|
||||||
required List<Task> tasks,
|
required List<BacklogBoardItemReadModel> items,
|
||||||
required String? searchText,
|
required String? searchText,
|
||||||
required Map<String, ProjectProfile> projectById,
|
|
||||||
}) {
|
}) {
|
||||||
final query = searchText?.trim().toLowerCase();
|
final query = searchText?.trim().toLowerCase();
|
||||||
if (query == null || query.isEmpty) {
|
if (query == null || query.isEmpty) {
|
||||||
return tasks;
|
return items;
|
||||||
}
|
}
|
||||||
return List<Task>.unmodifiable(
|
return List<BacklogBoardItemReadModel>.unmodifiable(
|
||||||
tasks.where((task) {
|
items.where((item) {
|
||||||
final project = projectById[task.projectId];
|
|
||||||
final haystack = [
|
final haystack = [
|
||||||
task.title,
|
item.title,
|
||||||
task.projectId,
|
if (item.subtitle != null) item.subtitle!,
|
||||||
if (project != null) project.name,
|
item.bucketReason,
|
||||||
..._readOnlyTagLabelsFor(task),
|
item.projectId,
|
||||||
|
item.projectName,
|
||||||
|
...item.tags,
|
||||||
].join(' ').toLowerCase();
|
].join(' ').toLowerCase();
|
||||||
return haystack.contains(query);
|
return haystack.contains(query);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Applies board-specific filters after card bucket metadata has been resolved.
|
||||||
|
List<BacklogBoardItemReadModel> _filterBoardItems({
|
||||||
|
required List<BacklogBoardItemReadModel> items,
|
||||||
|
required BacklogBoardFilterSelection filters,
|
||||||
|
}) {
|
||||||
|
if (filters.isEmpty) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
return List<BacklogBoardItemReadModel>.unmodifiable(
|
||||||
|
items.where((item) {
|
||||||
|
return _matchesPriorityFilters(item, filters.priorityGroups) &&
|
||||||
|
_matchesProjectFilters(item, filters.projectIds) &&
|
||||||
|
_matchesDurationFilters(
|
||||||
|
item,
|
||||||
|
filters.durationBuckets,
|
||||||
|
filters.missingDurationOnly,
|
||||||
|
) &&
|
||||||
|
_matchesBucketFilters(item, filters.buckets);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether [item] matches selected priority groups.
|
||||||
|
bool _matchesPriorityFilters(
|
||||||
|
BacklogBoardItemReadModel item,
|
||||||
|
Set<BacklogBoardPriorityFilter> priorityGroups,
|
||||||
|
) {
|
||||||
|
if (priorityGroups.isEmpty) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return priorityGroups.any((filter) {
|
||||||
|
return switch (filter) {
|
||||||
|
BacklogBoardPriorityFilter.high =>
|
||||||
|
item.priority == PriorityLevel.high ||
|
||||||
|
item.priority == PriorityLevel.veryHigh,
|
||||||
|
BacklogBoardPriorityFilter.medium =>
|
||||||
|
item.priority == PriorityLevel.medium,
|
||||||
|
BacklogBoardPriorityFilter.low => item.priority == PriorityLevel.low ||
|
||||||
|
item.priority == PriorityLevel.veryLow,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether [item] matches selected project ids.
|
||||||
|
bool _matchesProjectFilters(
|
||||||
|
BacklogBoardItemReadModel item,
|
||||||
|
Set<String> projectIds,
|
||||||
|
) {
|
||||||
|
return projectIds.isEmpty || projectIds.contains(item.projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether [item] matches selected duration buckets and missing-duration state.
|
||||||
|
bool _matchesDurationFilters(
|
||||||
|
BacklogBoardItemReadModel item,
|
||||||
|
Set<BacklogBoardDurationFilter> durationBuckets,
|
||||||
|
bool missingDurationOnly,
|
||||||
|
) {
|
||||||
|
final duration = item.durationMinutes;
|
||||||
|
if (missingDurationOnly && duration == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (missingDurationOnly && duration != null && durationBuckets.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (durationBuckets.isEmpty) {
|
||||||
|
return !missingDurationOnly;
|
||||||
|
}
|
||||||
|
if (duration == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return durationBuckets.any((filter) {
|
||||||
|
return switch (filter) {
|
||||||
|
BacklogBoardDurationFilter.zeroToThirty => duration <= 30,
|
||||||
|
BacklogBoardDurationFilter.thirtyToSixty =>
|
||||||
|
duration > 30 && duration <= 60,
|
||||||
|
BacklogBoardDurationFilter.sixtyToOneTwenty =>
|
||||||
|
duration > 60 && duration <= 120,
|
||||||
|
BacklogBoardDurationFilter.overOneTwenty => duration > 120,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether [item] matches selected board buckets.
|
||||||
|
bool _matchesBucketFilters(
|
||||||
|
BacklogBoardItemReadModel item,
|
||||||
|
Set<BacklogBoardBucket> buckets,
|
||||||
|
) {
|
||||||
|
return buckets.isEmpty || buckets.contains(item.bucket);
|
||||||
|
}
|
||||||
|
|
||||||
/// Sorts board tasks using the requested sort and optional project grouping.
|
/// Sorts board tasks using the requested sort and optional project grouping.
|
||||||
List<Task> _sortBoardTasks({
|
List<Task> _sortBoardTasks({
|
||||||
required List<Task> tasks,
|
required List<Task> tasks,
|
||||||
|
|
@ -308,20 +404,72 @@ class V1ApplicationManagementUseCases {
|
||||||
|
|
||||||
final indexed = sorted.asMap().entries.toList(growable: false);
|
final indexed = sorted.asMap().entries.toList(growable: false);
|
||||||
indexed.sort((a, b) {
|
indexed.sort((a, b) {
|
||||||
final projectComparison = _projectNameFor(
|
final groupComparison = _boardGroupComparison(
|
||||||
a.value.projectId,
|
left: a.value,
|
||||||
projectById,
|
right: b.value,
|
||||||
).compareTo(
|
groupMode: request.groupMode,
|
||||||
_projectNameFor(b.value.projectId, projectById),
|
projectById: projectById,
|
||||||
);
|
);
|
||||||
if (projectComparison != 0) {
|
if (groupComparison != 0) {
|
||||||
return projectComparison;
|
return groupComparison;
|
||||||
}
|
}
|
||||||
return a.key.compareTo(b.key);
|
return a.key.compareTo(b.key);
|
||||||
});
|
});
|
||||||
return List<Task>.unmodifiable(indexed.map((entry) => entry.value));
|
return List<Task>.unmodifiable(indexed.map((entry) => entry.value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compares two tasks for board grouping modes that preserve bucket columns.
|
||||||
|
int _boardGroupComparison({
|
||||||
|
required Task left,
|
||||||
|
required Task right,
|
||||||
|
required BacklogBoardGroupMode groupMode,
|
||||||
|
required Map<String, ProjectProfile> projectById,
|
||||||
|
}) {
|
||||||
|
return switch (groupMode) {
|
||||||
|
BacklogBoardGroupMode.none => 0,
|
||||||
|
BacklogBoardGroupMode.project => _projectNameFor(
|
||||||
|
left.projectId,
|
||||||
|
projectById,
|
||||||
|
).compareTo(
|
||||||
|
_projectNameFor(right.projectId, projectById),
|
||||||
|
),
|
||||||
|
BacklogBoardGroupMode.priority =>
|
||||||
|
_priorityGroupRank(right.priority).compareTo(
|
||||||
|
_priorityGroupRank(left.priority),
|
||||||
|
),
|
||||||
|
BacklogBoardGroupMode.duration =>
|
||||||
|
_durationGroupRank(left.durationMinutes).compareTo(
|
||||||
|
_durationGroupRank(right.durationMinutes),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts nullable priority to the board's coarse priority group rank.
|
||||||
|
int _priorityGroupRank(PriorityLevel? priority) {
|
||||||
|
return switch (priority) {
|
||||||
|
PriorityLevel.veryHigh || PriorityLevel.high => 2,
|
||||||
|
PriorityLevel.medium || null => 1,
|
||||||
|
PriorityLevel.low || PriorityLevel.veryLow => 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts nullable duration to the board's coarse duration group rank.
|
||||||
|
int _durationGroupRank(int? durationMinutes) {
|
||||||
|
if (durationMinutes == null) {
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
if (durationMinutes <= 30) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (durationMinutes <= 60) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (durationMinutes <= 120) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
/// Loads direct child task previews for [parentTasks].
|
/// Loads direct child task previews for [parentTasks].
|
||||||
Future<Map<String, List<Task>>> _loadChildrenByParentId({
|
Future<Map<String, List<Task>>> _loadChildrenByParentId({
|
||||||
required ApplicationUnitOfWorkRepositories repositories,
|
required ApplicationUnitOfWorkRepositories repositories,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ part of '../../backlog.dart';
|
||||||
/// example, `rewardVsEffort` maps to a simple derived score instead of a stored
|
/// example, `rewardVsEffort` maps to a simple derived score instead of a stored
|
||||||
/// field. Persistence can later index the underlying fields if needed.
|
/// field. Persistence can later index the underlying fields if needed.
|
||||||
enum BacklogSortKey {
|
enum BacklogSortKey {
|
||||||
|
/// Preserve the source/read-model order supplied by persistence.
|
||||||
|
custom,
|
||||||
|
|
||||||
/// Highest priority first.
|
/// Highest priority first.
|
||||||
priority,
|
priority,
|
||||||
|
|
||||||
|
|
@ -24,4 +27,7 @@ enum BacklogSortKey {
|
||||||
|
|
||||||
/// Most frequently pushed tasks first.
|
/// Most frequently pushed tasks first.
|
||||||
timesPushed,
|
timesPushed,
|
||||||
|
|
||||||
|
/// Shortest estimated duration first, with missing estimates last.
|
||||||
|
duration,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,7 @@ class BacklogView {
|
||||||
/// appear earlier, while older age uses the earliest [Task.createdAt] first.
|
/// appear earlier, while older age uses the earliest [Task.createdAt] first.
|
||||||
int _compareTasks(Task a, Task b, BacklogSortKey sortKey) {
|
int _compareTasks(Task a, Task b, BacklogSortKey sortKey) {
|
||||||
return switch (sortKey) {
|
return switch (sortKey) {
|
||||||
|
BacklogSortKey.custom => 0,
|
||||||
BacklogSortKey.priority =>
|
BacklogSortKey.priority =>
|
||||||
_priorityRank(b.priority).compareTo(_priorityRank(a.priority)),
|
_priorityRank(b.priority).compareTo(_priorityRank(a.priority)),
|
||||||
BacklogSortKey.rewardVsEffort =>
|
BacklogSortKey.rewardVsEffort =>
|
||||||
|
|
@ -121,6 +122,9 @@ class BacklogView {
|
||||||
a.backlogFreshnessAnchorAt.compareTo(b.backlogFreshnessAnchorAt),
|
a.backlogFreshnessAnchorAt.compareTo(b.backlogFreshnessAnchorAt),
|
||||||
BacklogSortKey.project => a.projectId.compareTo(b.projectId),
|
BacklogSortKey.project => a.projectId.compareTo(b.projectId),
|
||||||
BacklogSortKey.timesPushed => _timesPushed(b).compareTo(_timesPushed(a)),
|
BacklogSortKey.timesPushed => _timesPushed(b).compareTo(_timesPushed(a)),
|
||||||
|
BacklogSortKey.duration => _durationRank(a.durationMinutes).compareTo(
|
||||||
|
_durationRank(b.durationMinutes),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -177,3 +181,20 @@ int _rewardVsEffortScore(Task task) {
|
||||||
int _timesPushed(Task task) {
|
int _timesPushed(Task task) {
|
||||||
return task.stats.manuallyPushedCount + task.stats.autoPushedCount;
|
return task.stats.manuallyPushedCount + task.stats.autoPushedCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert nullable duration to a stable sort rank.
|
||||||
|
int _durationRank(int? durationMinutes) {
|
||||||
|
if (durationMinutes == null) {
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
if (durationMinutes <= 30) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (durationMinutes <= 60) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (durationMinutes <= 120) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -412,6 +412,117 @@ void main() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('applies backlog board filter selection and grouping', () async {
|
||||||
|
final store = InMemoryApplicationUnitOfWork(
|
||||||
|
initialProjects: [
|
||||||
|
ProjectProfile(id: 'home', name: 'Home', colorKey: 'home-blue'),
|
||||||
|
ProjectProfile(id: 'work', name: 'Work', colorKey: 'work-green'),
|
||||||
|
],
|
||||||
|
initialTasks: [
|
||||||
|
backlogTask(
|
||||||
|
id: 'long-work',
|
||||||
|
title: 'Long work task',
|
||||||
|
projectId: 'work',
|
||||||
|
createdAt: now.subtract(const Duration(days: 3)),
|
||||||
|
priority: PriorityLevel.medium,
|
||||||
|
reward: RewardLevel.medium,
|
||||||
|
difficulty: DifficultyLevel.medium,
|
||||||
|
durationMinutes: 75,
|
||||||
|
),
|
||||||
|
backlogTask(
|
||||||
|
id: 'high-work',
|
||||||
|
title: 'High work task',
|
||||||
|
projectId: 'work',
|
||||||
|
createdAt: now.subtract(const Duration(days: 2)),
|
||||||
|
priority: PriorityLevel.high,
|
||||||
|
reward: RewardLevel.medium,
|
||||||
|
difficulty: DifficultyLevel.easy,
|
||||||
|
durationMinutes: 25,
|
||||||
|
),
|
||||||
|
backlogTask(
|
||||||
|
id: 'low-home',
|
||||||
|
title: 'Low home task',
|
||||||
|
projectId: 'home',
|
||||||
|
createdAt: now.subtract(const Duration(days: 1)),
|
||||||
|
priority: PriorityLevel.low,
|
||||||
|
reward: RewardLevel.medium,
|
||||||
|
difficulty: DifficultyLevel.easy,
|
||||||
|
durationMinutes: 20,
|
||||||
|
),
|
||||||
|
backlogTask(
|
||||||
|
id: 'missing-work',
|
||||||
|
title: 'Missing duration task',
|
||||||
|
projectId: 'work',
|
||||||
|
createdAt: now,
|
||||||
|
priority: PriorityLevel.medium,
|
||||||
|
reward: RewardLevel.notSet,
|
||||||
|
difficulty: DifficultyLevel.notSet,
|
||||||
|
durationMinutes: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final useCases = V1ApplicationManagementUseCases(
|
||||||
|
applicationStore: store,
|
||||||
|
);
|
||||||
|
|
||||||
|
final filtered = (await useCases.getBacklogBoard(
|
||||||
|
GetBacklogBoardRequest(
|
||||||
|
context: appContext(operationId: 'board-filters', now: now),
|
||||||
|
localDate: CivilDate(2026, 6, 25),
|
||||||
|
boardFilters: BacklogBoardFilterSelection(
|
||||||
|
priorityGroups: const [BacklogBoardPriorityFilter.high],
|
||||||
|
projectIds: const ['work'],
|
||||||
|
durationBuckets: const [
|
||||||
|
BacklogBoardDurationFilter.zeroToThirty,
|
||||||
|
],
|
||||||
|
buckets: const [BacklogBoardBucket.needTimeBlock],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.requireValue;
|
||||||
|
final filteredIds = filtered.columns
|
||||||
|
.expand((column) => column.items)
|
||||||
|
.map((item) => item.taskId);
|
||||||
|
|
||||||
|
expect(filtered.summary.totalTaskCount, 1);
|
||||||
|
expect(filteredIds, ['high-work']);
|
||||||
|
|
||||||
|
final missing = (await useCases.getBacklogBoard(
|
||||||
|
GetBacklogBoardRequest(
|
||||||
|
context: appContext(operationId: 'board-missing', now: now),
|
||||||
|
localDate: CivilDate(2026, 6, 25),
|
||||||
|
filters: const [BacklogFilter.noRewardSet],
|
||||||
|
boardFilters: BacklogBoardFilterSelection(
|
||||||
|
missingDurationOnly: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.requireValue;
|
||||||
|
|
||||||
|
expect(missing.summary.totalTaskCount, 1);
|
||||||
|
expect(
|
||||||
|
missing.columns.expand((column) => column.items).single.taskId,
|
||||||
|
'missing-work',
|
||||||
|
);
|
||||||
|
|
||||||
|
final grouped = (await useCases.getBacklogBoard(
|
||||||
|
GetBacklogBoardRequest(
|
||||||
|
context: appContext(operationId: 'board-duration-group', now: now),
|
||||||
|
localDate: CivilDate(2026, 6, 25),
|
||||||
|
groupMode: BacklogBoardGroupMode.duration,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.requireValue;
|
||||||
|
final needTimeBlock = grouped.columns.singleWhere(
|
||||||
|
(column) => column.bucket == BacklogBoardBucket.needTimeBlock,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(needTimeBlock.items.map((item) => item.taskId), [
|
||||||
|
'high-work',
|
||||||
|
'long-work',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('loads backlog task detail with child previews', () async {
|
test('loads backlog task detail with child previews', () async {
|
||||||
final parent = backlogTask(
|
final parent = backlogTask(
|
||||||
id: 'parent',
|
id: 'parent',
|
||||||
|
|
|
||||||
|
|
@ -188,7 +188,7 @@ void main() {
|
||||||
reward: RewardLevel.low,
|
reward: RewardLevel.low,
|
||||||
difficulty: DifficultyLevel.hard,
|
difficulty: DifficultyLevel.hard,
|
||||||
projectId: 'zeta',
|
projectId: 'zeta',
|
||||||
);
|
).copyWith(durationMinutes: 90);
|
||||||
final highPriority = Task.quickCapture(
|
final highPriority = Task.quickCapture(
|
||||||
id: 'high',
|
id: 'high',
|
||||||
title: 'High',
|
title: 'High',
|
||||||
|
|
@ -198,6 +198,7 @@ void main() {
|
||||||
difficulty: DifficultyLevel.easy,
|
difficulty: DifficultyLevel.easy,
|
||||||
projectId: 'alpha',
|
projectId: 'alpha',
|
||||||
).copyWith(
|
).copyWith(
|
||||||
|
durationMinutes: 15,
|
||||||
stats: const TaskStatistics(autoPushedCount: 2),
|
stats: const TaskStatistics(autoPushedCount: 2),
|
||||||
);
|
);
|
||||||
final mediumPriority = Task.quickCapture(
|
final mediumPriority = Task.quickCapture(
|
||||||
|
|
@ -209,6 +210,7 @@ void main() {
|
||||||
difficulty: DifficultyLevel.medium,
|
difficulty: DifficultyLevel.medium,
|
||||||
projectId: 'beta',
|
projectId: 'beta',
|
||||||
).copyWith(
|
).copyWith(
|
||||||
|
durationMinutes: 45,
|
||||||
stats: const TaskStatistics(manuallyPushedCount: 1),
|
stats: const TaskStatistics(manuallyPushedCount: 1),
|
||||||
);
|
);
|
||||||
final view = BacklogView(
|
final view = BacklogView(
|
||||||
|
|
@ -216,6 +218,11 @@ void main() {
|
||||||
now: now,
|
now: now,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(view.sorted(BacklogSortKey.custom).map((task) => task.id), [
|
||||||
|
'low',
|
||||||
|
'medium',
|
||||||
|
'high',
|
||||||
|
]);
|
||||||
expect(view.sorted(BacklogSortKey.priority).map((task) => task.id), [
|
expect(view.sorted(BacklogSortKey.priority).map((task) => task.id), [
|
||||||
'high',
|
'high',
|
||||||
'medium',
|
'medium',
|
||||||
|
|
@ -235,6 +242,11 @@ void main() {
|
||||||
'medium',
|
'medium',
|
||||||
'low',
|
'low',
|
||||||
]);
|
]);
|
||||||
|
expect(view.sorted(BacklogSortKey.duration).map((task) => task.id), [
|
||||||
|
'high',
|
||||||
|
'medium',
|
||||||
|
'low',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('backlog staleness markers cover default thresholds', () {
|
test('backlog staleness markers cover default thresholds', () {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue