feat(backlog): add board controls

This commit is contained in:
Ashley Venn 2026-07-07 13:33:55 -07:00
parent 2bcbba46b6
commit dd25bdfaae
17 changed files with 1559 additions and 231 deletions

View file

@ -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.

View file

@ -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,
), ),

View file

@ -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,
), ),

View file

@ -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) {

View file

@ -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'),
];

View file

@ -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.

View file

@ -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);

View file

@ -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,
); );
} }

View file

@ -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';

View file

@ -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);
}
}

View file

@ -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,
} }

View file

@ -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;

View file

@ -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,

View file

@ -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,
} }

View file

@ -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;
}

View file

@ -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',

View file

@ -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', () {