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].
|
||||
ApplicationOperationContext context(String operationId) {
|
||||
ApplicationOperationContext context(String operationId, {DateTime? now}) {
|
||||
return ApplicationOperationContext.start(
|
||||
operationId: operationId,
|
||||
ownerTimeZone: OwnerTimeZoneContext(
|
||||
ownerId: ownerId,
|
||||
timeZoneId: timeZoneId,
|
||||
),
|
||||
clock: FixedClock(readAt),
|
||||
clock: FixedClock(now ?? readAt),
|
||||
idGenerator: SequentialIdGenerator(prefix: operationId),
|
||||
);
|
||||
}
|
||||
|
|
@ -215,18 +215,18 @@ class DemoSchedulerComposition {
|
|||
createdAt: createdAt,
|
||||
),
|
||||
_task(
|
||||
id: 'cleaned-kitchen-counter-early',
|
||||
id: 'cleaned-kitchen-counter',
|
||||
title: 'Cleaned kitchen counter',
|
||||
type: TaskType.surprise,
|
||||
type: TaskType.flexible,
|
||||
status: TaskStatus.completed,
|
||||
date: date,
|
||||
startHour: 16,
|
||||
startMinute: 45,
|
||||
endHour: 17,
|
||||
endMinute: 0,
|
||||
startHour: 8,
|
||||
startMinute: 15,
|
||||
endHour: 8,
|
||||
endMinute: 30,
|
||||
reward: RewardLevel.medium,
|
||||
difficulty: DifficultyLevel.medium,
|
||||
completedAt: _instant(date, 16, 20),
|
||||
completedAt: _instant(date, 8, 5),
|
||||
createdAt: createdAt,
|
||||
),
|
||||
_task(
|
||||
|
|
@ -313,21 +313,6 @@ class DemoSchedulerComposition {
|
|||
difficulty: DifficultyLevel.notSet,
|
||||
createdAt: createdAt,
|
||||
),
|
||||
_task(
|
||||
id: 'cleaned-kitchen-counter-late',
|
||||
title: 'Cleaned kitchen counter',
|
||||
type: TaskType.surprise,
|
||||
status: TaskStatus.completed,
|
||||
date: date,
|
||||
startHour: 20,
|
||||
startMinute: 15,
|
||||
endHour: 20,
|
||||
endMinute: 30,
|
||||
reward: RewardLevel.medium,
|
||||
difficulty: DifficultyLevel.medium,
|
||||
completedAt: _instant(date, 20, 20),
|
||||
createdAt: createdAt,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ library;
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../controllers/scheduler_command_controller.dart';
|
||||
import '../controllers/today_screen_controller.dart';
|
||||
import '../models/today_screen_models.dart';
|
||||
import '../theme/focus_flow_theme.dart';
|
||||
|
|
@ -47,21 +48,40 @@ class FocusFlowHome extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _FocusFlowHomeState extends State<FocusFlowHome> {
|
||||
late final TodayScreenController controller = widget.composition
|
||||
.createTodayScreenController();
|
||||
late final TodayScreenController controller;
|
||||
late final SchedulerCommandController commandController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller = widget.composition.createTodayScreenController();
|
||||
commandController = widget.composition.createCommandController(
|
||||
refreshReads: controller.load,
|
||||
);
|
||||
controller.load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
commandController.dispose();
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _toggleCardCompletion(TimelineCardModel card) async {
|
||||
if (!card.canToggleCompletion) {
|
||||
return;
|
||||
}
|
||||
if (card.isCompleted) {
|
||||
await commandController.uncompleteTask(taskId: card.id);
|
||||
} else {
|
||||
await commandController.completeTask(
|
||||
taskId: card.id,
|
||||
taskType: card.taskType,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
|
|
@ -84,12 +104,14 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
),
|
||||
selectedCard: controller.selectedCard,
|
||||
onSelectCard: controller.selectCard,
|
||||
onCompleteCard: _toggleCardCompletion,
|
||||
onClearSelection: controller.clearSelection,
|
||||
),
|
||||
TodayScreenReady(:final data) => _TodayScreenScaffold(
|
||||
data: data,
|
||||
selectedCard: controller.selectedCard,
|
||||
onSelectCard: controller.selectCard,
|
||||
onCompleteCard: _toggleCardCompletion,
|
||||
onClearSelection: controller.clearSelection,
|
||||
),
|
||||
};
|
||||
|
|
@ -118,12 +140,14 @@ class _TodayScreenScaffold extends StatefulWidget {
|
|||
required this.data,
|
||||
required this.selectedCard,
|
||||
required this.onSelectCard,
|
||||
required this.onCompleteCard,
|
||||
required this.onClearSelection,
|
||||
});
|
||||
|
||||
final TodayScreenData data;
|
||||
final TimelineCardModel? selectedCard;
|
||||
final ValueChanged<TimelineCardModel> onSelectCard;
|
||||
final Future<void> Function(TimelineCardModel card) onCompleteCard;
|
||||
final VoidCallback onClearSelection;
|
||||
|
||||
@override
|
||||
|
|
@ -160,12 +184,16 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
|
|||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TopBar(dateLabel: widget.data.dateLabel),
|
||||
if (widget.data.requiredBanner == null)
|
||||
const SizedBox(height: 16)
|
||||
else ...[
|
||||
const SizedBox(height: 24),
|
||||
RequiredBanner(
|
||||
model: widget.data.requiredBanner,
|
||||
onShowUpcoming: _showUpcomingRequiredTask,
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
],
|
||||
Expanded(
|
||||
child: TimelineView(
|
||||
cards: widget.data.cards,
|
||||
|
|
@ -173,6 +201,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
|
|||
scrollTargetCardId: _timelineScrollTargetCardId,
|
||||
scrollRequest: _timelineScrollRequest,
|
||||
onCardSelected: widget.onSelectCard,
|
||||
onCardCompleted: widget.onCompleteCard,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -191,6 +220,9 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
|
|||
child: TaskSelectionModal(
|
||||
card: widget.selectedCard!,
|
||||
onClose: widget.onClearSelection,
|
||||
onStatusPressed: widget.selectedCard!.canToggleCompletion
|
||||
? () => widget.onCompleteCard(widget.selectedCard!)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import 'package:scheduler_core/scheduler_core.dart';
|
|||
|
||||
/// Builds an application operation context for a UI command operation.
|
||||
typedef OperationContextFactory =
|
||||
ApplicationOperationContext Function(String operationId);
|
||||
ApplicationOperationContext Function(String operationId, {DateTime? now});
|
||||
|
||||
/// Refreshes any reads that should reflect a completed command.
|
||||
typedef ReadRefresh = Future<void> Function();
|
||||
|
|
@ -70,7 +70,8 @@ class SchedulerCommandController extends ChangeNotifier {
|
|||
required this.contextFor,
|
||||
required this.localDate,
|
||||
required this.refreshReads,
|
||||
});
|
||||
DateTime Function()? now,
|
||||
}) : now = now ?? _utcNow;
|
||||
|
||||
/// Scheduler write use cases invoked by this controller.
|
||||
final V1ApplicationCommandUseCases commands;
|
||||
|
|
@ -83,6 +84,9 @@ class SchedulerCommandController extends ChangeNotifier {
|
|||
|
||||
/// Refresh callback invoked after commands that mutate read state.
|
||||
final ReadRefresh refreshReads;
|
||||
|
||||
/// Clock used by direct UI commands such as marking a task complete.
|
||||
final DateTime Function() now;
|
||||
var _sequence = 0;
|
||||
SchedulerCommandState _state = const SchedulerCommandIdle();
|
||||
|
||||
|
|
@ -132,13 +136,70 @@ class SchedulerCommandController extends ChangeNotifier {
|
|||
required String taskId,
|
||||
DateTime? expectedUpdatedAt,
|
||||
}) async {
|
||||
await completeTask(
|
||||
taskId: taskId,
|
||||
taskType: TaskType.flexible,
|
||||
expectedUpdatedAt: expectedUpdatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// Completes a planned task from the Today timeline.
|
||||
Future<void> completeTask({
|
||||
required String taskId,
|
||||
required TaskType taskType,
|
||||
DateTime? expectedUpdatedAt,
|
||||
}) async {
|
||||
final completedAt = now();
|
||||
await _run(
|
||||
label: 'Completing task',
|
||||
successMessage: 'Task completed',
|
||||
refreshAfterSuccess: true,
|
||||
action: (operationId) {
|
||||
return commands.completeFlexibleTask(
|
||||
context: contextFor(operationId),
|
||||
final context = contextFor(operationId, now: completedAt);
|
||||
return switch (taskType) {
|
||||
TaskType.flexible => commands.completeFlexibleTask(
|
||||
context: context,
|
||||
localDate: localDate,
|
||||
taskId: taskId,
|
||||
expectedUpdatedAt: expectedUpdatedAt,
|
||||
),
|
||||
TaskType.critical ||
|
||||
TaskType.inflexible => commands.applyRequiredTaskAction(
|
||||
context: context,
|
||||
localDate: localDate,
|
||||
taskId: taskId,
|
||||
action: RequiredTaskAction.done,
|
||||
expectedUpdatedAt: expectedUpdatedAt,
|
||||
),
|
||||
TaskType.locked ||
|
||||
TaskType.surprise ||
|
||||
TaskType.freeSlot => Future.value(
|
||||
ApplicationResult.failure(
|
||||
ApplicationFailure(
|
||||
code: ApplicationFailureCode.validation,
|
||||
entityId: taskId,
|
||||
detailCode: 'unsupportedTaskType',
|
||||
),
|
||||
),
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns a completed task to its planned state.
|
||||
Future<void> uncompleteTask({
|
||||
required String taskId,
|
||||
DateTime? expectedUpdatedAt,
|
||||
}) async {
|
||||
final occurredAt = now();
|
||||
await _run(
|
||||
label: 'Reopening task',
|
||||
successMessage: 'Task marked as not done',
|
||||
refreshAfterSuccess: true,
|
||||
action: (operationId) {
|
||||
return commands.uncompleteTask(
|
||||
context: contextFor(operationId, now: occurredAt),
|
||||
localDate: localDate,
|
||||
taskId: taskId,
|
||||
expectedUpdatedAt: expectedUpdatedAt,
|
||||
|
|
@ -183,4 +244,6 @@ class SchedulerCommandController extends ChangeNotifier {
|
|||
_state = next;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
static DateTime _utcNow() => DateTime.now().toUtc();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ class TodayScreenController extends ChangeNotifier {
|
|||
}
|
||||
|
||||
final data = TodayScreenData.fromTodayState(result.requireValue);
|
||||
_syncSelectedCard(data);
|
||||
_state = data.cards.isEmpty
|
||||
? const TodayScreenEmpty()
|
||||
: TodayScreenReady(data);
|
||||
|
|
@ -103,4 +104,18 @@ class TodayScreenController extends ChangeNotifier {
|
|||
_selectedCard = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _syncSelectedCard(TodayScreenData data) {
|
||||
final selected = _selectedCard;
|
||||
if (selected == null) {
|
||||
return;
|
||||
}
|
||||
for (final card in data.cards) {
|
||||
if (card.id == selected.id) {
|
||||
_selectedCard = card;
|
||||
return;
|
||||
}
|
||||
}
|
||||
_selectedCard = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,12 +124,14 @@ class TimelineCardModel {
|
|||
required this.timeText,
|
||||
required this.startMinutes,
|
||||
required this.endMinutes,
|
||||
required this.taskType,
|
||||
required this.visualKind,
|
||||
required this.rewardIconToken,
|
||||
required this.difficultyIconToken,
|
||||
required this.isCompleted,
|
||||
required this.isSelectable,
|
||||
required this.showsQuickActions,
|
||||
this.completedTimeText,
|
||||
this.durationMinutes,
|
||||
});
|
||||
|
||||
|
|
@ -146,18 +148,21 @@ class TimelineCardModel {
|
|||
? ''
|
||||
: '$duration min'
|
||||
: '${_formatTime(start)} - ${_formatTime(end)}';
|
||||
final completedAt = item.item.completedAt ?? item.item.end ?? item.end;
|
||||
return TimelineCardModel(
|
||||
id: item.id,
|
||||
title: title,
|
||||
typeLabel: _typeLabelFor(visualKind),
|
||||
typeLabel: _typeLabelFor(item.taskType, visualKind),
|
||||
subtitle: _subtitleFor(item, visualKind, timeText),
|
||||
timeText: timeText,
|
||||
startMinutes: _minutesSinceMidnight(start),
|
||||
endMinutes: _minutesSinceMidnight(end),
|
||||
taskType: item.taskType,
|
||||
visualKind: visualKind,
|
||||
rewardIconToken: item.item.rewardIconToken,
|
||||
difficultyIconToken: item.item.difficultyIconToken,
|
||||
durationMinutes: duration,
|
||||
completedTimeText: isCompleted ? _formatTime(completedAt) : null,
|
||||
isCompleted: isCompleted,
|
||||
isSelectable: true,
|
||||
showsQuickActions: !isCompleted && visualKind != TaskVisualKind.freeSlot,
|
||||
|
|
@ -179,6 +184,9 @@ class TimelineCardModel {
|
|||
/// Display-ready time or duration text.
|
||||
final String timeText;
|
||||
|
||||
/// Display-ready completion time, if the task has been completed.
|
||||
final String? completedTimeText;
|
||||
|
||||
/// Card start position in minutes since midnight.
|
||||
final int startMinutes;
|
||||
|
||||
|
|
@ -188,6 +196,9 @@ class TimelineCardModel {
|
|||
/// Optional task duration in minutes.
|
||||
final int? durationMinutes;
|
||||
|
||||
/// Source task scheduling behavior type.
|
||||
final TaskType taskType;
|
||||
|
||||
/// Visual category used for styling.
|
||||
final TaskVisualKind visualKind;
|
||||
|
||||
|
|
@ -206,6 +217,20 @@ class TimelineCardModel {
|
|||
/// Whether the card should show compact quick-action controls.
|
||||
final bool showsQuickActions;
|
||||
|
||||
/// Whether the left status control can toggle this task's completion.
|
||||
bool get canToggleCompletion {
|
||||
return switch (taskType) {
|
||||
TaskType.flexible || TaskType.critical || TaskType.inflexible => true,
|
||||
TaskType.locked || TaskType.surprise || TaskType.freeSlot => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// Whether the left status control can complete this task.
|
||||
bool get canComplete => canToggleCompletion && !isCompleted;
|
||||
|
||||
/// Whether the left status control can return this task to planned.
|
||||
bool get canUncomplete => canToggleCompletion && isCompleted;
|
||||
|
||||
/// Accessibility and modal label for the reward icon.
|
||||
String get rewardLabel {
|
||||
return switch (rewardIconToken) {
|
||||
|
|
@ -247,13 +272,17 @@ TaskVisualKind _visualKindFor(TodayTimelineItem item) {
|
|||
};
|
||||
}
|
||||
|
||||
String _typeLabelFor(TaskVisualKind visualKind) {
|
||||
return switch (visualKind) {
|
||||
TaskVisualKind.flexible => 'Flexible',
|
||||
TaskVisualKind.required => 'Required',
|
||||
TaskVisualKind.appointment => 'Required',
|
||||
TaskVisualKind.freeSlot => 'Free Slot',
|
||||
TaskVisualKind.completedSurprise => 'Surprise task',
|
||||
String _typeLabelFor(TaskType taskType, TaskVisualKind visualKind) {
|
||||
if (visualKind == TaskVisualKind.completedSurprise) {
|
||||
return 'Completed';
|
||||
}
|
||||
return switch (taskType) {
|
||||
TaskType.flexible => 'Flexible',
|
||||
TaskType.critical => 'Required',
|
||||
TaskType.inflexible => 'Required',
|
||||
TaskType.freeSlot => 'Free Slot',
|
||||
TaskType.surprise => 'Surprise task',
|
||||
TaskType.locked => 'Locked',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -265,9 +294,10 @@ String _subtitleFor(
|
|||
if (visualKind == TaskVisualKind.freeSlot) {
|
||||
return 'Intentional rest';
|
||||
}
|
||||
if (visualKind == TaskVisualKind.completedSurprise) {
|
||||
final completedAt = _formatTime(item.item.end ?? item.end);
|
||||
return 'Surprise task - Completed at $completedAt';
|
||||
if (item.taskStatus == TaskStatus.completed ||
|
||||
visualKind == TaskVisualKind.completedSurprise) {
|
||||
return 'Completed at: '
|
||||
'${_formatTime(item.item.completedAt ?? item.item.end ?? item.end)}';
|
||||
}
|
||||
if (item.taskType == TaskType.flexible && item.item.durationMinutes != null) {
|
||||
return '${item.item.durationMinutes} min';
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ abstract final class FocusFlowTokens {
|
|||
static const bannerRadius = 8.0;
|
||||
|
||||
/// Border radius for timeline cards.
|
||||
static const cardRadius = 8.0;
|
||||
static const cardRadius = 12.0;
|
||||
|
||||
/// Border radius for task selection modals.
|
||||
static const modalRadius = 8.0;
|
||||
|
|
@ -111,7 +111,7 @@ abstract final class FocusFlowTokens {
|
|||
static const cardMetaSize = 16.0;
|
||||
|
||||
/// Font size for sidebar navigation labels.
|
||||
static const sidebarNavSize = 18.0;
|
||||
static const sidebarNavSize = 16.0;
|
||||
|
||||
/// Font size for modal titles.
|
||||
static const modalTitleSize = 26.0;
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ class RequiredBanner extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final banner = model;
|
||||
if (banner == null) {
|
||||
return const SizedBox(height: _RequiredBannerMetrics.referenceHeight);
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
|
|
@ -119,11 +119,11 @@ class _RequiredBannerMetrics {
|
|||
double get iconSize => 24 * scale;
|
||||
double get iconGap => 26 * scale;
|
||||
double get buttonGap => 20 * scale;
|
||||
double get textSize => 19 * scale;
|
||||
double get textSize => (19 * scale).clamp(13.0, 14.0).toDouble();
|
||||
double get buttonWidth => 176 * scale;
|
||||
double get buttonHeight => 46 * scale;
|
||||
double get buttonRadius => FocusFlowTokens.smallButtonRadius * scale;
|
||||
double get buttonTextSize => 16 * scale;
|
||||
double get buttonTextSize => (16 * scale).clamp(11.0, 12.5).toDouble();
|
||||
double get buttonIconSize => 20 * scale;
|
||||
double get buttonTextGap => 6 * scale;
|
||||
EdgeInsets get buttonPadding => EdgeInsets.symmetric(horizontal: 12 * scale);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ class TaskSelectionModal extends StatelessWidget {
|
|||
const TaskSelectionModal({
|
||||
required this.card,
|
||||
required this.onClose,
|
||||
this.onStatusPressed,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -23,6 +24,9 @@ class TaskSelectionModal extends StatelessWidget {
|
|||
/// Callback invoked when the modal should close.
|
||||
final VoidCallback onClose;
|
||||
|
||||
/// Callback invoked when the status circle should toggle completion.
|
||||
final VoidCallback? onStatusPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = _accentFor(card.visualKind);
|
||||
|
|
@ -43,13 +47,10 @@ class TaskSelectionModal extends StatelessWidget {
|
|||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: accent, width: 4),
|
||||
),
|
||||
_ModalStatusButton(
|
||||
card: card,
|
||||
color: accent,
|
||||
onPressed: onStatusPressed,
|
||||
),
|
||||
const SizedBox(width: 28),
|
||||
Expanded(
|
||||
|
|
@ -65,13 +66,7 @@ class TaskSelectionModal extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${card.typeLabel} - ${card.timeText}',
|
||||
style: const TextStyle(
|
||||
color: FocusFlowTokens.textMuted,
|
||||
fontSize: 17,
|
||||
),
|
||||
),
|
||||
_HeaderMetadata(card: card),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -137,6 +132,93 @@ class TaskSelectionModal extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
class _ModalStatusButton extends StatelessWidget {
|
||||
const _ModalStatusButton({
|
||||
required this.card,
|
||||
required this.color,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final TimelineCardModel card;
|
||||
final Color color;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ring = Container(
|
||||
key: const ValueKey('task-modal-status'),
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: card.isCompleted ? color : Colors.transparent,
|
||||
border: Border.all(color: color, width: 4),
|
||||
),
|
||||
child: card.isCompleted
|
||||
? const Icon(
|
||||
Icons.check,
|
||||
color: FocusFlowTokens.appBackground,
|
||||
size: 26,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
if (onPressed == null) {
|
||||
return ring;
|
||||
}
|
||||
return Tooltip(
|
||||
message: card.isCompleted ? 'Mark not done' : 'Mark complete',
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: card.isCompleted
|
||||
? 'Mark ${card.title} not done'
|
||||
: 'Mark ${card.title} complete',
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onPressed,
|
||||
child: ring,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeaderMetadata extends StatelessWidget {
|
||||
const _HeaderMetadata({required this.card});
|
||||
|
||||
final TimelineCardModel card;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const metadataStyle = TextStyle(
|
||||
color: FocusFlowTokens.textMuted,
|
||||
fontSize: 17,
|
||||
);
|
||||
if (!card.isCompleted) {
|
||||
return Text('${card.typeLabel} - ${card.timeText}', style: metadataStyle);
|
||||
}
|
||||
final completedTime = card.completedTimeText;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
completedTime == null || completedTime.isEmpty
|
||||
? 'Completed'
|
||||
: 'Completed - $completedTime',
|
||||
style: metadataStyle,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'Planned: ${card.timeText}',
|
||||
style: metadataStyle.copyWith(fontSize: 15),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoTile extends StatelessWidget {
|
||||
const _InfoTile({required this.child, required this.label});
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,12 @@ import 'reward_icon.dart';
|
|||
/// Interactive card that renders a scheduled task on the compact timeline.
|
||||
class TaskTimelineCard extends StatefulWidget {
|
||||
/// Creates a task timeline card.
|
||||
const TaskTimelineCard({required this.card, required this.onTap, super.key});
|
||||
const TaskTimelineCard({
|
||||
required this.card,
|
||||
required this.onTap,
|
||||
this.onStatusPressed,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Card model to render.
|
||||
final TimelineCardModel card;
|
||||
|
|
@ -21,6 +26,9 @@ class TaskTimelineCard extends StatefulWidget {
|
|||
/// Callback invoked when the card is tapped.
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// Callback invoked when the left status control is clicked.
|
||||
final VoidCallback? onStatusPressed;
|
||||
|
||||
@override
|
||||
State<TaskTimelineCard> createState() => _TaskTimelineCardState();
|
||||
}
|
||||
|
|
@ -38,50 +46,36 @@ class _TaskTimelineCardState extends State<TaskTimelineCard> {
|
|||
constraints,
|
||||
kind: card.visualKind,
|
||||
);
|
||||
final content = metrics.isCompact
|
||||
final content = SizedBox.expand(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: metrics.isCompact
|
||||
? _CompactCardText(
|
||||
card: card,
|
||||
color: colors.accent,
|
||||
metrics: metrics,
|
||||
onStatusPressed: widget.onStatusPressed,
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
_StatusRing(
|
||||
: _ExpandedCardContent(
|
||||
card: card,
|
||||
color: colors.accent,
|
||||
colors: colors,
|
||||
metrics: metrics,
|
||||
onStatusPressed: widget.onStatusPressed,
|
||||
),
|
||||
SizedBox(width: metrics.statusGap),
|
||||
Expanded(
|
||||
child: _CardText(
|
||||
),
|
||||
Positioned(
|
||||
top: metrics.indicatorTop,
|
||||
right: 0,
|
||||
child: _TrailingControls(
|
||||
card: card,
|
||||
color: colors.accent,
|
||||
colors: colors,
|
||||
metrics: metrics,
|
||||
),
|
||||
),
|
||||
if (card.showsQuickActions)
|
||||
_QuickActions(
|
||||
color: colors.accent,
|
||||
metrics: metrics,
|
||||
visible: _isHovered,
|
||||
),
|
||||
_Badge(
|
||||
metrics: metrics,
|
||||
child: RewardIcon(
|
||||
color: colors.reward,
|
||||
size: metrics.rewardIconSize,
|
||||
),
|
||||
),
|
||||
SizedBox(width: metrics.badgeGap),
|
||||
_Badge(
|
||||
metrics: metrics,
|
||||
child: DifficultyBars(
|
||||
difficulty: card.difficultyIconToken,
|
||||
color: colors.reward,
|
||||
size: metrics.difficultySize,
|
||||
actionsVisible: card.showsQuickActions && _isHovered,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return MouseRegion(
|
||||
onEnter: (_) {
|
||||
|
|
@ -167,29 +161,36 @@ class _CardMetrics {
|
|||
double get borderWidth => math.max(0.75, 1.2 * scale);
|
||||
double get shadowBlur => isCompact ? 0 : 18 * scale;
|
||||
double get shadowSpread => isCompact ? 0 : scale;
|
||||
double get statusRingSize => 34 * scale;
|
||||
double get statusBorderWidth => math.max(1.2, 3 * scale);
|
||||
double get freeSlotStatusBorderWidth => math.max(1, 2 * scale);
|
||||
double get statusIconSize => 24 * scale;
|
||||
double get statusGap => 22 * scale;
|
||||
double get actionButtonSize => 40 * scale;
|
||||
double get actionIconSize => 22 * scale;
|
||||
double get actionGap => 10 * scale;
|
||||
double get badgeWidth => 62 * scale;
|
||||
double get badgeHeight => 48 * scale;
|
||||
double get badgeRadius => 7 * scale;
|
||||
double get badgeGap => 8 * scale;
|
||||
double get rewardIconSize => 26 * scale;
|
||||
double get titleSize => FocusFlowTokens.cardTitleSize * scale;
|
||||
double get metaSize => FocusFlowTokens.cardMetaSize * scale;
|
||||
double get statusRingSize {
|
||||
final availableHeight = math.max(8, height - padding.vertical);
|
||||
return math.min(24, availableHeight * 0.78).toDouble();
|
||||
}
|
||||
|
||||
double get statusBorderWidth => math.max(1.4, statusRingSize * 0.08);
|
||||
double get statusIconSize => statusRingSize * 0.58;
|
||||
double get statusGap => isCompact ? 7 : 22 * scale;
|
||||
double get actionButtonSize => (30 * scale).clamp(14.0, 22.0).toDouble();
|
||||
double get actionIconSize => (16 * scale).clamp(9.0, 13.0).toDouble();
|
||||
double get actionGap => math.max(3, 5 * scale);
|
||||
double get actionsWidth => actionButtonSize * 4 + actionGap;
|
||||
double get indicatorTop => isCompact ? 0 : math.max(1, 2 * scale);
|
||||
double get indicatorGap => math.max(3, 5 * scale);
|
||||
double get rewardIconSize => 14;
|
||||
double get titleSize => 10;
|
||||
double get metaSize => 7;
|
||||
double get titleMetaGap => 4 * scale;
|
||||
double get freeSlotTimeGap => 10 * scale;
|
||||
double get compactTextGap => math.max(6, 16 * scale);
|
||||
Size get difficultySize => Size(54 * scale, 34 * scale);
|
||||
double get compactTextGap => math.max(3, 8 * scale);
|
||||
double get indicatorWidth =>
|
||||
rewardIconSize + indicatorGap + difficultySize.width;
|
||||
double get expandedTrailingReserve =>
|
||||
indicatorWidth + math.max(8, 12 * scale);
|
||||
double get compactTrailingReserve => indicatorWidth + math.max(6, 8 * scale);
|
||||
Size get difficultySize => Size(26, 14);
|
||||
EdgeInsets get padding {
|
||||
if (isCompact) {
|
||||
return EdgeInsets.symmetric(
|
||||
horizontal: math.max(4, 12 * scale),
|
||||
horizontal: math.max(4, 8 * scale),
|
||||
vertical: math.max(1, 3 * scale),
|
||||
);
|
||||
}
|
||||
|
|
@ -197,36 +198,100 @@ class _CardMetrics {
|
|||
}
|
||||
}
|
||||
|
||||
class _ExpandedCardContent extends StatelessWidget {
|
||||
const _ExpandedCardContent({
|
||||
required this.card,
|
||||
required this.colors,
|
||||
required this.metrics,
|
||||
required this.onStatusPressed,
|
||||
});
|
||||
|
||||
final TimelineCardModel card;
|
||||
final _CardColors colors;
|
||||
final _CardMetrics metrics;
|
||||
final VoidCallback? onStatusPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
if (card.visualKind != TaskVisualKind.freeSlot) ...[
|
||||
_StatusRing(
|
||||
card: card,
|
||||
color: colors.accent,
|
||||
metrics: metrics,
|
||||
onPressed: card.canToggleCompletion ? onStatusPressed : null,
|
||||
),
|
||||
SizedBox(width: metrics.statusGap),
|
||||
],
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(right: metrics.expandedTrailingReserve),
|
||||
child: _CardText(
|
||||
card: card,
|
||||
color: colors.accent,
|
||||
metrics: metrics,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusRing extends StatelessWidget {
|
||||
const _StatusRing({
|
||||
required this.card,
|
||||
required this.color,
|
||||
required this.metrics,
|
||||
this.onPressed,
|
||||
});
|
||||
|
||||
final TimelineCardModel card;
|
||||
final Color color;
|
||||
final _CardMetrics metrics;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final border = Border.all(
|
||||
color: color,
|
||||
width: card.visualKind == TaskVisualKind.freeSlot
|
||||
? metrics.freeSlotStatusBorderWidth
|
||||
: metrics.statusBorderWidth,
|
||||
style: card.visualKind == TaskVisualKind.freeSlot
|
||||
? BorderStyle.solid
|
||||
: BorderStyle.solid,
|
||||
);
|
||||
return Container(
|
||||
final border = Border.all(color: color, width: metrics.statusBorderWidth);
|
||||
final ring = Container(
|
||||
key: ValueKey('${card.id}-status'),
|
||||
width: metrics.statusRingSize,
|
||||
height: metrics.statusRingSize,
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, border: border),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: card.isCompleted ? color : Colors.transparent,
|
||||
border: border,
|
||||
),
|
||||
child: card.isCompleted
|
||||
? Icon(Icons.check, color: color, size: metrics.statusIconSize)
|
||||
? Icon(
|
||||
Icons.check,
|
||||
color: FocusFlowTokens.appBackground,
|
||||
size: metrics.statusIconSize,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
if (onPressed == null) {
|
||||
return ring;
|
||||
}
|
||||
return Tooltip(
|
||||
message: card.isCompleted ? 'Mark not done' : 'Mark complete',
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: card.isCompleted
|
||||
? 'Mark ${card.title} not done'
|
||||
: 'Mark ${card.title} complete',
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onPressed,
|
||||
child: ring,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -313,11 +378,13 @@ class _CompactCardText extends StatelessWidget {
|
|||
required this.card,
|
||||
required this.color,
|
||||
required this.metrics,
|
||||
required this.onStatusPressed,
|
||||
});
|
||||
|
||||
final TimelineCardModel card;
|
||||
final Color color;
|
||||
final _CardMetrics metrics;
|
||||
final VoidCallback? onStatusPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -344,8 +411,18 @@ class _CompactCardText extends StatelessWidget {
|
|||
final line = Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
if (card.visualKind != TaskVisualKind.freeSlot) ...[
|
||||
_StatusRing(
|
||||
card: card,
|
||||
color: color,
|
||||
metrics: metrics,
|
||||
onPressed: card.canToggleCompletion ? onStatusPressed : null,
|
||||
),
|
||||
SizedBox(width: metrics.statusGap),
|
||||
],
|
||||
Flexible(
|
||||
flex: 2,
|
||||
fit: FlexFit.loose,
|
||||
child: Text(
|
||||
card.title,
|
||||
maxLines: 1,
|
||||
|
|
@ -355,7 +432,8 @@ class _CompactCardText extends StatelessWidget {
|
|||
),
|
||||
SizedBox(width: metrics.compactTextGap),
|
||||
Flexible(
|
||||
flex: 2,
|
||||
flex: 1,
|
||||
fit: FlexFit.loose,
|
||||
child: Text(
|
||||
metaText,
|
||||
maxLines: 1,
|
||||
|
|
@ -363,6 +441,7 @@ class _CompactCardText extends StatelessWidget {
|
|||
style: metaStyle,
|
||||
),
|
||||
),
|
||||
SizedBox(width: metrics.compactTrailingReserve),
|
||||
],
|
||||
);
|
||||
if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) {
|
||||
|
|
@ -389,14 +468,53 @@ class _CompactCardText extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
class _TrailingControls extends StatelessWidget {
|
||||
const _TrailingControls({
|
||||
required this.card,
|
||||
required this.colors,
|
||||
required this.metrics,
|
||||
required this.actionsVisible,
|
||||
});
|
||||
|
||||
final TimelineCardModel card;
|
||||
final _CardColors colors;
|
||||
final _CardMetrics metrics;
|
||||
final bool actionsVisible;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (card.showsQuickActions)
|
||||
_QuickActions(
|
||||
color: colors.accent,
|
||||
backgroundColor: colors.background.withValues(alpha: 1),
|
||||
metrics: metrics,
|
||||
visible: actionsVisible,
|
||||
),
|
||||
RewardIcon(color: colors.reward, size: metrics.rewardIconSize),
|
||||
SizedBox(width: metrics.indicatorGap),
|
||||
DifficultyBars(
|
||||
difficulty: card.difficultyIconToken,
|
||||
color: colors.reward,
|
||||
size: metrics.difficultySize,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuickActions extends StatelessWidget {
|
||||
const _QuickActions({
|
||||
required this.color,
|
||||
required this.backgroundColor,
|
||||
required this.metrics,
|
||||
required this.visible,
|
||||
});
|
||||
|
||||
final Color color;
|
||||
final Color backgroundColor;
|
||||
final _CardMetrics metrics;
|
||||
final bool visible;
|
||||
|
||||
|
|
@ -406,14 +524,29 @@ class _QuickActions extends StatelessWidget {
|
|||
excluding: !visible,
|
||||
child: IgnorePointer(
|
||||
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(
|
||||
opacity: visible ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 120),
|
||||
duration: const Duration(milliseconds: 90),
|
||||
curve: Curves.easeOut,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_NoOpIcon(icon: Icons.check, color: color, metrics: metrics),
|
||||
_NoOpIcon(
|
||||
icon: Icons.check,
|
||||
color: color,
|
||||
metrics: metrics,
|
||||
),
|
||||
_NoOpIcon(
|
||||
icon: Icons.arrow_forward,
|
||||
color: color,
|
||||
|
|
@ -434,6 +567,9 @@ class _QuickActions extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -451,40 +587,16 @@ class _NoOpIcon extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
onPressed: () {},
|
||||
constraints: BoxConstraints.tightFor(
|
||||
width: metrics.actionButtonSize,
|
||||
height: metrics.actionButtonSize,
|
||||
return Tooltip(
|
||||
message: 'No-op action',
|
||||
child: GestureDetector(
|
||||
onTap: () {},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: SizedBox.square(
|
||||
dimension: metrics.actionButtonSize,
|
||||
child: Icon(icon, color: color, size: metrics.actionIconSize),
|
||||
),
|
||||
color: color,
|
||||
iconSize: metrics.actionIconSize,
|
||||
padding: EdgeInsets.zero,
|
||||
icon: Icon(icon),
|
||||
tooltip: 'No-op action',
|
||||
visualDensity: VisualDensity.compact,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Badge extends StatelessWidget {
|
||||
const _Badge({required this.child, required this.metrics});
|
||||
|
||||
final Widget child;
|
||||
final _CardMetrics metrics;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: metrics.badgeWidth,
|
||||
height: metrics.badgeHeight,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: FocusFlowTokens.panelBackground.withValues(alpha: 0.84),
|
||||
borderRadius: BorderRadius.circular(metrics.badgeRadius),
|
||||
border: Border.all(color: FocusFlowTokens.subtleBorder),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,32 +9,38 @@ import 'timeline_geometry.dart';
|
|||
/// Time labels and rail shown beside the compact timeline.
|
||||
class TimelineAxis extends StatelessWidget {
|
||||
/// Creates a timeline axis for [geometry].
|
||||
const TimelineAxis({required this.geometry, super.key});
|
||||
const TimelineAxis({required this.geometry, this.topInset = 12, super.key});
|
||||
|
||||
/// Geometry used to position labels and tick marks.
|
||||
final TimelineGeometry geometry;
|
||||
|
||||
/// Vertical inset before the first timeline tick.
|
||||
final double topInset;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ticks = geometry.ticks();
|
||||
return SizedBox(
|
||||
width: FocusFlowTokens.timelineRailWidth,
|
||||
height: geometry.totalHeight + 24,
|
||||
height: geometry.totalHeight + topInset + 24,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: 92,
|
||||
top: 0,
|
||||
top: topInset,
|
||||
bottom: 0,
|
||||
child: Container(width: 2, color: FocusFlowTokens.rail),
|
||||
),
|
||||
for (final tick in ticks)
|
||||
Positioned(
|
||||
top: tick.y - 10,
|
||||
top: tick.y + topInset - 10,
|
||||
left: 0,
|
||||
width: 86,
|
||||
child: Text(
|
||||
tick.label,
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.visible,
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
color: tick.major
|
||||
|
|
@ -47,7 +53,7 @@ class TimelineAxis extends StatelessWidget {
|
|||
),
|
||||
for (final tick in ticks.where((tick) => tick.major))
|
||||
Positioned(
|
||||
top: tick.y - 6,
|
||||
top: tick.y + topInset - 6,
|
||||
left: 87,
|
||||
child: const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -66,24 +72,28 @@ class TimelineAxis extends StatelessWidget {
|
|||
/// Background grid aligned to timeline tick marks.
|
||||
class TimelineGrid extends StatelessWidget {
|
||||
/// Creates a timeline grid for [geometry].
|
||||
const TimelineGrid({required this.geometry, super.key});
|
||||
const TimelineGrid({required this.geometry, this.topInset = 12, super.key});
|
||||
|
||||
/// Geometry used to position grid lines.
|
||||
final TimelineGeometry geometry;
|
||||
|
||||
/// Vertical inset before the first timeline tick.
|
||||
final double topInset;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomPaint(
|
||||
painter: _TimelineGridPainter(geometry),
|
||||
painter: _TimelineGridPainter(geometry: geometry, topInset: topInset),
|
||||
size: Size.infinite,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TimelineGridPainter extends CustomPainter {
|
||||
const _TimelineGridPainter(this.geometry);
|
||||
const _TimelineGridPainter({required this.geometry, required this.topInset});
|
||||
|
||||
final TimelineGeometry geometry;
|
||||
final double topInset;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
|
|
@ -92,8 +102,8 @@ class _TimelineGridPainter extends CustomPainter {
|
|||
..strokeWidth = 1;
|
||||
for (final tick in geometry.ticks()) {
|
||||
canvas.drawLine(
|
||||
Offset(0, tick.y),
|
||||
Offset(size.width, tick.y),
|
||||
Offset(0, tick.y + topInset),
|
||||
Offset(size.width, tick.y + topInset),
|
||||
paint
|
||||
..color = tick.major
|
||||
? FocusFlowTokens.gridLine.withValues(alpha: 0.72)
|
||||
|
|
@ -104,6 +114,6 @@ class _TimelineGridPainter extends CustomPainter {
|
|||
|
||||
@override
|
||||
bool shouldRepaint(covariant _TimelineGridPainter oldDelegate) {
|
||||
return oldDelegate.geometry != geometry;
|
||||
return oldDelegate.geometry != geometry || oldDelegate.topInset != topInset;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,26 @@ class TimelineGeometry {
|
|||
];
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
other is TimelineGeometry &&
|
||||
other.startMinutes == startMinutes &&
|
||||
other.endMinutes == endMinutes &&
|
||||
other.pixelsPerMinute == pixelsPerMinute &&
|
||||
other.minCardHeight == minCardHeight;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(
|
||||
startMinutes,
|
||||
endMinutes,
|
||||
pixelsPerMinute,
|
||||
minCardHeight,
|
||||
);
|
||||
}
|
||||
|
||||
static String _labelFor(int minutes) {
|
||||
final hour24 = (minutes ~/ 60) % 24;
|
||||
final minute = minutes % 60;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/// Renders Timeline View pieces for the FocusFlow Flutter timeline.
|
||||
library;
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/today_screen_models.dart';
|
||||
|
|
@ -16,6 +17,7 @@ class TimelineView extends StatefulWidget {
|
|||
required this.cards,
|
||||
required this.range,
|
||||
required this.onCardSelected,
|
||||
this.onCardCompleted,
|
||||
this.now,
|
||||
this.scrollTargetCardId,
|
||||
this.scrollRequest = 0,
|
||||
|
|
@ -31,6 +33,9 @@ class TimelineView extends StatefulWidget {
|
|||
/// Callback invoked when a card is selected.
|
||||
final ValueChanged<TimelineCardModel> onCardSelected;
|
||||
|
||||
/// Callback invoked when a task card status control requests completion.
|
||||
final ValueChanged<TimelineCardModel>? onCardCompleted;
|
||||
|
||||
/// Clock used to center the initial scroll position.
|
||||
final DateTime Function()? now;
|
||||
|
||||
|
|
@ -45,17 +50,37 @@ class TimelineView extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _TimelineViewState extends State<TimelineView> {
|
||||
static const _topInset = 12.0;
|
||||
|
||||
final _scrollController = ScrollController();
|
||||
bool _didSetInitialScroll = false;
|
||||
var _handledScrollRequest = 0;
|
||||
late TimelineGeometry _geometry;
|
||||
late double _contentHeight;
|
||||
late List<_TimelineCardPlacement> _placements;
|
||||
late int _cardsLayoutHash;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_updateLayoutCache();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TimelineView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.range.startMinutes != widget.range.startMinutes ||
|
||||
oldWidget.range.endMinutes != widget.range.endMinutes) {
|
||||
final rangeChanged =
|
||||
oldWidget.range.startMinutes != widget.range.startMinutes ||
|
||||
oldWidget.range.endMinutes != widget.range.endMinutes;
|
||||
if (rangeChanged) {
|
||||
_didSetInitialScroll = false;
|
||||
}
|
||||
final layoutChanged = _layoutHashFor(widget.cards) != _cardsLayoutHash;
|
||||
if (rangeChanged || layoutChanged) {
|
||||
_updateLayoutCache();
|
||||
} else if (!identical(oldWidget.cards, widget.cards)) {
|
||||
_refreshPlacementCards();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -66,28 +91,25 @@ class _TimelineViewState extends State<TimelineView> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final geometry = TimelineGeometry(
|
||||
startMinutes: widget.range.startMinutes,
|
||||
endMinutes: widget.range.endMinutes,
|
||||
pixelsPerMinute: 2.45,
|
||||
);
|
||||
final contentHeight = geometry.totalHeight + 24;
|
||||
final placements = _TimelineCardPlacement.pack(
|
||||
widget.cards,
|
||||
geometry: geometry,
|
||||
);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
_scheduleInitialScroll(geometry, constraints.maxHeight);
|
||||
_scheduleTargetScroll(geometry);
|
||||
return SingleChildScrollView(
|
||||
_scheduleInitialScroll(_geometry, constraints.maxHeight);
|
||||
_scheduleTargetScroll(_geometry);
|
||||
return ScrollConfiguration(
|
||||
behavior: const _TimelineScrollBehavior(),
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
child: SizedBox(
|
||||
height: contentHeight,
|
||||
height: _contentHeight,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TimelineAxis(geometry: geometry),
|
||||
RepaintBoundary(
|
||||
child: TimelineAxis(
|
||||
geometry: _geometry,
|
||||
topInset: _topInset,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 18),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
|
|
@ -97,23 +119,31 @@ class _TimelineViewState extends State<TimelineView> {
|
|||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
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(
|
||||
top: geometry.yForMinutesSinceMidnight(
|
||||
placement.card.startMinutes,
|
||||
),
|
||||
top: placement.top + _topInset,
|
||||
left: placement.left(trackWidth),
|
||||
width: placement.width(trackWidth),
|
||||
height: geometry.heightForDuration(
|
||||
placement.card.endMinutes -
|
||||
placement.card.startMinutes,
|
||||
),
|
||||
height: placement.height,
|
||||
child: RepaintBoundary(
|
||||
child: TaskTimelineCard(
|
||||
card: placement.card,
|
||||
onTap: () =>
|
||||
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(
|
||||
TimelineGeometry geometry,
|
||||
double viewportHeight,
|
||||
|
|
@ -146,7 +207,9 @@ class _TimelineViewState extends State<TimelineView> {
|
|||
.clamp(geometry.startMinutes, geometry.endMinutes)
|
||||
.toInt();
|
||||
final centeredOffset =
|
||||
geometry.yForMinutesSinceMidnight(clampedMinute) - viewportHeight / 2;
|
||||
geometry.yForMinutesSinceMidnight(clampedMinute) +
|
||||
_topInset -
|
||||
viewportHeight / 2;
|
||||
final maxOffset = _scrollController.position.maxScrollExtent;
|
||||
final offset = centeredOffset.clamp(0, maxOffset).toDouble();
|
||||
_scrollController.jumpTo(offset);
|
||||
|
|
@ -175,7 +238,9 @@ class _TimelineViewState extends State<TimelineView> {
|
|||
return;
|
||||
}
|
||||
final targetOffset =
|
||||
geometry.yForMinutesSinceMidnight(targetCard.startMinutes) - 24;
|
||||
geometry.yForMinutesSinceMidnight(targetCard.startMinutes) +
|
||||
_topInset -
|
||||
24;
|
||||
final maxOffset = _scrollController.position.maxScrollExtent;
|
||||
final offset = targetOffset.clamp(0, maxOffset).toDouble();
|
||||
_scrollController.jumpTo(offset);
|
||||
|
|
@ -184,11 +249,22 @@ class _TimelineViewState extends State<TimelineView> {
|
|||
}
|
||||
}
|
||||
|
||||
class _TimelineScrollBehavior extends MaterialScrollBehavior {
|
||||
const _TimelineScrollBehavior();
|
||||
|
||||
@override
|
||||
Set<PointerDeviceKind> get dragDevices {
|
||||
return {...super.dragDevices, PointerDeviceKind.mouse};
|
||||
}
|
||||
}
|
||||
|
||||
class _TimelineCardPlacement {
|
||||
const _TimelineCardPlacement({
|
||||
required this.card,
|
||||
required this.lane,
|
||||
required this.laneCount,
|
||||
required this.top,
|
||||
required this.height,
|
||||
});
|
||||
|
||||
static const _laneGap = 8.0;
|
||||
|
|
@ -196,6 +272,21 @@ class _TimelineCardPlacement {
|
|||
final TimelineCardModel card;
|
||||
final int lane;
|
||||
final int laneCount;
|
||||
final double top;
|
||||
final double height;
|
||||
|
||||
_TimelineCardPlacement withCard(TimelineCardModel nextCard) {
|
||||
if (identical(card, nextCard)) {
|
||||
return this;
|
||||
}
|
||||
return _TimelineCardPlacement(
|
||||
card: nextCard,
|
||||
lane: lane,
|
||||
laneCount: laneCount,
|
||||
top: top,
|
||||
height: height,
|
||||
);
|
||||
}
|
||||
|
||||
static List<_TimelineCardPlacement> pack(
|
||||
List<TimelineCardModel> cards, {
|
||||
|
|
@ -272,6 +363,10 @@ class _TimelineCardPlacement {
|
|||
card: card,
|
||||
lane: laneByCard[card]!,
|
||||
laneCount: laneCount,
|
||||
top: _visualStart(card, geometry),
|
||||
height: geometry.heightForDuration(
|
||||
card.endMinutes - card.startMinutes,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ import 'package:focus_flow_flutter/models/today_screen_models.dart';
|
|||
import 'package:focus_flow_flutter/theme/focus_flow_theme.dart';
|
||||
import 'package:focus_flow_flutter/widgets/required_banner.dart';
|
||||
import 'package:focus_flow_flutter/widgets/timeline/difficulty_bars.dart';
|
||||
import 'package:focus_flow_flutter/widgets/timeline/reward_icon.dart';
|
||||
import 'package:focus_flow_flutter/widgets/timeline/task_timeline_card.dart';
|
||||
import 'package:focus_flow_flutter/widgets/timeline/timeline_axis.dart';
|
||||
import 'package:focus_flow_flutter/widgets/timeline/timeline_geometry.dart';
|
||||
import 'package:focus_flow_flutter/widgets/timeline/timeline_view.dart';
|
||||
import 'package:focus_flow_flutter/widgets/top_bar.dart';
|
||||
|
||||
|
|
@ -43,7 +46,9 @@ void main() {
|
|||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Clean coffee maker'), findsOneWidget);
|
||||
expect(find.text('Cleaned kitchen counter'), findsNWidgets(2));
|
||||
expect(find.text('Cleaned kitchen counter'), findsOneWidget);
|
||||
expect(find.text('Completed at: 8:05 AM'), findsOneWidget);
|
||||
expect(find.textContaining('Surprise task'), findsNothing);
|
||||
expect(find.text('Sort mail'), findsOneWidget);
|
||||
expect(find.text('Start laundry'), findsOneWidget);
|
||||
expect(find.text('Water plants'), findsOneWidget);
|
||||
|
|
@ -67,7 +72,7 @@ void main() {
|
|||
expect(_kindFor(data, 'doctor-appointment'), TaskVisualKind.appointment);
|
||||
expect(_kindFor(data, 'free-slot'), TaskVisualKind.freeSlot);
|
||||
expect(
|
||||
_kindFor(data, 'cleaned-kitchen-counter-early'),
|
||||
_kindFor(data, 'cleaned-kitchen-counter'),
|
||||
TaskVisualKind.completedSurprise,
|
||||
);
|
||||
},
|
||||
|
|
@ -180,7 +185,7 @@ void main() {
|
|||
await tester.tap(find.text('Show upcoming'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(scrollable.position.pixels, closeTo(2622, 1));
|
||||
expect(scrollable.position.pixels, closeTo(2634, 1));
|
||||
expect(find.byKey(const ValueKey('pay-bill')), findsOneWidget);
|
||||
});
|
||||
|
||||
|
|
@ -205,10 +210,69 @@ void main() {
|
|||
final scrollable = tester.state<ScrollableState>(find.byType(Scrollable));
|
||||
final position = scrollable.position;
|
||||
expect(position.maxScrollExtent, greaterThan(3000));
|
||||
expect(position.pixels, closeTo(1564, 1));
|
||||
expect(position.pixels, closeTo(1576, 1));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('timeline hour labels stay on one line', (tester) async {
|
||||
await _pumpConstrainedWidget(
|
||||
tester,
|
||||
size: const Size(140, 220),
|
||||
child: const TimelineAxis(
|
||||
geometry: TimelineGeometry(
|
||||
startMinutes: 0,
|
||||
endMinutes: 60,
|
||||
pixelsPerMinute: 2.45,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
final midnight = tester.widget<Text>(find.text('12:00 AM'));
|
||||
final oneAm = tester.widget<Text>(find.text('1:00 AM'));
|
||||
expect(midnight.maxLines, 1);
|
||||
expect(oneAm.maxLines, 1);
|
||||
expect(midnight.softWrap, isFalse);
|
||||
expect(oneAm.softWrap, isFalse);
|
||||
expect(midnight.overflow, TextOverflow.visible);
|
||||
expect(oneAm.overflow, TextOverflow.visible);
|
||||
expect(
|
||||
tester.getSize(find.text('12:00 AM')).height,
|
||||
closeTo(tester.getSize(find.text('1:00 AM')).height, 0.1),
|
||||
);
|
||||
expect(
|
||||
tester.getRect(find.text('12:00 AM')).top,
|
||||
greaterThanOrEqualTo(tester.getRect(find.byType(TimelineAxis)).top),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('timeline scrolls with a mouse drag', (tester) async {
|
||||
await _pumpConstrainedWidget(
|
||||
tester,
|
||||
size: const Size(680, 400),
|
||||
child: TimelineView(
|
||||
cards: const [],
|
||||
range: TodayScreenData.defaultRange,
|
||||
now: () => DateTime(2026, 6, 30, 12),
|
||||
onCardSelected: (_) {},
|
||||
),
|
||||
);
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
final scrollable = tester.state<ScrollableState>(find.byType(Scrollable));
|
||||
final beforeDrag = scrollable.position.pixels;
|
||||
await tester.drag(
|
||||
find.byType(SingleChildScrollView),
|
||||
const Offset(0, -180),
|
||||
kind: PointerDeviceKind.mouse,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(scrollable.position.pixels, greaterThan(beforeDrag + 80));
|
||||
});
|
||||
|
||||
testWidgets('timeline splits partial overlaps and reuses empty lanes', (
|
||||
tester,
|
||||
) async {
|
||||
|
|
@ -289,8 +353,8 @@ void main() {
|
|||
final cleanCoffee = tester.getRect(
|
||||
find.byKey(const ValueKey('clean-coffee-maker')),
|
||||
);
|
||||
final cleanedKitchenEarly = tester.getRect(
|
||||
find.byKey(const ValueKey('cleaned-kitchen-counter-early')),
|
||||
final cleanedKitchenCounter = tester.getRect(
|
||||
find.byKey(const ValueKey('cleaned-kitchen-counter')),
|
||||
);
|
||||
|
||||
expect(startLaundry.left, greaterThan(sortMail.left));
|
||||
|
|
@ -299,10 +363,14 @@ void main() {
|
|||
expect(waterPlants.width, closeTo(sortMail.width, 1));
|
||||
expect(sortMail.right, lessThan(startLaundry.left));
|
||||
expect(waterPlants.right, lessThan(startLaundry.left));
|
||||
expect(cleanCoffee.left, closeTo(cleanedKitchenEarly.left, 1));
|
||||
expect(cleanCoffee.height, closeTo(15 * 2.45, 0.5));
|
||||
expect(cleanedKitchenEarly.height, closeTo(15 * 2.45, 0.5));
|
||||
expect(cleanCoffee.bottom, lessThan(cleanedKitchenEarly.top));
|
||||
expect(cleanedKitchenCounter.height, closeTo(15 * 2.45, 0.5));
|
||||
expect(
|
||||
data.cards
|
||||
.singleWhere((card) => card.id == 'cleaned-kitchen-counter')
|
||||
.startMinutes,
|
||||
8 * 60 + 15,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('top bar controls scale inside a compact header width', (
|
||||
|
|
@ -345,6 +413,24 @@ void main() {
|
|||
expect(tester.getSize(find.byType(OutlinedButton)).width, lessThan(176));
|
||||
});
|
||||
|
||||
testWidgets('empty required banner does not reserve vertical space', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(480, 160));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: FocusFlowTheme.dark(),
|
||||
home: const Scaffold(
|
||||
body: Align(alignment: Alignment.topLeft, child: RequiredBanner()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.getSize(find.byType(RequiredBanner)).height, 0);
|
||||
});
|
||||
|
||||
testWidgets('task card controls scale inside a narrow task bubble', (
|
||||
tester,
|
||||
) async {
|
||||
|
|
@ -373,12 +459,21 @@ void main() {
|
|||
tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity)).opacity,
|
||||
1,
|
||||
);
|
||||
|
||||
final button = tester.widget<IconButton>(
|
||||
find.widgetWithIcon(IconButton, Icons.arrow_forward),
|
||||
final actionBackground = tester.widget<AnimatedContainer>(
|
||||
find.byType(AnimatedContainer),
|
||||
);
|
||||
final decoration = actionBackground.decoration;
|
||||
expect(decoration, isA<BoxDecoration>());
|
||||
final boxDecoration = decoration! as BoxDecoration;
|
||||
expect(boxDecoration.color, const Color(0xFF021A0C));
|
||||
expect(boxDecoration.border, isNull);
|
||||
|
||||
final icon = tester.widget<Icon>(find.byIcon(Icons.arrow_forward));
|
||||
expect(icon.size, lessThan(18));
|
||||
expect(
|
||||
tester.getSize(find.byIcon(Icons.arrow_forward)).width,
|
||||
lessThan(32),
|
||||
);
|
||||
expect(button.iconSize, lessThan(18));
|
||||
expect(button.constraints?.maxWidth, lessThan(32));
|
||||
|
||||
await gesture.removePointer();
|
||||
});
|
||||
|
|
@ -401,9 +496,110 @@ void main() {
|
|||
|
||||
final title = tester.getRect(find.text('Five minute reset'));
|
||||
final time = tester.getRect(find.text('5 min'));
|
||||
final statusCircle = tester.getRect(
|
||||
find.byKey(const ValueKey('short-inline-time-status')),
|
||||
);
|
||||
|
||||
expect(statusCircle.height, lessThan(20));
|
||||
expect(statusCircle.right, lessThan(title.left));
|
||||
expect(title.right, lessThan(time.left));
|
||||
expect((title.center.dy - time.center.dy).abs(), lessThan(2));
|
||||
expect(find.byType(RewardIcon), findsOneWidget);
|
||||
expect(find.byType(DifficultyBars), findsOneWidget);
|
||||
|
||||
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
await gesture.addPointer(
|
||||
location: tester.getCenter(
|
||||
find.byKey(const ValueKey('short-inline-time')),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity)).opacity,
|
||||
1,
|
||||
);
|
||||
expect(find.text('5 min'), findsOneWidget);
|
||||
|
||||
await gesture.removePointer();
|
||||
});
|
||||
|
||||
testWidgets('status circle toggles task completion without opening modal', (
|
||||
tester,
|
||||
) async {
|
||||
final composition = _composition();
|
||||
await _pumpPlanApp(tester, composition: composition);
|
||||
|
||||
await _scrollIntoView(tester, const ValueKey('clean-coffee-maker'));
|
||||
final beforeClick = DateTime.now().toUtc();
|
||||
await tester.tap(find.byKey(const ValueKey('clean-coffee-maker-status')));
|
||||
await tester.pumpAndSettle();
|
||||
final afterClick = DateTime.now().toUtc();
|
||||
|
||||
final completedTask = composition.store.currentTasks.singleWhere(
|
||||
(task) => task.id == 'clean-coffee-maker',
|
||||
);
|
||||
final scheduledStart = completedTask.scheduledStart;
|
||||
final scheduledEnd = completedTask.scheduledEnd;
|
||||
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
|
||||
expect(completedTask.status, TaskStatus.completed);
|
||||
expect(completedTask.completedAt, isNotNull);
|
||||
expect(completedTask.completedAt!.isBefore(beforeClick), isFalse);
|
||||
expect(completedTask.completedAt!.isAfter(afterClick), isFalse);
|
||||
expect(find.textContaining('Completed at:'), findsWidgets);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('clean-coffee-maker-status')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final reopenedTask = composition.store.currentTasks.singleWhere(
|
||||
(task) => task.id == 'clean-coffee-maker',
|
||||
);
|
||||
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
|
||||
expect(reopenedTask.status, TaskStatus.planned);
|
||||
expect(reopenedTask.scheduledStart, scheduledStart);
|
||||
expect(reopenedTask.scheduledEnd, scheduledEnd);
|
||||
expect(reopenedTask.actualStart, isNull);
|
||||
expect(reopenedTask.actualEnd, isNull);
|
||||
expect(reopenedTask.completedAt, isNull);
|
||||
});
|
||||
|
||||
testWidgets('task modal status circle toggles and keeps details in sync', (
|
||||
tester,
|
||||
) async {
|
||||
final composition = _composition();
|
||||
await _pumpPlanApp(tester, composition: composition);
|
||||
|
||||
await _scrollIntoView(tester, const ValueKey('water-plants'));
|
||||
await tester.tap(find.byKey(const ValueKey('water-plants')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
|
||||
expect(find.text('Flexible - 3:20 PM - 4:00 PM'), findsOneWidget);
|
||||
expect(find.textContaining('Completed -'), findsNothing);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('task-modal-status')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final completedTask = composition.store.currentTasks.singleWhere(
|
||||
(task) => task.id == 'water-plants',
|
||||
);
|
||||
expect(completedTask.status, TaskStatus.completed);
|
||||
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
|
||||
expect(find.text('Water plants'), findsWidgets);
|
||||
expect(find.textContaining('Completed -'), findsOneWidget);
|
||||
expect(find.text('Planned: 3:20 PM - 4:00 PM'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.check), findsWidgets);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('task-modal-status')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final reopenedTask = composition.store.currentTasks.singleWhere(
|
||||
(task) => task.id == 'water-plants',
|
||||
);
|
||||
expect(reopenedTask.status, TaskStatus.planned);
|
||||
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
|
||||
expect(find.text('Flexible - 3:20 PM - 4:00 PM'), findsOneWidget);
|
||||
expect(find.textContaining('Completed -'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('free slot text scales inside a short task bubble', (
|
||||
|
|
@ -423,15 +619,21 @@ void main() {
|
|||
);
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.byKey(const ValueKey('short-free-slot-status')), findsNothing);
|
||||
expect(find.text('Intentional rest'), findsOneWidget);
|
||||
expect(find.text('6:15 PM - 6:30 PM'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pumpPlanApp(WidgetTester tester) async {
|
||||
Future<void> _pumpPlanApp(
|
||||
WidgetTester tester, {
|
||||
DemoSchedulerComposition? composition,
|
||||
}) async {
|
||||
await tester.binding.setSurfaceSize(const Size(1586, 992));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(FocusFlowApp(composition: _composition()));
|
||||
await tester.pumpWidget(
|
||||
FocusFlowApp(composition: composition ?? _composition()),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
|
|
@ -473,7 +675,11 @@ Future<void> _pumpTimelineCard(
|
|||
child: SizedBox(
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
child: TaskTimelineCard(card: card, onTap: () {}),
|
||||
child: TaskTimelineCard(
|
||||
card: card,
|
||||
onTap: () {},
|
||||
onStatusPressed: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -510,8 +716,19 @@ TimelineCardModel _timelineCard({
|
|||
int startMinutes = 18 * 60,
|
||||
int endMinutes = 18 * 60 + 30,
|
||||
TaskVisualKind visualKind = TaskVisualKind.flexible,
|
||||
TaskType? taskType,
|
||||
bool showsQuickActions = true,
|
||||
String? completedTimeText,
|
||||
}) {
|
||||
final resolvedTaskType =
|
||||
taskType ??
|
||||
switch (visualKind) {
|
||||
TaskVisualKind.flexible => TaskType.flexible,
|
||||
TaskVisualKind.required => TaskType.critical,
|
||||
TaskVisualKind.appointment => TaskType.inflexible,
|
||||
TaskVisualKind.freeSlot => TaskType.freeSlot,
|
||||
TaskVisualKind.completedSurprise => TaskType.flexible,
|
||||
};
|
||||
return TimelineCardModel(
|
||||
id: id,
|
||||
title: title,
|
||||
|
|
@ -520,9 +737,11 @@ TimelineCardModel _timelineCard({
|
|||
timeText: timeText,
|
||||
startMinutes: startMinutes,
|
||||
endMinutes: endMinutes,
|
||||
taskType: resolvedTaskType,
|
||||
visualKind: visualKind,
|
||||
rewardIconToken: TimelineRewardIconToken.medium,
|
||||
difficultyIconToken: TimelineDifficultyIconToken.medium,
|
||||
completedTimeText: completedTimeText,
|
||||
isCompleted: false,
|
||||
isSelectable: true,
|
||||
showsQuickActions: showsQuickActions,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ enum ApplicationCommandCode {
|
|||
pushFlexibleToTomorrowTopOfQueue,
|
||||
moveFlexibleToBacklog,
|
||||
completeFlexibleTask,
|
||||
uncompleteTask,
|
||||
applyRequiredTaskAction,
|
||||
logSurpriseTask,
|
||||
createProtectedFreeSlot,
|
||||
|
|
@ -558,6 +559,63 @@ class V1ApplicationCommandUseCases {
|
|||
);
|
||||
}
|
||||
|
||||
/// Mark a completed scheduled task as still needing to be done.
|
||||
Future<ApplicationResult<ApplicationCommandResult>> uncompleteTask({
|
||||
required ApplicationOperationContext context,
|
||||
required CivilDate localDate,
|
||||
required String taskId,
|
||||
DateTime? expectedUpdatedAt,
|
||||
}) {
|
||||
const commandCode = ApplicationCommandCode.uncompleteTask;
|
||||
return applicationStore.run<ApplicationCommandResult>(
|
||||
context: context,
|
||||
operationName: commandCode.name,
|
||||
action: (repositories) async {
|
||||
var state = await _loadPlanningState(repositories, context, localDate);
|
||||
var task = _taskById(state.tasks, taskId);
|
||||
if (task == null) {
|
||||
task = await repositories.tasks.findById(taskId);
|
||||
if (task != null) {
|
||||
state = state.withTask(task);
|
||||
}
|
||||
}
|
||||
if (task == null) {
|
||||
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
||||
}
|
||||
final staleFailure = _staleFailure(task, expectedUpdatedAt);
|
||||
if (staleFailure != null) {
|
||||
return ApplicationResult.failure(staleFailure);
|
||||
}
|
||||
if (task.status != TaskStatus.completed ||
|
||||
(!task.isFlexible && !task.isRequiredVisible)) {
|
||||
return _failure(
|
||||
ApplicationFailureCode.conflict,
|
||||
entityId: taskId,
|
||||
detailCode: TaskTransitionOutcomeCode.invalidState.name,
|
||||
);
|
||||
}
|
||||
final uncompleted = task.copyWith(
|
||||
status: TaskStatus.planned,
|
||||
updatedAt: context.now,
|
||||
clearActualInterval: true,
|
||||
clearCompletion: true,
|
||||
);
|
||||
return _persist(
|
||||
repositories: repositories,
|
||||
commandCode: commandCode,
|
||||
context: context,
|
||||
beforeTasks: state.tasks,
|
||||
afterTasks: _replaceTask(state.tasks, uncompleted),
|
||||
activities: const <TaskActivity>[],
|
||||
readHint: ApplicationCommandReadHint(
|
||||
localDate: localDate,
|
||||
affectedTaskIds: [taskId],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Apply a required visible task lifecycle action.
|
||||
Future<ApplicationResult<ApplicationCommandResult>> applyRequiredTaskAction({
|
||||
required ApplicationOperationContext context,
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ class TimelineItem {
|
|||
required this.category,
|
||||
this.start,
|
||||
this.end,
|
||||
this.completedAt,
|
||||
this.durationMinutes,
|
||||
}) : quickActions = List<TimelineQuickAction>.unmodifiable(quickActions);
|
||||
|
||||
|
|
@ -109,6 +110,9 @@ class TimelineItem {
|
|||
/// Timeline placement end, if the source item has one.
|
||||
final DateTime? end;
|
||||
|
||||
/// Completion instant, if the source task has been completed.
|
||||
final DateTime? completedAt;
|
||||
|
||||
/// Duration display value for flexible task cards.
|
||||
final int? durationMinutes;
|
||||
|
||||
|
|
@ -198,6 +202,7 @@ class TimelineItemMapper {
|
|||
showsExplicitTime: _showsExplicitTime(task.type),
|
||||
start: task.scheduledStart,
|
||||
end: task.scheduledEnd,
|
||||
completedAt: task.completedAt,
|
||||
durationMinutes: _durationMinutesFor(task),
|
||||
quickActions: _quickActionsFor(task.type),
|
||||
category: task.isLocked
|
||||
|
|
|
|||
|
|
@ -311,6 +311,43 @@ void main() {
|
|||
expect(store.currentProjectStatistics.single.completedTaskCount, 1);
|
||||
});
|
||||
|
||||
test('uncomplete task clears completion while preserving schedule',
|
||||
() async {
|
||||
final completed = task(
|
||||
id: 'completed',
|
||||
title: 'Completed',
|
||||
type: TaskType.flexible,
|
||||
status: TaskStatus.completed,
|
||||
createdAt: createdAt,
|
||||
start: DateTime.utc(2026, 6, 25, 9),
|
||||
end: DateTime.utc(2026, 6, 25, 9, 30),
|
||||
actualStart: DateTime.utc(2026, 6, 25, 9),
|
||||
actualEnd: DateTime.utc(2026, 6, 25, 9, 20),
|
||||
completedAt: DateTime.utc(2026, 6, 25, 9, 20),
|
||||
);
|
||||
final store = storeWithSettings(tasks: [completed]);
|
||||
|
||||
final result = await commands(store).uncompleteTask(
|
||||
context: appContext(
|
||||
'uncomplete-task',
|
||||
DateTime.utc(2026, 6, 25, 9, 25),
|
||||
),
|
||||
localDate: day,
|
||||
taskId: completed.id,
|
||||
);
|
||||
|
||||
expect(result.isSuccess, isTrue);
|
||||
final reopened = taskById(store.currentTasks, completed.id);
|
||||
expect(reopened.status, TaskStatus.planned);
|
||||
expect(reopened.scheduledStart, completed.scheduledStart);
|
||||
expect(reopened.scheduledEnd, completed.scheduledEnd);
|
||||
expect(reopened.actualStart, isNull);
|
||||
expect(reopened.actualEnd, isNull);
|
||||
expect(reopened.completedAt, isNull);
|
||||
expect(reopened.updatedAt, DateTime.utc(2026, 6, 25, 9, 25));
|
||||
expect(store.currentTaskActivities, isEmpty);
|
||||
});
|
||||
|
||||
test('surprise logging creates completed work and repairs flexible overlap',
|
||||
() async {
|
||||
final planned = task(
|
||||
|
|
@ -525,6 +562,9 @@ Task task({
|
|||
required DateTime createdAt,
|
||||
DateTime? start,
|
||||
DateTime? end,
|
||||
DateTime? actualStart,
|
||||
DateTime? actualEnd,
|
||||
DateTime? completedAt,
|
||||
int? durationMinutes,
|
||||
String projectId = 'home',
|
||||
String? parentTaskId,
|
||||
|
|
@ -539,6 +579,9 @@ Task task({
|
|||
(start != null && end != null ? end.difference(start).inMinutes : 30),
|
||||
scheduledStart: start,
|
||||
scheduledEnd: end,
|
||||
actualStart: actualStart,
|
||||
actualEnd: actualEnd,
|
||||
completedAt: completedAt,
|
||||
parentTaskId: parentTaskId,
|
||||
createdAt: createdAt,
|
||||
updatedAt: createdAt,
|
||||
|
|
|
|||
Loading…
Reference in a new issue