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]. /// Creates an application operation context for the supplied [operationId].
ApplicationOperationContext context(String operationId) { ApplicationOperationContext context(String operationId, {DateTime? now}) {
return ApplicationOperationContext.start( return ApplicationOperationContext.start(
operationId: operationId, operationId: operationId,
ownerTimeZone: OwnerTimeZoneContext( ownerTimeZone: OwnerTimeZoneContext(
ownerId: ownerId, ownerId: ownerId,
timeZoneId: timeZoneId, timeZoneId: timeZoneId,
), ),
clock: FixedClock(readAt), clock: FixedClock(now ?? readAt),
idGenerator: SequentialIdGenerator(prefix: operationId), idGenerator: SequentialIdGenerator(prefix: operationId),
); );
} }
@ -215,18 +215,18 @@ class DemoSchedulerComposition {
createdAt: createdAt, createdAt: createdAt,
), ),
_task( _task(
id: 'cleaned-kitchen-counter-early', id: 'cleaned-kitchen-counter',
title: 'Cleaned kitchen counter', title: 'Cleaned kitchen counter',
type: TaskType.surprise, type: TaskType.flexible,
status: TaskStatus.completed, status: TaskStatus.completed,
date: date, date: date,
startHour: 16, startHour: 8,
startMinute: 45, startMinute: 15,
endHour: 17, endHour: 8,
endMinute: 0, endMinute: 30,
reward: RewardLevel.medium, reward: RewardLevel.medium,
difficulty: DifficultyLevel.medium, difficulty: DifficultyLevel.medium,
completedAt: _instant(date, 16, 20), completedAt: _instant(date, 8, 5),
createdAt: createdAt, createdAt: createdAt,
), ),
_task( _task(
@ -313,21 +313,6 @@ class DemoSchedulerComposition {
difficulty: DifficultyLevel.notSet, difficulty: DifficultyLevel.notSet,
createdAt: createdAt, 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 'package:flutter/material.dart';
import '../controllers/scheduler_command_controller.dart';
import '../controllers/today_screen_controller.dart'; import '../controllers/today_screen_controller.dart';
import '../models/today_screen_models.dart'; import '../models/today_screen_models.dart';
import '../theme/focus_flow_theme.dart'; import '../theme/focus_flow_theme.dart';
@ -47,21 +48,40 @@ class FocusFlowHome extends StatefulWidget {
} }
class _FocusFlowHomeState extends State<FocusFlowHome> { class _FocusFlowHomeState extends State<FocusFlowHome> {
late final TodayScreenController controller = widget.composition late final TodayScreenController controller;
.createTodayScreenController(); late final SchedulerCommandController commandController;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
controller = widget.composition.createTodayScreenController();
commandController = widget.composition.createCommandController(
refreshReads: controller.load,
);
controller.load(); controller.load();
} }
@override @override
void dispose() { void dispose() {
commandController.dispose();
controller.dispose(); controller.dispose();
super.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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -84,12 +104,14 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
), ),
selectedCard: controller.selectedCard, selectedCard: controller.selectedCard,
onSelectCard: controller.selectCard, onSelectCard: controller.selectCard,
onCompleteCard: _toggleCardCompletion,
onClearSelection: controller.clearSelection, onClearSelection: controller.clearSelection,
), ),
TodayScreenReady(:final data) => _TodayScreenScaffold( TodayScreenReady(:final data) => _TodayScreenScaffold(
data: data, data: data,
selectedCard: controller.selectedCard, selectedCard: controller.selectedCard,
onSelectCard: controller.selectCard, onSelectCard: controller.selectCard,
onCompleteCard: _toggleCardCompletion,
onClearSelection: controller.clearSelection, onClearSelection: controller.clearSelection,
), ),
}; };
@ -118,12 +140,14 @@ class _TodayScreenScaffold extends StatefulWidget {
required this.data, required this.data,
required this.selectedCard, required this.selectedCard,
required this.onSelectCard, required this.onSelectCard,
required this.onCompleteCard,
required this.onClearSelection, required this.onClearSelection,
}); });
final TodayScreenData data; final TodayScreenData data;
final TimelineCardModel? selectedCard; final TimelineCardModel? selectedCard;
final ValueChanged<TimelineCardModel> onSelectCard; final ValueChanged<TimelineCardModel> onSelectCard;
final Future<void> Function(TimelineCardModel card) onCompleteCard;
final VoidCallback onClearSelection; final VoidCallback onClearSelection;
@override @override
@ -160,12 +184,16 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
TopBar(dateLabel: widget.data.dateLabel), TopBar(dateLabel: widget.data.dateLabel),
if (widget.data.requiredBanner == null)
const SizedBox(height: 16)
else ...[
const SizedBox(height: 24), const SizedBox(height: 24),
RequiredBanner( RequiredBanner(
model: widget.data.requiredBanner, model: widget.data.requiredBanner,
onShowUpcoming: _showUpcomingRequiredTask, onShowUpcoming: _showUpcomingRequiredTask,
), ),
const SizedBox(height: 22), const SizedBox(height: 22),
],
Expanded( Expanded(
child: TimelineView( child: TimelineView(
cards: widget.data.cards, cards: widget.data.cards,
@ -173,6 +201,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
scrollTargetCardId: _timelineScrollTargetCardId, scrollTargetCardId: _timelineScrollTargetCardId,
scrollRequest: _timelineScrollRequest, scrollRequest: _timelineScrollRequest,
onCardSelected: widget.onSelectCard, onCardSelected: widget.onSelectCard,
onCardCompleted: widget.onCompleteCard,
), ),
), ),
], ],
@ -191,6 +220,9 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
child: TaskSelectionModal( child: TaskSelectionModal(
card: widget.selectedCard!, card: widget.selectedCard!,
onClose: widget.onClearSelection, 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. /// Builds an application operation context for a UI command operation.
typedef OperationContextFactory = typedef OperationContextFactory =
ApplicationOperationContext Function(String operationId); ApplicationOperationContext Function(String operationId, {DateTime? now});
/// Refreshes any reads that should reflect a completed command. /// Refreshes any reads that should reflect a completed command.
typedef ReadRefresh = Future<void> Function(); typedef ReadRefresh = Future<void> Function();
@ -70,7 +70,8 @@ class SchedulerCommandController extends ChangeNotifier {
required this.contextFor, required this.contextFor,
required this.localDate, required this.localDate,
required this.refreshReads, required this.refreshReads,
}); DateTime Function()? now,
}) : now = now ?? _utcNow;
/// Scheduler write use cases invoked by this controller. /// Scheduler write use cases invoked by this controller.
final V1ApplicationCommandUseCases commands; final V1ApplicationCommandUseCases commands;
@ -83,6 +84,9 @@ class SchedulerCommandController extends ChangeNotifier {
/// Refresh callback invoked after commands that mutate read state. /// Refresh callback invoked after commands that mutate read state.
final ReadRefresh refreshReads; final ReadRefresh refreshReads;
/// Clock used by direct UI commands such as marking a task complete.
final DateTime Function() now;
var _sequence = 0; var _sequence = 0;
SchedulerCommandState _state = const SchedulerCommandIdle(); SchedulerCommandState _state = const SchedulerCommandIdle();
@ -132,13 +136,70 @@ class SchedulerCommandController extends ChangeNotifier {
required String taskId, required String taskId,
DateTime? expectedUpdatedAt, DateTime? expectedUpdatedAt,
}) async { }) 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( await _run(
label: 'Completing task', label: 'Completing task',
successMessage: 'Task completed', successMessage: 'Task completed',
refreshAfterSuccess: true, refreshAfterSuccess: true,
action: (operationId) { action: (operationId) {
return commands.completeFlexibleTask( final context = contextFor(operationId, now: completedAt);
context: contextFor(operationId), 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, localDate: localDate,
taskId: taskId, taskId: taskId,
expectedUpdatedAt: expectedUpdatedAt, expectedUpdatedAt: expectedUpdatedAt,
@ -183,4 +244,6 @@ class SchedulerCommandController extends ChangeNotifier {
_state = next; _state = next;
notifyListeners(); notifyListeners();
} }
static DateTime _utcNow() => DateTime.now().toUtc();
} }

View file

@ -76,6 +76,7 @@ class TodayScreenController extends ChangeNotifier {
} }
final data = TodayScreenData.fromTodayState(result.requireValue); final data = TodayScreenData.fromTodayState(result.requireValue);
_syncSelectedCard(data);
_state = data.cards.isEmpty _state = data.cards.isEmpty
? const TodayScreenEmpty() ? const TodayScreenEmpty()
: TodayScreenReady(data); : TodayScreenReady(data);
@ -103,4 +104,18 @@ class TodayScreenController extends ChangeNotifier {
_selectedCard = null; _selectedCard = null;
notifyListeners(); 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.timeText,
required this.startMinutes, required this.startMinutes,
required this.endMinutes, required this.endMinutes,
required this.taskType,
required this.visualKind, required this.visualKind,
required this.rewardIconToken, required this.rewardIconToken,
required this.difficultyIconToken, required this.difficultyIconToken,
required this.isCompleted, required this.isCompleted,
required this.isSelectable, required this.isSelectable,
required this.showsQuickActions, required this.showsQuickActions,
this.completedTimeText,
this.durationMinutes, this.durationMinutes,
}); });
@ -146,18 +148,21 @@ class TimelineCardModel {
? '' ? ''
: '$duration min' : '$duration min'
: '${_formatTime(start)} - ${_formatTime(end)}'; : '${_formatTime(start)} - ${_formatTime(end)}';
final completedAt = item.item.completedAt ?? item.item.end ?? item.end;
return TimelineCardModel( return TimelineCardModel(
id: item.id, id: item.id,
title: title, title: title,
typeLabel: _typeLabelFor(visualKind), typeLabel: _typeLabelFor(item.taskType, visualKind),
subtitle: _subtitleFor(item, visualKind, timeText), subtitle: _subtitleFor(item, visualKind, timeText),
timeText: timeText, timeText: timeText,
startMinutes: _minutesSinceMidnight(start), startMinutes: _minutesSinceMidnight(start),
endMinutes: _minutesSinceMidnight(end), endMinutes: _minutesSinceMidnight(end),
taskType: item.taskType,
visualKind: visualKind, visualKind: visualKind,
rewardIconToken: item.item.rewardIconToken, rewardIconToken: item.item.rewardIconToken,
difficultyIconToken: item.item.difficultyIconToken, difficultyIconToken: item.item.difficultyIconToken,
durationMinutes: duration, durationMinutes: duration,
completedTimeText: isCompleted ? _formatTime(completedAt) : null,
isCompleted: isCompleted, isCompleted: isCompleted,
isSelectable: true, isSelectable: true,
showsQuickActions: !isCompleted && visualKind != TaskVisualKind.freeSlot, showsQuickActions: !isCompleted && visualKind != TaskVisualKind.freeSlot,
@ -179,6 +184,9 @@ class TimelineCardModel {
/// Display-ready time or duration text. /// Display-ready time or duration text.
final String timeText; final String timeText;
/// Display-ready completion time, if the task has been completed.
final String? completedTimeText;
/// Card start position in minutes since midnight. /// Card start position in minutes since midnight.
final int startMinutes; final int startMinutes;
@ -188,6 +196,9 @@ class TimelineCardModel {
/// Optional task duration in minutes. /// Optional task duration in minutes.
final int? durationMinutes; final int? durationMinutes;
/// Source task scheduling behavior type.
final TaskType taskType;
/// Visual category used for styling. /// Visual category used for styling.
final TaskVisualKind visualKind; final TaskVisualKind visualKind;
@ -206,6 +217,20 @@ class TimelineCardModel {
/// Whether the card should show compact quick-action controls. /// Whether the card should show compact quick-action controls.
final bool showsQuickActions; 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. /// Accessibility and modal label for the reward icon.
String get rewardLabel { String get rewardLabel {
return switch (rewardIconToken) { return switch (rewardIconToken) {
@ -247,13 +272,17 @@ TaskVisualKind _visualKindFor(TodayTimelineItem item) {
}; };
} }
String _typeLabelFor(TaskVisualKind visualKind) { String _typeLabelFor(TaskType taskType, TaskVisualKind visualKind) {
return switch (visualKind) { if (visualKind == TaskVisualKind.completedSurprise) {
TaskVisualKind.flexible => 'Flexible', return 'Completed';
TaskVisualKind.required => 'Required', }
TaskVisualKind.appointment => 'Required', return switch (taskType) {
TaskVisualKind.freeSlot => 'Free Slot', TaskType.flexible => 'Flexible',
TaskVisualKind.completedSurprise => 'Surprise task', 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) { if (visualKind == TaskVisualKind.freeSlot) {
return 'Intentional rest'; return 'Intentional rest';
} }
if (visualKind == TaskVisualKind.completedSurprise) { if (item.taskStatus == TaskStatus.completed ||
final completedAt = _formatTime(item.item.end ?? item.end); visualKind == TaskVisualKind.completedSurprise) {
return 'Surprise task - Completed at $completedAt'; return 'Completed at: '
'${_formatTime(item.item.completedAt ?? item.item.end ?? item.end)}';
} }
if (item.taskType == TaskType.flexible && item.item.durationMinutes != null) { if (item.taskType == TaskType.flexible && item.item.durationMinutes != null) {
return '${item.item.durationMinutes} min'; return '${item.item.durationMinutes} min';

View file

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

View file

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

View file

@ -14,6 +14,7 @@ class TaskSelectionModal extends StatelessWidget {
const TaskSelectionModal({ const TaskSelectionModal({
required this.card, required this.card,
required this.onClose, required this.onClose,
this.onStatusPressed,
super.key, super.key,
}); });
@ -23,6 +24,9 @@ class TaskSelectionModal extends StatelessWidget {
/// Callback invoked when the modal should close. /// Callback invoked when the modal should close.
final VoidCallback onClose; final VoidCallback onClose;
/// Callback invoked when the status circle should toggle completion.
final VoidCallback? onStatusPressed;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final accent = _accentFor(card.visualKind); final accent = _accentFor(card.visualKind);
@ -43,13 +47,10 @@ class TaskSelectionModal extends StatelessWidget {
children: [ children: [
Row( Row(
children: [ children: [
Container( _ModalStatusButton(
width: 44, card: card,
height: 44, color: accent,
decoration: BoxDecoration( onPressed: onStatusPressed,
shape: BoxShape.circle,
border: Border.all(color: accent, width: 4),
),
), ),
const SizedBox(width: 28), const SizedBox(width: 28),
Expanded( Expanded(
@ -65,13 +66,7 @@ class TaskSelectionModal extends StatelessWidget {
), ),
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( _HeaderMetadata(card: card),
'${card.typeLabel} - ${card.timeText}',
style: const TextStyle(
color: FocusFlowTokens.textMuted,
fontSize: 17,
),
),
], ],
), ),
), ),
@ -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 { class _InfoTile extends StatelessWidget {
const _InfoTile({required this.child, required this.label}); 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. /// Interactive card that renders a scheduled task on the compact timeline.
class TaskTimelineCard extends StatefulWidget { class TaskTimelineCard extends StatefulWidget {
/// Creates a task timeline card. /// 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. /// Card model to render.
final TimelineCardModel card; final TimelineCardModel card;
@ -21,6 +26,9 @@ class TaskTimelineCard extends StatefulWidget {
/// Callback invoked when the card is tapped. /// Callback invoked when the card is tapped.
final VoidCallback onTap; final VoidCallback onTap;
/// Callback invoked when the left status control is clicked.
final VoidCallback? onStatusPressed;
@override @override
State<TaskTimelineCard> createState() => _TaskTimelineCardState(); State<TaskTimelineCard> createState() => _TaskTimelineCardState();
} }
@ -38,50 +46,36 @@ class _TaskTimelineCardState extends State<TaskTimelineCard> {
constraints, constraints,
kind: card.visualKind, kind: card.visualKind,
); );
final content = metrics.isCompact final content = SizedBox.expand(
child: Stack(
children: [
Positioned.fill(
child: metrics.isCompact
? _CompactCardText( ? _CompactCardText(
card: card, card: card,
color: colors.accent, color: colors.accent,
metrics: metrics, metrics: metrics,
onStatusPressed: widget.onStatusPressed,
) )
: Row( : _ExpandedCardContent(
children: [
_StatusRing(
card: card, card: card,
color: colors.accent, colors: colors,
metrics: metrics, metrics: metrics,
onStatusPressed: widget.onStatusPressed,
), ),
SizedBox(width: metrics.statusGap), ),
Expanded( Positioned(
child: _CardText( top: metrics.indicatorTop,
right: 0,
child: _TrailingControls(
card: card, card: card,
color: colors.accent, colors: colors,
metrics: metrics, metrics: metrics,
), actionsVisible: card.showsQuickActions && _isHovered,
),
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,
), ),
), ),
], ],
),
); );
return MouseRegion( return MouseRegion(
onEnter: (_) { onEnter: (_) {
@ -167,29 +161,36 @@ class _CardMetrics {
double get borderWidth => math.max(0.75, 1.2 * scale); double get borderWidth => math.max(0.75, 1.2 * scale);
double get shadowBlur => isCompact ? 0 : 18 * scale; double get shadowBlur => isCompact ? 0 : 18 * scale;
double get shadowSpread => isCompact ? 0 : scale; double get shadowSpread => isCompact ? 0 : scale;
double get statusRingSize => 34 * scale; double get statusRingSize {
double get statusBorderWidth => math.max(1.2, 3 * scale); final availableHeight = math.max(8, height - padding.vertical);
double get freeSlotStatusBorderWidth => math.max(1, 2 * scale); return math.min(24, availableHeight * 0.78).toDouble();
double get statusIconSize => 24 * scale; }
double get statusGap => 22 * scale;
double get actionButtonSize => 40 * scale; double get statusBorderWidth => math.max(1.4, statusRingSize * 0.08);
double get actionIconSize => 22 * scale; double get statusIconSize => statusRingSize * 0.58;
double get actionGap => 10 * scale; double get statusGap => isCompact ? 7 : 22 * scale;
double get badgeWidth => 62 * scale; double get actionButtonSize => (30 * scale).clamp(14.0, 22.0).toDouble();
double get badgeHeight => 48 * scale; double get actionIconSize => (16 * scale).clamp(9.0, 13.0).toDouble();
double get badgeRadius => 7 * scale; double get actionGap => math.max(3, 5 * scale);
double get badgeGap => 8 * scale; double get actionsWidth => actionButtonSize * 4 + actionGap;
double get rewardIconSize => 26 * scale; double get indicatorTop => isCompact ? 0 : math.max(1, 2 * scale);
double get titleSize => FocusFlowTokens.cardTitleSize * scale; double get indicatorGap => math.max(3, 5 * scale);
double get metaSize => FocusFlowTokens.cardMetaSize * scale; double get rewardIconSize => 14;
double get titleSize => 10;
double get metaSize => 7;
double get titleMetaGap => 4 * scale; double get titleMetaGap => 4 * scale;
double get freeSlotTimeGap => 10 * scale; double get freeSlotTimeGap => 10 * scale;
double get compactTextGap => math.max(6, 16 * scale); double get compactTextGap => math.max(3, 8 * scale);
Size get difficultySize => Size(54 * scale, 34 * 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 { EdgeInsets get padding {
if (isCompact) { if (isCompact) {
return EdgeInsets.symmetric( return EdgeInsets.symmetric(
horizontal: math.max(4, 12 * scale), horizontal: math.max(4, 8 * scale),
vertical: math.max(1, 3 * 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 { class _StatusRing extends StatelessWidget {
const _StatusRing({ const _StatusRing({
required this.card, required this.card,
required this.color, required this.color,
required this.metrics, required this.metrics,
this.onPressed,
}); });
final TimelineCardModel card; final TimelineCardModel card;
final Color color; final Color color;
final _CardMetrics metrics; final _CardMetrics metrics;
final VoidCallback? onPressed;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final border = Border.all( final border = Border.all(color: color, width: metrics.statusBorderWidth);
color: color, final ring = Container(
width: card.visualKind == TaskVisualKind.freeSlot key: ValueKey('${card.id}-status'),
? metrics.freeSlotStatusBorderWidth
: metrics.statusBorderWidth,
style: card.visualKind == TaskVisualKind.freeSlot
? BorderStyle.solid
: BorderStyle.solid,
);
return Container(
width: metrics.statusRingSize, width: metrics.statusRingSize,
height: 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 child: card.isCompleted
? Icon(Icons.check, color: color, size: metrics.statusIconSize) ? Icon(
Icons.check,
color: FocusFlowTokens.appBackground,
size: metrics.statusIconSize,
)
: null, : 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.card,
required this.color, required this.color,
required this.metrics, required this.metrics,
required this.onStatusPressed,
}); });
final TimelineCardModel card; final TimelineCardModel card;
final Color color; final Color color;
final _CardMetrics metrics; final _CardMetrics metrics;
final VoidCallback? onStatusPressed;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -344,8 +411,18 @@ class _CompactCardText extends StatelessWidget {
final line = Row( final line = Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Expanded( if (card.visualKind != TaskVisualKind.freeSlot) ...[
flex: 3, _StatusRing(
card: card,
color: color,
metrics: metrics,
onPressed: card.canToggleCompletion ? onStatusPressed : null,
),
SizedBox(width: metrics.statusGap),
],
Flexible(
flex: 2,
fit: FlexFit.loose,
child: Text( child: Text(
card.title, card.title,
maxLines: 1, maxLines: 1,
@ -355,7 +432,8 @@ class _CompactCardText extends StatelessWidget {
), ),
SizedBox(width: metrics.compactTextGap), SizedBox(width: metrics.compactTextGap),
Flexible( Flexible(
flex: 2, flex: 1,
fit: FlexFit.loose,
child: Text( child: Text(
metaText, metaText,
maxLines: 1, maxLines: 1,
@ -363,6 +441,7 @@ class _CompactCardText extends StatelessWidget {
style: metaStyle, style: metaStyle,
), ),
), ),
SizedBox(width: metrics.compactTrailingReserve),
], ],
); );
if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) { 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 { class _QuickActions extends StatelessWidget {
const _QuickActions({ const _QuickActions({
required this.color, required this.color,
required this.backgroundColor,
required this.metrics, required this.metrics,
required this.visible, required this.visible,
}); });
final Color color; final Color color;
final Color backgroundColor;
final _CardMetrics metrics; final _CardMetrics metrics;
final bool visible; final bool visible;
@ -406,14 +524,29 @@ class _QuickActions extends StatelessWidget {
excluding: !visible, excluding: !visible,
child: IgnorePointer( child: IgnorePointer(
ignoring: !visible, ignoring: !visible,
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( child: AnimatedOpacity(
opacity: visible ? 1 : 0, opacity: visible ? 1 : 0,
duration: const Duration(milliseconds: 120), duration: const Duration(milliseconds: 90),
curve: Curves.easeOut, curve: Curves.easeOut,
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_NoOpIcon(icon: Icons.check, color: color, metrics: metrics), _NoOpIcon(
icon: Icons.check,
color: color,
metrics: metrics,
),
_NoOpIcon( _NoOpIcon(
icon: Icons.arrow_forward, icon: Icons.arrow_forward,
color: color, color: color,
@ -434,6 +567,9 @@ class _QuickActions extends StatelessWidget {
), ),
), ),
), ),
),
),
),
); );
} }
} }
@ -451,40 +587,16 @@ class _NoOpIcon extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return IconButton( return Tooltip(
onPressed: () {}, message: 'No-op action',
constraints: BoxConstraints.tightFor( child: GestureDetector(
width: metrics.actionButtonSize, onTap: () {},
height: metrics.actionButtonSize, 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. /// Time labels and rail shown beside the compact timeline.
class TimelineAxis extends StatelessWidget { class TimelineAxis extends StatelessWidget {
/// Creates a timeline axis for [geometry]. /// 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. /// Geometry used to position labels and tick marks.
final TimelineGeometry geometry; final TimelineGeometry geometry;
/// Vertical inset before the first timeline tick.
final double topInset;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ticks = geometry.ticks(); final ticks = geometry.ticks();
return SizedBox( return SizedBox(
width: FocusFlowTokens.timelineRailWidth, width: FocusFlowTokens.timelineRailWidth,
height: geometry.totalHeight + 24, height: geometry.totalHeight + topInset + 24,
child: Stack( child: Stack(
children: [ children: [
Positioned( Positioned(
left: 92, left: 92,
top: 0, top: topInset,
bottom: 0, bottom: 0,
child: Container(width: 2, color: FocusFlowTokens.rail), child: Container(width: 2, color: FocusFlowTokens.rail),
), ),
for (final tick in ticks) for (final tick in ticks)
Positioned( Positioned(
top: tick.y - 10, top: tick.y + topInset - 10,
left: 0, left: 0,
width: 86, width: 86,
child: Text( child: Text(
tick.label, tick.label,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.visible,
textAlign: TextAlign.right, textAlign: TextAlign.right,
style: TextStyle( style: TextStyle(
color: tick.major color: tick.major
@ -47,7 +53,7 @@ class TimelineAxis extends StatelessWidget {
), ),
for (final tick in ticks.where((tick) => tick.major)) for (final tick in ticks.where((tick) => tick.major))
Positioned( Positioned(
top: tick.y - 6, top: tick.y + topInset - 6,
left: 87, left: 87,
child: const DecoratedBox( child: const DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
@ -66,24 +72,28 @@ class TimelineAxis extends StatelessWidget {
/// Background grid aligned to timeline tick marks. /// Background grid aligned to timeline tick marks.
class TimelineGrid extends StatelessWidget { class TimelineGrid extends StatelessWidget {
/// Creates a timeline grid for [geometry]. /// 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. /// Geometry used to position grid lines.
final TimelineGeometry geometry; final TimelineGeometry geometry;
/// Vertical inset before the first timeline tick.
final double topInset;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return CustomPaint( return CustomPaint(
painter: _TimelineGridPainter(geometry), painter: _TimelineGridPainter(geometry: geometry, topInset: topInset),
size: Size.infinite, size: Size.infinite,
); );
} }
} }
class _TimelineGridPainter extends CustomPainter { class _TimelineGridPainter extends CustomPainter {
const _TimelineGridPainter(this.geometry); const _TimelineGridPainter({required this.geometry, required this.topInset});
final TimelineGeometry geometry; final TimelineGeometry geometry;
final double topInset;
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
@ -92,8 +102,8 @@ class _TimelineGridPainter extends CustomPainter {
..strokeWidth = 1; ..strokeWidth = 1;
for (final tick in geometry.ticks()) { for (final tick in geometry.ticks()) {
canvas.drawLine( canvas.drawLine(
Offset(0, tick.y), Offset(0, tick.y + topInset),
Offset(size.width, tick.y), Offset(size.width, tick.y + topInset),
paint paint
..color = tick.major ..color = tick.major
? FocusFlowTokens.gridLine.withValues(alpha: 0.72) ? FocusFlowTokens.gridLine.withValues(alpha: 0.72)
@ -104,6 +114,6 @@ class _TimelineGridPainter extends CustomPainter {
@override @override
bool shouldRepaint(covariant _TimelineGridPainter oldDelegate) { 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) { static String _labelFor(int minutes) {
final hour24 = (minutes ~/ 60) % 24; final hour24 = (minutes ~/ 60) % 24;
final minute = minutes % 60; final minute = minutes % 60;

View file

@ -1,6 +1,7 @@
/// Renders Timeline View pieces for the FocusFlow Flutter timeline. /// Renders Timeline View pieces for the FocusFlow Flutter timeline.
library; library;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../models/today_screen_models.dart'; import '../../models/today_screen_models.dart';
@ -16,6 +17,7 @@ class TimelineView extends StatefulWidget {
required this.cards, required this.cards,
required this.range, required this.range,
required this.onCardSelected, required this.onCardSelected,
this.onCardCompleted,
this.now, this.now,
this.scrollTargetCardId, this.scrollTargetCardId,
this.scrollRequest = 0, this.scrollRequest = 0,
@ -31,6 +33,9 @@ class TimelineView extends StatefulWidget {
/// Callback invoked when a card is selected. /// Callback invoked when a card is selected.
final ValueChanged<TimelineCardModel> onCardSelected; 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. /// Clock used to center the initial scroll position.
final DateTime Function()? now; final DateTime Function()? now;
@ -45,17 +50,37 @@ class TimelineView extends StatefulWidget {
} }
class _TimelineViewState extends State<TimelineView> { class _TimelineViewState extends State<TimelineView> {
static const _topInset = 12.0;
final _scrollController = ScrollController(); final _scrollController = ScrollController();
bool _didSetInitialScroll = false; bool _didSetInitialScroll = false;
var _handledScrollRequest = 0; var _handledScrollRequest = 0;
late TimelineGeometry _geometry;
late double _contentHeight;
late List<_TimelineCardPlacement> _placements;
late int _cardsLayoutHash;
@override
void initState() {
super.initState();
_updateLayoutCache();
}
@override @override
void didUpdateWidget(covariant TimelineView oldWidget) { void didUpdateWidget(covariant TimelineView oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.range.startMinutes != widget.range.startMinutes || final rangeChanged =
oldWidget.range.endMinutes != widget.range.endMinutes) { oldWidget.range.startMinutes != widget.range.startMinutes ||
oldWidget.range.endMinutes != widget.range.endMinutes;
if (rangeChanged) {
_didSetInitialScroll = false; _didSetInitialScroll = false;
} }
final layoutChanged = _layoutHashFor(widget.cards) != _cardsLayoutHash;
if (rangeChanged || layoutChanged) {
_updateLayoutCache();
} else if (!identical(oldWidget.cards, widget.cards)) {
_refreshPlacementCards();
}
} }
@override @override
@ -66,28 +91,25 @@ class _TimelineViewState extends State<TimelineView> {
@override @override
Widget build(BuildContext context) { 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( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
_scheduleInitialScroll(geometry, constraints.maxHeight); _scheduleInitialScroll(_geometry, constraints.maxHeight);
_scheduleTargetScroll(geometry); _scheduleTargetScroll(_geometry);
return SingleChildScrollView( return ScrollConfiguration(
behavior: const _TimelineScrollBehavior(),
child: SingleChildScrollView(
controller: _scrollController, controller: _scrollController,
child: SizedBox( child: SizedBox(
height: contentHeight, height: _contentHeight,
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
TimelineAxis(geometry: geometry), RepaintBoundary(
child: TimelineAxis(
geometry: _geometry,
topInset: _topInset,
),
),
const SizedBox(width: 18), const SizedBox(width: 18),
Expanded( Expanded(
child: LayoutBuilder( child: LayoutBuilder(
@ -97,23 +119,31 @@ class _TimelineViewState extends State<TimelineView> {
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
Positioned.fill( Positioned.fill(
child: TimelineGrid(geometry: geometry), child: RepaintBoundary(
child: TimelineGrid(
geometry: _geometry,
topInset: _topInset,
), ),
for (final placement in placements) ),
),
for (final placement in _placements)
Positioned( Positioned(
top: geometry.yForMinutesSinceMidnight( top: placement.top + _topInset,
placement.card.startMinutes,
),
left: placement.left(trackWidth), left: placement.left(trackWidth),
width: placement.width(trackWidth), width: placement.width(trackWidth),
height: geometry.heightForDuration( height: placement.height,
placement.card.endMinutes - child: RepaintBoundary(
placement.card.startMinutes,
),
child: TaskTimelineCard( child: TaskTimelineCard(
card: placement.card, card: placement.card,
onTap: () => onTap: () =>
widget.onCardSelected(placement.card), widget.onCardSelected(placement.card),
onStatusPressed:
widget.onCardCompleted == null
? null
: () => widget.onCardCompleted!(
placement.card,
),
),
), ),
), ),
], ],
@ -124,11 +154,42 @@ 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( void _scheduleInitialScroll(
TimelineGeometry geometry, TimelineGeometry geometry,
double viewportHeight, double viewportHeight,
@ -146,7 +207,9 @@ class _TimelineViewState extends State<TimelineView> {
.clamp(geometry.startMinutes, geometry.endMinutes) .clamp(geometry.startMinutes, geometry.endMinutes)
.toInt(); .toInt();
final centeredOffset = final centeredOffset =
geometry.yForMinutesSinceMidnight(clampedMinute) - viewportHeight / 2; geometry.yForMinutesSinceMidnight(clampedMinute) +
_topInset -
viewportHeight / 2;
final maxOffset = _scrollController.position.maxScrollExtent; final maxOffset = _scrollController.position.maxScrollExtent;
final offset = centeredOffset.clamp(0, maxOffset).toDouble(); final offset = centeredOffset.clamp(0, maxOffset).toDouble();
_scrollController.jumpTo(offset); _scrollController.jumpTo(offset);
@ -175,7 +238,9 @@ class _TimelineViewState extends State<TimelineView> {
return; return;
} }
final targetOffset = final targetOffset =
geometry.yForMinutesSinceMidnight(targetCard.startMinutes) - 24; geometry.yForMinutesSinceMidnight(targetCard.startMinutes) +
_topInset -
24;
final maxOffset = _scrollController.position.maxScrollExtent; final maxOffset = _scrollController.position.maxScrollExtent;
final offset = targetOffset.clamp(0, maxOffset).toDouble(); final offset = targetOffset.clamp(0, maxOffset).toDouble();
_scrollController.jumpTo(offset); _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 { class _TimelineCardPlacement {
const _TimelineCardPlacement({ const _TimelineCardPlacement({
required this.card, required this.card,
required this.lane, required this.lane,
required this.laneCount, required this.laneCount,
required this.top,
required this.height,
}); });
static const _laneGap = 8.0; static const _laneGap = 8.0;
@ -196,6 +272,21 @@ class _TimelineCardPlacement {
final TimelineCardModel card; final TimelineCardModel card;
final int lane; final int lane;
final int laneCount; 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( static List<_TimelineCardPlacement> pack(
List<TimelineCardModel> cards, { List<TimelineCardModel> cards, {
@ -272,6 +363,10 @@ class _TimelineCardPlacement {
card: card, card: card,
lane: laneByCard[card]!, lane: laneByCard[card]!,
laneCount: laneCount, 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/theme/focus_flow_theme.dart';
import 'package:focus_flow_flutter/widgets/required_banner.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/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/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/timeline/timeline_view.dart';
import 'package:focus_flow_flutter/widgets/top_bar.dart'; import 'package:focus_flow_flutter/widgets/top_bar.dart';
@ -43,7 +46,9 @@ void main() {
findsOneWidget, findsOneWidget,
); );
expect(find.text('Clean coffee maker'), 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('Sort mail'), findsOneWidget);
expect(find.text('Start laundry'), findsOneWidget); expect(find.text('Start laundry'), findsOneWidget);
expect(find.text('Water plants'), findsOneWidget); expect(find.text('Water plants'), findsOneWidget);
@ -67,7 +72,7 @@ void main() {
expect(_kindFor(data, 'doctor-appointment'), TaskVisualKind.appointment); expect(_kindFor(data, 'doctor-appointment'), TaskVisualKind.appointment);
expect(_kindFor(data, 'free-slot'), TaskVisualKind.freeSlot); expect(_kindFor(data, 'free-slot'), TaskVisualKind.freeSlot);
expect( expect(
_kindFor(data, 'cleaned-kitchen-counter-early'), _kindFor(data, 'cleaned-kitchen-counter'),
TaskVisualKind.completedSurprise, TaskVisualKind.completedSurprise,
); );
}, },
@ -180,7 +185,7 @@ void main() {
await tester.tap(find.text('Show upcoming')); await tester.tap(find.text('Show upcoming'));
await tester.pumpAndSettle(); 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); expect(find.byKey(const ValueKey('pay-bill')), findsOneWidget);
}); });
@ -205,10 +210,69 @@ void main() {
final scrollable = tester.state<ScrollableState>(find.byType(Scrollable)); final scrollable = tester.state<ScrollableState>(find.byType(Scrollable));
final position = scrollable.position; final position = scrollable.position;
expect(position.maxScrollExtent, greaterThan(3000)); 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', ( testWidgets('timeline splits partial overlaps and reuses empty lanes', (
tester, tester,
) async { ) async {
@ -289,8 +353,8 @@ void main() {
final cleanCoffee = tester.getRect( final cleanCoffee = tester.getRect(
find.byKey(const ValueKey('clean-coffee-maker')), find.byKey(const ValueKey('clean-coffee-maker')),
); );
final cleanedKitchenEarly = tester.getRect( final cleanedKitchenCounter = tester.getRect(
find.byKey(const ValueKey('cleaned-kitchen-counter-early')), find.byKey(const ValueKey('cleaned-kitchen-counter')),
); );
expect(startLaundry.left, greaterThan(sortMail.left)); expect(startLaundry.left, greaterThan(sortMail.left));
@ -299,10 +363,14 @@ void main() {
expect(waterPlants.width, closeTo(sortMail.width, 1)); expect(waterPlants.width, closeTo(sortMail.width, 1));
expect(sortMail.right, lessThan(startLaundry.left)); expect(sortMail.right, lessThan(startLaundry.left));
expect(waterPlants.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(cleanCoffee.height, closeTo(15 * 2.45, 0.5));
expect(cleanedKitchenEarly.height, closeTo(15 * 2.45, 0.5)); expect(cleanedKitchenCounter.height, closeTo(15 * 2.45, 0.5));
expect(cleanCoffee.bottom, lessThan(cleanedKitchenEarly.top)); expect(
data.cards
.singleWhere((card) => card.id == 'cleaned-kitchen-counter')
.startMinutes,
8 * 60 + 15,
);
}); });
testWidgets('top bar controls scale inside a compact header width', ( 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)); 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', ( testWidgets('task card controls scale inside a narrow task bubble', (
tester, tester,
) async { ) async {
@ -373,12 +459,21 @@ void main() {
tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity)).opacity, tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity)).opacity,
1, 1,
); );
final actionBackground = tester.widget<AnimatedContainer>(
final button = tester.widget<IconButton>( find.byType(AnimatedContainer),
find.widgetWithIcon(IconButton, Icons.arrow_forward), );
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(); await gesture.removePointer();
}); });
@ -401,9 +496,110 @@ void main() {
final title = tester.getRect(find.text('Five minute reset')); final title = tester.getRect(find.text('Five minute reset'));
final time = tester.getRect(find.text('5 min')); 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.right, lessThan(time.left));
expect((title.center.dy - time.center.dy).abs(), lessThan(2)); 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', ( testWidgets('free slot text scales inside a short task bubble', (
@ -423,15 +619,21 @@ void main() {
); );
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
expect(find.byKey(const ValueKey('short-free-slot-status')), findsNothing);
expect(find.text('Intentional rest'), findsOneWidget); expect(find.text('Intentional rest'), findsOneWidget);
expect(find.text('6:15 PM - 6:30 PM'), 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)); await tester.binding.setSurfaceSize(const Size(1586, 992));
addTearDown(() => tester.binding.setSurfaceSize(null)); addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(FocusFlowApp(composition: _composition())); await tester.pumpWidget(
FocusFlowApp(composition: composition ?? _composition()),
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
} }
@ -473,7 +675,11 @@ Future<void> _pumpTimelineCard(
child: SizedBox( child: SizedBox(
width: size.width, width: size.width,
height: size.height, 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 startMinutes = 18 * 60,
int endMinutes = 18 * 60 + 30, int endMinutes = 18 * 60 + 30,
TaskVisualKind visualKind = TaskVisualKind.flexible, TaskVisualKind visualKind = TaskVisualKind.flexible,
TaskType? taskType,
bool showsQuickActions = true, 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( return TimelineCardModel(
id: id, id: id,
title: title, title: title,
@ -520,9 +737,11 @@ TimelineCardModel _timelineCard({
timeText: timeText, timeText: timeText,
startMinutes: startMinutes, startMinutes: startMinutes,
endMinutes: endMinutes, endMinutes: endMinutes,
taskType: resolvedTaskType,
visualKind: visualKind, visualKind: visualKind,
rewardIconToken: TimelineRewardIconToken.medium, rewardIconToken: TimelineRewardIconToken.medium,
difficultyIconToken: TimelineDifficultyIconToken.medium, difficultyIconToken: TimelineDifficultyIconToken.medium,
completedTimeText: completedTimeText,
isCompleted: false, isCompleted: false,
isSelectable: true, isSelectable: true,
showsQuickActions: showsQuickActions, showsQuickActions: showsQuickActions,

View file

@ -28,6 +28,7 @@ enum ApplicationCommandCode {
pushFlexibleToTomorrowTopOfQueue, pushFlexibleToTomorrowTopOfQueue,
moveFlexibleToBacklog, moveFlexibleToBacklog,
completeFlexibleTask, completeFlexibleTask,
uncompleteTask,
applyRequiredTaskAction, applyRequiredTaskAction,
logSurpriseTask, logSurpriseTask,
createProtectedFreeSlot, 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. /// Apply a required visible task lifecycle action.
Future<ApplicationResult<ApplicationCommandResult>> applyRequiredTaskAction({ Future<ApplicationResult<ApplicationCommandResult>> applyRequiredTaskAction({
required ApplicationOperationContext context, required ApplicationOperationContext context,

View file

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

View file

@ -311,6 +311,43 @@ void main() {
expect(store.currentProjectStatistics.single.completedTaskCount, 1); 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', test('surprise logging creates completed work and repairs flexible overlap',
() async { () async {
final planned = task( final planned = task(
@ -525,6 +562,9 @@ Task task({
required DateTime createdAt, required DateTime createdAt,
DateTime? start, DateTime? start,
DateTime? end, DateTime? end,
DateTime? actualStart,
DateTime? actualEnd,
DateTime? completedAt,
int? durationMinutes, int? durationMinutes,
String projectId = 'home', String projectId = 'home',
String? parentTaskId, String? parentTaskId,
@ -539,6 +579,9 @@ Task task({
(start != null && end != null ? end.difference(start).inMinutes : 30), (start != null && end != null ? end.difference(start).inMinutes : 30),
scheduledStart: start, scheduledStart: start,
scheduledEnd: end, scheduledEnd: end,
actualStart: actualStart,
actualEnd: actualEnd,
completedAt: completedAt,
parentTaskId: parentTaskId, parentTaskId: parentTaskId,
createdAt: createdAt, createdAt: createdAt,
updatedAt: createdAt, updatedAt: createdAt,