fix: refine timeline task interactions
This commit is contained in:
parent
f6afe0d793
commit
52dcc84b7d
16 changed files with 1054 additions and 285 deletions
|
|
@ -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,
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue