refactor: split focused Flutter UI files

This commit is contained in:
Ashley Venn 2026-07-01 12:17:02 -07:00
parent 52dcc84b7d
commit cbefe201d3
50 changed files with 2415 additions and 2324 deletions

View file

@ -0,0 +1,24 @@
part of '../demo_scheduler_composition.dart';
/// Formats a civil date as a long month/day/year label.
String _formatDateLabel(CivilDate date) {
const months = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
return '${months[date.month - 1]} ${date.day}, ${date.year}';
}
DateTime _instant(CivilDate date, int hour, [int minute = 0]) {
return DateTime.utc(date.year, date.month, date.day, hour, minute);
}

View file

@ -0,0 +1,156 @@
part of '../demo_scheduler_composition.dart';
List<Task> _todaySeedTasks(CivilDate date, DateTime createdAt) {
return [
_seedTask(
id: 'clean-coffee-maker',
title: 'Clean coffee maker',
type: TaskType.flexible,
status: TaskStatus.planned,
date: date,
startHour: 16,
startMinute: 15,
endHour: 16,
endMinute: 30,
reward: RewardLevel.medium,
difficulty: DifficultyLevel.easy,
createdAt: createdAt,
),
_seedTask(
id: 'cleaned-kitchen-counter',
title: 'Cleaned kitchen counter',
type: TaskType.flexible,
status: TaskStatus.completed,
date: date,
startHour: 8,
startMinute: 15,
endHour: 8,
endMinute: 30,
reward: RewardLevel.medium,
difficulty: DifficultyLevel.medium,
completedAt: _instant(date, 8, 5),
createdAt: createdAt,
),
_seedTask(
id: 'sort-mail',
title: 'Sort mail',
type: TaskType.flexible,
status: TaskStatus.planned,
date: date,
startHour: 14,
startMinute: 40,
endHour: 15,
endMinute: 20,
reward: RewardLevel.low,
difficulty: DifficultyLevel.easy,
createdAt: createdAt,
),
_seedTask(
id: 'start-laundry',
title: 'Start laundry',
type: TaskType.flexible,
status: TaskStatus.planned,
date: date,
startHour: 15,
startMinute: 0,
endHour: 15,
endMinute: 40,
reward: RewardLevel.medium,
difficulty: DifficultyLevel.medium,
createdAt: createdAt,
),
_seedTask(
id: 'water-plants',
title: 'Water plants',
type: TaskType.flexible,
status: TaskStatus.planned,
date: date,
startHour: 15,
startMinute: 20,
endHour: 16,
endMinute: 0,
reward: RewardLevel.medium,
difficulty: DifficultyLevel.medium,
createdAt: createdAt,
),
_seedTask(
id: 'pay-bill',
title: 'Pay bill',
type: TaskType.critical,
status: TaskStatus.planned,
date: date,
startHour: 18,
startMinute: 0,
endHour: 18,
endMinute: 10,
reward: RewardLevel.low,
difficulty: DifficultyLevel.medium,
createdAt: createdAt,
),
_seedTask(
id: 'doctor-appointment',
title: 'Doctor appointment',
type: TaskType.inflexible,
status: TaskStatus.planned,
date: date,
startHour: 18,
startMinute: 30,
endHour: 19,
endMinute: 15,
reward: RewardLevel.medium,
difficulty: DifficultyLevel.hard,
createdAt: createdAt,
),
_seedTask(
id: 'free-slot',
title: 'Free Slot',
type: TaskType.freeSlot,
status: TaskStatus.planned,
date: date,
startHour: 19,
startMinute: 15,
endHour: 20,
endMinute: 15,
reward: RewardLevel.notSet,
difficulty: DifficultyLevel.notSet,
createdAt: createdAt,
),
];
}
Task _seedTask({
required String id,
required String title,
required TaskType type,
required TaskStatus status,
required CivilDate date,
required int startHour,
required int startMinute,
required int endHour,
required int endMinute,
required RewardLevel reward,
required DifficultyLevel difficulty,
required DateTime createdAt,
DateTime? completedAt,
}) {
final start = _instant(date, startHour, startMinute);
final end = _instant(date, endHour, endMinute);
return Task(
id: id,
title: title,
projectId: DemoSchedulerComposition.projectId,
type: type,
status: status,
priority: PriorityLevel.medium,
reward: reward,
difficulty: difficulty,
durationMinutes: end.difference(start).inMinutes,
scheduledStart: start,
scheduledEnd: end,
actualStart: status == TaskStatus.completed ? start : null,
actualEnd: status == TaskStatus.completed ? end : null,
completedAt: completedAt,
createdAt: createdAt,
updatedAt: completedAt ?? createdAt,
);
}

View file

@ -7,6 +7,9 @@ import '../controllers/scheduler_command_controller.dart';
import '../controllers/scheduler_read_controller.dart'; import '../controllers/scheduler_read_controller.dart';
import '../controllers/today_screen_controller.dart'; import '../controllers/today_screen_controller.dart';
part 'demo/demo_date_formatter.dart';
part 'demo/demo_seed_tasks.dart';
/// Wires the Flutter demo UI to in-memory scheduler application use cases. /// Wires the Flutter demo UI to in-memory scheduler application use cases.
class DemoSchedulerComposition { class DemoSchedulerComposition {
DemoSchedulerComposition._({ DemoSchedulerComposition._({
@ -46,7 +49,7 @@ class DemoSchedulerComposition {
final DateTime readAt; final DateTime readAt;
/// Human-readable label for [date]. /// Human-readable label for [date].
String get dateLabel => formatDateLabel(date); String get dateLabel => _formatDateLabel(date);
/// Creates a deterministic seeded composition for the demo app and tests. /// Creates a deterministic seeded composition for the demo app and tests.
factory DemoSchedulerComposition.seeded({ factory DemoSchedulerComposition.seeded({
@ -178,182 +181,4 @@ class DemoSchedulerComposition {
idGenerator: SequentialIdGenerator(prefix: operationId), idGenerator: SequentialIdGenerator(prefix: operationId),
); );
} }
/// Formats a civil date as a long month/day/year label.
static String formatDateLabel(CivilDate date) {
const months = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
return '${months[date.month - 1]} ${date.day}, ${date.year}';
}
static List<Task> _todaySeedTasks(CivilDate date, DateTime createdAt) {
return [
_task(
id: 'clean-coffee-maker',
title: 'Clean coffee maker',
type: TaskType.flexible,
status: TaskStatus.planned,
date: date,
startHour: 16,
startMinute: 15,
endHour: 16,
endMinute: 30,
reward: RewardLevel.medium,
difficulty: DifficultyLevel.easy,
createdAt: createdAt,
),
_task(
id: 'cleaned-kitchen-counter',
title: 'Cleaned kitchen counter',
type: TaskType.flexible,
status: TaskStatus.completed,
date: date,
startHour: 8,
startMinute: 15,
endHour: 8,
endMinute: 30,
reward: RewardLevel.medium,
difficulty: DifficultyLevel.medium,
completedAt: _instant(date, 8, 5),
createdAt: createdAt,
),
_task(
id: 'sort-mail',
title: 'Sort mail',
type: TaskType.flexible,
status: TaskStatus.planned,
date: date,
startHour: 14,
startMinute: 40,
endHour: 15,
endMinute: 20,
reward: RewardLevel.low,
difficulty: DifficultyLevel.easy,
createdAt: createdAt,
),
_task(
id: 'start-laundry',
title: 'Start laundry',
type: TaskType.flexible,
status: TaskStatus.planned,
date: date,
startHour: 15,
startMinute: 0,
endHour: 15,
endMinute: 40,
reward: RewardLevel.medium,
difficulty: DifficultyLevel.medium,
createdAt: createdAt,
),
_task(
id: 'water-plants',
title: 'Water plants',
type: TaskType.flexible,
status: TaskStatus.planned,
date: date,
startHour: 15,
startMinute: 20,
endHour: 16,
endMinute: 0,
reward: RewardLevel.medium,
difficulty: DifficultyLevel.medium,
createdAt: createdAt,
),
_task(
id: 'pay-bill',
title: 'Pay bill',
type: TaskType.critical,
status: TaskStatus.planned,
date: date,
startHour: 18,
startMinute: 0,
endHour: 18,
endMinute: 10,
reward: RewardLevel.low,
difficulty: DifficultyLevel.medium,
createdAt: createdAt,
),
_task(
id: 'doctor-appointment',
title: 'Doctor appointment',
type: TaskType.inflexible,
status: TaskStatus.planned,
date: date,
startHour: 18,
startMinute: 30,
endHour: 19,
endMinute: 15,
reward: RewardLevel.medium,
difficulty: DifficultyLevel.hard,
createdAt: createdAt,
),
_task(
id: 'free-slot',
title: 'Free Slot',
type: TaskType.freeSlot,
status: TaskStatus.planned,
date: date,
startHour: 19,
startMinute: 15,
endHour: 20,
endMinute: 15,
reward: RewardLevel.notSet,
difficulty: DifficultyLevel.notSet,
createdAt: createdAt,
),
];
}
static Task _task({
required String id,
required String title,
required TaskType type,
required TaskStatus status,
required CivilDate date,
required int startHour,
required int startMinute,
required int endHour,
required int endMinute,
required RewardLevel reward,
required DifficultyLevel difficulty,
required DateTime createdAt,
DateTime? completedAt,
}) {
final start = _instant(date, startHour, startMinute);
final end = _instant(date, endHour, endMinute);
return Task(
id: id,
title: title,
projectId: projectId,
type: type,
status: status,
priority: PriorityLevel.medium,
reward: reward,
difficulty: difficulty,
durationMinutes: end.difference(start).inMinutes,
scheduledStart: start,
scheduledEnd: end,
actualStart: status == TaskStatus.completed ? start : null,
actualEnd: status == TaskStatus.completed ? end : null,
completedAt: completedAt,
createdAt: createdAt,
updatedAt: completedAt ?? createdAt,
);
}
static DateTime _instant(CivilDate date, int hour, [int minute = 0]) {
return DateTime.utc(date.year, date.month, date.day, hour, minute);
}
} }

View file

@ -16,6 +16,12 @@ import '../widgets/timeline/timeline_view.dart';
import '../widgets/top_bar.dart'; import '../widgets/top_bar.dart';
import 'demo_scheduler_composition.dart'; import 'demo_scheduler_composition.dart';
part 'home/focus_flow_home.dart';
part 'home/focus_flow_home_state.dart';
part 'home/plan_issue.dart';
part 'home/today_screen_scaffold.dart';
part 'home/today_screen_scaffold_state.dart';
/// Root Material app for the FocusFlow desktop UI. /// Root Material app for the FocusFlow desktop UI.
class FocusFlowApp extends StatelessWidget { class FocusFlowApp extends StatelessWidget {
/// Creates a FocusFlow app from an application composition. /// Creates a FocusFlow app from an application composition.
@ -34,199 +40,3 @@ class FocusFlowApp extends StatelessWidget {
); );
} }
} }
/// Stateful home screen that owns the Today controller lifecycle.
class FocusFlowHome extends StatefulWidget {
/// Creates the FocusFlow home screen from an application composition.
const FocusFlowHome({required this.composition, super.key});
/// Factory and controller bundle used by the home screen.
final DemoSchedulerComposition composition;
@override
State<FocusFlowHome> createState() => _FocusFlowHomeState();
}
class _FocusFlowHomeState extends State<FocusFlowHome> {
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(
backgroundColor: FocusFlowTokens.appBackground,
body: AppShell(
sidebar: const Sidebar(),
child: AnimatedBuilder(
animation: controller,
builder: (context, _) {
return switch (controller.state) {
TodayScreenLoading() => const Center(
child: CircularProgressIndicator(),
),
TodayScreenFailure(:final message) => _PlanIssue(
message: message,
),
TodayScreenEmpty() => _TodayScreenScaffold(
data: TodayScreenData.empty(
dateLabel: widget.composition.dateLabel,
),
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,
),
};
},
),
),
);
}
}
class _PlanIssue extends StatelessWidget {
const _PlanIssue({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Center(
child: Text(message, style: Theme.of(context).textTheme.titleMedium),
);
}
}
class _TodayScreenScaffold extends StatefulWidget {
const _TodayScreenScaffold({
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
State<_TodayScreenScaffold> createState() => _TodayScreenScaffoldState();
}
class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
String? _timelineScrollTargetCardId;
var _timelineScrollRequest = 0;
void _showUpcomingRequiredTask() {
final targetCardId = widget.data.requiredBanner?.timelineCardId;
if (targetCardId == null) {
return;
}
setState(() {
_timelineScrollTargetCardId = targetCardId;
_timelineScrollRequest += 1;
});
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
FocusFlowTokens.mainGutter,
32,
FocusFlowTokens.mainGutter,
28,
),
child: Column(
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,
range: widget.data.timelineRange,
scrollTargetCardId: _timelineScrollTargetCardId,
scrollRequest: _timelineScrollRequest,
onCardSelected: widget.onSelectCard,
onCardCompleted: widget.onCompleteCard,
),
),
],
),
),
if (widget.selectedCard != null) ...[
Positioned.fill(
child: GestureDetector(
key: const ValueKey('modal-click-away'),
behavior: HitTestBehavior.opaque,
onTap: widget.onClearSelection,
child: const ColoredBox(color: Colors.transparent),
),
),
Center(
child: TaskSelectionModal(
card: widget.selectedCard!,
onClose: widget.onClearSelection,
onStatusPressed: widget.selectedCard!.canToggleCompletion
? () => widget.onCompleteCard(widget.selectedCard!)
: null,
),
),
],
],
);
}
}

View file

@ -0,0 +1,13 @@
part of '../focus_flow_app.dart';
/// Stateful home screen that owns the Today controller lifecycle.
class FocusFlowHome extends StatefulWidget {
/// Creates the FocusFlow home screen from an application composition.
const FocusFlowHome({required this.composition, super.key});
/// Factory and controller bundle used by the home screen.
final DemoSchedulerComposition composition;
@override
State<FocusFlowHome> createState() => _FocusFlowHomeState();
}

View file

@ -0,0 +1,76 @@
part of '../focus_flow_app.dart';
class _FocusFlowHomeState extends State<FocusFlowHome> {
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(
backgroundColor: FocusFlowTokens.appBackground,
body: AppShell(
sidebar: const Sidebar(),
child: AnimatedBuilder(
animation: controller,
builder: (context, _) {
return switch (controller.state) {
TodayScreenLoading() => const Center(
child: CircularProgressIndicator(),
),
TodayScreenFailure(:final message) => _PlanIssue(
message: message,
),
TodayScreenEmpty() => _TodayScreenScaffold(
data: TodayScreenData.empty(
dateLabel: widget.composition.dateLabel,
),
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,
),
};
},
),
),
);
}
}

View file

@ -0,0 +1,14 @@
part of '../focus_flow_app.dart';
class _PlanIssue extends StatelessWidget {
const _PlanIssue({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Center(
child: Text(message, style: Theme.of(context).textTheme.titleMedium),
);
}
}

View file

@ -0,0 +1,20 @@
part of '../focus_flow_app.dart';
class _TodayScreenScaffold extends StatefulWidget {
const _TodayScreenScaffold({
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
State<_TodayScreenScaffold> createState() => _TodayScreenScaffoldState();
}

View file

@ -0,0 +1,78 @@
part of '../focus_flow_app.dart';
class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
String? _timelineScrollTargetCardId;
var _timelineScrollRequest = 0;
void _showUpcomingRequiredTask() {
final targetCardId = widget.data.requiredBanner?.timelineCardId;
if (targetCardId == null) {
return;
}
setState(() {
_timelineScrollTargetCardId = targetCardId;
_timelineScrollRequest += 1;
});
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
FocusFlowTokens.mainGutter,
32,
FocusFlowTokens.mainGutter,
28,
),
child: Column(
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,
range: widget.data.timelineRange,
scrollTargetCardId: _timelineScrollTargetCardId,
scrollRequest: _timelineScrollRequest,
onCardSelected: widget.onSelectCard,
onCardCompleted: widget.onCompleteCard,
),
),
],
),
),
if (widget.selectedCard != null) ...[
Positioned.fill(
child: GestureDetector(
key: const ValueKey('modal-click-away'),
behavior: HitTestBehavior.opaque,
onTap: widget.onClearSelection,
child: const ColoredBox(color: Colors.transparent),
),
),
Center(
child: TaskSelectionModal(
card: widget.selectedCard!,
onClose: widget.onClearSelection,
onStatusPressed: widget.selectedCard!.canToggleCompletion
? () => widget.onCompleteCard(widget.selectedCard!)
: null,
),
),
],
],
);
}
}

View file

@ -0,0 +1,8 @@
part of '../scheduler_command_controller.dart';
/// Builds an application operation context for a UI command operation.
typedef OperationContextFactory =
ApplicationOperationContext Function(String operationId, {DateTime? now});
/// Refreshes any reads that should reflect a completed command.
typedef ReadRefresh = Future<void> Function();

View file

@ -0,0 +1,187 @@
part of '../scheduler_command_controller.dart';
/// Runs scheduler write commands and exposes command state to widgets.
class SchedulerCommandController extends ChangeNotifier {
/// Creates a command controller from scheduler use cases and UI callbacks.
SchedulerCommandController({
required this.commands,
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;
/// Factory for command-scoped application operation contexts.
final OperationContextFactory contextFor;
/// Local date commands should operate against.
final CivilDate localDate;
/// 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();
/// Current command execution state.
SchedulerCommandState get state => _state;
/// Captures [title] as a backlog task.
Future<void> quickCaptureToBacklog(String title) async {
await _run(
label: 'Capturing task',
successMessage: 'Task captured',
refreshAfterSuccess: true,
action: (operationId) {
return commands.quickCaptureToBacklog(
context: contextFor(operationId),
title: title,
projectId: 'home',
);
},
);
}
/// Schedules a backlog task into the next available slot.
Future<void> scheduleBacklogItem({
required String taskId,
required int durationMinutes,
required DateTime expectedUpdatedAt,
}) async {
await _run(
label: 'Scheduling task',
successMessage: 'Task scheduled',
refreshAfterSuccess: true,
action: (operationId) {
return commands.scheduleBacklogItemToNextAvailableSlot(
context: contextFor(operationId),
localDate: localDate,
taskId: taskId,
durationMinutes: durationMinutes,
expectedUpdatedAt: expectedUpdatedAt,
);
},
);
}
/// Completes a planned flexible task from the Today view.
Future<void> completeFlexibleTask({
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) {
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,
);
},
);
}
Future<void> _run({
required String label,
required String successMessage,
required bool refreshAfterSuccess,
required Future<ApplicationResult<ApplicationCommandResult>> Function(
String operationId,
)
action,
}) async {
final operationId = _nextOperationId();
_setState(SchedulerCommandRunning(label));
try {
final result = await action(operationId);
final failure = result.failure;
if (failure != null) {
_setState(SchedulerCommandFailure(failure: failure));
return;
}
if (refreshAfterSuccess) {
await refreshReads();
}
_setState(SchedulerCommandSuccess(successMessage));
} on Object catch (error) {
_setState(SchedulerCommandFailure(error: error));
}
}
String _nextOperationId() {
_sequence += 1;
return 'ui-command-$_sequence';
}
void _setState(SchedulerCommandState next) {
_state = next;
notifyListeners();
}
static DateTime _utcNow() => DateTime.now().toUtc();
}

View file

@ -0,0 +1,52 @@
part of '../scheduler_command_controller.dart';
/// Base state for scheduler command execution in the UI.
sealed class SchedulerCommandState {
/// Creates a scheduler command state.
const SchedulerCommandState();
}
/// Indicates that no scheduler command is currently running.
class SchedulerCommandIdle extends SchedulerCommandState {
/// Creates an idle command state.
const SchedulerCommandIdle();
}
/// Indicates that a scheduler command is currently running.
class SchedulerCommandRunning extends SchedulerCommandState {
/// Creates a running command state with a user-facing [label].
const SchedulerCommandRunning(this.label);
/// User-facing description of the command in progress.
final String label;
}
/// Indicates that a scheduler command completed successfully.
class SchedulerCommandSuccess extends SchedulerCommandState {
/// Creates a success command state with a user-facing [message].
const SchedulerCommandSuccess(this.message);
/// User-facing success message.
final String message;
}
/// Indicates that a scheduler command failed.
class SchedulerCommandFailure extends SchedulerCommandState {
/// Creates a failure state from an application [failure] or unexpected [error].
const SchedulerCommandFailure({this.failure, this.error});
/// Typed application failure returned by the scheduler core.
final ApplicationFailure? failure;
/// Unexpected error thrown while running the command.
final Object? error;
/// Stable label for display and diagnostics.
String get codeLabel {
final typed = failure;
if (typed != null) {
return typed.detailCode ?? typed.code.name;
}
return 'unexpected';
}
}

View file

@ -4,246 +4,6 @@ library;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:scheduler_core/scheduler_core.dart'; import 'package:scheduler_core/scheduler_core.dart';
/// Builds an application operation context for a UI command operation. part 'command/scheduler_command_callbacks.dart';
typedef OperationContextFactory = part 'command/scheduler_command_controller_impl.dart';
ApplicationOperationContext Function(String operationId, {DateTime? now}); part 'command/scheduler_command_state.dart';
/// Refreshes any reads that should reflect a completed command.
typedef ReadRefresh = Future<void> Function();
/// Base state for scheduler command execution in the UI.
sealed class SchedulerCommandState {
/// Creates a scheduler command state.
const SchedulerCommandState();
}
/// Indicates that no scheduler command is currently running.
class SchedulerCommandIdle extends SchedulerCommandState {
/// Creates an idle command state.
const SchedulerCommandIdle();
}
/// Indicates that a scheduler command is currently running.
class SchedulerCommandRunning extends SchedulerCommandState {
/// Creates a running command state with a user-facing [label].
const SchedulerCommandRunning(this.label);
/// User-facing description of the command in progress.
final String label;
}
/// Indicates that a scheduler command completed successfully.
class SchedulerCommandSuccess extends SchedulerCommandState {
/// Creates a success command state with a user-facing [message].
const SchedulerCommandSuccess(this.message);
/// User-facing success message.
final String message;
}
/// Indicates that a scheduler command failed.
class SchedulerCommandFailure extends SchedulerCommandState {
/// Creates a failure state from an application [failure] or unexpected [error].
const SchedulerCommandFailure({this.failure, this.error});
/// Typed application failure returned by the scheduler core.
final ApplicationFailure? failure;
/// Unexpected error thrown while running the command.
final Object? error;
/// Stable label for display and diagnostics.
String get codeLabel {
final typed = failure;
if (typed != null) {
return typed.detailCode ?? typed.code.name;
}
return 'unexpected';
}
}
/// Runs scheduler write commands and exposes command state to widgets.
class SchedulerCommandController extends ChangeNotifier {
/// Creates a command controller from scheduler use cases and UI callbacks.
SchedulerCommandController({
required this.commands,
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;
/// Factory for command-scoped application operation contexts.
final OperationContextFactory contextFor;
/// Local date commands should operate against.
final CivilDate localDate;
/// 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();
/// Current command execution state.
SchedulerCommandState get state => _state;
/// Captures [title] as a backlog task.
Future<void> quickCaptureToBacklog(String title) async {
await _run(
label: 'Capturing task',
successMessage: 'Task captured',
refreshAfterSuccess: true,
action: (operationId) {
return commands.quickCaptureToBacklog(
context: contextFor(operationId),
title: title,
projectId: 'home',
);
},
);
}
/// Schedules a backlog task into the next available slot.
Future<void> scheduleBacklogItem({
required String taskId,
required int durationMinutes,
required DateTime expectedUpdatedAt,
}) async {
await _run(
label: 'Scheduling task',
successMessage: 'Task scheduled',
refreshAfterSuccess: true,
action: (operationId) {
return commands.scheduleBacklogItemToNextAvailableSlot(
context: contextFor(operationId),
localDate: localDate,
taskId: taskId,
durationMinutes: durationMinutes,
expectedUpdatedAt: expectedUpdatedAt,
);
},
);
}
/// Completes a planned flexible task from the Today view.
Future<void> completeFlexibleTask({
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) {
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,
);
},
);
}
Future<void> _run({
required String label,
required String successMessage,
required bool refreshAfterSuccess,
required Future<ApplicationResult<ApplicationCommandResult>> Function(
String operationId,
)
action,
}) async {
final operationId = _nextOperationId();
_setState(SchedulerCommandRunning(label));
try {
final result = await action(operationId);
final failure = result.failure;
if (failure != null) {
_setState(SchedulerCommandFailure(failure: failure));
return;
}
if (refreshAfterSuccess) {
await refreshReads();
}
_setState(SchedulerCommandSuccess(successMessage));
} on Object catch (error) {
_setState(SchedulerCommandFailure(error: error));
}
}
String _nextOperationId() {
_sequence += 1;
return 'ui-command-$_sequence';
}
void _setState(SchedulerCommandState next) {
_state = next;
notifyListeners();
}
static DateTime _utcNow() => DateTime.now().toUtc();
}

View file

@ -0,0 +1,20 @@
part of '../today_screen_models.dart';
/// Presentation model for the next-required-task banner.
class RequiredBannerModel {
/// Creates a required banner model.
const RequiredBannerModel({
required this.timelineCardId,
required this.title,
required this.timeText,
});
/// Timeline card id linked to the highlighted required task.
final String timelineCardId;
/// Title of the next required task.
final String title;
/// Display-ready time for the next required task.
final String timeText;
}

View file

@ -0,0 +1,19 @@
part of '../today_screen_models.dart';
/// Visual category used to style timeline cards.
enum TaskVisualKind {
/// Flexible planned work.
flexible,
/// Critical required task.
required,
/// Inflexible appointment-style task.
appointment,
/// Intentional free time.
freeSlot,
/// Completed surprise task.
completedSurprise,
}

View file

@ -0,0 +1,37 @@
part of '../../today_screen_models.dart';
String _dateLabel(CivilDate date) {
const months = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
return '${months[date.month - 1]} ${date.day}, ${date.year}';
}
int _minutesSinceMidnight(DateTime? value) {
if (value == null) {
return 0;
}
return value.hour * 60 + value.minute;
}
String _formatTime(DateTime? value) {
if (value == null) {
return '';
}
final period = value.hour >= 12 ? 'PM' : 'AM';
final rawHour = value.hour % 12;
final hour = rawHour == 0 ? 12 : rawHour;
final minute = value.minute.toString().padLeft(2, '0');
return '$hour:$minute $period';
}

View file

@ -0,0 +1,144 @@
part of '../today_screen_models.dart';
/// Presentation model for a single compact timeline card.
class TimelineCardModel {
/// Creates a timeline card model.
const TimelineCardModel({
required this.id,
required this.title,
required this.typeLabel,
required this.subtitle,
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,
});
/// Maps a scheduler timeline item into card presentation data.
factory TimelineCardModel.fromItem(TodayTimelineItem item) {
final start = item.start;
final end = item.end;
final visualKind = _visualKindFor(item);
final isCompleted = item.taskStatus == TaskStatus.completed;
final title = item.item.displayTitle;
final duration = item.item.durationMinutes;
final timeText = start == null || end == null
? duration == null
? ''
: '$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(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,
);
}
/// Stable card identifier.
final String id;
/// Primary task title shown on the card.
final String title;
/// Short task type label.
final String typeLabel;
/// Secondary line shown under the title.
final String subtitle;
/// 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;
/// Card end position in minutes since midnight.
final int endMinutes;
/// Optional task duration in minutes.
final int? durationMinutes;
/// Source task scheduling behavior type.
final TaskType taskType;
/// Visual category used for styling.
final TaskVisualKind visualKind;
/// Reward icon token from the scheduler core.
final TimelineRewardIconToken rewardIconToken;
/// Difficulty icon token from the scheduler core.
final TimelineDifficultyIconToken difficultyIconToken;
/// Whether the card represents completed work.
final bool isCompleted;
/// Whether tapping the card should open its selection modal.
final bool isSelectable;
/// 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) {
TimelineRewardIconToken.notSet => 'Reward not set',
TimelineRewardIconToken.veryLow => 'Reward level 1',
TimelineRewardIconToken.low => 'Reward level 2',
TimelineRewardIconToken.medium => 'Reward level 3',
TimelineRewardIconToken.high => 'Reward level 4',
TimelineRewardIconToken.veryHigh => 'Reward level 5',
};
}
/// Accessibility and modal label for the effort icon.
String get effortLabel {
return switch (difficultyIconToken) {
TimelineDifficultyIconToken.notSet => 'Effort not set',
TimelineDifficultyIconToken.veryEasy => 'Very easy effort',
TimelineDifficultyIconToken.easy => 'Easy effort',
TimelineDifficultyIconToken.medium => 'Medium effort',
TimelineDifficultyIconToken.hard => 'Hard effort',
TimelineDifficultyIconToken.veryHard => 'Very hard effort',
};
}
}

View file

@ -0,0 +1,50 @@
part of '../today_screen_models.dart';
TaskVisualKind _visualKindFor(TodayTimelineItem item) {
if (item.taskStatus == TaskStatus.completed ||
(item.taskType == TaskType.surprise &&
item.taskStatus == TaskStatus.completed)) {
return TaskVisualKind.completedSurprise;
}
return switch (item.taskType) {
TaskType.flexible => TaskVisualKind.flexible,
TaskType.critical => TaskVisualKind.required,
TaskType.inflexible => TaskVisualKind.appointment,
TaskType.freeSlot => TaskVisualKind.freeSlot,
TaskType.surprise => TaskVisualKind.completedSurprise,
TaskType.locked => TaskVisualKind.completedSurprise,
};
}
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',
};
}
String _subtitleFor(
TodayTimelineItem item,
TaskVisualKind visualKind,
String timeText,
) {
if (visualKind == TaskVisualKind.freeSlot) {
return 'Intentional rest';
}
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';
}
return timeText;
}

View file

@ -0,0 +1,16 @@
part of '../today_screen_models.dart';
/// Visible timeline range expressed as minutes since midnight.
class TimelineRangeModel {
/// Creates a timeline range model.
const TimelineRangeModel({
required this.startMinutes,
required this.endMinutes,
});
/// First visible minute from midnight.
final int startMinutes;
/// Last visible minute from midnight.
final int endMinutes;
}

View file

@ -0,0 +1,59 @@
part of '../today_screen_models.dart';
/// Presentation model for the compact Today screen.
class TodayScreenData {
/// Creates Today screen data.
const TodayScreenData({
required this.dateLabel,
required this.timelineRange,
required this.cards,
this.requiredBanner,
});
/// Creates an empty Today screen model for [dateLabel].
factory TodayScreenData.empty({required String dateLabel}) {
return TodayScreenData(
dateLabel: dateLabel,
timelineRange: defaultRange,
cards: const [],
);
}
/// Maps scheduler core [TodayState] into UI presentation data.
factory TodayScreenData.fromTodayState(TodayState state) {
final cards = [
for (final item in state.timelineItems) TimelineCardModel.fromItem(item),
];
final nextRequired = state.nextRequiredItem;
return TodayScreenData(
dateLabel: _dateLabel(state.date),
timelineRange: defaultRange,
requiredBanner: nextRequired == null
? null
: RequiredBannerModel(
timelineCardId: nextRequired.id,
title: nextRequired.item.displayTitle,
timeText: _formatTime(nextRequired.start),
),
cards: List<TimelineCardModel>.unmodifiable(cards),
);
}
/// Default visible range for the compact timeline.
static const defaultRange = TimelineRangeModel(
startMinutes: 0,
endMinutes: 24 * 60,
);
/// Display-ready date label.
final String dateLabel;
/// Optional next-required-task banner model.
final RequiredBannerModel? requiredBanner;
/// Visible timeline range.
final TimelineRangeModel timelineRange;
/// Timeline cards to render.
final List<TimelineCardModel> cards;
}

View file

@ -3,340 +3,10 @@ library;
import 'package:scheduler_core/scheduler_core.dart'; import 'package:scheduler_core/scheduler_core.dart';
/// Presentation model for the next-required-task banner. part 'today/required_banner_model.dart';
class RequiredBannerModel { part 'today/task_visual_kind.dart';
/// Creates a required banner model. part 'today/time/time_string_formatter.dart';
const RequiredBannerModel({ part 'today/timeline_card_model.dart';
required this.timelineCardId, part 'today/timeline_item_presentation_mapper.dart';
required this.title, part 'today/timeline_range_model.dart';
required this.timeText, part 'today/today_screen_data.dart';
});
/// Timeline card id linked to the highlighted required task.
final String timelineCardId;
/// Title of the next required task.
final String title;
/// Display-ready time for the next required task.
final String timeText;
}
/// Visible timeline range expressed as minutes since midnight.
class TimelineRangeModel {
/// Creates a timeline range model.
const TimelineRangeModel({
required this.startMinutes,
required this.endMinutes,
});
/// First visible minute from midnight.
final int startMinutes;
/// Last visible minute from midnight.
final int endMinutes;
}
/// Visual category used to style timeline cards.
enum TaskVisualKind {
/// Flexible planned work.
flexible,
/// Critical required task.
required,
/// Inflexible appointment-style task.
appointment,
/// Intentional free time.
freeSlot,
/// Completed surprise task.
completedSurprise,
}
/// Presentation model for the compact Today screen.
class TodayScreenData {
/// Creates Today screen data.
const TodayScreenData({
required this.dateLabel,
required this.timelineRange,
required this.cards,
this.requiredBanner,
});
/// Creates an empty Today screen model for [dateLabel].
factory TodayScreenData.empty({required String dateLabel}) {
return TodayScreenData(
dateLabel: dateLabel,
timelineRange: defaultRange,
cards: const [],
);
}
/// Maps scheduler core [TodayState] into UI presentation data.
factory TodayScreenData.fromTodayState(TodayState state) {
final cards = [
for (final item in state.timelineItems) TimelineCardModel.fromItem(item),
];
final nextRequired = state.nextRequiredItem;
return TodayScreenData(
dateLabel: _dateLabel(state.date),
timelineRange: defaultRange,
requiredBanner: nextRequired == null
? null
: RequiredBannerModel(
timelineCardId: nextRequired.id,
title: nextRequired.item.displayTitle,
timeText: _formatTime(nextRequired.start),
),
cards: List<TimelineCardModel>.unmodifiable(cards),
);
}
/// Default visible range for the compact timeline.
static const defaultRange = TimelineRangeModel(
startMinutes: 0,
endMinutes: 24 * 60,
);
/// Display-ready date label.
final String dateLabel;
/// Optional next-required-task banner model.
final RequiredBannerModel? requiredBanner;
/// Visible timeline range.
final TimelineRangeModel timelineRange;
/// Timeline cards to render.
final List<TimelineCardModel> cards;
}
/// Presentation model for a single compact timeline card.
class TimelineCardModel {
/// Creates a timeline card model.
const TimelineCardModel({
required this.id,
required this.title,
required this.typeLabel,
required this.subtitle,
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,
});
/// Maps a scheduler timeline item into card presentation data.
factory TimelineCardModel.fromItem(TodayTimelineItem item) {
final start = item.start;
final end = item.end;
final visualKind = _visualKindFor(item);
final isCompleted = item.taskStatus == TaskStatus.completed;
final title = item.item.displayTitle;
final duration = item.item.durationMinutes;
final timeText = start == null || end == null
? duration == null
? ''
: '$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(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,
);
}
/// Stable card identifier.
final String id;
/// Primary task title shown on the card.
final String title;
/// Short task type label.
final String typeLabel;
/// Secondary line shown under the title.
final String subtitle;
/// 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;
/// Card end position in minutes since midnight.
final int endMinutes;
/// Optional task duration in minutes.
final int? durationMinutes;
/// Source task scheduling behavior type.
final TaskType taskType;
/// Visual category used for styling.
final TaskVisualKind visualKind;
/// Reward icon token from the scheduler core.
final TimelineRewardIconToken rewardIconToken;
/// Difficulty icon token from the scheduler core.
final TimelineDifficultyIconToken difficultyIconToken;
/// Whether the card represents completed work.
final bool isCompleted;
/// Whether tapping the card should open its selection modal.
final bool isSelectable;
/// 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) {
TimelineRewardIconToken.notSet => 'Reward not set',
TimelineRewardIconToken.veryLow => 'Reward level 1',
TimelineRewardIconToken.low => 'Reward level 2',
TimelineRewardIconToken.medium => 'Reward level 3',
TimelineRewardIconToken.high => 'Reward level 4',
TimelineRewardIconToken.veryHigh => 'Reward level 5',
};
}
/// Accessibility and modal label for the effort icon.
String get effortLabel {
return switch (difficultyIconToken) {
TimelineDifficultyIconToken.notSet => 'Effort not set',
TimelineDifficultyIconToken.veryEasy => 'Very easy effort',
TimelineDifficultyIconToken.easy => 'Easy effort',
TimelineDifficultyIconToken.medium => 'Medium effort',
TimelineDifficultyIconToken.hard => 'Hard effort',
TimelineDifficultyIconToken.veryHard => 'Very hard effort',
};
}
}
TaskVisualKind _visualKindFor(TodayTimelineItem item) {
if (item.taskStatus == TaskStatus.completed ||
(item.taskType == TaskType.surprise &&
item.taskStatus == TaskStatus.completed)) {
return TaskVisualKind.completedSurprise;
}
return switch (item.taskType) {
TaskType.flexible => TaskVisualKind.flexible,
TaskType.critical => TaskVisualKind.required,
TaskType.inflexible => TaskVisualKind.appointment,
TaskType.freeSlot => TaskVisualKind.freeSlot,
TaskType.surprise => TaskVisualKind.completedSurprise,
TaskType.locked => TaskVisualKind.completedSurprise,
};
}
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',
};
}
String _subtitleFor(
TodayTimelineItem item,
TaskVisualKind visualKind,
String timeText,
) {
if (visualKind == TaskVisualKind.freeSlot) {
return 'Intentional rest';
}
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';
}
return timeText;
}
String _dateLabel(CivilDate date) {
const months = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
return '${months[date.month - 1]} ${date.day}, ${date.year}';
}
int _minutesSinceMidnight(DateTime? value) {
if (value == null) {
return 0;
}
return value.hour * 60 + value.minute;
}
String _formatTime(DateTime? value) {
if (value == null) {
return '';
}
final period = value.hour >= 12 ? 'PM' : 'AM';
final rawHour = value.hour % 12;
final hour = rawHour == 0 ? 12 : rawHour;
final minute = value.minute.toString().padLeft(2, '0');
return '$hour:$minute $period';
}

View file

@ -0,0 +1,32 @@
part of '../task_selection_modal.dart';
class _ActionButton extends StatelessWidget {
const _ActionButton({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
return OutlinedButton.icon(
onPressed: () {},
style: OutlinedButton.styleFrom(
foregroundColor: FocusFlowTokens.textPrimary,
side: const BorderSide(color: FocusFlowTokens.subtleBorder),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
FocusFlowTokens.smallButtonRadius,
),
),
),
icon: Icon(icon),
label: Text(
label,
style: const TextStyle(
fontSize: FocusFlowTokens.modalButtonSize,
fontWeight: FontWeight.w700,
),
),
);
}
}

View file

@ -0,0 +1,35 @@
part of '../task_selection_modal.dart';
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),
),
],
);
}
}

View file

@ -0,0 +1,38 @@
part of '../task_selection_modal.dart';
class _InfoTile extends StatelessWidget {
const _InfoTile({required this.child, required this.label});
final Widget child;
final String label;
@override
Widget build(BuildContext context) {
return Container(
height: 58,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(FocusFlowTokens.smallButtonRadius),
border: Border.all(color: FocusFlowTokens.subtleBorder),
),
child: Row(
children: [
child,
const SizedBox(width: 18),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: FocusFlowTokens.textPrimary,
fontSize: 17,
fontWeight: FontWeight.w700,
),
),
),
],
),
);
}
}

View file

@ -0,0 +1,11 @@
part of '../task_selection_modal.dart';
Color _accentFor(TaskVisualKind kind) {
return switch (kind) {
TaskVisualKind.flexible => FocusFlowTokens.flexibleGreen,
TaskVisualKind.required => FocusFlowTokens.requiredPink,
TaskVisualKind.appointment => FocusFlowTokens.appointmentBlue,
TaskVisualKind.freeSlot => FocusFlowTokens.restPurple,
TaskVisualKind.completedSurprise => FocusFlowTokens.completedGray,
};
}

View file

@ -0,0 +1,54 @@
part of '../task_selection_modal.dart';
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,
),
),
),
);
}
}

View file

@ -8,6 +8,12 @@ import '../theme/focus_flow_tokens.dart';
import 'timeline/difficulty_bars.dart'; import 'timeline/difficulty_bars.dart';
import 'timeline/reward_icon.dart'; import 'timeline/reward_icon.dart';
part 'task_selection/action_button.dart';
part 'task_selection/header_metadata.dart';
part 'task_selection/info_tile.dart';
part 'task_selection/modal_accent.dart';
part 'task_selection/modal_status_button.dart';
/// Modal shown when a selectable timeline task card is opened. /// Modal shown when a selectable timeline task card is opened.
class TaskSelectionModal extends StatelessWidget { class TaskSelectionModal extends StatelessWidget {
/// Creates a task selection modal for [card]. /// Creates a task selection modal for [card].
@ -131,168 +137,3 @@ 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});
final Widget child;
final String label;
@override
Widget build(BuildContext context) {
return Container(
height: 58,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(FocusFlowTokens.smallButtonRadius),
border: Border.all(color: FocusFlowTokens.subtleBorder),
),
child: Row(
children: [
child,
const SizedBox(width: 18),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: FocusFlowTokens.textPrimary,
fontSize: 17,
fontWeight: FontWeight.w700,
),
),
),
],
),
);
}
}
class _ActionButton extends StatelessWidget {
const _ActionButton({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
return OutlinedButton.icon(
onPressed: () {},
style: OutlinedButton.styleFrom(
foregroundColor: FocusFlowTokens.textPrimary,
side: const BorderSide(color: FocusFlowTokens.subtleBorder),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
FocusFlowTokens.smallButtonRadius,
),
),
),
icon: Icon(icon),
label: Text(
label,
style: const TextStyle(
fontSize: FocusFlowTokens.modalButtonSize,
fontWeight: FontWeight.w700,
),
),
);
}
}
Color _accentFor(TaskVisualKind kind) {
return switch (kind) {
TaskVisualKind.flexible => FocusFlowTokens.flexibleGreen,
TaskVisualKind.required => FocusFlowTokens.requiredPink,
TaskVisualKind.appointment => FocusFlowTokens.appointmentBlue,
TaskVisualKind.freeSlot => FocusFlowTokens.restPurple,
TaskVisualKind.completedSurprise => FocusFlowTokens.completedGray,
};
}

View file

@ -0,0 +1,21 @@
part of '../timeline_axis.dart';
/// Background grid aligned to timeline tick marks.
class TimelineGrid extends StatelessWidget {
/// Creates a timeline grid for [geometry].
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: geometry, topInset: topInset),
size: Size.infinite,
);
}
}

View file

@ -0,0 +1,30 @@
part of '../timeline_axis.dart';
class _TimelineGridPainter extends CustomPainter {
const _TimelineGridPainter({required this.geometry, required this.topInset});
final TimelineGeometry geometry;
final double topInset;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = FocusFlowTokens.gridLine
..strokeWidth = 1;
for (final tick in geometry.ticks()) {
canvas.drawLine(
Offset(0, tick.y + topInset),
Offset(size.width, tick.y + topInset),
paint
..color = tick.major
? FocusFlowTokens.gridLine.withValues(alpha: 0.72)
: FocusFlowTokens.gridLine,
);
}
}
@override
bool shouldRepaint(covariant _TimelineGridPainter oldDelegate) {
return oldDelegate.geometry != geometry || oldDelegate.topInset != topInset;
}
}

View file

@ -0,0 +1,43 @@
part of '../task_timeline_card.dart';
class _CardColors {
const _CardColors({
required this.accent,
required this.background,
required this.reward,
});
final Color accent;
final Color background;
final Color reward;
factory _CardColors.forKind(TaskVisualKind kind) {
return switch (kind) {
TaskVisualKind.flexible => const _CardColors(
accent: FocusFlowTokens.flexibleGreen,
background: Color(0xCC021A0C),
reward: FocusFlowTokens.flexibleGreen,
),
TaskVisualKind.required => const _CardColors(
accent: FocusFlowTokens.requiredPink,
background: Color(0xCC230814),
reward: FocusFlowTokens.requiredPink,
),
TaskVisualKind.appointment => const _CardColors(
accent: FocusFlowTokens.appointmentBlue,
background: Color(0xCC031429),
reward: FocusFlowTokens.appointmentBlue,
),
TaskVisualKind.freeSlot => const _CardColors(
accent: FocusFlowTokens.restPurple,
background: Color(0xCC210A33),
reward: FocusFlowTokens.restPurple,
),
TaskVisualKind.completedSurprise => const _CardColors(
accent: FocusFlowTokens.completedGray,
background: Color(0xAA1C1D25),
reward: FocusFlowTokens.restPurple,
),
};
}
}

View file

@ -0,0 +1,77 @@
part of '../task_timeline_card.dart';
class _CardMetrics {
const _CardMetrics({required this.scale, required this.height});
static const _referenceWidth = 640.0;
static const _referenceHeight = 88.0;
static const _freeSlotReferenceHeight = 108.0;
static const _compactHeightThreshold = 56.0;
static const _minimumScale = 0.16;
factory _CardMetrics.fromConstraints(
BoxConstraints constraints, {
required TaskVisualKind kind,
}) {
final width = constraints.hasBoundedWidth
? constraints.maxWidth
: _referenceWidth;
final height = constraints.hasBoundedHeight
? constraints.maxHeight
: _referenceHeight;
final widthScale = width / _referenceWidth;
final heightScale =
height /
(kind == TaskVisualKind.freeSlot
? _freeSlotReferenceHeight
: _referenceHeight);
final scale = math.min(widthScale, heightScale).clamp(_minimumScale, 1.0);
return _CardMetrics(scale: scale.toDouble(), height: height);
}
final double scale;
final double height;
bool get isCompact => height < _compactHeightThreshold;
double get cardRadius =>
math.min(FocusFlowTokens.cardRadius * scale, height / 2);
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 {
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(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, 8 * scale),
vertical: math.max(1, 3 * scale),
);
}
return EdgeInsets.fromLTRB(18 * scale, 10 * scale, 14 * scale, 10 * scale);
}
}

View file

@ -0,0 +1,79 @@
part of '../task_timeline_card.dart';
class _CardText extends StatelessWidget {
const _CardText({
required this.card,
required this.color,
required this.metrics,
});
final TimelineCardModel card;
final Color color;
final _CardMetrics metrics;
@override
Widget build(BuildContext context) {
final titleStyle = TextStyle(
color: card.isCompleted
? FocusFlowTokens.completedText
: FocusFlowTokens.textPrimary,
fontSize: metrics.titleSize,
fontWeight: FontWeight.w800,
decoration: card.isCompleted ? TextDecoration.lineThrough : null,
decorationColor: FocusFlowTokens.completedText,
decorationThickness: 2 * metrics.scale,
);
return LayoutBuilder(
builder: (context, constraints) {
final textBlock = Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
card.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: titleStyle,
),
SizedBox(height: metrics.titleMetaGap),
Text(
card.subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: card.isCompleted ? FocusFlowTokens.completedText : color,
fontSize: metrics.metaSize,
fontStyle: card.visualKind == TaskVisualKind.freeSlot
? FontStyle.italic
: FontStyle.normal,
fontWeight: card.visualKind == TaskVisualKind.flexible
? FontWeight.w700
: FontWeight.w500,
),
),
if (card.visualKind == TaskVisualKind.freeSlot) ...[
SizedBox(height: metrics.freeSlotTimeGap),
Text(
card.timeText,
style: TextStyle(
color: color,
fontSize: metrics.metaSize,
fontWeight: FontWeight.w600,
),
),
],
],
);
if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) {
return textBlock;
}
return FittedBox(
alignment: Alignment.centerLeft,
fit: BoxFit.scaleDown,
child: SizedBox(width: constraints.maxWidth, child: textBlock),
);
},
);
}
}

View file

@ -0,0 +1,96 @@
part of '../task_timeline_card.dart';
class _CompactCardText extends StatelessWidget {
const _CompactCardText({
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) {
final metaText = _compactMetaText(card);
final titleStyle = TextStyle(
color: card.isCompleted
? FocusFlowTokens.completedText
: FocusFlowTokens.textPrimary,
fontSize: metrics.titleSize,
height: 1,
fontWeight: FontWeight.w800,
decoration: card.isCompleted ? TextDecoration.lineThrough : null,
decorationColor: FocusFlowTokens.completedText,
decorationThickness: math.max(1, 2 * metrics.scale),
);
final metaStyle = TextStyle(
color: card.isCompleted ? FocusFlowTokens.completedText : color,
fontSize: metrics.metaSize,
height: 1,
fontWeight: FontWeight.w700,
);
return LayoutBuilder(
builder: (context, constraints) {
final line = Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
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,
overflow: TextOverflow.ellipsis,
style: titleStyle,
),
),
SizedBox(width: metrics.compactTextGap),
Flexible(
flex: 1,
fit: FlexFit.loose,
child: Text(
metaText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: metaStyle,
),
),
SizedBox(width: metrics.compactTrailingReserve),
],
);
if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) {
return line;
}
return FittedBox(
alignment: Alignment.centerLeft,
fit: BoxFit.scaleDown,
child: SizedBox(width: constraints.maxWidth, child: line),
);
},
);
}
String _compactMetaText(TimelineCardModel card) {
if (card.visualKind == TaskVisualKind.freeSlot &&
card.timeText.isNotEmpty) {
return card.timeText;
}
if (card.subtitle.isNotEmpty) {
return card.subtitle;
}
return card.timeText;
}
}

View file

@ -0,0 +1,42 @@
part of '../task_timeline_card.dart';
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,
),
),
),
],
);
}
}

View file

@ -0,0 +1,28 @@
part of '../task_timeline_card.dart';
class _NoOpIcon extends StatelessWidget {
const _NoOpIcon({
required this.icon,
required this.color,
required this.metrics,
});
final IconData icon;
final Color color;
final _CardMetrics metrics;
@override
Widget build(BuildContext context) {
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),
),
),
);
}
}

View file

@ -0,0 +1,70 @@
part of '../task_timeline_card.dart';
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;
@override
Widget build(BuildContext context) {
return ExcludeSemantics(
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: 90),
curve: Curves.easeOut,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_NoOpIcon(
icon: Icons.check,
color: color,
metrics: metrics,
),
_NoOpIcon(
icon: Icons.arrow_forward,
color: color,
metrics: metrics,
),
_NoOpIcon(
icon: Icons.calendar_today,
color: color,
metrics: metrics,
),
_NoOpIcon(
icon: Icons.wb_sunny_outlined,
color: color,
metrics: metrics,
),
SizedBox(width: metrics.actionGap),
],
),
),
),
),
),
),
);
}
}

View file

@ -0,0 +1,57 @@
part of '../task_timeline_card.dart';
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: metrics.statusBorderWidth);
final ring = Container(
key: ValueKey('${card.id}-status'),
width: metrics.statusRingSize,
height: metrics.statusRingSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: card.isCompleted ? color : Colors.transparent,
border: border,
),
child: card.isCompleted
? 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,
),
),
),
);
}
}

View file

@ -0,0 +1,90 @@
part of '../task_timeline_card.dart';
class _TaskTimelineCardState extends State<TaskTimelineCard> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
final card = widget.card;
final colors = _CardColors.forKind(card.visualKind);
return LayoutBuilder(
builder: (context, constraints) {
final metrics = _CardMetrics.fromConstraints(
constraints,
kind: card.visualKind,
);
final content = SizedBox.expand(
child: Stack(
children: [
Positioned.fill(
child: metrics.isCompact
? _CompactCardText(
card: card,
color: colors.accent,
metrics: metrics,
onStatusPressed: widget.onStatusPressed,
)
: _ExpandedCardContent(
card: card,
colors: colors,
metrics: metrics,
onStatusPressed: widget.onStatusPressed,
),
),
Positioned(
top: metrics.indicatorTop,
right: 0,
child: _TrailingControls(
card: card,
colors: colors,
metrics: metrics,
actionsVisible: card.showsQuickActions && _isHovered,
),
),
],
),
);
return MouseRegion(
onEnter: (_) {
setState(() {
_isHovered = true;
});
},
onExit: (_) {
setState(() {
_isHovered = false;
});
},
child: Material(
color: Colors.transparent,
child: InkWell(
key: ValueKey(card.id),
borderRadius: BorderRadius.circular(metrics.cardRadius),
onTap: widget.onTap,
child: Container(
padding: metrics.padding,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: colors.background,
borderRadius: BorderRadius.circular(metrics.cardRadius),
border: Border.all(
color: colors.accent,
width: metrics.borderWidth,
),
boxShadow: [
BoxShadow(
color: colors.accent.withValues(alpha: 0.12),
blurRadius: metrics.shadowBlur,
spreadRadius: metrics.shadowSpread,
),
],
),
child: content,
),
),
),
);
},
);
}
}

View file

@ -0,0 +1,38 @@
part of '../task_timeline_card.dart';
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,
),
],
);
}
}

View file

@ -10,6 +10,17 @@ import '../../theme/focus_flow_tokens.dart';
import 'difficulty_bars.dart'; import 'difficulty_bars.dart';
import 'reward_icon.dart'; import 'reward_icon.dart';
part 'task_card/card_colors.dart';
part 'task_card/card_metrics.dart';
part 'task_card/card_text.dart';
part 'task_card/compact_card_text.dart';
part 'task_card/expanded_card_content.dart';
part 'task_card/no_op_icon.dart';
part 'task_card/quick_actions.dart';
part 'task_card/status_ring.dart';
part 'task_card/task_timeline_card_state.dart';
part 'task_card/trailing_controls.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.
@ -32,613 +43,3 @@ class TaskTimelineCard extends StatefulWidget {
@override @override
State<TaskTimelineCard> createState() => _TaskTimelineCardState(); State<TaskTimelineCard> createState() => _TaskTimelineCardState();
} }
class _TaskTimelineCardState extends State<TaskTimelineCard> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
final card = widget.card;
final colors = _CardColors.forKind(card.visualKind);
return LayoutBuilder(
builder: (context, constraints) {
final metrics = _CardMetrics.fromConstraints(
constraints,
kind: card.visualKind,
);
final content = SizedBox.expand(
child: Stack(
children: [
Positioned.fill(
child: metrics.isCompact
? _CompactCardText(
card: card,
color: colors.accent,
metrics: metrics,
onStatusPressed: widget.onStatusPressed,
)
: _ExpandedCardContent(
card: card,
colors: colors,
metrics: metrics,
onStatusPressed: widget.onStatusPressed,
),
),
Positioned(
top: metrics.indicatorTop,
right: 0,
child: _TrailingControls(
card: card,
colors: colors,
metrics: metrics,
actionsVisible: card.showsQuickActions && _isHovered,
),
),
],
),
);
return MouseRegion(
onEnter: (_) {
setState(() {
_isHovered = true;
});
},
onExit: (_) {
setState(() {
_isHovered = false;
});
},
child: Material(
color: Colors.transparent,
child: InkWell(
key: ValueKey(card.id),
borderRadius: BorderRadius.circular(metrics.cardRadius),
onTap: widget.onTap,
child: Container(
padding: metrics.padding,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: colors.background,
borderRadius: BorderRadius.circular(metrics.cardRadius),
border: Border.all(
color: colors.accent,
width: metrics.borderWidth,
),
boxShadow: [
BoxShadow(
color: colors.accent.withValues(alpha: 0.12),
blurRadius: metrics.shadowBlur,
spreadRadius: metrics.shadowSpread,
),
],
),
child: content,
),
),
),
);
},
);
}
}
class _CardMetrics {
const _CardMetrics({required this.scale, required this.height});
static const _referenceWidth = 640.0;
static const _referenceHeight = 88.0;
static const _freeSlotReferenceHeight = 108.0;
static const _compactHeightThreshold = 56.0;
static const _minimumScale = 0.16;
factory _CardMetrics.fromConstraints(
BoxConstraints constraints, {
required TaskVisualKind kind,
}) {
final width = constraints.hasBoundedWidth
? constraints.maxWidth
: _referenceWidth;
final height = constraints.hasBoundedHeight
? constraints.maxHeight
: _referenceHeight;
final widthScale = width / _referenceWidth;
final heightScale =
height /
(kind == TaskVisualKind.freeSlot
? _freeSlotReferenceHeight
: _referenceHeight);
final scale = math.min(widthScale, heightScale).clamp(_minimumScale, 1.0);
return _CardMetrics(scale: scale.toDouble(), height: height);
}
final double scale;
final double height;
bool get isCompact => height < _compactHeightThreshold;
double get cardRadius =>
math.min(FocusFlowTokens.cardRadius * scale, height / 2);
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 {
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(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, 8 * scale),
vertical: math.max(1, 3 * scale),
);
}
return EdgeInsets.fromLTRB(18 * scale, 10 * scale, 14 * scale, 10 * scale);
}
}
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: metrics.statusBorderWidth);
final ring = Container(
key: ValueKey('${card.id}-status'),
width: metrics.statusRingSize,
height: metrics.statusRingSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: card.isCompleted ? color : Colors.transparent,
border: border,
),
child: card.isCompleted
? 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,
),
),
),
);
}
}
class _CardText extends StatelessWidget {
const _CardText({
required this.card,
required this.color,
required this.metrics,
});
final TimelineCardModel card;
final Color color;
final _CardMetrics metrics;
@override
Widget build(BuildContext context) {
final titleStyle = TextStyle(
color: card.isCompleted
? FocusFlowTokens.completedText
: FocusFlowTokens.textPrimary,
fontSize: metrics.titleSize,
fontWeight: FontWeight.w800,
decoration: card.isCompleted ? TextDecoration.lineThrough : null,
decorationColor: FocusFlowTokens.completedText,
decorationThickness: 2 * metrics.scale,
);
return LayoutBuilder(
builder: (context, constraints) {
final textBlock = Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
card.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: titleStyle,
),
SizedBox(height: metrics.titleMetaGap),
Text(
card.subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: card.isCompleted ? FocusFlowTokens.completedText : color,
fontSize: metrics.metaSize,
fontStyle: card.visualKind == TaskVisualKind.freeSlot
? FontStyle.italic
: FontStyle.normal,
fontWeight: card.visualKind == TaskVisualKind.flexible
? FontWeight.w700
: FontWeight.w500,
),
),
if (card.visualKind == TaskVisualKind.freeSlot) ...[
SizedBox(height: metrics.freeSlotTimeGap),
Text(
card.timeText,
style: TextStyle(
color: color,
fontSize: metrics.metaSize,
fontWeight: FontWeight.w600,
),
),
],
],
);
if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) {
return textBlock;
}
return FittedBox(
alignment: Alignment.centerLeft,
fit: BoxFit.scaleDown,
child: SizedBox(width: constraints.maxWidth, child: textBlock),
);
},
);
}
}
class _CompactCardText extends StatelessWidget {
const _CompactCardText({
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) {
final metaText = _compactMetaText(card);
final titleStyle = TextStyle(
color: card.isCompleted
? FocusFlowTokens.completedText
: FocusFlowTokens.textPrimary,
fontSize: metrics.titleSize,
height: 1,
fontWeight: FontWeight.w800,
decoration: card.isCompleted ? TextDecoration.lineThrough : null,
decorationColor: FocusFlowTokens.completedText,
decorationThickness: math.max(1, 2 * metrics.scale),
);
final metaStyle = TextStyle(
color: card.isCompleted ? FocusFlowTokens.completedText : color,
fontSize: metrics.metaSize,
height: 1,
fontWeight: FontWeight.w700,
);
return LayoutBuilder(
builder: (context, constraints) {
final line = Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
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,
overflow: TextOverflow.ellipsis,
style: titleStyle,
),
),
SizedBox(width: metrics.compactTextGap),
Flexible(
flex: 1,
fit: FlexFit.loose,
child: Text(
metaText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: metaStyle,
),
),
SizedBox(width: metrics.compactTrailingReserve),
],
);
if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) {
return line;
}
return FittedBox(
alignment: Alignment.centerLeft,
fit: BoxFit.scaleDown,
child: SizedBox(width: constraints.maxWidth, child: line),
);
},
);
}
String _compactMetaText(TimelineCardModel card) {
if (card.visualKind == TaskVisualKind.freeSlot &&
card.timeText.isNotEmpty) {
return card.timeText;
}
if (card.subtitle.isNotEmpty) {
return card.subtitle;
}
return card.timeText;
}
}
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;
@override
Widget build(BuildContext context) {
return ExcludeSemantics(
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: 90),
curve: Curves.easeOut,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_NoOpIcon(
icon: Icons.check,
color: color,
metrics: metrics,
),
_NoOpIcon(
icon: Icons.arrow_forward,
color: color,
metrics: metrics,
),
_NoOpIcon(
icon: Icons.calendar_today,
color: color,
metrics: metrics,
),
_NoOpIcon(
icon: Icons.wb_sunny_outlined,
color: color,
metrics: metrics,
),
SizedBox(width: metrics.actionGap),
],
),
),
),
),
),
),
);
}
}
class _NoOpIcon extends StatelessWidget {
const _NoOpIcon({
required this.icon,
required this.color,
required this.metrics,
});
final IconData icon;
final Color color;
final _CardMetrics metrics;
@override
Widget build(BuildContext context) {
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),
),
),
);
}
}
class _CardColors {
const _CardColors({
required this.accent,
required this.background,
required this.reward,
});
final Color accent;
final Color background;
final Color reward;
factory _CardColors.forKind(TaskVisualKind kind) {
return switch (kind) {
TaskVisualKind.flexible => const _CardColors(
accent: FocusFlowTokens.flexibleGreen,
background: Color(0xCC021A0C),
reward: FocusFlowTokens.flexibleGreen,
),
TaskVisualKind.required => const _CardColors(
accent: FocusFlowTokens.requiredPink,
background: Color(0xCC230814),
reward: FocusFlowTokens.requiredPink,
),
TaskVisualKind.appointment => const _CardColors(
accent: FocusFlowTokens.appointmentBlue,
background: Color(0xCC031429),
reward: FocusFlowTokens.appointmentBlue,
),
TaskVisualKind.freeSlot => const _CardColors(
accent: FocusFlowTokens.restPurple,
background: Color(0xCC210A33),
reward: FocusFlowTokens.restPurple,
),
TaskVisualKind.completedSurprise => const _CardColors(
accent: FocusFlowTokens.completedGray,
background: Color(0xAA1C1D25),
reward: FocusFlowTokens.restPurple,
),
};
}
}

View file

@ -6,6 +6,9 @@ import 'package:flutter/material.dart';
import '../../theme/focus_flow_tokens.dart'; import '../../theme/focus_flow_tokens.dart';
import 'timeline_geometry.dart'; import 'timeline_geometry.dart';
part 'axis/timeline_grid.dart';
part 'axis/timeline_grid_painter.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].
@ -68,52 +71,3 @@ 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, 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: geometry, topInset: topInset),
size: Size.infinite,
);
}
}
class _TimelineGridPainter extends CustomPainter {
const _TimelineGridPainter({required this.geometry, required this.topInset});
final TimelineGeometry geometry;
final double topInset;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = FocusFlowTokens.gridLine
..strokeWidth = 1;
for (final tick in geometry.ticks()) {
canvas.drawLine(
Offset(0, tick.y + topInset),
Offset(size.width, tick.y + topInset),
paint
..color = tick.major
? FocusFlowTokens.gridLine.withValues(alpha: 0.72)
: FocusFlowTokens.gridLine,
);
}
}
@override
bool shouldRepaint(covariant _TimelineGridPainter oldDelegate) {
return oldDelegate.geometry != geometry || oldDelegate.topInset != topInset;
}
}

View file

@ -10,6 +10,10 @@ import 'task_timeline_card.dart';
import 'timeline_axis.dart'; import 'timeline_axis.dart';
import 'timeline_geometry.dart'; import 'timeline_geometry.dart';
part 'timeline_view/timeline_card_placement.dart';
part 'timeline_view/timeline_scroll_behavior.dart';
part 'timeline_view/timeline_view_state.dart';
/// Scrollable compact timeline view for Today cards. /// Scrollable compact timeline view for Today cards.
class TimelineView extends StatefulWidget { class TimelineView extends StatefulWidget {
/// Creates a timeline view. /// Creates a timeline view.
@ -48,374 +52,3 @@ class TimelineView extends StatefulWidget {
@override @override
State<TimelineView> createState() => _TimelineViewState(); State<TimelineView> createState() => _TimelineViewState();
} }
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);
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
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
_scheduleInitialScroll(_geometry, constraints.maxHeight);
_scheduleTargetScroll(_geometry);
return ScrollConfiguration(
behavior: const _TimelineScrollBehavior(),
child: SingleChildScrollView(
controller: _scrollController,
child: SizedBox(
height: _contentHeight,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RepaintBoundary(
child: TimelineAxis(
geometry: _geometry,
topInset: _topInset,
),
),
const SizedBox(width: 18),
Expanded(
child: LayoutBuilder(
builder: (context, cardConstraints) {
final trackWidth = cardConstraints.maxWidth;
return Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: RepaintBoundary(
child: TimelineGrid(
geometry: _geometry,
topInset: _topInset,
),
),
),
for (final placement in _placements)
Positioned(
top: placement.top + _topInset,
left: placement.left(trackWidth),
width: placement.width(trackWidth),
height: placement.height,
child: RepaintBoundary(
child: TaskTimelineCard(
card: placement.card,
onTap: () =>
widget.onCardSelected(placement.card),
onStatusPressed:
widget.onCardCompleted == null
? null
: () => widget.onCardCompleted!(
placement.card,
),
),
),
),
],
);
},
),
),
],
),
),
),
);
},
);
}
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,
) {
if (_didSetInitialScroll || !viewportHeight.isFinite) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scrollController.hasClients) {
return;
}
final now = widget.now?.call() ?? DateTime.now();
final currentMinute = now.hour * 60 + now.minute;
final clampedMinute = currentMinute
.clamp(geometry.startMinutes, geometry.endMinutes)
.toInt();
final centeredOffset =
geometry.yForMinutesSinceMidnight(clampedMinute) +
_topInset -
viewportHeight / 2;
final maxOffset = _scrollController.position.maxScrollExtent;
final offset = centeredOffset.clamp(0, maxOffset).toDouble();
_scrollController.jumpTo(offset);
_didSetInitialScroll = true;
});
}
void _scheduleTargetScroll(TimelineGeometry geometry) {
final targetCardId = widget.scrollTargetCardId;
if (targetCardId == null || widget.scrollRequest == _handledScrollRequest) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scrollController.hasClients) {
return;
}
TimelineCardModel? targetCard;
for (final card in widget.cards) {
if (card.id == targetCardId) {
targetCard = card;
break;
}
}
if (targetCard == null) {
_handledScrollRequest = widget.scrollRequest;
return;
}
final targetOffset =
geometry.yForMinutesSinceMidnight(targetCard.startMinutes) +
_topInset -
24;
final maxOffset = _scrollController.position.maxScrollExtent;
final offset = targetOffset.clamp(0, maxOffset).toDouble();
_scrollController.jumpTo(offset);
_handledScrollRequest = widget.scrollRequest;
});
}
}
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;
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, {
required TimelineGeometry geometry,
}) {
if (cards.isEmpty) {
return const [];
}
final sorted = [...cards]
..sort((a, b) {
final startComparison = a.startMinutes.compareTo(b.startMinutes);
if (startComparison != 0) {
return startComparison;
}
final endComparison = a.endMinutes.compareTo(b.endMinutes);
if (endComparison != 0) {
return endComparison;
}
return a.id.compareTo(b.id);
});
final placements = <_TimelineCardPlacement>[];
var groupStart = 0;
var groupEnd = _visualEnd(sorted.first, geometry);
for (var index = 1; index < sorted.length; index += 1) {
final card = sorted[index];
final cardStart = _visualStart(card, geometry);
final cardEnd = _visualEnd(card, geometry);
if (cardStart < groupEnd) {
if (cardEnd > groupEnd) {
groupEnd = cardEnd;
}
continue;
}
placements.addAll(
_packGroup(sorted.sublist(groupStart, index), geometry: geometry),
);
groupStart = index;
groupEnd = cardEnd;
}
placements.addAll(
_packGroup(sorted.sublist(groupStart), geometry: geometry),
);
return placements;
}
static List<_TimelineCardPlacement> _packGroup(
List<TimelineCardModel> group, {
required TimelineGeometry geometry,
}) {
final laneEnds = <double>[];
final laneByCard = <TimelineCardModel, int>{};
for (final card in group) {
final cardStart = _visualStart(card, geometry);
final cardEnd = _visualEnd(card, geometry);
var assignedLane = -1;
for (var lane = 0; lane < laneEnds.length; lane += 1) {
if (laneEnds[lane] <= cardStart) {
assignedLane = lane;
break;
}
}
if (assignedLane == -1) {
assignedLane = laneEnds.length;
laneEnds.add(cardEnd);
} else {
laneEnds[assignedLane] = cardEnd;
}
laneByCard[card] = assignedLane;
}
final laneCount = laneEnds.length;
return [
for (final card in group)
_TimelineCardPlacement(
card: card,
lane: laneByCard[card]!,
laneCount: laneCount,
top: _visualStart(card, geometry),
height: geometry.heightForDuration(
card.endMinutes - card.startMinutes,
),
),
];
}
static double _visualStart(
TimelineCardModel card,
TimelineGeometry geometry,
) {
return geometry.yForMinutesSinceMidnight(card.startMinutes);
}
static double _visualEnd(TimelineCardModel card, TimelineGeometry geometry) {
final duration = card.endMinutes - card.startMinutes;
return _visualStart(card, geometry) + geometry.heightForDuration(duration);
}
double left(double trackWidth) {
final laneWidth = _laneWidth(trackWidth);
final laneGap = _laneGapForWidth(trackWidth);
return FocusFlowTokens.cardHorizontalPadding + lane * (laneWidth + laneGap);
}
double width(double trackWidth) {
return _laneWidth(trackWidth);
}
double _availableWidth(double trackWidth) {
final availableWidth = trackWidth - FocusFlowTokens.cardHorizontalPadding;
if (availableWidth < 0) {
return 0;
}
return availableWidth;
}
double _laneWidth(double trackWidth) {
final availableWidth = _availableWidth(trackWidth);
final laneGap = _laneGapForWidth(trackWidth);
return (availableWidth - laneGap * (laneCount - 1)) / laneCount;
}
double _laneGapForWidth(double trackWidth) {
if (laneCount == 1) {
return 0;
}
final availableWidth = _availableWidth(trackWidth);
final totalGap = _laneGap * (laneCount - 1);
if (availableWidth <= totalGap) {
return 0;
}
return _laneGap;
}
}

View file

@ -0,0 +1,163 @@
part of '../timeline_view.dart';
class _TimelineCardPlacement {
const _TimelineCardPlacement({
required this.card,
required this.lane,
required this.laneCount,
required this.top,
required this.height,
});
static const _laneGap = 8.0;
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, {
required TimelineGeometry geometry,
}) {
if (cards.isEmpty) {
return const [];
}
final sorted = [...cards]
..sort((a, b) {
final startComparison = a.startMinutes.compareTo(b.startMinutes);
if (startComparison != 0) {
return startComparison;
}
final endComparison = a.endMinutes.compareTo(b.endMinutes);
if (endComparison != 0) {
return endComparison;
}
return a.id.compareTo(b.id);
});
final placements = <_TimelineCardPlacement>[];
var groupStart = 0;
var groupEnd = _visualEnd(sorted.first, geometry);
for (var index = 1; index < sorted.length; index += 1) {
final card = sorted[index];
final cardStart = _visualStart(card, geometry);
final cardEnd = _visualEnd(card, geometry);
if (cardStart < groupEnd) {
if (cardEnd > groupEnd) {
groupEnd = cardEnd;
}
continue;
}
placements.addAll(
_packGroup(sorted.sublist(groupStart, index), geometry: geometry),
);
groupStart = index;
groupEnd = cardEnd;
}
placements.addAll(
_packGroup(sorted.sublist(groupStart), geometry: geometry),
);
return placements;
}
static List<_TimelineCardPlacement> _packGroup(
List<TimelineCardModel> group, {
required TimelineGeometry geometry,
}) {
final laneEnds = <double>[];
final laneByCard = <TimelineCardModel, int>{};
for (final card in group) {
final cardStart = _visualStart(card, geometry);
final cardEnd = _visualEnd(card, geometry);
var assignedLane = -1;
for (var lane = 0; lane < laneEnds.length; lane += 1) {
if (laneEnds[lane] <= cardStart) {
assignedLane = lane;
break;
}
}
if (assignedLane == -1) {
assignedLane = laneEnds.length;
laneEnds.add(cardEnd);
} else {
laneEnds[assignedLane] = cardEnd;
}
laneByCard[card] = assignedLane;
}
final laneCount = laneEnds.length;
return [
for (final card in group)
_TimelineCardPlacement(
card: card,
lane: laneByCard[card]!,
laneCount: laneCount,
top: _visualStart(card, geometry),
height: geometry.heightForDuration(
card.endMinutes - card.startMinutes,
),
),
];
}
static double _visualStart(
TimelineCardModel card,
TimelineGeometry geometry,
) {
return geometry.yForMinutesSinceMidnight(card.startMinutes);
}
static double _visualEnd(TimelineCardModel card, TimelineGeometry geometry) {
final duration = card.endMinutes - card.startMinutes;
return _visualStart(card, geometry) + geometry.heightForDuration(duration);
}
double left(double trackWidth) {
final laneWidth = _laneWidth(trackWidth);
final laneGap = _laneGapForWidth(trackWidth);
return FocusFlowTokens.cardHorizontalPadding + lane * (laneWidth + laneGap);
}
double width(double trackWidth) {
return _laneWidth(trackWidth);
}
double _availableWidth(double trackWidth) {
final availableWidth = trackWidth - FocusFlowTokens.cardHorizontalPadding;
if (availableWidth < 0) {
return 0;
}
return availableWidth;
}
double _laneWidth(double trackWidth) {
final availableWidth = _availableWidth(trackWidth);
final laneGap = _laneGapForWidth(trackWidth);
return (availableWidth - laneGap * (laneCount - 1)) / laneCount;
}
double _laneGapForWidth(double trackWidth) {
if (laneCount == 1) {
return 0;
}
final availableWidth = _availableWidth(trackWidth);
final totalGap = _laneGap * (laneCount - 1);
if (availableWidth <= totalGap) {
return 0;
}
return _laneGap;
}
}

View file

@ -0,0 +1,10 @@
part of '../timeline_view.dart';
class _TimelineScrollBehavior extends MaterialScrollBehavior {
const _TimelineScrollBehavior();
@override
Set<PointerDeviceKind> get dragDevices {
return {...super.dragDevices, PointerDeviceKind.mouse};
}
}

View file

@ -0,0 +1,201 @@
part of '../timeline_view.dart';
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);
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
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
_scheduleInitialScroll(_geometry, constraints.maxHeight);
_scheduleTargetScroll(_geometry);
return ScrollConfiguration(
behavior: const _TimelineScrollBehavior(),
child: SingleChildScrollView(
controller: _scrollController,
child: SizedBox(
height: _contentHeight,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RepaintBoundary(
child: TimelineAxis(
geometry: _geometry,
topInset: _topInset,
),
),
const SizedBox(width: 18),
Expanded(
child: LayoutBuilder(
builder: (context, cardConstraints) {
final trackWidth = cardConstraints.maxWidth;
return Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: RepaintBoundary(
child: TimelineGrid(
geometry: _geometry,
topInset: _topInset,
),
),
),
for (final placement in _placements)
Positioned(
top: placement.top + _topInset,
left: placement.left(trackWidth),
width: placement.width(trackWidth),
height: placement.height,
child: RepaintBoundary(
child: TaskTimelineCard(
card: placement.card,
onTap: () =>
widget.onCardSelected(placement.card),
onStatusPressed:
widget.onCardCompleted == null
? null
: () => widget.onCardCompleted!(
placement.card,
),
),
),
),
],
);
},
),
),
],
),
),
),
);
},
);
}
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,
) {
if (_didSetInitialScroll || !viewportHeight.isFinite) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scrollController.hasClients) {
return;
}
final now = widget.now?.call() ?? DateTime.now();
final currentMinute = now.hour * 60 + now.minute;
final clampedMinute = currentMinute
.clamp(geometry.startMinutes, geometry.endMinutes)
.toInt();
final centeredOffset =
geometry.yForMinutesSinceMidnight(clampedMinute) +
_topInset -
viewportHeight / 2;
final maxOffset = _scrollController.position.maxScrollExtent;
final offset = centeredOffset.clamp(0, maxOffset).toDouble();
_scrollController.jumpTo(offset);
_didSetInitialScroll = true;
});
}
void _scheduleTargetScroll(TimelineGeometry geometry) {
final targetCardId = widget.scrollTargetCardId;
if (targetCardId == null || widget.scrollRequest == _handledScrollRequest) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scrollController.hasClients) {
return;
}
TimelineCardModel? targetCard;
for (final card in widget.cards) {
if (card.id == targetCardId) {
targetCard = card;
break;
}
}
if (targetCard == null) {
_handledScrollRequest = widget.scrollRequest;
return;
}
final targetOffset =
geometry.yForMinutesSinceMidnight(targetCard.startMinutes) +
_topInset -
24;
final maxOffset = _scrollController.position.maxScrollExtent;
final offset = targetOffset.clamp(0, maxOffset).toDouble();
_scrollController.jumpTo(offset);
_handledScrollRequest = widget.scrollRequest;
});
}
}

View file

@ -5,6 +5,11 @@ import 'package:flutter/material.dart';
import '../theme/focus_flow_tokens.dart'; import '../theme/focus_flow_tokens.dart';
part 'top_bar/top_bar_icon_button.dart';
part 'top_bar/top_bar_metrics.dart';
part 'top_bar/top_bar_mode_toggle.dart';
part 'top_bar/top_bar_settings_button.dart';
/// Header bar for the compact Today view. /// Header bar for the compact Today view.
class TopBar extends StatelessWidget { class TopBar extends StatelessWidget {
/// Creates a top bar with a display-ready [dateLabel]. /// Creates a top bar with a display-ready [dateLabel].
@ -79,177 +84,3 @@ class TopBar extends StatelessWidget {
); );
} }
} }
class _TopBarMetrics {
const _TopBarMetrics(this.scale);
static const referenceHeight = 48.0;
static const _referenceWidth = 900.0;
static const _minimumScale = 0.44;
factory _TopBarMetrics.fromWidth(double width) {
final safeWidth = width.isFinite ? width : _referenceWidth;
final scale = (safeWidth / _referenceWidth).clamp(_minimumScale, 1.0);
return _TopBarMetrics(scale.toDouble());
}
final double scale;
double get titleWidth => 112 * scale;
double get titleSize => FocusFlowTokens.pageTitleSize * scale;
double get titleGap => 30 * scale;
double get controlHeight => referenceHeight * scale;
double get iconButtonSize => referenceHeight * scale;
double get iconSize => 24 * scale;
double get dateWidth => 144 * scale;
double get dateSize => 17 * scale;
double get modeWidth => 220 * scale;
double get modeHeight => referenceHeight * scale;
double get modeLabelSize => 17 * scale;
double get settingsGap => 28 * scale;
double get settingsWidth => 136 * scale;
double get settingsHeight => referenceHeight * scale;
double get settingsIconSize => 20 * scale;
double get settingsTextSize => 15 * scale;
double get settingsTextGap => 8 * scale;
double get borderRadius => FocusFlowTokens.smallButtonRadius * scale;
double get borderWidth => scale < 0.7 ? 0.8 : 1;
EdgeInsets get buttonPadding => EdgeInsets.symmetric(horizontal: 12 * scale);
}
class _SettingsButton extends StatelessWidget {
const _SettingsButton({required this.metrics});
final _TopBarMetrics metrics;
@override
Widget build(BuildContext context) {
return OutlinedButton(
onPressed: () {},
style: OutlinedButton.styleFrom(
foregroundColor: FocusFlowTokens.textPrimary,
side: BorderSide(
color: FocusFlowTokens.subtleBorder,
width: metrics.borderWidth,
),
fixedSize: Size(metrics.settingsWidth, metrics.settingsHeight),
minimumSize: Size.zero,
padding: metrics.buttonPadding,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(metrics.borderRadius),
),
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.wb_sunny_outlined, size: metrics.settingsIconSize),
SizedBox(width: metrics.settingsTextGap),
Text(
'Settings',
style: TextStyle(
fontSize: metrics.settingsTextSize,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
}
class _IconButton extends StatelessWidget {
const _IconButton({
required this.icon,
required this.metrics,
required this.onPressed,
});
final IconData icon;
final _TopBarMetrics metrics;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: onPressed,
constraints: BoxConstraints.tightFor(
width: metrics.iconButtonSize,
height: metrics.iconButtonSize,
),
color: FocusFlowTokens.textPrimary,
iconSize: metrics.iconSize,
padding: EdgeInsets.zero,
icon: Icon(icon),
visualDensity: VisualDensity.compact,
);
}
}
class _ModeToggle extends StatelessWidget {
const _ModeToggle({required this.metrics});
final _TopBarMetrics metrics;
@override
Widget build(BuildContext context) {
return Container(
height: metrics.modeHeight,
width: metrics.modeWidth,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(metrics.borderRadius),
border: Border.all(
color: FocusFlowTokens.subtleBorder,
width: metrics.borderWidth,
),
),
child: Row(
children: [
Expanded(
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: FocusFlowTokens.glassPanelStrong,
borderRadius: BorderRadius.circular(metrics.borderRadius),
border: Border.all(
color: FocusFlowTokens.navBorder,
width: metrics.borderWidth,
),
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'Compact',
style: TextStyle(fontSize: metrics.modeLabelSize),
),
),
),
),
Expanded(
child: TextButton(
onPressed: () {},
style: TextButton.styleFrom(
minimumSize: Size.zero,
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'Normal',
style: TextStyle(
color: FocusFlowTokens.textPrimary,
fontSize: metrics.modeLabelSize,
),
),
),
),
),
],
),
);
}
}

View file

@ -0,0 +1,29 @@
part of '../top_bar.dart';
class _IconButton extends StatelessWidget {
const _IconButton({
required this.icon,
required this.metrics,
required this.onPressed,
});
final IconData icon;
final _TopBarMetrics metrics;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: onPressed,
constraints: BoxConstraints.tightFor(
width: metrics.iconButtonSize,
height: metrics.iconButtonSize,
),
color: FocusFlowTokens.textPrimary,
iconSize: metrics.iconSize,
padding: EdgeInsets.zero,
icon: Icon(icon),
visualDensity: VisualDensity.compact,
);
}
}

View file

@ -0,0 +1,38 @@
part of '../top_bar.dart';
class _TopBarMetrics {
const _TopBarMetrics(this.scale);
static const referenceHeight = 48.0;
static const _referenceWidth = 900.0;
static const _minimumScale = 0.44;
factory _TopBarMetrics.fromWidth(double width) {
final safeWidth = width.isFinite ? width : _referenceWidth;
final scale = (safeWidth / _referenceWidth).clamp(_minimumScale, 1.0);
return _TopBarMetrics(scale.toDouble());
}
final double scale;
double get titleWidth => 112 * scale;
double get titleSize => FocusFlowTokens.pageTitleSize * scale;
double get titleGap => 30 * scale;
double get controlHeight => referenceHeight * scale;
double get iconButtonSize => referenceHeight * scale;
double get iconSize => 24 * scale;
double get dateWidth => 144 * scale;
double get dateSize => 17 * scale;
double get modeWidth => 220 * scale;
double get modeHeight => referenceHeight * scale;
double get modeLabelSize => 17 * scale;
double get settingsGap => 28 * scale;
double get settingsWidth => 136 * scale;
double get settingsHeight => referenceHeight * scale;
double get settingsIconSize => 20 * scale;
double get settingsTextSize => 15 * scale;
double get settingsTextGap => 8 * scale;
double get borderRadius => FocusFlowTokens.smallButtonRadius * scale;
double get borderWidth => scale < 0.7 ? 0.8 : 1;
EdgeInsets get buttonPadding => EdgeInsets.symmetric(horizontal: 12 * scale);
}

View file

@ -0,0 +1,66 @@
part of '../top_bar.dart';
class _ModeToggle extends StatelessWidget {
const _ModeToggle({required this.metrics});
final _TopBarMetrics metrics;
@override
Widget build(BuildContext context) {
return Container(
height: metrics.modeHeight,
width: metrics.modeWidth,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(metrics.borderRadius),
border: Border.all(
color: FocusFlowTokens.subtleBorder,
width: metrics.borderWidth,
),
),
child: Row(
children: [
Expanded(
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: FocusFlowTokens.glassPanelStrong,
borderRadius: BorderRadius.circular(metrics.borderRadius),
border: Border.all(
color: FocusFlowTokens.navBorder,
width: metrics.borderWidth,
),
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'Compact',
style: TextStyle(fontSize: metrics.modeLabelSize),
),
),
),
),
Expanded(
child: TextButton(
onPressed: () {},
style: TextButton.styleFrom(
minimumSize: Size.zero,
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'Normal',
style: TextStyle(
color: FocusFlowTokens.textPrimary,
fontSize: metrics.modeLabelSize,
),
),
),
),
),
],
),
);
}
}

View file

@ -0,0 +1,45 @@
part of '../top_bar.dart';
class _SettingsButton extends StatelessWidget {
const _SettingsButton({required this.metrics});
final _TopBarMetrics metrics;
@override
Widget build(BuildContext context) {
return OutlinedButton(
onPressed: () {},
style: OutlinedButton.styleFrom(
foregroundColor: FocusFlowTokens.textPrimary,
side: BorderSide(
color: FocusFlowTokens.subtleBorder,
width: metrics.borderWidth,
),
fixedSize: Size(metrics.settingsWidth, metrics.settingsHeight),
minimumSize: Size.zero,
padding: metrics.buttonPadding,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(metrics.borderRadius),
),
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.wb_sunny_outlined, size: metrics.settingsIconSize),
SizedBox(width: metrics.settingsTextGap),
Text(
'Settings',
style: TextStyle(
fontSize: metrics.settingsTextSize,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
}