Wire Backlog nav, Break up, Settings dialog + merge Backlog Board feature #20

Open
pyr0ball wants to merge 26 commits from Circuit-Forge/focus-flow:feat/9-12-13-wire-backlog-nav-and-modal-actions into main
7 changed files with 1313 additions and 220 deletions
Showing only changes of commit 2bcbba46b6 - Show all commits

View file

@ -116,3 +116,24 @@ Freeform Tags Metadata.md` for a later migration-backed feature.
shells, loading/empty/failure states, and a compact summary panel shell.
Detailed cards, charts, and drawer contents remain for later UI Plan 2
blocks.
## Block 04 Board Columns and Cards
1. Backlog bucket, priority, project, reward, and future tag-chip colors now
live in `apps/focus_flow_flutter/lib/theme/backlog_board_visual_tokens.dart`
instead of inline card mappings.
2. `BacklogBoardScreen` delegates column rendering to reusable board column and
task card widgets. The board keeps the summary panel fixed on the right and
horizontally scrolls the four-column board if the available desktop width is
constrained.
3. `BacklogTaskCard` renders only public `BacklogBoardItemReadModel` data:
title, subtitle or bucket reason, duration, project dot/name, priority flag,
difficulty bars, solid reward marks, selected outline, and direct child
previews.
4. Add-task controls intentionally call a no-op bucket callback until Block 8
wires the create-task command flow. Card taps select through
`BacklogBoardController.selectTask`, which performs a read-only detail query.
5. The current demo seed still represents mockup child rows as normal backlog
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.

View file

@ -0,0 +1,150 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Defines Backlog board visual tokens for the FocusFlow Flutter UI.
library;
import 'package:flutter/material.dart';
import 'package:scheduler_core/scheduler_core.dart';
import 'focus_flow_tokens.dart';
/// Maps Backlog board read-model tokens to Flutter colors and icons.
abstract final class BacklogBoardVisualTokens {
/// Accent color for the Do Next bucket.
static const doNextAccent = Color(0xFF24E65A);
/// Accent color for the Need Time Block bucket.
static const needTimeBlockAccent = Color(0xFFFFA51F);
/// Accent color for the Break Up First bucket.
static const breakUpFirstAccent = Color(0xFFB45CFF);
/// Accent color for the Not Now bucket.
static const notNowAccent = Color(0xFF35BFFF);
/// Red flag color for high-priority Backlog cards.
static const priorityHigh = Color(0xFFFF3F5B);
/// Yellow flag color for medium-priority Backlog cards.
static const priorityMedium = Color(0xFFFFD447);
/// Green flag color for low-priority Backlog cards.
static const priorityLow = Color(0xFF4BE064);
/// Project dot color for Home.
static const projectHome = Color(0xFF4D86FF);
/// Project dot color for Work.
static const projectWork = Color(0xFFFF7A1A);
/// Project dot color for Personal.
static const projectPersonal = Color(0xFFB45CFF);
/// Project dot color for Learning.
static const projectLearning = Color(0xFF38BDF8);
/// Project dot color for Side Project.
static const projectSideProject = Color(0xFF2F6BFF);
/// Resolves a Backlog column accent token to a display color.
static Color accentTokenColor(
String token, {
BacklogBoardBucket? fallbackBucket,
}) {
return switch (token) {
'backlog-do-next' => doNextAccent,
'backlog-time-block' => needTimeBlockAccent,
'backlog-break-up' => breakUpFirstAccent,
'backlog-not-now' => notNowAccent,
'green' => doNextAccent,
'yellow' => needTimeBlockAccent,
'purple' => breakUpFirstAccent,
'blue' => notNowAccent,
_ =>
fallbackBucket == null
? FocusFlowTokens.textMuted
: bucketAccent(fallbackBucket),
};
}
/// Resolves a board bucket to its accent color.
static Color bucketAccent(BacklogBoardBucket bucket) {
return switch (bucket) {
BacklogBoardBucket.doNext => doNextAccent,
BacklogBoardBucket.needTimeBlock => needTimeBlockAccent,
BacklogBoardBucket.breakUpFirst => breakUpFirstAccent,
BacklogBoardBucket.notNow => notNowAccent,
};
}
/// Resolves a board bucket to its header icon.
static IconData bucketIcon(BacklogBoardBucket bucket) {
return switch (bucket) {
BacklogBoardBucket.doNext => Icons.check_circle_outline,
BacklogBoardBucket.needTimeBlock => Icons.schedule,
BacklogBoardBucket.breakUpFirst => Icons.adjust,
BacklogBoardBucket.notNow => Icons.cloud_outlined,
};
}
/// Resolves a task priority to a flag color.
static Color priorityColor(PriorityLevel? priority) {
return switch (priority) {
PriorityLevel.veryHigh || PriorityLevel.high => priorityHigh,
PriorityLevel.medium => priorityMedium,
PriorityLevel.low || PriorityLevel.veryLow => priorityLow,
null => FocusFlowTokens.textFaint,
};
}
/// Resolves a task priority to a compact card label.
static String priorityLabel(PriorityLevel? priority) {
return switch (priority) {
PriorityLevel.veryHigh || PriorityLevel.high => 'High',
PriorityLevel.medium => 'Med',
PriorityLevel.low || PriorityLevel.veryLow => 'Low',
null => 'None',
};
}
/// Resolves a project color token to the Backlog board dot color.
static Color projectColor(String token) {
return switch (token) {
'project-home' || 'home' => projectHome,
'project-work' || 'work' => projectWork,
'project-personal' || 'personal' => projectPersonal,
'project-learning' || 'learning' => projectLearning,
'project-side-project' || 'side-project' => projectSideProject,
_ => FocusFlowTokens.textMuted,
};
}
/// Resolves a reward level to the number of solid reward marks to render.
static int rewardCount(RewardLevel reward) {
return switch (reward) {
RewardLevel.notSet => 0,
RewardLevel.veryLow => 1,
RewardLevel.low => 2,
RewardLevel.medium => 3,
RewardLevel.high => 4,
RewardLevel.veryHigh => 5,
};
}
/// Resolves a Backlog tag chip background color for drawer surfaces.
static Color tagChipBackground(String label) {
return switch (label.toLowerCase()) {
'someday' => notNowAccent.withValues(alpha: 0.16),
_ => FocusFlowTokens.elevatedPanel,
};
}
/// Resolves a Backlog tag chip foreground color for drawer surfaces.
static Color tagChipForeground(String label) {
return switch (label.toLowerCase()) {
'someday' => notNowAccent,
_ => FocusFlowTokens.textMuted,
};
}
}

View file

@ -0,0 +1,269 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Renders Backlog board columns for the FocusFlow Flutter UI.
library;
import 'package:flutter/material.dart';
import 'package:scheduler_core/scheduler_core.dart';
import '../../theme/backlog_board_visual_tokens.dart';
import '../../theme/focus_flow_tokens.dart';
import 'backlog_task_card.dart';
/// Callback invoked when a Backlog task should become the selected task.
typedef BacklogTaskSelected = void Function(String taskId);
/// Callback invoked when the user requests a new task for a bucket.
typedef BacklogBucketAddRequested = void Function(BacklogBoardBucket bucket);
/// Board column containing a Backlog bucket header, task cards, and footer.
class BacklogBoardColumn extends StatelessWidget {
/// Creates a Backlog board column.
const BacklogBoardColumn({
required this.column,
required this.selectedTaskId,
required this.onTaskSelected,
required this.onAddTask,
super.key,
});
/// Column read model supplied by scheduler core.
final BacklogBoardColumnReadModel column;
/// Currently selected task id, if any.
final String? selectedTaskId;
/// Selection callback for task cards.
final BacklogTaskSelected onTaskSelected;
/// Add-task callback shared by header and footer controls.
final BacklogBucketAddRequested onAddTask;
/// 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 accent = BacklogBoardVisualTokens.accentTokenColor(
column.accentToken,
fallbackBucket: column.bucket,
);
return DecoratedBox(
decoration: BoxDecoration(
color: FocusFlowTokens.panelBackground,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: accent.withValues(alpha: 0.64)),
boxShadow: [
BoxShadow(
color: accent.withValues(alpha: 0.06),
blurRadius: 28,
spreadRadius: -10,
),
],
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
BacklogColumnHeader(
column: column,
accent: accent,
onAddTask: () {
onAddTask(column.bucket);
},
),
const SizedBox(height: 14),
Expanded(
child: column.items.isEmpty
? _EmptyColumnHint(accent: accent)
: ListView.separated(
padding: EdgeInsets.zero,
itemCount: column.items.length,
separatorBuilder: (context, index) =>
const SizedBox(height: 8),
itemBuilder: (context, index) {
final item = column.items[index];
return BacklogTaskCard(
item: item,
selected: item.taskId == selectedTaskId,
onTap: () {
onTaskSelected(item.taskId);
},
);
},
),
),
const SizedBox(height: 9),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () {
onAddTask(column.bucket);
},
icon: Icon(Icons.add, color: accent, size: 18),
label: Text('Add task', style: TextStyle(color: accent)),
),
),
],
),
),
);
}
}
/// Header for a Backlog board column.
class BacklogColumnHeader extends StatelessWidget {
/// Creates a Backlog column header.
const BacklogColumnHeader({
required this.column,
required this.accent,
required this.onAddTask,
super.key,
});
/// Column read model that supplies title, subtitle, bucket, and count.
final BacklogBoardColumnReadModel column;
/// Accent color resolved from the column token.
final Color accent;
/// Callback invoked by the header add button.
final VoidCallback onAddTask;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(
BacklogBoardVisualTokens.bucketIcon(column.bucket),
color: accent,
size: 22,
),
const SizedBox(width: 9),
Expanded(
child: Text(
column.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: FocusFlowTokens.textPrimary,
fontSize: 14.5,
fontWeight: FontWeight.w800,
),
),
),
const SizedBox(width: 8),
_ColumnCountPill(label: '${column.count}', color: accent),
const SizedBox(width: 4),
IconButton(
onPressed: onAddTask,
icon: const Icon(Icons.add),
color: FocusFlowTokens.textMuted,
tooltip: 'Add task',
iconSize: 18,
visualDensity: VisualDensity.compact,
),
],
),
const SizedBox(height: 7),
Padding(
padding: const EdgeInsets.only(left: 32),
child: Text(
column.subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: FocusFlowTokens.textMuted,
fontSize: 11,
height: 1.25,
),
),
),
],
);
}
}
/// Small count pill used by Backlog column headers.
class _ColumnCountPill extends StatelessWidget {
/// Creates a column count pill.
const _ColumnCountPill({required this.label, required this.color});
/// Count label.
final String label;
/// Accent color for the pill.
final Color color;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(7),
),
child: Text(
label,
style: TextStyle(
color: color,
fontSize: 11,
fontWeight: FontWeight.w800,
),
),
);
}
}
/// Placeholder content for an empty board column.
class _EmptyColumnHint extends StatelessWidget {
/// Creates an empty-column hint.
const _EmptyColumnHint({required this.accent});
/// Accent color for the empty state icon.
final Color accent;
/// 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 Center(
child: DecoratedBox(
decoration: BoxDecoration(
color: FocusFlowTokens.elevatedPanel.withValues(alpha: 0.42),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: FocusFlowTokens.subtleBorder.withValues(alpha: 0.62),
),
),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.inbox_outlined, color: accent, size: 22),
const SizedBox(height: 8),
const Text(
'No tasks here',
textAlign: TextAlign.center,
style: TextStyle(
color: FocusFlowTokens.textMuted,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
],
),
),
),
);
}
}

View file

@ -12,6 +12,7 @@ import 'package:scheduler_core/scheduler_core.dart';
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';
/// Backlog board screen backed by a public scheduler read controller.
class BacklogBoardScreen extends StatelessWidget {
@ -45,12 +46,27 @@ class BacklogBoardScreen extends StatelessWidget {
BacklogBoardEmpty(:final data) => _BacklogFrame(
taskCountLabel: '${data.summary.totalTaskCount} tasks',
controller: controller,
body: _BacklogReadyBody(data: data, isEmpty: true),
body: _BacklogReadyBody(
data: data,
selectedTaskId: controller.selectedTaskId,
onTaskSelected: (taskId) {
unawaited(controller.selectTask(taskId));
},
onAddTask: _handleAddTaskPlaceholder,
isEmpty: true,
),
),
BacklogBoardReady(:final data) => _BacklogFrame(
taskCountLabel: '${data.summary.totalTaskCount} tasks',
controller: controller,
body: _BacklogReadyBody(data: data),
body: _BacklogReadyBody(
data: data,
selectedTaskId: controller.selectedTaskId,
onTaskSelected: (taskId) {
unawaited(controller.selectTask(taskId));
},
onAddTask: _handleAddTaskPlaceholder,
),
),
};
},
@ -58,6 +74,9 @@ class BacklogBoardScreen extends StatelessWidget {
}
}
/// Handles pre-Block-8 add-task button presses without mutating scheduler data.
void _handleAddTaskPlaceholder(BacklogBoardBucket bucket) {}
/// Main Backlog frame containing header, controls, body, and overlay slot.
class _BacklogFrame extends StatelessWidget {
/// Creates the Backlog frame.
@ -396,11 +415,26 @@ class _BacklogFailureBody extends StatelessWidget {
/// Ready or empty Backlog board body.
class _BacklogReadyBody extends StatelessWidget {
/// Creates a ready body.
const _BacklogReadyBody({required this.data, this.isEmpty = false});
const _BacklogReadyBody({
required this.data,
required this.selectedTaskId,
required this.onTaskSelected,
required this.onAddTask,
this.isEmpty = false,
});
/// Backlog board data.
final BacklogBoardScreenData data;
/// Currently selected task id, if any.
final String? selectedTaskId;
/// Callback invoked when a task card is selected.
final BacklogTaskSelected onTaskSelected;
/// Callback invoked when a column add-task control is pressed.
final BacklogBucketAddRequested onAddTask;
/// Whether the board has no tasks.
final bool isEmpty;
@ -412,14 +446,11 @@ class _BacklogReadyBody extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final column in data.columns) ...[
Expanded(child: _BacklogColumnShell(column: column)),
if (column != data.columns.last) const SizedBox(width: 10),
],
],
child: _BacklogBoardColumns(
columns: data.columns,
selectedTaskId: selectedTaskId,
onTaskSelected: onTaskSelected,
onAddTask: onAddTask,
),
),
const SizedBox(width: 16),
@ -438,170 +469,77 @@ class _BacklogReadyBody extends StatelessWidget {
}
}
/// One Backlog board column shell.
class _BacklogColumnShell extends StatelessWidget {
/// Creates a column shell.
const _BacklogColumnShell({required this.column});
/// Column read model.
final BacklogBoardColumnReadModel column;
/// 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 accent = _accentColor(column.bucket);
return DecoratedBox(
decoration: BoxDecoration(
color: FocusFlowTokens.panelBackground,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: accent.withValues(alpha: 0.62)),
),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(_bucketIcon(column.bucket), color: accent, size: 22),
const SizedBox(width: 10),
Expanded(
child: Text(
column.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: FocusFlowTokens.textPrimary,
fontSize: 15,
fontWeight: FontWeight.w800,
),
),
),
_CountPill(label: '${column.count}', color: accent),
IconButton(
onPressed: () {},
icon: const Icon(Icons.add),
color: FocusFlowTokens.textMuted,
tooltip: 'Add task',
iconSize: 18,
visualDensity: VisualDensity.compact,
),
],
),
const SizedBox(height: 8),
Text(
column.subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: FocusFlowTokens.textMuted,
fontSize: 12,
),
),
const SizedBox(height: 16),
Expanded(
child: ListView.separated(
physics: const NeverScrollableScrollPhysics(),
itemCount: column.items.isEmpty
? 2
: column.items.take(4).length,
separatorBuilder: (context, index) =>
const SizedBox(height: 10),
itemBuilder: (context, index) {
final hasItem = column.items.isNotEmpty;
return _ColumnSkeletonCard(
accent: accent,
active: hasItem,
title: hasItem ? column.items[index].title : null,
);
},
),
),
const SizedBox(height: 10),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () {},
icon: Icon(Icons.add, color: accent),
label: Text('Add task', style: TextStyle(color: accent)),
),
),
],
),
),
);
}
}
/// Placeholder card used until Block 04 fills in card details.
class _ColumnSkeletonCard extends StatelessWidget {
/// Creates a skeleton card.
const _ColumnSkeletonCard({
required this.accent,
this.title,
this.active = false,
/// Horizontally scrollable set of Backlog board columns.
class _BacklogBoardColumns extends StatelessWidget {
/// Creates a board column layout.
const _BacklogBoardColumns({
required this.columns,
required this.selectedTaskId,
required this.onTaskSelected,
required this.onAddTask,
});
/// Accent color inherited from the column.
final Color accent;
/// Columns to render in scheduler-provided display order.
final List<BacklogBoardColumnReadModel> columns;
/// Optional task title for a loaded board item.
final String? title;
/// Currently selected task id, if any.
final String? selectedTaskId;
/// Whether this skeleton represents a loaded item.
final bool active;
/// Callback invoked when a task card is selected.
final BacklogTaskSelected onTaskSelected;
/// Callback invoked when an add-task control is pressed.
final BacklogBucketAddRequested onAddTask;
/// 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(
height: 86,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: FocusFlowTokens.elevatedPanel.withValues(
alpha: active ? 1 : 0.55,
),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: FocusFlowTokens.subtleBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
if (columns.isEmpty) {
return const SizedBox.shrink();
}
return LayoutBuilder(
builder: (context, constraints) {
const columnGap = 10.0;
const minColumnWidth = 260.0;
const maxColumnWidth = 300.0;
final gapWidth = columnGap * (columns.length - 1);
final availableWidth = constraints.hasBoundedWidth
? constraints.maxWidth
: maxColumnWidth * columns.length + gapWidth;
final columnWidth = ((availableWidth - gapWidth) / columns.length)
.clamp(minColumnWidth, maxColumnWidth)
.toDouble();
final boardWidth = columnWidth * columns.length + gapWidth;
final contentWidth = boardWidth < availableWidth
? availableWidth
: boardWidth;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SizedBox(
width: contentWidth,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Container(
width: 14,
height: 14,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(3),
border: Border.all(color: FocusFlowTokens.textFaint),
),
),
const SizedBox(width: 10),
Expanded(
child: title == null
? _SkeletonLine(widthFactor: 0.7, color: accent)
: Text(
title!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: FocusFlowTokens.textPrimary,
fontWeight: FontWeight.w700,
),
for (var index = 0; index < columns.length; index += 1) ...[
SizedBox(
width: columnWidth,
child: BacklogBoardColumn(
column: columns[index],
selectedTaskId: selectedTaskId,
onTaskSelected: onTaskSelected,
onAddTask: onAddTask,
),
),
if (index != columns.length - 1)
const SizedBox(width: columnGap),
],
],
),
const Spacer(),
_SkeletonLine(widthFactor: active ? 0.55 : 0.42, color: accent),
const SizedBox(height: 8),
_SkeletonLine(widthFactor: active ? 0.78 : 0.58, color: accent),
],
),
);
},
);
}
}
@ -860,19 +798,16 @@ class _BacklogStatePanel extends StatelessWidget {
/// Small count pill used by headers.
class _CountPill extends StatelessWidget {
/// Creates a count pill.
const _CountPill({required this.label, this.color});
const _CountPill({required this.label});
/// Pill label.
final String label;
/// Optional accent color.
final Color? color;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
final accent = color ?? FocusFlowTokens.textMuted;
const accent = FocusFlowTokens.textMuted;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
@ -881,8 +816,8 @@ class _CountPill extends StatelessWidget {
),
child: Text(
label,
style: TextStyle(
color: color ?? FocusFlowTokens.textPrimary,
style: const TextStyle(
color: FocusFlowTokens.textPrimary,
fontSize: 12,
fontWeight: FontWeight.w800,
),
@ -890,52 +825,3 @@ class _CountPill extends StatelessWidget {
);
}
}
/// Skeleton line used by placeholder cards.
class _SkeletonLine extends StatelessWidget {
/// Creates a skeleton line.
const _SkeletonLine({required this.widthFactor, required this.color});
/// Fractional width for the line.
final double widthFactor;
/// Accent color for the line.
final Color color;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return FractionallySizedBox(
widthFactor: widthFactor,
alignment: Alignment.centerLeft,
child: Container(
height: 6,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.22),
borderRadius: BorderRadius.circular(999),
),
),
);
}
}
/// Resolves a display color for [bucket].
Color _accentColor(BacklogBoardBucket bucket) {
return switch (bucket) {
BacklogBoardBucket.doNext => FocusFlowTokens.flexibleGreen,
BacklogBoardBucket.needTimeBlock => FocusFlowTokens.warningYellow,
BacklogBoardBucket.breakUpFirst => FocusFlowTokens.restPurple,
BacklogBoardBucket.notNow => FocusFlowTokens.appointmentBlue,
};
}
/// Resolves a display icon for [bucket].
IconData _bucketIcon(BacklogBoardBucket bucket) {
return switch (bucket) {
BacklogBoardBucket.doNext => Icons.check_circle_outline,
BacklogBoardBucket.needTimeBlock => Icons.schedule,
BacklogBoardBucket.breakUpFirst => Icons.adjust,
BacklogBoardBucket.notNow => Icons.cloud_outlined,
};
}

View file

@ -0,0 +1,516 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Renders Backlog board task cards for the FocusFlow Flutter UI.
library;
import 'package:flutter/material.dart';
import 'package:scheduler_core/scheduler_core.dart';
import '../../theme/backlog_board_visual_tokens.dart';
import '../../theme/focus_flow_tokens.dart';
import '../timeline/difficulty_bars.dart';
import '../timeline/reward_icon.dart';
/// Card widget for one Backlog board task read model.
class BacklogTaskCard extends StatelessWidget {
/// Creates a Backlog task card.
const BacklogTaskCard({
required this.item,
required this.selected,
required this.onTap,
super.key,
});
/// Read model that supplies all card content.
final BacklogBoardItemReadModel item;
/// Whether this card is the current UI selection.
final bool selected;
/// Callback invoked when the user selects the card.
final VoidCallback onTap;
/// 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 accent = BacklogBoardVisualTokens.bucketAccent(item.bucket);
final subtitle = item.subtitle ?? item.bucketReason;
return Material(
color: Colors.transparent,
child: InkWell(
key: ValueKey('backlog-card-${item.taskId}'),
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: AnimatedContainer(
key: ValueKey('backlog-card-container-${item.taskId}'),
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
padding: const EdgeInsets.fromLTRB(10, 10, 10, 11),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
FocusFlowTokens.elevatedPanel,
accent.withValues(alpha: 0.06),
],
),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: selected
? FocusFlowTokens.accentMagenta
: FocusFlowTokens.subtleBorder,
width: selected ? 2 : 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.18),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_CardTitleRow(item: item, selected: selected),
if (subtitle.isNotEmpty) ...[
const SizedBox(height: 5),
Padding(
padding: const EdgeInsets.only(left: 24),
child: Text(
subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: FocusFlowTokens.textMuted,
fontSize: 11,
height: 1.22,
),
),
),
],
const SizedBox(height: 10),
_CardMetadataRow(item: item),
const SizedBox(height: 10),
Divider(
height: 1,
color: FocusFlowTokens.subtleBorder.withValues(alpha: 0.55),
),
const SizedBox(height: 9),
_CardEffortRow(item: item, accent: accent),
if (item.childPreview.isNotEmpty) ...[
const SizedBox(height: 10),
_ChildPreviewSection(children: item.childPreview),
],
],
),
),
),
);
}
}
/// Header row with checkbox-like selection square and task title.
class _CardTitleRow extends StatelessWidget {
/// Creates a title row for [item].
const _CardTitleRow({required this.item, required this.selected});
/// Task card data to display.
final BacklogBoardItemReadModel item;
/// Whether this card is selected.
final bool selected;
/// 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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 15,
height: 15,
margin: const EdgeInsets.only(top: 2),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(3),
border: Border.all(
color: selected
? FocusFlowTokens.accentMagenta
: FocusFlowTokens.textFaint,
width: selected ? 1.8 : 1.2,
),
color: selected
? FocusFlowTokens.accentMagenta.withValues(alpha: 0.12)
: Colors.transparent,
),
child: selected
? const Icon(
Icons.check,
color: FocusFlowTokens.accentMagenta,
size: 11,
)
: null,
),
const SizedBox(width: 9),
Expanded(
child: Text(
item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: FocusFlowTokens.textPrimary,
fontSize: 13.5,
fontWeight: FontWeight.w800,
height: 1.18,
),
),
),
],
);
}
}
/// Row of duration, project, and priority metadata.
class _CardMetadataRow extends StatelessWidget {
/// Creates a metadata row for [item].
const _CardMetadataRow({required this.item});
/// Task card data to display.
final BacklogBoardItemReadModel item;
/// 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 projectColor = BacklogBoardVisualTokens.projectColor(
item.projectColorToken,
);
final priorityColor = BacklogBoardVisualTokens.priorityColor(item.priority);
return Wrap(
spacing: 12,
runSpacing: 7,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_IconMetadata(
icon: Icons.schedule,
color: FocusFlowTokens.textMuted,
label: _durationLabel(item.durationMinutes),
),
_DotMetadata(color: projectColor, label: item.projectName),
_IconMetadata(
icon: Icons.flag,
color: priorityColor,
label: BacklogBoardVisualTokens.priorityLabel(item.priority),
),
],
);
}
}
/// Compact metadata item with a Material icon.
class _IconMetadata extends StatelessWidget {
/// Creates icon metadata.
const _IconMetadata({
required this.icon,
required this.color,
required this.label,
});
/// Icon shown before the label.
final IconData icon;
/// Color used by the icon.
final Color color;
/// Display 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 Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: color, size: 13),
const SizedBox(width: 5),
Text(
label,
style: const TextStyle(
color: FocusFlowTokens.textMuted,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
],
);
}
}
/// Compact metadata item with a color dot.
class _DotMetadata extends StatelessWidget {
/// Creates dot metadata.
const _DotMetadata({required this.color, required this.label});
/// Dot color.
final Color color;
/// Display 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 Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(
color: FocusFlowTokens.textMuted,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
],
);
}
}
/// Footer row with difficulty bars and reward marks.
class _CardEffortRow extends StatelessWidget {
/// Creates an effort row for [item].
const _CardEffortRow({required this.item, required this.accent});
/// Task card data to display.
final BacklogBoardItemReadModel item;
/// Bucket accent used for compact indicators.
final Color accent;
/// 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: _MetricCluster(
label: 'Difficulty',
child: DifficultyBars.forLevel(
difficulty: item.difficulty,
color: accent,
size: const Size(36, 18),
),
),
),
const SizedBox(width: 10),
Expanded(
child: _MetricCluster(
label: 'Reward',
alignment: MainAxisAlignment.end,
child: _RewardMarks(reward: item.reward, color: accent),
),
),
],
);
}
}
/// Label-and-value cluster used by the card footer.
class _MetricCluster extends StatelessWidget {
/// Creates a compact metric cluster.
const _MetricCluster({
required this.label,
required this.child,
this.alignment = MainAxisAlignment.start,
});
/// Metric label.
final String label;
/// Indicator widget.
final Widget child;
/// Horizontal alignment for the cluster content.
final MainAxisAlignment alignment;
/// 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(
mainAxisAlignment: alignment,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: FocusFlowTokens.textMuted,
fontSize: 10.5,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 6),
child,
],
);
}
}
/// Solid reward marks rendered from a domain reward level.
class _RewardMarks extends StatelessWidget {
/// Creates reward marks.
const _RewardMarks({required this.reward, required this.color});
/// Reward level to display.
final RewardLevel reward;
/// Mark color.
final Color color;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
final count = BacklogBoardVisualTokens.rewardCount(reward);
if (count == 0) {
return const Text(
'None',
style: TextStyle(color: FocusFlowTokens.textFaint, fontSize: 10.5),
);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var index = 0; index < count; index += 1)
Padding(
padding: EdgeInsets.only(left: index == 0 ? 0 : 1.5),
child: RewardIcon(color: color, size: 11),
),
],
);
}
}
/// Nested child preview rows for parent Backlog cards.
class _ChildPreviewSection extends StatelessWidget {
/// Creates a child preview section.
const _ChildPreviewSection({required this.children});
/// Child preview read models.
final List<BacklogChildPreviewReadModel> 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 DecoratedBox(
decoration: BoxDecoration(
border: Border(
left: BorderSide(
color: FocusFlowTokens.subtleBorder.withValues(alpha: 0.75),
),
),
),
child: Padding(
padding: const EdgeInsets.only(left: 10),
child: Column(
children: [
for (final child in children)
_ChildPreviewRow(
key: ValueKey('backlog-child-${child.taskId}'),
child: child,
),
],
),
),
);
}
}
/// One nested child preview row inside a Backlog task card.
class _ChildPreviewRow extends StatelessWidget {
/// Creates a child preview row.
const _ChildPreviewRow({required this.child, super.key});
/// Child preview data.
final BacklogChildPreviewReadModel child;
/// 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(top: 7),
child: Row(
children: [
Icon(
child.isCompleted
? Icons.check_box_outlined
: Icons.check_box_outline_blank,
color: FocusFlowTokens.textFaint,
size: 14,
),
const SizedBox(width: 8),
Expanded(
child: Text(
child.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: child.isCompleted
? FocusFlowTokens.completedText
: FocusFlowTokens.textMuted,
fontSize: 11,
decoration: child.isCompleted
? TextDecoration.lineThrough
: TextDecoration.none,
),
),
),
if (child.durationMinutes != null) ...[
const SizedBox(width: 8),
_IconMetadata(
icon: Icons.schedule,
color: FocusFlowTokens.textFaint,
label: _durationLabel(child.durationMinutes),
),
],
],
),
);
}
}
/// Formats [durationMinutes] as compact card metadata.
String _durationLabel(int? durationMinutes) {
if (durationMinutes == null) {
return 'No estimate';
}
if (durationMinutes < 60) {
return '$durationMinutes min';
}
final hours = durationMinutes ~/ 60;
final minutes = durationMinutes % 60;
if (minutes == 0) {
return '${hours}h';
}
return '${hours}h ${minutes}m';
}

View file

@ -19,6 +19,21 @@ class DifficultyBars extends StatelessWidget {
super.key,
});
/// Creates difficulty bars directly from a domain difficulty level.
factory DifficultyBars.forLevel({
required DifficultyLevel difficulty,
Color color = FocusFlowTokens.restPurple,
Size size = const Size(54, 34),
Key? key,
}) {
return DifficultyBars(
key: key,
difficulty: tokenForLevel(difficulty),
color: color,
size: size,
);
}
/// Difficulty token that determines how many bars are filled.
final TimelineDifficultyIconToken difficulty;
@ -52,6 +67,18 @@ class DifficultyBars extends StatelessWidget {
};
}
/// Converts a domain difficulty level to a timeline difficulty token.
static TimelineDifficultyIconToken tokenForLevel(DifficultyLevel difficulty) {
return switch (difficulty) {
DifficultyLevel.notSet => TimelineDifficultyIconToken.notSet,
DifficultyLevel.veryEasy => TimelineDifficultyIconToken.veryEasy,
DifficultyLevel.easy => TimelineDifficultyIconToken.easy,
DifficultyLevel.medium => TimelineDifficultyIconToken.medium,
DifficultyLevel.hard => TimelineDifficultyIconToken.hard,
DifficultyLevel.veryHard => TimelineDifficultyIconToken.veryHard,
};
}
/// 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

View file

@ -0,0 +1,224 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Tests Backlog board screen card rendering.
library;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:scheduler_core/scheduler_core.dart';
import 'package:focus_flow_flutter/controllers/backlog_board_controller.dart';
import 'package:focus_flow_flutter/controllers/selected_date_controller.dart';
import 'package:focus_flow_flutter/theme/focus_flow_theme.dart';
import 'package:focus_flow_flutter/theme/focus_flow_tokens.dart';
import 'package:focus_flow_flutter/widgets/backlog/backlog_board_screen.dart';
/// Runs Backlog board widget tests.
void main() {
testWidgets('renders card metadata child previews and selected state', (
tester,
) async {
final detailReads = <String>[];
final item = _boardItemWithChildren();
final board = _boardWithItem(item);
final selectedDates = SelectedDateController(
initialDate: CivilDate(2026, 7, 7),
);
final controller = BacklogBoardController(
readBoard: (_) async => ApplicationResult.success(board),
readTaskDetail: (taskId, localDate) async {
detailReads.add(taskId);
return ApplicationResult.success(_detailFor(item));
},
selectedDates: selectedDates,
dateLabelFor: (date) => date.toIsoString(),
);
addTearDown(controller.dispose);
addTearDown(selectedDates.dispose);
await controller.load();
await _pumpBacklogScreen(tester, controller);
expect(find.text('Do Next'), findsOneWidget);
expect(find.text('Need Time Block'), findsOneWidget);
expect(find.text('Break Up First'), findsOneWidget);
expect(find.text('Not Now'), findsOneWidget);
final card = find.byKey(const ValueKey('backlog-card-plan-week'));
expect(card, findsOneWidget);
expect(find.text('Plan next week'), findsOneWidget);
expect(find.text('Outline priorities + tasks'), findsOneWidget);
expect(
find.descendant(of: card, matching: find.text('30 min')),
findsOneWidget,
);
expect(
find.descendant(of: card, matching: find.text('Work')),
findsOneWidget,
);
expect(
find.descendant(of: card, matching: find.text('High')),
findsOneWidget,
);
expect(
find.descendant(of: card, matching: find.text('Difficulty')),
findsOneWidget,
);
expect(
find.descendant(of: card, matching: find.text('Reward')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('backlog-child-define-weekly-goals')),
findsOneWidget,
);
expect(find.text('Define weekly goals'), findsOneWidget);
expect(find.text('15 min'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('backlog-card-plan-week')));
await tester.pumpAndSettle();
expect(detailReads, ['plan-week']);
expect(controller.selectedTaskId, 'plan-week');
final cardContainer = tester.widget<AnimatedContainer>(
find.byKey(const ValueKey('backlog-card-container-plan-week')),
);
final decoration = cardContainer.decoration! as BoxDecoration;
final border = decoration.border! as Border;
expect(border.top.color, FocusFlowTokens.accentMagenta);
});
}
/// Pumps [controller] inside the dark FocusFlow app shell test harness.
Future<void> _pumpBacklogScreen(
WidgetTester tester,
BacklogBoardController controller,
) async {
await tester.binding.setSurfaceSize(const Size(1320, 820));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
MaterialApp(
theme: FocusFlowTheme.dark(),
home: Scaffold(
body: SizedBox(
width: 1300,
height: 780,
child: BacklogBoardScreen(controller: controller),
),
),
),
);
await tester.pumpAndSettle();
}
/// Builds a board item with child preview rows for widget tests.
BacklogBoardItemReadModel _boardItemWithChildren() {
return BacklogBoardItemReadModel(
taskId: 'plan-week',
title: 'Plan next week',
subtitle: 'Outline priorities + tasks',
projectId: 'work',
projectName: 'Work',
projectColorToken: 'project-work',
durationMinutes: 30,
priority: PriorityLevel.high,
reward: RewardLevel.high,
difficulty: DifficultyLevel.medium,
bucket: BacklogBoardBucket.breakUpFirst,
bucketReason: 'Already has child-task planning.',
createdAt: DateTime.utc(2026, 7, 7),
updatedAt: DateTime.utc(2026, 7, 7),
childPreview: const [
BacklogChildPreviewReadModel(
taskId: 'define-weekly-goals',
title: 'Define weekly goals',
durationMinutes: 15,
isCompleted: false,
),
],
tags: const ['someday'],
);
}
/// Builds a Backlog board containing [item] and the other stable empty columns.
BacklogBoardQueryResult _boardWithItem(BacklogBoardItemReadModel item) {
return BacklogBoardQueryResult(
ownerId: 'owner-1',
localDate: CivilDate(2026, 7, 7),
columns: [
BacklogBoardColumnReadModel(
bucket: BacklogBoardBucket.doNext,
title: 'Do Next',
subtitle: 'Ready when you have a small opening.',
accentToken: 'backlog-do-next',
items: const [],
),
BacklogBoardColumnReadModel(
bucket: BacklogBoardBucket.needTimeBlock,
title: 'Need Time Block',
subtitle: 'Give these a protected block.',
accentToken: 'backlog-time-block',
items: const [],
),
BacklogBoardColumnReadModel(
bucket: BacklogBoardBucket.breakUpFirst,
title: 'Break Up First',
subtitle: 'Split these before scheduling.',
accentToken: 'backlog-break-up',
items: [item],
),
BacklogBoardColumnReadModel(
bucket: BacklogBoardBucket.notNow,
title: 'Not Now',
subtitle: 'Keep these out of today pressure.',
accentToken: 'backlog-not-now',
items: const [],
),
],
summary: BacklogBoardSummaryReadModel(
totalTaskCount: 1,
totalEstimatedMinutes: item.durationMinutes ?? 0,
priorityDistribution: BacklogDistributionReadModel(
title: 'By priority',
entries: const [
BacklogDistributionEntryReadModel(
id: 'high',
label: 'High',
count: 1,
percentage: 1,
colorToken: 'priority-high',
),
],
),
projectDistribution: BacklogDistributionReadModel(
title: 'By project',
entries: const [
BacklogDistributionEntryReadModel(
id: 'work',
label: 'Work',
count: 1,
percentage: 1,
colorToken: 'project-work',
),
],
),
durationDistribution: BacklogDistributionReadModel(
title: 'Duration distribution',
entries: const [],
),
planningTip: 'Pick one task.',
),
projectOptions: const [],
);
}
/// Builds selected-task detail for [item].
BacklogTaskDetailReadModel _detailFor(BacklogBoardItemReadModel item) {
return BacklogTaskDetailReadModel(
item: item,
addedLabel: 'Added today',
tags: item.tags,
projectOptions: const [],
suggestedSlots: const [],
);
}