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.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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 '../../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,169 +469,76 @@ 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,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
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: [
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
/// 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
|
||||
|
|
|
|||
|
|
@ -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