Wire Backlog nav, Break up, Settings dialog + merge Backlog Board feature #20
7 changed files with 1313 additions and 220 deletions
|
|
@ -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.
|
shells, loading/empty/failure states, and a compact summary panel shell.
|
||||||
Detailed cards, charts, and drawer contents remain for later UI Plan 2
|
Detailed cards, charts, and drawer contents remain for later UI Plan 2
|
||||||
blocks.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import 'package:scheduler_core/scheduler_core.dart';
|
||||||
import '../../controllers/backlog_board_controller.dart';
|
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';
|
||||||
|
|
||||||
/// 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 {
|
||||||
|
|
@ -45,12 +46,27 @@ class BacklogBoardScreen extends StatelessWidget {
|
||||||
BacklogBoardEmpty(:final data) => _BacklogFrame(
|
BacklogBoardEmpty(:final data) => _BacklogFrame(
|
||||||
taskCountLabel: '${data.summary.totalTaskCount} tasks',
|
taskCountLabel: '${data.summary.totalTaskCount} tasks',
|
||||||
controller: controller,
|
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(
|
BacklogBoardReady(:final data) => _BacklogFrame(
|
||||||
taskCountLabel: '${data.summary.totalTaskCount} tasks',
|
taskCountLabel: '${data.summary.totalTaskCount} tasks',
|
||||||
controller: controller,
|
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.
|
/// Main Backlog frame containing header, controls, body, and overlay slot.
|
||||||
class _BacklogFrame extends StatelessWidget {
|
class _BacklogFrame extends StatelessWidget {
|
||||||
/// Creates the Backlog frame.
|
/// Creates the Backlog frame.
|
||||||
|
|
@ -396,11 +415,26 @@ class _BacklogFailureBody extends StatelessWidget {
|
||||||
/// Ready or empty Backlog board body.
|
/// Ready or empty Backlog board body.
|
||||||
class _BacklogReadyBody extends StatelessWidget {
|
class _BacklogReadyBody extends StatelessWidget {
|
||||||
/// Creates a ready body.
|
/// 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.
|
/// Backlog board data.
|
||||||
final BacklogBoardScreenData 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.
|
/// Whether the board has no tasks.
|
||||||
final bool isEmpty;
|
final bool isEmpty;
|
||||||
|
|
||||||
|
|
@ -412,14 +446,11 @@ class _BacklogReadyBody extends StatelessWidget {
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Row(
|
child: _BacklogBoardColumns(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
columns: data.columns,
|
||||||
children: [
|
selectedTaskId: selectedTaskId,
|
||||||
for (final column in data.columns) ...[
|
onTaskSelected: onTaskSelected,
|
||||||
Expanded(child: _BacklogColumnShell(column: column)),
|
onAddTask: onAddTask,
|
||||||
if (column != data.columns.last) const SizedBox(width: 10),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
|
|
@ -438,170 +469,77 @@ class _BacklogReadyBody extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One Backlog board column shell.
|
/// Horizontally scrollable set of Backlog board columns.
|
||||||
class _BacklogColumnShell extends StatelessWidget {
|
class _BacklogBoardColumns extends StatelessWidget {
|
||||||
/// Creates a column shell.
|
/// Creates a board column layout.
|
||||||
const _BacklogColumnShell({required this.column});
|
const _BacklogBoardColumns({
|
||||||
|
required this.columns,
|
||||||
/// Column read model.
|
required this.selectedTaskId,
|
||||||
final BacklogBoardColumnReadModel column;
|
required this.onTaskSelected,
|
||||||
|
required this.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 = _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,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Accent color inherited from the column.
|
/// Columns to render in scheduler-provided display order.
|
||||||
final Color accent;
|
final List<BacklogBoardColumnReadModel> columns;
|
||||||
|
|
||||||
/// Optional task title for a loaded board item.
|
/// Currently selected task id, if any.
|
||||||
final String? title;
|
final String? selectedTaskId;
|
||||||
|
|
||||||
/// Whether this skeleton represents a loaded item.
|
/// Callback invoked when a task card is selected.
|
||||||
final bool active;
|
final BacklogTaskSelected onTaskSelected;
|
||||||
|
|
||||||
|
/// Callback invoked when an add-task control is pressed.
|
||||||
|
final BacklogBucketAddRequested onAddTask;
|
||||||
|
|
||||||
/// Builds the widget subtree for this component.
|
/// Builds the widget subtree for this component.
|
||||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
if (columns.isEmpty) {
|
||||||
height: 86,
|
return const SizedBox.shrink();
|
||||||
padding: const EdgeInsets.all(12),
|
}
|
||||||
decoration: BoxDecoration(
|
return LayoutBuilder(
|
||||||
color: FocusFlowTokens.elevatedPanel.withValues(
|
builder: (context, constraints) {
|
||||||
alpha: active ? 1 : 0.55,
|
const columnGap = 10.0;
|
||||||
),
|
const minColumnWidth = 260.0;
|
||||||
borderRadius: BorderRadius.circular(8),
|
const maxColumnWidth = 300.0;
|
||||||
border: Border.all(color: FocusFlowTokens.subtleBorder),
|
final gapWidth = columnGap * (columns.length - 1);
|
||||||
),
|
final availableWidth = constraints.hasBoundedWidth
|
||||||
child: Column(
|
? constraints.maxWidth
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
: 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: [
|
children: [
|
||||||
Row(
|
for (var index = 0; index < columns.length; index += 1) ...[
|
||||||
children: [
|
SizedBox(
|
||||||
Container(
|
width: columnWidth,
|
||||||
width: 14,
|
child: BacklogBoardColumn(
|
||||||
height: 14,
|
column: columns[index],
|
||||||
decoration: BoxDecoration(
|
selectedTaskId: selectedTaskId,
|
||||||
borderRadius: BorderRadius.circular(3),
|
onTaskSelected: onTaskSelected,
|
||||||
border: Border.all(color: FocusFlowTokens.textFaint),
|
onAddTask: onAddTask,
|
||||||
),
|
|
||||||
),
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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.
|
/// Small count pill used by headers.
|
||||||
class _CountPill extends StatelessWidget {
|
class _CountPill extends StatelessWidget {
|
||||||
/// Creates a count pill.
|
/// Creates a count pill.
|
||||||
const _CountPill({required this.label, this.color});
|
const _CountPill({required this.label});
|
||||||
|
|
||||||
/// Pill label.
|
/// Pill label.
|
||||||
final String label;
|
final String label;
|
||||||
|
|
||||||
/// Optional accent color.
|
|
||||||
final Color? color;
|
|
||||||
|
|
||||||
/// Builds the widget subtree for this component.
|
/// Builds the widget subtree for this component.
|
||||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final accent = color ?? FocusFlowTokens.textMuted;
|
const accent = FocusFlowTokens.textMuted;
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -881,8 +816,8 @@ class _CountPill extends StatelessWidget {
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
label,
|
label,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
color: color ?? FocusFlowTokens.textPrimary,
|
color: FocusFlowTokens.textPrimary,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w800,
|
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,21 @@ class DifficultyBars extends StatelessWidget {
|
||||||
super.key,
|
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.
|
/// Difficulty token that determines how many bars are filled.
|
||||||
final TimelineDifficultyIconToken difficulty;
|
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.
|
/// Builds the widget subtree for this component.
|
||||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -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 [],
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue