fix: refine timeline task interactions

This commit is contained in:
Ashley Venn 2026-07-01 12:01:18 -07:00
parent f6afe0d793
commit 52dcc84b7d
16 changed files with 1054 additions and 285 deletions

View file

@ -167,14 +167,14 @@ class DemoSchedulerComposition {
}
/// Creates an application operation context for the supplied [operationId].
ApplicationOperationContext context(String operationId) {
ApplicationOperationContext context(String operationId, {DateTime? now}) {
return ApplicationOperationContext.start(
operationId: operationId,
ownerTimeZone: OwnerTimeZoneContext(
ownerId: ownerId,
timeZoneId: timeZoneId,
),
clock: FixedClock(readAt),
clock: FixedClock(now ?? readAt),
idGenerator: SequentialIdGenerator(prefix: operationId),
);
}
@ -215,18 +215,18 @@ class DemoSchedulerComposition {
createdAt: createdAt,
),
_task(
id: 'cleaned-kitchen-counter-early',
id: 'cleaned-kitchen-counter',
title: 'Cleaned kitchen counter',
type: TaskType.surprise,
type: TaskType.flexible,
status: TaskStatus.completed,
date: date,
startHour: 16,
startMinute: 45,
endHour: 17,
endMinute: 0,
startHour: 8,
startMinute: 15,
endHour: 8,
endMinute: 30,
reward: RewardLevel.medium,
difficulty: DifficultyLevel.medium,
completedAt: _instant(date, 16, 20),
completedAt: _instant(date, 8, 5),
createdAt: createdAt,
),
_task(
@ -313,21 +313,6 @@ class DemoSchedulerComposition {
difficulty: DifficultyLevel.notSet,
createdAt: createdAt,
),
_task(
id: 'cleaned-kitchen-counter-late',
title: 'Cleaned kitchen counter',
type: TaskType.surprise,
status: TaskStatus.completed,
date: date,
startHour: 20,
startMinute: 15,
endHour: 20,
endMinute: 30,
reward: RewardLevel.medium,
difficulty: DifficultyLevel.medium,
completedAt: _instant(date, 20, 20),
createdAt: createdAt,
),
];
}

View file

@ -3,6 +3,7 @@ library;
import 'package:flutter/material.dart';
import '../controllers/scheduler_command_controller.dart';
import '../controllers/today_screen_controller.dart';
import '../models/today_screen_models.dart';
import '../theme/focus_flow_theme.dart';
@ -47,21 +48,40 @@ class FocusFlowHome extends StatefulWidget {
}
class _FocusFlowHomeState extends State<FocusFlowHome> {
late final TodayScreenController controller = widget.composition
.createTodayScreenController();
late final TodayScreenController controller;
late final SchedulerCommandController commandController;
@override
void initState() {
super.initState();
controller = widget.composition.createTodayScreenController();
commandController = widget.composition.createCommandController(
refreshReads: controller.load,
);
controller.load();
}
@override
void dispose() {
commandController.dispose();
controller.dispose();
super.dispose();
}
Future<void> _toggleCardCompletion(TimelineCardModel card) async {
if (!card.canToggleCompletion) {
return;
}
if (card.isCompleted) {
await commandController.uncompleteTask(taskId: card.id);
} else {
await commandController.completeTask(
taskId: card.id,
taskType: card.taskType,
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -84,12 +104,14 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
),
selectedCard: controller.selectedCard,
onSelectCard: controller.selectCard,
onCompleteCard: _toggleCardCompletion,
onClearSelection: controller.clearSelection,
),
TodayScreenReady(:final data) => _TodayScreenScaffold(
data: data,
selectedCard: controller.selectedCard,
onSelectCard: controller.selectCard,
onCompleteCard: _toggleCardCompletion,
onClearSelection: controller.clearSelection,
),
};
@ -118,12 +140,14 @@ class _TodayScreenScaffold extends StatefulWidget {
required this.data,
required this.selectedCard,
required this.onSelectCard,
required this.onCompleteCard,
required this.onClearSelection,
});
final TodayScreenData data;
final TimelineCardModel? selectedCard;
final ValueChanged<TimelineCardModel> onSelectCard;
final Future<void> Function(TimelineCardModel card) onCompleteCard;
final VoidCallback onClearSelection;
@override
@ -160,12 +184,16 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TopBar(dateLabel: widget.data.dateLabel),
const SizedBox(height: 24),
RequiredBanner(
model: widget.data.requiredBanner,
onShowUpcoming: _showUpcomingRequiredTask,
),
const SizedBox(height: 22),
if (widget.data.requiredBanner == null)
const SizedBox(height: 16)
else ...[
const SizedBox(height: 24),
RequiredBanner(
model: widget.data.requiredBanner,
onShowUpcoming: _showUpcomingRequiredTask,
),
const SizedBox(height: 22),
],
Expanded(
child: TimelineView(
cards: widget.data.cards,
@ -173,6 +201,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
scrollTargetCardId: _timelineScrollTargetCardId,
scrollRequest: _timelineScrollRequest,
onCardSelected: widget.onSelectCard,
onCardCompleted: widget.onCompleteCard,
),
),
],
@ -191,6 +220,9 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
child: TaskSelectionModal(
card: widget.selectedCard!,
onClose: widget.onClearSelection,
onStatusPressed: widget.selectedCard!.canToggleCompletion
? () => widget.onCompleteCard(widget.selectedCard!)
: null,
),
),
],

View file

@ -6,7 +6,7 @@ import 'package:scheduler_core/scheduler_core.dart';
/// Builds an application operation context for a UI command operation.
typedef OperationContextFactory =
ApplicationOperationContext Function(String operationId);
ApplicationOperationContext Function(String operationId, {DateTime? now});
/// Refreshes any reads that should reflect a completed command.
typedef ReadRefresh = Future<void> Function();
@ -70,7 +70,8 @@ class SchedulerCommandController extends ChangeNotifier {
required this.contextFor,
required this.localDate,
required this.refreshReads,
});
DateTime Function()? now,
}) : now = now ?? _utcNow;
/// Scheduler write use cases invoked by this controller.
final V1ApplicationCommandUseCases commands;
@ -83,6 +84,9 @@ class SchedulerCommandController extends ChangeNotifier {
/// Refresh callback invoked after commands that mutate read state.
final ReadRefresh refreshReads;
/// Clock used by direct UI commands such as marking a task complete.
final DateTime Function() now;
var _sequence = 0;
SchedulerCommandState _state = const SchedulerCommandIdle();
@ -132,13 +136,70 @@ class SchedulerCommandController extends ChangeNotifier {
required String taskId,
DateTime? expectedUpdatedAt,
}) async {
await completeTask(
taskId: taskId,
taskType: TaskType.flexible,
expectedUpdatedAt: expectedUpdatedAt,
);
}
/// Completes a planned task from the Today timeline.
Future<void> completeTask({
required String taskId,
required TaskType taskType,
DateTime? expectedUpdatedAt,
}) async {
final completedAt = now();
await _run(
label: 'Completing task',
successMessage: 'Task completed',
refreshAfterSuccess: true,
action: (operationId) {
return commands.completeFlexibleTask(
context: contextFor(operationId),
final context = contextFor(operationId, now: completedAt);
return switch (taskType) {
TaskType.flexible => commands.completeFlexibleTask(
context: context,
localDate: localDate,
taskId: taskId,
expectedUpdatedAt: expectedUpdatedAt,
),
TaskType.critical ||
TaskType.inflexible => commands.applyRequiredTaskAction(
context: context,
localDate: localDate,
taskId: taskId,
action: RequiredTaskAction.done,
expectedUpdatedAt: expectedUpdatedAt,
),
TaskType.locked ||
TaskType.surprise ||
TaskType.freeSlot => Future.value(
ApplicationResult.failure(
ApplicationFailure(
code: ApplicationFailureCode.validation,
entityId: taskId,
detailCode: 'unsupportedTaskType',
),
),
),
};
},
);
}
/// Returns a completed task to its planned state.
Future<void> uncompleteTask({
required String taskId,
DateTime? expectedUpdatedAt,
}) async {
final occurredAt = now();
await _run(
label: 'Reopening task',
successMessage: 'Task marked as not done',
refreshAfterSuccess: true,
action: (operationId) {
return commands.uncompleteTask(
context: contextFor(operationId, now: occurredAt),
localDate: localDate,
taskId: taskId,
expectedUpdatedAt: expectedUpdatedAt,
@ -183,4 +244,6 @@ class SchedulerCommandController extends ChangeNotifier {
_state = next;
notifyListeners();
}
static DateTime _utcNow() => DateTime.now().toUtc();
}

View file

@ -76,6 +76,7 @@ class TodayScreenController extends ChangeNotifier {
}
final data = TodayScreenData.fromTodayState(result.requireValue);
_syncSelectedCard(data);
_state = data.cards.isEmpty
? const TodayScreenEmpty()
: TodayScreenReady(data);
@ -103,4 +104,18 @@ class TodayScreenController extends ChangeNotifier {
_selectedCard = null;
notifyListeners();
}
void _syncSelectedCard(TodayScreenData data) {
final selected = _selectedCard;
if (selected == null) {
return;
}
for (final card in data.cards) {
if (card.id == selected.id) {
_selectedCard = card;
return;
}
}
_selectedCard = null;
}
}

View file

@ -124,12 +124,14 @@ class TimelineCardModel {
required this.timeText,
required this.startMinutes,
required this.endMinutes,
required this.taskType,
required this.visualKind,
required this.rewardIconToken,
required this.difficultyIconToken,
required this.isCompleted,
required this.isSelectable,
required this.showsQuickActions,
this.completedTimeText,
this.durationMinutes,
});
@ -146,18 +148,21 @@ class TimelineCardModel {
? ''
: '$duration min'
: '${_formatTime(start)} - ${_formatTime(end)}';
final completedAt = item.item.completedAt ?? item.item.end ?? item.end;
return TimelineCardModel(
id: item.id,
title: title,
typeLabel: _typeLabelFor(visualKind),
typeLabel: _typeLabelFor(item.taskType, visualKind),
subtitle: _subtitleFor(item, visualKind, timeText),
timeText: timeText,
startMinutes: _minutesSinceMidnight(start),
endMinutes: _minutesSinceMidnight(end),
taskType: item.taskType,
visualKind: visualKind,
rewardIconToken: item.item.rewardIconToken,
difficultyIconToken: item.item.difficultyIconToken,
durationMinutes: duration,
completedTimeText: isCompleted ? _formatTime(completedAt) : null,
isCompleted: isCompleted,
isSelectable: true,
showsQuickActions: !isCompleted && visualKind != TaskVisualKind.freeSlot,
@ -179,6 +184,9 @@ class TimelineCardModel {
/// Display-ready time or duration text.
final String timeText;
/// Display-ready completion time, if the task has been completed.
final String? completedTimeText;
/// Card start position in minutes since midnight.
final int startMinutes;
@ -188,6 +196,9 @@ class TimelineCardModel {
/// Optional task duration in minutes.
final int? durationMinutes;
/// Source task scheduling behavior type.
final TaskType taskType;
/// Visual category used for styling.
final TaskVisualKind visualKind;
@ -206,6 +217,20 @@ class TimelineCardModel {
/// Whether the card should show compact quick-action controls.
final bool showsQuickActions;
/// Whether the left status control can toggle this task's completion.
bool get canToggleCompletion {
return switch (taskType) {
TaskType.flexible || TaskType.critical || TaskType.inflexible => true,
TaskType.locked || TaskType.surprise || TaskType.freeSlot => false,
};
}
/// Whether the left status control can complete this task.
bool get canComplete => canToggleCompletion && !isCompleted;
/// Whether the left status control can return this task to planned.
bool get canUncomplete => canToggleCompletion && isCompleted;
/// Accessibility and modal label for the reward icon.
String get rewardLabel {
return switch (rewardIconToken) {
@ -247,13 +272,17 @@ TaskVisualKind _visualKindFor(TodayTimelineItem item) {
};
}
String _typeLabelFor(TaskVisualKind visualKind) {
return switch (visualKind) {
TaskVisualKind.flexible => 'Flexible',
TaskVisualKind.required => 'Required',
TaskVisualKind.appointment => 'Required',
TaskVisualKind.freeSlot => 'Free Slot',
TaskVisualKind.completedSurprise => 'Surprise task',
String _typeLabelFor(TaskType taskType, TaskVisualKind visualKind) {
if (visualKind == TaskVisualKind.completedSurprise) {
return 'Completed';
}
return switch (taskType) {
TaskType.flexible => 'Flexible',
TaskType.critical => 'Required',
TaskType.inflexible => 'Required',
TaskType.freeSlot => 'Free Slot',
TaskType.surprise => 'Surprise task',
TaskType.locked => 'Locked',
};
}
@ -265,9 +294,10 @@ String _subtitleFor(
if (visualKind == TaskVisualKind.freeSlot) {
return 'Intentional rest';
}
if (visualKind == TaskVisualKind.completedSurprise) {
final completedAt = _formatTime(item.item.end ?? item.end);
return 'Surprise task - Completed at $completedAt';
if (item.taskStatus == TaskStatus.completed ||
visualKind == TaskVisualKind.completedSurprise) {
return 'Completed at: '
'${_formatTime(item.item.completedAt ?? item.item.end ?? item.end)}';
}
if (item.taskType == TaskType.flexible && item.item.durationMinutes != null) {
return '${item.item.durationMinutes} min';

View file

@ -93,7 +93,7 @@ abstract final class FocusFlowTokens {
static const bannerRadius = 8.0;
/// Border radius for timeline cards.
static const cardRadius = 8.0;
static const cardRadius = 12.0;
/// Border radius for task selection modals.
static const modalRadius = 8.0;
@ -111,7 +111,7 @@ abstract final class FocusFlowTokens {
static const cardMetaSize = 16.0;
/// Font size for sidebar navigation labels.
static const sidebarNavSize = 18.0;
static const sidebarNavSize = 16.0;
/// Font size for modal titles.
static const modalTitleSize = 26.0;

View file

@ -21,7 +21,7 @@ class RequiredBanner extends StatelessWidget {
Widget build(BuildContext context) {
final banner = model;
if (banner == null) {
return const SizedBox(height: _RequiredBannerMetrics.referenceHeight);
return const SizedBox.shrink();
}
return LayoutBuilder(
builder: (context, constraints) {
@ -119,11 +119,11 @@ class _RequiredBannerMetrics {
double get iconSize => 24 * scale;
double get iconGap => 26 * scale;
double get buttonGap => 20 * scale;
double get textSize => 19 * scale;
double get textSize => (19 * scale).clamp(13.0, 14.0).toDouble();
double get buttonWidth => 176 * scale;
double get buttonHeight => 46 * scale;
double get buttonRadius => FocusFlowTokens.smallButtonRadius * scale;
double get buttonTextSize => 16 * scale;
double get buttonTextSize => (16 * scale).clamp(11.0, 12.5).toDouble();
double get buttonIconSize => 20 * scale;
double get buttonTextGap => 6 * scale;
EdgeInsets get buttonPadding => EdgeInsets.symmetric(horizontal: 12 * scale);

View file

@ -14,6 +14,7 @@ class TaskSelectionModal extends StatelessWidget {
const TaskSelectionModal({
required this.card,
required this.onClose,
this.onStatusPressed,
super.key,
});
@ -23,6 +24,9 @@ class TaskSelectionModal extends StatelessWidget {
/// Callback invoked when the modal should close.
final VoidCallback onClose;
/// Callback invoked when the status circle should toggle completion.
final VoidCallback? onStatusPressed;
@override
Widget build(BuildContext context) {
final accent = _accentFor(card.visualKind);
@ -43,13 +47,10 @@ class TaskSelectionModal extends StatelessWidget {
children: [
Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: accent, width: 4),
),
_ModalStatusButton(
card: card,
color: accent,
onPressed: onStatusPressed,
),
const SizedBox(width: 28),
Expanded(
@ -65,13 +66,7 @@ class TaskSelectionModal extends StatelessWidget {
),
),
const SizedBox(height: 6),
Text(
'${card.typeLabel} - ${card.timeText}',
style: const TextStyle(
color: FocusFlowTokens.textMuted,
fontSize: 17,
),
),
_HeaderMetadata(card: card),
],
),
),
@ -137,6 +132,93 @@ class TaskSelectionModal extends StatelessWidget {
}
}
class _ModalStatusButton extends StatelessWidget {
const _ModalStatusButton({
required this.card,
required this.color,
required this.onPressed,
});
final TimelineCardModel card;
final Color color;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
final ring = Container(
key: const ValueKey('task-modal-status'),
width: 44,
height: 44,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: card.isCompleted ? color : Colors.transparent,
border: Border.all(color: color, width: 4),
),
child: card.isCompleted
? const Icon(
Icons.check,
color: FocusFlowTokens.appBackground,
size: 26,
)
: null,
);
if (onPressed == null) {
return ring;
}
return Tooltip(
message: card.isCompleted ? 'Mark not done' : 'Mark complete',
child: Semantics(
button: true,
label: card.isCompleted
? 'Mark ${card.title} not done'
: 'Mark ${card.title} complete',
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onPressed,
child: ring,
),
),
),
);
}
}
class _HeaderMetadata extends StatelessWidget {
const _HeaderMetadata({required this.card});
final TimelineCardModel card;
@override
Widget build(BuildContext context) {
const metadataStyle = TextStyle(
color: FocusFlowTokens.textMuted,
fontSize: 17,
);
if (!card.isCompleted) {
return Text('${card.typeLabel} - ${card.timeText}', style: metadataStyle);
}
final completedTime = card.completedTimeText;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
completedTime == null || completedTime.isEmpty
? 'Completed'
: 'Completed - $completedTime',
style: metadataStyle,
),
const SizedBox(height: 3),
Text(
'Planned: ${card.timeText}',
style: metadataStyle.copyWith(fontSize: 15),
),
],
);
}
}
class _InfoTile extends StatelessWidget {
const _InfoTile({required this.child, required this.label});

View file

@ -13,7 +13,12 @@ import 'reward_icon.dart';
/// Interactive card that renders a scheduled task on the compact timeline.
class TaskTimelineCard extends StatefulWidget {
/// Creates a task timeline card.
const TaskTimelineCard({required this.card, required this.onTap, super.key});
const TaskTimelineCard({
required this.card,
required this.onTap,
this.onStatusPressed,
super.key,
});
/// Card model to render.
final TimelineCardModel card;
@ -21,6 +26,9 @@ class TaskTimelineCard extends StatefulWidget {
/// Callback invoked when the card is tapped.
final VoidCallback onTap;
/// Callback invoked when the left status control is clicked.
final VoidCallback? onStatusPressed;
@override
State<TaskTimelineCard> createState() => _TaskTimelineCardState();
}
@ -38,51 +46,37 @@ class _TaskTimelineCardState extends State<TaskTimelineCard> {
constraints,
kind: card.visualKind,
);
final content = metrics.isCompact
? _CompactCardText(
card: card,
color: colors.accent,
metrics: metrics,
)
: Row(
children: [
_StatusRing(
card: card,
color: colors.accent,
metrics: metrics,
),
SizedBox(width: metrics.statusGap),
Expanded(
child: _CardText(
card: card,
color: colors.accent,
metrics: metrics,
),
),
if (card.showsQuickActions)
_QuickActions(
color: colors.accent,
metrics: metrics,
visible: _isHovered,
),
_Badge(
metrics: metrics,
child: RewardIcon(
color: colors.reward,
size: metrics.rewardIconSize,
),
),
SizedBox(width: metrics.badgeGap),
_Badge(
metrics: metrics,
child: DifficultyBars(
difficulty: card.difficultyIconToken,
color: colors.reward,
size: metrics.difficultySize,
),
),
],
);
final content = SizedBox.expand(
child: Stack(
children: [
Positioned.fill(
child: metrics.isCompact
? _CompactCardText(
card: card,
color: colors.accent,
metrics: metrics,
onStatusPressed: widget.onStatusPressed,
)
: _ExpandedCardContent(
card: card,
colors: colors,
metrics: metrics,
onStatusPressed: widget.onStatusPressed,
),
),
Positioned(
top: metrics.indicatorTop,
right: 0,
child: _TrailingControls(
card: card,
colors: colors,
metrics: metrics,
actionsVisible: card.showsQuickActions && _isHovered,
),
),
],
),
);
return MouseRegion(
onEnter: (_) {
setState(() {
@ -167,29 +161,36 @@ class _CardMetrics {
double get borderWidth => math.max(0.75, 1.2 * scale);
double get shadowBlur => isCompact ? 0 : 18 * scale;
double get shadowSpread => isCompact ? 0 : scale;
double get statusRingSize => 34 * scale;
double get statusBorderWidth => math.max(1.2, 3 * scale);
double get freeSlotStatusBorderWidth => math.max(1, 2 * scale);
double get statusIconSize => 24 * scale;
double get statusGap => 22 * scale;
double get actionButtonSize => 40 * scale;
double get actionIconSize => 22 * scale;
double get actionGap => 10 * scale;
double get badgeWidth => 62 * scale;
double get badgeHeight => 48 * scale;
double get badgeRadius => 7 * scale;
double get badgeGap => 8 * scale;
double get rewardIconSize => 26 * scale;
double get titleSize => FocusFlowTokens.cardTitleSize * scale;
double get metaSize => FocusFlowTokens.cardMetaSize * scale;
double get statusRingSize {
final availableHeight = math.max(8, height - padding.vertical);
return math.min(24, availableHeight * 0.78).toDouble();
}
double get statusBorderWidth => math.max(1.4, statusRingSize * 0.08);
double get statusIconSize => statusRingSize * 0.58;
double get statusGap => isCompact ? 7 : 22 * scale;
double get actionButtonSize => (30 * scale).clamp(14.0, 22.0).toDouble();
double get actionIconSize => (16 * scale).clamp(9.0, 13.0).toDouble();
double get actionGap => math.max(3, 5 * scale);
double get actionsWidth => actionButtonSize * 4 + actionGap;
double get indicatorTop => isCompact ? 0 : math.max(1, 2 * scale);
double get indicatorGap => math.max(3, 5 * scale);
double get rewardIconSize => 14;
double get titleSize => 10;
double get metaSize => 7;
double get titleMetaGap => 4 * scale;
double get freeSlotTimeGap => 10 * scale;
double get compactTextGap => math.max(6, 16 * scale);
Size get difficultySize => Size(54 * scale, 34 * scale);
double get compactTextGap => math.max(3, 8 * scale);
double get indicatorWidth =>
rewardIconSize + indicatorGap + difficultySize.width;
double get expandedTrailingReserve =>
indicatorWidth + math.max(8, 12 * scale);
double get compactTrailingReserve => indicatorWidth + math.max(6, 8 * scale);
Size get difficultySize => Size(26, 14);
EdgeInsets get padding {
if (isCompact) {
return EdgeInsets.symmetric(
horizontal: math.max(4, 12 * scale),
horizontal: math.max(4, 8 * scale),
vertical: math.max(1, 3 * scale),
);
}
@ -197,36 +198,100 @@ class _CardMetrics {
}
}
class _ExpandedCardContent extends StatelessWidget {
const _ExpandedCardContent({
required this.card,
required this.colors,
required this.metrics,
required this.onStatusPressed,
});
final TimelineCardModel card;
final _CardColors colors;
final _CardMetrics metrics;
final VoidCallback? onStatusPressed;
@override
Widget build(BuildContext context) {
return Row(
children: [
if (card.visualKind != TaskVisualKind.freeSlot) ...[
_StatusRing(
card: card,
color: colors.accent,
metrics: metrics,
onPressed: card.canToggleCompletion ? onStatusPressed : null,
),
SizedBox(width: metrics.statusGap),
],
Expanded(
child: Padding(
padding: EdgeInsets.only(right: metrics.expandedTrailingReserve),
child: _CardText(
card: card,
color: colors.accent,
metrics: metrics,
),
),
),
],
);
}
}
class _StatusRing extends StatelessWidget {
const _StatusRing({
required this.card,
required this.color,
required this.metrics,
this.onPressed,
});
final TimelineCardModel card;
final Color color;
final _CardMetrics metrics;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
final border = Border.all(
color: color,
width: card.visualKind == TaskVisualKind.freeSlot
? metrics.freeSlotStatusBorderWidth
: metrics.statusBorderWidth,
style: card.visualKind == TaskVisualKind.freeSlot
? BorderStyle.solid
: BorderStyle.solid,
);
return Container(
final border = Border.all(color: color, width: metrics.statusBorderWidth);
final ring = Container(
key: ValueKey('${card.id}-status'),
width: metrics.statusRingSize,
height: metrics.statusRingSize,
decoration: BoxDecoration(shape: BoxShape.circle, border: border),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: card.isCompleted ? color : Colors.transparent,
border: border,
),
child: card.isCompleted
? Icon(Icons.check, color: color, size: metrics.statusIconSize)
? Icon(
Icons.check,
color: FocusFlowTokens.appBackground,
size: metrics.statusIconSize,
)
: null,
);
if (onPressed == null) {
return ring;
}
return Tooltip(
message: card.isCompleted ? 'Mark not done' : 'Mark complete',
child: Semantics(
button: true,
label: card.isCompleted
? 'Mark ${card.title} not done'
: 'Mark ${card.title} complete',
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onPressed,
child: ring,
),
),
),
);
}
}
@ -313,11 +378,13 @@ class _CompactCardText extends StatelessWidget {
required this.card,
required this.color,
required this.metrics,
required this.onStatusPressed,
});
final TimelineCardModel card;
final Color color;
final _CardMetrics metrics;
final VoidCallback? onStatusPressed;
@override
Widget build(BuildContext context) {
@ -344,8 +411,18 @@ class _CompactCardText extends StatelessWidget {
final line = Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
flex: 3,
if (card.visualKind != TaskVisualKind.freeSlot) ...[
_StatusRing(
card: card,
color: color,
metrics: metrics,
onPressed: card.canToggleCompletion ? onStatusPressed : null,
),
SizedBox(width: metrics.statusGap),
],
Flexible(
flex: 2,
fit: FlexFit.loose,
child: Text(
card.title,
maxLines: 1,
@ -355,7 +432,8 @@ class _CompactCardText extends StatelessWidget {
),
SizedBox(width: metrics.compactTextGap),
Flexible(
flex: 2,
flex: 1,
fit: FlexFit.loose,
child: Text(
metaText,
maxLines: 1,
@ -363,6 +441,7 @@ class _CompactCardText extends StatelessWidget {
style: metaStyle,
),
),
SizedBox(width: metrics.compactTrailingReserve),
],
);
if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) {
@ -389,14 +468,53 @@ class _CompactCardText extends StatelessWidget {
}
}
class _TrailingControls extends StatelessWidget {
const _TrailingControls({
required this.card,
required this.colors,
required this.metrics,
required this.actionsVisible,
});
final TimelineCardModel card;
final _CardColors colors;
final _CardMetrics metrics;
final bool actionsVisible;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (card.showsQuickActions)
_QuickActions(
color: colors.accent,
backgroundColor: colors.background.withValues(alpha: 1),
metrics: metrics,
visible: actionsVisible,
),
RewardIcon(color: colors.reward, size: metrics.rewardIconSize),
SizedBox(width: metrics.indicatorGap),
DifficultyBars(
difficulty: card.difficultyIconToken,
color: colors.reward,
size: metrics.difficultySize,
),
],
);
}
}
class _QuickActions extends StatelessWidget {
const _QuickActions({
required this.color,
required this.backgroundColor,
required this.metrics,
required this.visible,
});
final Color color;
final Color backgroundColor;
final _CardMetrics metrics;
final bool visible;
@ -406,31 +524,49 @@ class _QuickActions extends StatelessWidget {
excluding: !visible,
child: IgnorePointer(
ignoring: !visible,
child: AnimatedOpacity(
opacity: visible ? 1 : 0,
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_NoOpIcon(icon: Icons.check, color: color, metrics: metrics),
_NoOpIcon(
icon: Icons.arrow_forward,
color: color,
metrics: metrics,
child: ClipRect(
child: AnimatedContainer(
width: visible ? metrics.actionsWidth : 0,
height: metrics.actionButtonSize,
decoration: BoxDecoration(color: backgroundColor),
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
child: OverflowBox(
minWidth: metrics.actionsWidth,
maxWidth: metrics.actionsWidth,
alignment: Alignment.centerRight,
child: AnimatedOpacity(
opacity: visible ? 1 : 0,
duration: const Duration(milliseconds: 90),
curve: Curves.easeOut,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_NoOpIcon(
icon: Icons.check,
color: color,
metrics: metrics,
),
_NoOpIcon(
icon: Icons.arrow_forward,
color: color,
metrics: metrics,
),
_NoOpIcon(
icon: Icons.calendar_today,
color: color,
metrics: metrics,
),
_NoOpIcon(
icon: Icons.wb_sunny_outlined,
color: color,
metrics: metrics,
),
SizedBox(width: metrics.actionGap),
],
),
),
_NoOpIcon(
icon: Icons.calendar_today,
color: color,
metrics: metrics,
),
_NoOpIcon(
icon: Icons.wb_sunny_outlined,
color: color,
metrics: metrics,
),
SizedBox(width: metrics.actionGap),
],
),
),
),
),
@ -451,40 +587,16 @@ class _NoOpIcon extends StatelessWidget {
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: () {},
constraints: BoxConstraints.tightFor(
width: metrics.actionButtonSize,
height: metrics.actionButtonSize,
return Tooltip(
message: 'No-op action',
child: GestureDetector(
onTap: () {},
behavior: HitTestBehavior.opaque,
child: SizedBox.square(
dimension: metrics.actionButtonSize,
child: Icon(icon, color: color, size: metrics.actionIconSize),
),
),
color: color,
iconSize: metrics.actionIconSize,
padding: EdgeInsets.zero,
icon: Icon(icon),
tooltip: 'No-op action',
visualDensity: VisualDensity.compact,
);
}
}
class _Badge extends StatelessWidget {
const _Badge({required this.child, required this.metrics});
final Widget child;
final _CardMetrics metrics;
@override
Widget build(BuildContext context) {
return Container(
width: metrics.badgeWidth,
height: metrics.badgeHeight,
alignment: Alignment.center,
decoration: BoxDecoration(
color: FocusFlowTokens.panelBackground.withValues(alpha: 0.84),
borderRadius: BorderRadius.circular(metrics.badgeRadius),
border: Border.all(color: FocusFlowTokens.subtleBorder),
),
child: child,
);
}
}

View file

@ -9,32 +9,38 @@ import 'timeline_geometry.dart';
/// Time labels and rail shown beside the compact timeline.
class TimelineAxis extends StatelessWidget {
/// Creates a timeline axis for [geometry].
const TimelineAxis({required this.geometry, super.key});
const TimelineAxis({required this.geometry, this.topInset = 12, super.key});
/// Geometry used to position labels and tick marks.
final TimelineGeometry geometry;
/// Vertical inset before the first timeline tick.
final double topInset;
@override
Widget build(BuildContext context) {
final ticks = geometry.ticks();
return SizedBox(
width: FocusFlowTokens.timelineRailWidth,
height: geometry.totalHeight + 24,
height: geometry.totalHeight + topInset + 24,
child: Stack(
children: [
Positioned(
left: 92,
top: 0,
top: topInset,
bottom: 0,
child: Container(width: 2, color: FocusFlowTokens.rail),
),
for (final tick in ticks)
Positioned(
top: tick.y - 10,
top: tick.y + topInset - 10,
left: 0,
width: 86,
child: Text(
tick.label,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.visible,
textAlign: TextAlign.right,
style: TextStyle(
color: tick.major
@ -47,7 +53,7 @@ class TimelineAxis extends StatelessWidget {
),
for (final tick in ticks.where((tick) => tick.major))
Positioned(
top: tick.y - 6,
top: tick.y + topInset - 6,
left: 87,
child: const DecoratedBox(
decoration: BoxDecoration(
@ -66,24 +72,28 @@ class TimelineAxis extends StatelessWidget {
/// Background grid aligned to timeline tick marks.
class TimelineGrid extends StatelessWidget {
/// Creates a timeline grid for [geometry].
const TimelineGrid({required this.geometry, super.key});
const TimelineGrid({required this.geometry, this.topInset = 12, super.key});
/// Geometry used to position grid lines.
final TimelineGeometry geometry;
/// Vertical inset before the first timeline tick.
final double topInset;
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: _TimelineGridPainter(geometry),
painter: _TimelineGridPainter(geometry: geometry, topInset: topInset),
size: Size.infinite,
);
}
}
class _TimelineGridPainter extends CustomPainter {
const _TimelineGridPainter(this.geometry);
const _TimelineGridPainter({required this.geometry, required this.topInset});
final TimelineGeometry geometry;
final double topInset;
@override
void paint(Canvas canvas, Size size) {
@ -92,8 +102,8 @@ class _TimelineGridPainter extends CustomPainter {
..strokeWidth = 1;
for (final tick in geometry.ticks()) {
canvas.drawLine(
Offset(0, tick.y),
Offset(size.width, tick.y),
Offset(0, tick.y + topInset),
Offset(size.width, tick.y + topInset),
paint
..color = tick.major
? FocusFlowTokens.gridLine.withValues(alpha: 0.72)
@ -104,6 +114,6 @@ class _TimelineGridPainter extends CustomPainter {
@override
bool shouldRepaint(covariant _TimelineGridPainter oldDelegate) {
return oldDelegate.geometry != geometry;
return oldDelegate.geometry != geometry || oldDelegate.topInset != topInset;
}
}

View file

@ -60,6 +60,26 @@ class TimelineGeometry {
];
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
other is TimelineGeometry &&
other.startMinutes == startMinutes &&
other.endMinutes == endMinutes &&
other.pixelsPerMinute == pixelsPerMinute &&
other.minCardHeight == minCardHeight;
}
@override
int get hashCode {
return Object.hash(
startMinutes,
endMinutes,
pixelsPerMinute,
minCardHeight,
);
}
static String _labelFor(int minutes) {
final hour24 = (minutes ~/ 60) % 24;
final minute = minutes % 60;

View file

@ -1,6 +1,7 @@
/// Renders Timeline View pieces for the FocusFlow Flutter timeline.
library;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../../models/today_screen_models.dart';
@ -16,6 +17,7 @@ class TimelineView extends StatefulWidget {
required this.cards,
required this.range,
required this.onCardSelected,
this.onCardCompleted,
this.now,
this.scrollTargetCardId,
this.scrollRequest = 0,
@ -31,6 +33,9 @@ class TimelineView extends StatefulWidget {
/// Callback invoked when a card is selected.
final ValueChanged<TimelineCardModel> onCardSelected;
/// Callback invoked when a task card status control requests completion.
final ValueChanged<TimelineCardModel>? onCardCompleted;
/// Clock used to center the initial scroll position.
final DateTime Function()? now;
@ -45,17 +50,37 @@ class TimelineView extends StatefulWidget {
}
class _TimelineViewState extends State<TimelineView> {
static const _topInset = 12.0;
final _scrollController = ScrollController();
bool _didSetInitialScroll = false;
var _handledScrollRequest = 0;
late TimelineGeometry _geometry;
late double _contentHeight;
late List<_TimelineCardPlacement> _placements;
late int _cardsLayoutHash;
@override
void initState() {
super.initState();
_updateLayoutCache();
}
@override
void didUpdateWidget(covariant TimelineView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.range.startMinutes != widget.range.startMinutes ||
oldWidget.range.endMinutes != widget.range.endMinutes) {
final rangeChanged =
oldWidget.range.startMinutes != widget.range.startMinutes ||
oldWidget.range.endMinutes != widget.range.endMinutes;
if (rangeChanged) {
_didSetInitialScroll = false;
}
final layoutChanged = _layoutHashFor(widget.cards) != _cardsLayoutHash;
if (rangeChanged || layoutChanged) {
_updateLayoutCache();
} else if (!identical(oldWidget.cards, widget.cards)) {
_refreshPlacementCards();
}
}
@override
@ -66,62 +91,68 @@ class _TimelineViewState extends State<TimelineView> {
@override
Widget build(BuildContext context) {
final geometry = TimelineGeometry(
startMinutes: widget.range.startMinutes,
endMinutes: widget.range.endMinutes,
pixelsPerMinute: 2.45,
);
final contentHeight = geometry.totalHeight + 24;
final placements = _TimelineCardPlacement.pack(
widget.cards,
geometry: geometry,
);
return LayoutBuilder(
builder: (context, constraints) {
_scheduleInitialScroll(geometry, constraints.maxHeight);
_scheduleTargetScroll(geometry);
return SingleChildScrollView(
controller: _scrollController,
child: SizedBox(
height: contentHeight,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TimelineAxis(geometry: geometry),
const SizedBox(width: 18),
Expanded(
child: LayoutBuilder(
builder: (context, cardConstraints) {
final trackWidth = cardConstraints.maxWidth;
return Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: TimelineGrid(geometry: geometry),
),
for (final placement in placements)
Positioned(
top: geometry.yForMinutesSinceMidnight(
placement.card.startMinutes,
),
left: placement.left(trackWidth),
width: placement.width(trackWidth),
height: geometry.heightForDuration(
placement.card.endMinutes -
placement.card.startMinutes,
),
child: TaskTimelineCard(
card: placement.card,
onTap: () =>
widget.onCardSelected(placement.card),
_scheduleInitialScroll(_geometry, constraints.maxHeight);
_scheduleTargetScroll(_geometry);
return ScrollConfiguration(
behavior: const _TimelineScrollBehavior(),
child: SingleChildScrollView(
controller: _scrollController,
child: SizedBox(
height: _contentHeight,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RepaintBoundary(
child: TimelineAxis(
geometry: _geometry,
topInset: _topInset,
),
),
const SizedBox(width: 18),
Expanded(
child: LayoutBuilder(
builder: (context, cardConstraints) {
final trackWidth = cardConstraints.maxWidth;
return Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: RepaintBoundary(
child: TimelineGrid(
geometry: _geometry,
topInset: _topInset,
),
),
),
],
);
},
for (final placement in _placements)
Positioned(
top: placement.top + _topInset,
left: placement.left(trackWidth),
width: placement.width(trackWidth),
height: placement.height,
child: RepaintBoundary(
child: TaskTimelineCard(
card: placement.card,
onTap: () =>
widget.onCardSelected(placement.card),
onStatusPressed:
widget.onCardCompleted == null
? null
: () => widget.onCardCompleted!(
placement.card,
),
),
),
),
],
);
},
),
),
),
],
],
),
),
),
);
@ -129,6 +160,36 @@ class _TimelineViewState extends State<TimelineView> {
);
}
void _updateLayoutCache() {
_geometry = TimelineGeometry(
startMinutes: widget.range.startMinutes,
endMinutes: widget.range.endMinutes,
pixelsPerMinute: 2.45,
);
_contentHeight = _geometry.totalHeight + _topInset + 24;
_placements = _TimelineCardPlacement.pack(
widget.cards,
geometry: _geometry,
);
_cardsLayoutHash = _layoutHashFor(widget.cards);
}
int _layoutHashFor(List<TimelineCardModel> cards) {
return Object.hashAll(
cards.map(
(card) => Object.hash(card.id, card.startMinutes, card.endMinutes),
),
);
}
void _refreshPlacementCards() {
final cardsById = {for (final card in widget.cards) card.id: card};
_placements = [
for (final placement in _placements)
placement.withCard(cardsById[placement.card.id] ?? placement.card),
];
}
void _scheduleInitialScroll(
TimelineGeometry geometry,
double viewportHeight,
@ -146,7 +207,9 @@ class _TimelineViewState extends State<TimelineView> {
.clamp(geometry.startMinutes, geometry.endMinutes)
.toInt();
final centeredOffset =
geometry.yForMinutesSinceMidnight(clampedMinute) - viewportHeight / 2;
geometry.yForMinutesSinceMidnight(clampedMinute) +
_topInset -
viewportHeight / 2;
final maxOffset = _scrollController.position.maxScrollExtent;
final offset = centeredOffset.clamp(0, maxOffset).toDouble();
_scrollController.jumpTo(offset);
@ -175,7 +238,9 @@ class _TimelineViewState extends State<TimelineView> {
return;
}
final targetOffset =
geometry.yForMinutesSinceMidnight(targetCard.startMinutes) - 24;
geometry.yForMinutesSinceMidnight(targetCard.startMinutes) +
_topInset -
24;
final maxOffset = _scrollController.position.maxScrollExtent;
final offset = targetOffset.clamp(0, maxOffset).toDouble();
_scrollController.jumpTo(offset);
@ -184,11 +249,22 @@ class _TimelineViewState extends State<TimelineView> {
}
}
class _TimelineScrollBehavior extends MaterialScrollBehavior {
const _TimelineScrollBehavior();
@override
Set<PointerDeviceKind> get dragDevices {
return {...super.dragDevices, PointerDeviceKind.mouse};
}
}
class _TimelineCardPlacement {
const _TimelineCardPlacement({
required this.card,
required this.lane,
required this.laneCount,
required this.top,
required this.height,
});
static const _laneGap = 8.0;
@ -196,6 +272,21 @@ class _TimelineCardPlacement {
final TimelineCardModel card;
final int lane;
final int laneCount;
final double top;
final double height;
_TimelineCardPlacement withCard(TimelineCardModel nextCard) {
if (identical(card, nextCard)) {
return this;
}
return _TimelineCardPlacement(
card: nextCard,
lane: lane,
laneCount: laneCount,
top: top,
height: height,
);
}
static List<_TimelineCardPlacement> pack(
List<TimelineCardModel> cards, {
@ -272,6 +363,10 @@ class _TimelineCardPlacement {
card: card,
lane: laneByCard[card]!,
laneCount: laneCount,
top: _visualStart(card, geometry),
height: geometry.heightForDuration(
card.endMinutes - card.startMinutes,
),
),
];
}

View file

@ -12,7 +12,10 @@ import 'package:focus_flow_flutter/models/today_screen_models.dart';
import 'package:focus_flow_flutter/theme/focus_flow_theme.dart';
import 'package:focus_flow_flutter/widgets/required_banner.dart';
import 'package:focus_flow_flutter/widgets/timeline/difficulty_bars.dart';
import 'package:focus_flow_flutter/widgets/timeline/reward_icon.dart';
import 'package:focus_flow_flutter/widgets/timeline/task_timeline_card.dart';
import 'package:focus_flow_flutter/widgets/timeline/timeline_axis.dart';
import 'package:focus_flow_flutter/widgets/timeline/timeline_geometry.dart';
import 'package:focus_flow_flutter/widgets/timeline/timeline_view.dart';
import 'package:focus_flow_flutter/widgets/top_bar.dart';
@ -43,7 +46,9 @@ void main() {
findsOneWidget,
);
expect(find.text('Clean coffee maker'), findsOneWidget);
expect(find.text('Cleaned kitchen counter'), findsNWidgets(2));
expect(find.text('Cleaned kitchen counter'), findsOneWidget);
expect(find.text('Completed at: 8:05 AM'), findsOneWidget);
expect(find.textContaining('Surprise task'), findsNothing);
expect(find.text('Sort mail'), findsOneWidget);
expect(find.text('Start laundry'), findsOneWidget);
expect(find.text('Water plants'), findsOneWidget);
@ -67,7 +72,7 @@ void main() {
expect(_kindFor(data, 'doctor-appointment'), TaskVisualKind.appointment);
expect(_kindFor(data, 'free-slot'), TaskVisualKind.freeSlot);
expect(
_kindFor(data, 'cleaned-kitchen-counter-early'),
_kindFor(data, 'cleaned-kitchen-counter'),
TaskVisualKind.completedSurprise,
);
},
@ -180,7 +185,7 @@ void main() {
await tester.tap(find.text('Show upcoming'));
await tester.pumpAndSettle();
expect(scrollable.position.pixels, closeTo(2622, 1));
expect(scrollable.position.pixels, closeTo(2634, 1));
expect(find.byKey(const ValueKey('pay-bill')), findsOneWidget);
});
@ -205,10 +210,69 @@ void main() {
final scrollable = tester.state<ScrollableState>(find.byType(Scrollable));
final position = scrollable.position;
expect(position.maxScrollExtent, greaterThan(3000));
expect(position.pixels, closeTo(1564, 1));
expect(position.pixels, closeTo(1576, 1));
},
);
testWidgets('timeline hour labels stay on one line', (tester) async {
await _pumpConstrainedWidget(
tester,
size: const Size(140, 220),
child: const TimelineAxis(
geometry: TimelineGeometry(
startMinutes: 0,
endMinutes: 60,
pixelsPerMinute: 2.45,
),
),
);
expect(tester.takeException(), isNull);
final midnight = tester.widget<Text>(find.text('12:00 AM'));
final oneAm = tester.widget<Text>(find.text('1:00 AM'));
expect(midnight.maxLines, 1);
expect(oneAm.maxLines, 1);
expect(midnight.softWrap, isFalse);
expect(oneAm.softWrap, isFalse);
expect(midnight.overflow, TextOverflow.visible);
expect(oneAm.overflow, TextOverflow.visible);
expect(
tester.getSize(find.text('12:00 AM')).height,
closeTo(tester.getSize(find.text('1:00 AM')).height, 0.1),
);
expect(
tester.getRect(find.text('12:00 AM')).top,
greaterThanOrEqualTo(tester.getRect(find.byType(TimelineAxis)).top),
);
});
testWidgets('timeline scrolls with a mouse drag', (tester) async {
await _pumpConstrainedWidget(
tester,
size: const Size(680, 400),
child: TimelineView(
cards: const [],
range: TodayScreenData.defaultRange,
now: () => DateTime(2026, 6, 30, 12),
onCardSelected: (_) {},
),
);
expect(tester.takeException(), isNull);
final scrollable = tester.state<ScrollableState>(find.byType(Scrollable));
final beforeDrag = scrollable.position.pixels;
await tester.drag(
find.byType(SingleChildScrollView),
const Offset(0, -180),
kind: PointerDeviceKind.mouse,
);
await tester.pumpAndSettle();
expect(scrollable.position.pixels, greaterThan(beforeDrag + 80));
});
testWidgets('timeline splits partial overlaps and reuses empty lanes', (
tester,
) async {
@ -289,8 +353,8 @@ void main() {
final cleanCoffee = tester.getRect(
find.byKey(const ValueKey('clean-coffee-maker')),
);
final cleanedKitchenEarly = tester.getRect(
find.byKey(const ValueKey('cleaned-kitchen-counter-early')),
final cleanedKitchenCounter = tester.getRect(
find.byKey(const ValueKey('cleaned-kitchen-counter')),
);
expect(startLaundry.left, greaterThan(sortMail.left));
@ -299,10 +363,14 @@ void main() {
expect(waterPlants.width, closeTo(sortMail.width, 1));
expect(sortMail.right, lessThan(startLaundry.left));
expect(waterPlants.right, lessThan(startLaundry.left));
expect(cleanCoffee.left, closeTo(cleanedKitchenEarly.left, 1));
expect(cleanCoffee.height, closeTo(15 * 2.45, 0.5));
expect(cleanedKitchenEarly.height, closeTo(15 * 2.45, 0.5));
expect(cleanCoffee.bottom, lessThan(cleanedKitchenEarly.top));
expect(cleanedKitchenCounter.height, closeTo(15 * 2.45, 0.5));
expect(
data.cards
.singleWhere((card) => card.id == 'cleaned-kitchen-counter')
.startMinutes,
8 * 60 + 15,
);
});
testWidgets('top bar controls scale inside a compact header width', (
@ -345,6 +413,24 @@ void main() {
expect(tester.getSize(find.byType(OutlinedButton)).width, lessThan(176));
});
testWidgets('empty required banner does not reserve vertical space', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(480, 160));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
MaterialApp(
theme: FocusFlowTheme.dark(),
home: const Scaffold(
body: Align(alignment: Alignment.topLeft, child: RequiredBanner()),
),
),
);
await tester.pumpAndSettle();
expect(tester.getSize(find.byType(RequiredBanner)).height, 0);
});
testWidgets('task card controls scale inside a narrow task bubble', (
tester,
) async {
@ -373,12 +459,21 @@ void main() {
tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity)).opacity,
1,
);
final button = tester.widget<IconButton>(
find.widgetWithIcon(IconButton, Icons.arrow_forward),
final actionBackground = tester.widget<AnimatedContainer>(
find.byType(AnimatedContainer),
);
final decoration = actionBackground.decoration;
expect(decoration, isA<BoxDecoration>());
final boxDecoration = decoration! as BoxDecoration;
expect(boxDecoration.color, const Color(0xFF021A0C));
expect(boxDecoration.border, isNull);
final icon = tester.widget<Icon>(find.byIcon(Icons.arrow_forward));
expect(icon.size, lessThan(18));
expect(
tester.getSize(find.byIcon(Icons.arrow_forward)).width,
lessThan(32),
);
expect(button.iconSize, lessThan(18));
expect(button.constraints?.maxWidth, lessThan(32));
await gesture.removePointer();
});
@ -401,9 +496,110 @@ void main() {
final title = tester.getRect(find.text('Five minute reset'));
final time = tester.getRect(find.text('5 min'));
final statusCircle = tester.getRect(
find.byKey(const ValueKey('short-inline-time-status')),
);
expect(statusCircle.height, lessThan(20));
expect(statusCircle.right, lessThan(title.left));
expect(title.right, lessThan(time.left));
expect((title.center.dy - time.center.dy).abs(), lessThan(2));
expect(find.byType(RewardIcon), findsOneWidget);
expect(find.byType(DifficultyBars), findsOneWidget);
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(
location: tester.getCenter(
find.byKey(const ValueKey('short-inline-time')),
),
);
await tester.pumpAndSettle();
expect(
tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity)).opacity,
1,
);
expect(find.text('5 min'), findsOneWidget);
await gesture.removePointer();
});
testWidgets('status circle toggles task completion without opening modal', (
tester,
) async {
final composition = _composition();
await _pumpPlanApp(tester, composition: composition);
await _scrollIntoView(tester, const ValueKey('clean-coffee-maker'));
final beforeClick = DateTime.now().toUtc();
await tester.tap(find.byKey(const ValueKey('clean-coffee-maker-status')));
await tester.pumpAndSettle();
final afterClick = DateTime.now().toUtc();
final completedTask = composition.store.currentTasks.singleWhere(
(task) => task.id == 'clean-coffee-maker',
);
final scheduledStart = completedTask.scheduledStart;
final scheduledEnd = completedTask.scheduledEnd;
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
expect(completedTask.status, TaskStatus.completed);
expect(completedTask.completedAt, isNotNull);
expect(completedTask.completedAt!.isBefore(beforeClick), isFalse);
expect(completedTask.completedAt!.isAfter(afterClick), isFalse);
expect(find.textContaining('Completed at:'), findsWidgets);
await tester.tap(find.byKey(const ValueKey('clean-coffee-maker-status')));
await tester.pumpAndSettle();
final reopenedTask = composition.store.currentTasks.singleWhere(
(task) => task.id == 'clean-coffee-maker',
);
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
expect(reopenedTask.status, TaskStatus.planned);
expect(reopenedTask.scheduledStart, scheduledStart);
expect(reopenedTask.scheduledEnd, scheduledEnd);
expect(reopenedTask.actualStart, isNull);
expect(reopenedTask.actualEnd, isNull);
expect(reopenedTask.completedAt, isNull);
});
testWidgets('task modal status circle toggles and keeps details in sync', (
tester,
) async {
final composition = _composition();
await _pumpPlanApp(tester, composition: composition);
await _scrollIntoView(tester, const ValueKey('water-plants'));
await tester.tap(find.byKey(const ValueKey('water-plants')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
expect(find.text('Flexible - 3:20 PM - 4:00 PM'), findsOneWidget);
expect(find.textContaining('Completed -'), findsNothing);
await tester.tap(find.byKey(const ValueKey('task-modal-status')));
await tester.pumpAndSettle();
final completedTask = composition.store.currentTasks.singleWhere(
(task) => task.id == 'water-plants',
);
expect(completedTask.status, TaskStatus.completed);
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
expect(find.text('Water plants'), findsWidgets);
expect(find.textContaining('Completed -'), findsOneWidget);
expect(find.text('Planned: 3:20 PM - 4:00 PM'), findsOneWidget);
expect(find.byIcon(Icons.check), findsWidgets);
await tester.tap(find.byKey(const ValueKey('task-modal-status')));
await tester.pumpAndSettle();
final reopenedTask = composition.store.currentTasks.singleWhere(
(task) => task.id == 'water-plants',
);
expect(reopenedTask.status, TaskStatus.planned);
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
expect(find.text('Flexible - 3:20 PM - 4:00 PM'), findsOneWidget);
expect(find.textContaining('Completed -'), findsNothing);
});
testWidgets('free slot text scales inside a short task bubble', (
@ -423,15 +619,21 @@ void main() {
);
expect(tester.takeException(), isNull);
expect(find.byKey(const ValueKey('short-free-slot-status')), findsNothing);
expect(find.text('Intentional rest'), findsOneWidget);
expect(find.text('6:15 PM - 6:30 PM'), findsOneWidget);
});
}
Future<void> _pumpPlanApp(WidgetTester tester) async {
Future<void> _pumpPlanApp(
WidgetTester tester, {
DemoSchedulerComposition? composition,
}) async {
await tester.binding.setSurfaceSize(const Size(1586, 992));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(FocusFlowApp(composition: _composition()));
await tester.pumpWidget(
FocusFlowApp(composition: composition ?? _composition()),
);
await tester.pumpAndSettle();
}
@ -473,7 +675,11 @@ Future<void> _pumpTimelineCard(
child: SizedBox(
width: size.width,
height: size.height,
child: TaskTimelineCard(card: card, onTap: () {}),
child: TaskTimelineCard(
card: card,
onTap: () {},
onStatusPressed: () {},
),
),
),
),
@ -510,8 +716,19 @@ TimelineCardModel _timelineCard({
int startMinutes = 18 * 60,
int endMinutes = 18 * 60 + 30,
TaskVisualKind visualKind = TaskVisualKind.flexible,
TaskType? taskType,
bool showsQuickActions = true,
String? completedTimeText,
}) {
final resolvedTaskType =
taskType ??
switch (visualKind) {
TaskVisualKind.flexible => TaskType.flexible,
TaskVisualKind.required => TaskType.critical,
TaskVisualKind.appointment => TaskType.inflexible,
TaskVisualKind.freeSlot => TaskType.freeSlot,
TaskVisualKind.completedSurprise => TaskType.flexible,
};
return TimelineCardModel(
id: id,
title: title,
@ -520,9 +737,11 @@ TimelineCardModel _timelineCard({
timeText: timeText,
startMinutes: startMinutes,
endMinutes: endMinutes,
taskType: resolvedTaskType,
visualKind: visualKind,
rewardIconToken: TimelineRewardIconToken.medium,
difficultyIconToken: TimelineDifficultyIconToken.medium,
completedTimeText: completedTimeText,
isCompleted: false,
isSelectable: true,
showsQuickActions: showsQuickActions,

View file

@ -28,6 +28,7 @@ enum ApplicationCommandCode {
pushFlexibleToTomorrowTopOfQueue,
moveFlexibleToBacklog,
completeFlexibleTask,
uncompleteTask,
applyRequiredTaskAction,
logSurpriseTask,
createProtectedFreeSlot,
@ -558,6 +559,63 @@ class V1ApplicationCommandUseCases {
);
}
/// Mark a completed scheduled task as still needing to be done.
Future<ApplicationResult<ApplicationCommandResult>> uncompleteTask({
required ApplicationOperationContext context,
required CivilDate localDate,
required String taskId,
DateTime? expectedUpdatedAt,
}) {
const commandCode = ApplicationCommandCode.uncompleteTask;
return applicationStore.run<ApplicationCommandResult>(
context: context,
operationName: commandCode.name,
action: (repositories) async {
var state = await _loadPlanningState(repositories, context, localDate);
var task = _taskById(state.tasks, taskId);
if (task == null) {
task = await repositories.tasks.findById(taskId);
if (task != null) {
state = state.withTask(task);
}
}
if (task == null) {
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
}
final staleFailure = _staleFailure(task, expectedUpdatedAt);
if (staleFailure != null) {
return ApplicationResult.failure(staleFailure);
}
if (task.status != TaskStatus.completed ||
(!task.isFlexible && !task.isRequiredVisible)) {
return _failure(
ApplicationFailureCode.conflict,
entityId: taskId,
detailCode: TaskTransitionOutcomeCode.invalidState.name,
);
}
final uncompleted = task.copyWith(
status: TaskStatus.planned,
updatedAt: context.now,
clearActualInterval: true,
clearCompletion: true,
);
return _persist(
repositories: repositories,
commandCode: commandCode,
context: context,
beforeTasks: state.tasks,
afterTasks: _replaceTask(state.tasks, uncompleted),
activities: const <TaskActivity>[],
readHint: ApplicationCommandReadHint(
localDate: localDate,
affectedTaskIds: [taskId],
),
);
},
);
}
/// Apply a required visible task lifecycle action.
Future<ApplicationResult<ApplicationCommandResult>> applyRequiredTaskAction({
required ApplicationOperationContext context,

View file

@ -76,6 +76,7 @@ class TimelineItem {
required this.category,
this.start,
this.end,
this.completedAt,
this.durationMinutes,
}) : quickActions = List<TimelineQuickAction>.unmodifiable(quickActions);
@ -109,6 +110,9 @@ class TimelineItem {
/// Timeline placement end, if the source item has one.
final DateTime? end;
/// Completion instant, if the source task has been completed.
final DateTime? completedAt;
/// Duration display value for flexible task cards.
final int? durationMinutes;
@ -198,6 +202,7 @@ class TimelineItemMapper {
showsExplicitTime: _showsExplicitTime(task.type),
start: task.scheduledStart,
end: task.scheduledEnd,
completedAt: task.completedAt,
durationMinutes: _durationMinutesFor(task),
quickActions: _quickActionsFor(task.type),
category: task.isLocked

View file

@ -311,6 +311,43 @@ void main() {
expect(store.currentProjectStatistics.single.completedTaskCount, 1);
});
test('uncomplete task clears completion while preserving schedule',
() async {
final completed = task(
id: 'completed',
title: 'Completed',
type: TaskType.flexible,
status: TaskStatus.completed,
createdAt: createdAt,
start: DateTime.utc(2026, 6, 25, 9),
end: DateTime.utc(2026, 6, 25, 9, 30),
actualStart: DateTime.utc(2026, 6, 25, 9),
actualEnd: DateTime.utc(2026, 6, 25, 9, 20),
completedAt: DateTime.utc(2026, 6, 25, 9, 20),
);
final store = storeWithSettings(tasks: [completed]);
final result = await commands(store).uncompleteTask(
context: appContext(
'uncomplete-task',
DateTime.utc(2026, 6, 25, 9, 25),
),
localDate: day,
taskId: completed.id,
);
expect(result.isSuccess, isTrue);
final reopened = taskById(store.currentTasks, completed.id);
expect(reopened.status, TaskStatus.planned);
expect(reopened.scheduledStart, completed.scheduledStart);
expect(reopened.scheduledEnd, completed.scheduledEnd);
expect(reopened.actualStart, isNull);
expect(reopened.actualEnd, isNull);
expect(reopened.completedAt, isNull);
expect(reopened.updatedAt, DateTime.utc(2026, 6, 25, 9, 25));
expect(store.currentTaskActivities, isEmpty);
});
test('surprise logging creates completed work and repairs flexible overlap',
() async {
final planned = task(
@ -525,6 +562,9 @@ Task task({
required DateTime createdAt,
DateTime? start,
DateTime? end,
DateTime? actualStart,
DateTime? actualEnd,
DateTime? completedAt,
int? durationMinutes,
String projectId = 'home',
String? parentTaskId,
@ -539,6 +579,9 @@ Task task({
(start != null && end != null ? end.difference(start).inMinutes : 30),
scheduledStart: start,
scheduledEnd: end,
actualStart: actualStart,
actualEnd: actualEnd,
completedAt: completedAt,
parentTaskId: parentTaskId,
createdAt: createdAt,
updatedAt: createdAt,