Wire Backlog nav, Break up, Settings dialog + merge Backlog Board feature #20
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
|
||||
covered by a focused widget test using handcrafted public read models so the
|
||||
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,
|
||||
searchText: request.searchText,
|
||||
filters: request.filters,
|
||||
boardFilters: request.boardFilters,
|
||||
sortKey: request.sortKey,
|
||||
groupMode: request.groupMode,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -223,6 +223,7 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
|||
localDate: request.localDate,
|
||||
searchText: request.searchText,
|
||||
filters: request.filters,
|
||||
boardFilters: request.boardFilters,
|
||||
sortKey: request.sortKey,
|
||||
groupMode: request.groupMode,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ class BacklogBoardReadRequest {
|
|||
required this.localDate,
|
||||
this.searchText,
|
||||
List<BacklogFilter> filters = const <BacklogFilter>[],
|
||||
this.sortKey = BacklogSortKey.priority,
|
||||
this.boardFilters = const BacklogBoardFilterSelection.empty(),
|
||||
this.sortKey = BacklogSortKey.custom,
|
||||
this.groupMode = BacklogBoardGroupMode.none,
|
||||
}) : filters = List<BacklogFilter>.unmodifiable(filters);
|
||||
|
||||
|
|
@ -48,6 +49,9 @@ class BacklogBoardReadRequest {
|
|||
/// User-selected backlog filters.
|
||||
final List<BacklogFilter> filters;
|
||||
|
||||
/// User-selected board-specific filters.
|
||||
final BacklogBoardFilterSelection boardFilters;
|
||||
|
||||
/// User-selected task ordering.
|
||||
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.
|
||||
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.
|
||||
/// 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.
|
||||
/// 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.
|
||||
List<BacklogFilter> get filters => _filters;
|
||||
|
||||
/// Current board-specific filters applied to board reads.
|
||||
BacklogBoardFilterSelection get boardFilters => _boardFilters;
|
||||
|
||||
/// Current sort key applied to board reads.
|
||||
BacklogSortKey get sortKey => _sortKey;
|
||||
|
||||
|
|
@ -198,6 +210,7 @@ class BacklogBoardController extends ChangeNotifier {
|
|||
localDate: date,
|
||||
searchText: _searchText,
|
||||
filters: _filters,
|
||||
boardFilters: _boardFilters,
|
||||
sortKey: _sortKey,
|
||||
groupMode: _groupMode,
|
||||
),
|
||||
|
|
@ -333,6 +346,22 @@ class BacklogBoardController extends ChangeNotifier {
|
|||
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.
|
||||
Future<void> updateSortKey(BacklogSortKey value) async {
|
||||
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 '../../theme/focus_flow_tokens.dart';
|
||||
import 'backlog_board_column.dart';
|
||||
import 'backlog_board_controls.dart';
|
||||
|
||||
/// Backlog board screen backed by a public scheduler read controller.
|
||||
class BacklogBoardScreen extends StatelessWidget {
|
||||
|
|
@ -108,7 +109,7 @@ class _BacklogFrame extends StatelessWidget {
|
|||
children: [
|
||||
_BacklogHeader(taskCountLabel: taskCountLabel),
|
||||
const SizedBox(height: 22),
|
||||
_BacklogControls(controller: controller),
|
||||
BacklogBoardTopControls(controller: controller),
|
||||
const SizedBox(height: 18),
|
||||
Expanded(child: body),
|
||||
],
|
||||
|
|
@ -166,205 +167,12 @@ class _BacklogHeader extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
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.
|
||||
class _BacklogLoadingBody extends StatelessWidget {
|
||||
/// Creates a loading body.
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ void main() {
|
|||
await controller.load();
|
||||
|
||||
expect(capturedRequest!.localDate, CivilDate(2026, 7, 7));
|
||||
expect(capturedRequest!.sortKey, BacklogSortKey.custom);
|
||||
expect(capturedRequest!.boardFilters.isEmpty, isTrue);
|
||||
final state = controller.state;
|
||||
expect(state, isA<BacklogBoardReady>());
|
||||
expect((state as BacklogBoardReady).data.summary.totalTaskCount, 1);
|
||||
|
|
|
|||
|
|
@ -87,6 +87,114 @@ void main() {
|
|||
final border = decoration.border! as Border;
|
||||
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.
|
||||
|
|
@ -141,7 +249,10 @@ BacklogBoardItemReadModel _boardItemWithChildren() {
|
|||
}
|
||||
|
||||
/// 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(
|
||||
ownerId: 'owner-1',
|
||||
localDate: CivilDate(2026, 7, 7),
|
||||
|
|
@ -208,7 +319,7 @@ BacklogBoardQueryResult _boardWithItem(BacklogBoardItemReadModel item) {
|
|||
),
|
||||
planningTip: 'Pick one task.',
|
||||
),
|
||||
projectOptions: const [],
|
||||
projectOptions: projectOptions,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import '../persistence/repositories.dart';
|
|||
import '../scheduling/scheduling_engine.dart';
|
||||
part 'application_management/codes/application_management_command_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/get_backlog_board_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.
|
||||
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,
|
||||
this.searchText,
|
||||
List<BacklogFilter> filters = const <BacklogFilter>[],
|
||||
this.sortKey = BacklogSortKey.priority,
|
||||
BacklogBoardFilterSelection? boardFilters,
|
||||
this.sortKey = BacklogSortKey.custom,
|
||||
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.
|
||||
final ApplicationOperationContext context;
|
||||
|
|
@ -27,6 +30,9 @@ class GetBacklogBoardRequest {
|
|||
/// Optional user-facing backlog 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.
|
||||
final BacklogSortKey sortKey;
|
||||
|
||||
|
|
|
|||
|
|
@ -91,7 +91,9 @@ class V1ApplicationManagementUseCases {
|
|||
final ownerId = context.ownerTimeZone.ownerId;
|
||||
logger.debug(() => 'Loading backlog board. ownerId=$ownerId '
|
||||
'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}');
|
||||
final settings = await _ownerSettingsOrDefault(
|
||||
repositories,
|
||||
|
|
@ -107,13 +109,8 @@ class V1ApplicationManagementUseCases {
|
|||
request: request,
|
||||
settings: settings,
|
||||
);
|
||||
final searched = _searchBacklogTasks(
|
||||
tasks: filtered,
|
||||
searchText: request.searchText,
|
||||
projectById: projectById,
|
||||
);
|
||||
final sorted = _sortBoardTasks(
|
||||
tasks: searched,
|
||||
tasks: filtered,
|
||||
request: request,
|
||||
settings: settings,
|
||||
projectById: projectById,
|
||||
|
|
@ -131,15 +128,24 @@ class V1ApplicationManagementUseCases {
|
|||
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 '
|
||||
'candidateCount=${candidates.length} itemCount=${items.length}');
|
||||
'candidateCount=${candidates.length} '
|
||||
'itemCount=${boardFiltered.length}');
|
||||
return ApplicationResult.success(
|
||||
BacklogBoardQueryResult(
|
||||
ownerId: ownerId,
|
||||
localDate: request.localDate,
|
||||
summary: _summaryForBoardItems(items),
|
||||
columns: _columnsForBoardItems(items),
|
||||
summary: _summaryForBoardItems(boardFiltered),
|
||||
columns: _columnsForBoardItems(boardFiltered),
|
||||
projectOptions: _projectOptionsFor(projects),
|
||||
),
|
||||
);
|
||||
|
|
@ -266,30 +272,120 @@ class V1ApplicationManagementUseCases {
|
|||
return List<Task>.unmodifiable(filtered);
|
||||
}
|
||||
|
||||
/// Applies case-insensitive text search over task title, project, and derived tags.
|
||||
List<Task> _searchBacklogTasks({
|
||||
required List<Task> tasks,
|
||||
/// Applies case-insensitive text search over board card text.
|
||||
List<BacklogBoardItemReadModel> _searchBoardItems({
|
||||
required List<BacklogBoardItemReadModel> items,
|
||||
required String? searchText,
|
||||
required Map<String, ProjectProfile> projectById,
|
||||
}) {
|
||||
final query = searchText?.trim().toLowerCase();
|
||||
if (query == null || query.isEmpty) {
|
||||
return tasks;
|
||||
return items;
|
||||
}
|
||||
return List<Task>.unmodifiable(
|
||||
tasks.where((task) {
|
||||
final project = projectById[task.projectId];
|
||||
return List<BacklogBoardItemReadModel>.unmodifiable(
|
||||
items.where((item) {
|
||||
final haystack = [
|
||||
task.title,
|
||||
task.projectId,
|
||||
if (project != null) project.name,
|
||||
..._readOnlyTagLabelsFor(task),
|
||||
item.title,
|
||||
if (item.subtitle != null) item.subtitle!,
|
||||
item.bucketReason,
|
||||
item.projectId,
|
||||
item.projectName,
|
||||
...item.tags,
|
||||
].join(' ').toLowerCase();
|
||||
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.
|
||||
List<Task> _sortBoardTasks({
|
||||
required List<Task> tasks,
|
||||
|
|
@ -308,20 +404,72 @@ class V1ApplicationManagementUseCases {
|
|||
|
||||
final indexed = sorted.asMap().entries.toList(growable: false);
|
||||
indexed.sort((a, b) {
|
||||
final projectComparison = _projectNameFor(
|
||||
a.value.projectId,
|
||||
projectById,
|
||||
).compareTo(
|
||||
_projectNameFor(b.value.projectId, projectById),
|
||||
final groupComparison = _boardGroupComparison(
|
||||
left: a.value,
|
||||
right: b.value,
|
||||
groupMode: request.groupMode,
|
||||
projectById: projectById,
|
||||
);
|
||||
if (projectComparison != 0) {
|
||||
return projectComparison;
|
||||
if (groupComparison != 0) {
|
||||
return groupComparison;
|
||||
}
|
||||
return a.key.compareTo(b.key);
|
||||
});
|
||||
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].
|
||||
Future<Map<String, List<Task>>> _loadChildrenByParentId({
|
||||
required ApplicationUnitOfWorkRepositories repositories,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ part of '../../backlog.dart';
|
|||
/// example, `rewardVsEffort` maps to a simple derived score instead of a stored
|
||||
/// field. Persistence can later index the underlying fields if needed.
|
||||
enum BacklogSortKey {
|
||||
/// Preserve the source/read-model order supplied by persistence.
|
||||
custom,
|
||||
|
||||
/// Highest priority first.
|
||||
priority,
|
||||
|
||||
|
|
@ -24,4 +27,7 @@ enum BacklogSortKey {
|
|||
|
||||
/// Most frequently pushed tasks first.
|
||||
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.
|
||||
int _compareTasks(Task a, Task b, BacklogSortKey sortKey) {
|
||||
return switch (sortKey) {
|
||||
BacklogSortKey.custom => 0,
|
||||
BacklogSortKey.priority =>
|
||||
_priorityRank(b.priority).compareTo(_priorityRank(a.priority)),
|
||||
BacklogSortKey.rewardVsEffort =>
|
||||
|
|
@ -121,6 +122,9 @@ class BacklogView {
|
|||
a.backlogFreshnessAnchorAt.compareTo(b.backlogFreshnessAnchorAt),
|
||||
BacklogSortKey.project => a.projectId.compareTo(b.projectId),
|
||||
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) {
|
||||
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 {
|
||||
final parent = backlogTask(
|
||||
id: 'parent',
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ void main() {
|
|||
reward: RewardLevel.low,
|
||||
difficulty: DifficultyLevel.hard,
|
||||
projectId: 'zeta',
|
||||
);
|
||||
).copyWith(durationMinutes: 90);
|
||||
final highPriority = Task.quickCapture(
|
||||
id: 'high',
|
||||
title: 'High',
|
||||
|
|
@ -198,6 +198,7 @@ void main() {
|
|||
difficulty: DifficultyLevel.easy,
|
||||
projectId: 'alpha',
|
||||
).copyWith(
|
||||
durationMinutes: 15,
|
||||
stats: const TaskStatistics(autoPushedCount: 2),
|
||||
);
|
||||
final mediumPriority = Task.quickCapture(
|
||||
|
|
@ -209,6 +210,7 @@ void main() {
|
|||
difficulty: DifficultyLevel.medium,
|
||||
projectId: 'beta',
|
||||
).copyWith(
|
||||
durationMinutes: 45,
|
||||
stats: const TaskStatistics(manuallyPushedCount: 1),
|
||||
);
|
||||
final view = BacklogView(
|
||||
|
|
@ -216,6 +218,11 @@ void main() {
|
|||
now: now,
|
||||
);
|
||||
|
||||
expect(view.sorted(BacklogSortKey.custom).map((task) => task.id), [
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
]);
|
||||
expect(view.sorted(BacklogSortKey.priority).map((task) => task.id), [
|
||||
'high',
|
||||
'medium',
|
||||
|
|
@ -235,6 +242,11 @@ void main() {
|
|||
'medium',
|
||||
'low',
|
||||
]);
|
||||
expect(view.sorted(BacklogSortKey.duration).map((task) => task.id), [
|
||||
'high',
|
||||
'medium',
|
||||
'low',
|
||||
]);
|
||||
});
|
||||
|
||||
test('backlog staleness markers cover default thresholds', () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue