merge: block 29 app dart docs

This commit is contained in:
Ashley Venn 2026-07-01 13:55:01 -07:00
commit d65ebf0bd1
584 changed files with 30098 additions and 19246 deletions

15
.githooks/pre-commit Executable file
View file

@ -0,0 +1,15 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 FocusFlow contributors
# SPDX-License-Identifier: AGPL-3.0-only
# Blocks commits that do not meet the repository's formatting, analyzer,
# documentation, or SPDX/REUSE metadata gates.
set -euo pipefail
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$root_dir"
dart format --set-exit-if-changed packages apps scripts tool test
dart analyze
dart run tool/check_reuse.dart
dart run tool/check_docs.dart --skip-dartdoc

View file

@ -1,3 +1,6 @@
# SPDX-FileCopyrightText: 2026 FocusFlow contributors
# SPDX-License-Identifier: AGPL-3.0-only
# Runs analyzer, tests, coverage, docs, and tagged desktop packaging.
name: CI

View file

@ -1,3 +1,6 @@
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# AGENTS.md — Codex Project Rules (SQLiteFirst, July 2026)
This document supersedes all previous agent rule files. Treat it as the single source of truth.
@ -80,6 +83,17 @@ integrated.
---
## 6.1 File Hygiene
All future project files must include the relevant SPDX metadata for their file
format. New Dart files must include file-level Dartdoc library docs and Dartdoc
comments for every class, enum, enum value, constructor, method, field, and
top-level declaration. When adding code, keep large feature surfaces organized
under descriptive subfolders instead of expanding flat top-level `src` or app
directories.
---
## 7. Branch, Commit & CI
* Start each new block from `main` on a block branch named

View file

@ -0,0 +1,9 @@
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# Focus Flow Contributors
## Project Contact
Ashley Eva Venn
ashleyevavenn@gmail.com

17
REUSE.toml Normal file
View file

@ -0,0 +1,17 @@
# SPDX-FileCopyrightText: 2026 FocusFlow contributors
# SPDX-License-Identifier: AGPL-3.0-only
version = 1
# The project license text is intentionally kept in the repository root as
# LICENSE.md. These annotations cover package and app files that cannot safely
# carry inline SPDX comments, such as generated platform files, binary icons,
# JSON manifests, Xcode project files, and XML/plist resources.
[[annotations]]
path = [
"packages/**",
"apps/focus_flow_flutter/**",
]
precedence = "aggregate"
SPDX-FileCopyrightText = "2026 FocusFlow contributors"
SPDX-License-Identifier = "AGPL-3.0-only"

17
analysis_options.yaml Normal file
View file

@ -0,0 +1,17 @@
# SPDX-FileCopyrightText: 2026 FocusFlow contributors
# SPDX-License-Identifier: AGPL-3.0-only
include: package:lints/recommended.yaml
analyzer:
language:
strict-casts: true
strict-inference: true
strict-raw-types: true
linter:
rules:
public_member_api_docs: true
prefer_final_locals: true
prefer_final_fields: true
avoid_print: true

View file

@ -0,0 +1,6 @@
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# License
This app uses the repository-level license at [../../LICENSE.md](../../LICENSE.md).

View file

@ -1,3 +1,6 @@
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# FocusFlow Flutter
Flutter desktop app target for the FocusFlow UI work.

View file

@ -1,3 +1,6 @@
# SPDX-FileCopyrightText: 2026 FocusFlow contributors
# SPDX-License-Identifier: AGPL-3.0-only
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
@ -9,6 +12,12 @@
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
analyzer:
language:
strict-casts: true
strict-inference: true
strict-raw-types: true
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
@ -21,6 +30,10 @@ linter:
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
public_member_api_docs: true
prefer_final_locals: true
prefer_final_fields: true
avoid_print: true
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule

View file

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Exports the FocusFlow Flutter application entry points.
library;

View file

@ -0,0 +1,29 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
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}';
}
/// Top-level helper that performs the `_instant` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
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,163 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../demo_scheduler_composition.dart';
/// Top-level helper that performs the `_todaySeedTasks` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
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,
),
];
}
/// Top-level helper that performs the `_seedTask` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
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

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Composes the FocusFlow Flutter application around Demo Scheduler Composition.
library;
@ -7,7 +10,13 @@ import '../controllers/scheduler_command_controller.dart';
import '../controllers/scheduler_read_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.
class DemoSchedulerComposition {
/// Creates a `DemoSchedulerComposition._` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
DemoSchedulerComposition._({
required this.store,
required this.todayQuery,
@ -17,19 +26,37 @@ class DemoSchedulerComposition {
required this.readAt,
});
/// Fixed owner used by the seeded demo data.
static const ownerId = 'owner-1';
/// Fixed owner time zone used by the seeded demo data.
static const timeZoneId = 'UTC';
/// Fixed project id used by the seeded demo data.
static const projectId = 'home';
/// In-memory application store backing the demo.
final InMemoryApplicationUnitOfWork store;
/// Query object used to read the Today state.
final GetTodayStateQuery todayQuery;
/// Management use cases used by backlog-facing UI surfaces.
final V1ApplicationManagementUseCases managementUseCases;
/// Command use cases used by interactive UI controls.
final V1ApplicationCommandUseCases commandUseCases;
/// Local date represented by the seeded demo.
final CivilDate date;
/// Stable read clock used for deterministic demo operations.
final DateTime readAt;
String get dateLabel => formatDateLabel(date);
/// Human-readable label for [date].
String get dateLabel => _formatDateLabel(date);
/// Creates a deterministic seeded composition for the demo app and tests.
factory DemoSchedulerComposition.seeded({
Clock? clock,
CivilDate? selectedDate,
@ -87,6 +114,7 @@ class DemoSchedulerComposition {
);
}
/// Creates the controller used by the compact Today screen mockup.
TodayScreenController createTodayScreenController() {
return TodayScreenController(
read: () {
@ -101,6 +129,7 @@ class DemoSchedulerComposition {
);
}
/// Creates a generic read controller for Today-state panes.
UiReadController<TodayState> createTodayController() {
return ApplicationReadController<TodayState>(
read: () {
@ -118,6 +147,7 @@ class DemoSchedulerComposition {
);
}
/// Creates a generic read controller for backlog panes.
UiReadController<BacklogQueryResult> createBacklogController() {
return ApplicationReadController<BacklogQueryResult>(
read: () {
@ -132,6 +162,7 @@ class DemoSchedulerComposition {
);
}
/// Creates the command controller used by interactive scheduler controls.
SchedulerCommandController createCommandController({
required ReadRefresh refreshReads,
}) {
@ -143,165 +174,16 @@ class DemoSchedulerComposition {
);
}
ApplicationOperationContext context(String operationId) {
/// Creates an application operation context for the supplied [operationId].
ApplicationOperationContext context(String operationId, {DateTime? now}) {
return ApplicationOperationContext.start(
operationId: operationId,
ownerTimeZone: OwnerTimeZoneContext(
ownerId: ownerId,
timeZoneId: timeZoneId,
),
clock: FixedClock(readAt),
clock: FixedClock(now ?? readAt),
idGenerator: SequentialIdGenerator(prefix: operationId),
);
}
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-early',
title: 'Cleaned kitchen counter',
type: TaskType.surprise,
status: TaskStatus.completed,
date: date,
startHour: 16,
startMinute: 45,
endHour: 17,
endMinute: 0,
reward: RewardLevel.medium,
difficulty: DifficultyLevel.medium,
completedAt: _instant(date, 16, 20),
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,
),
_task(
id: 'cleaned-kitchen-counter-late',
title: 'Cleaned kitchen counter',
type: TaskType.surprise,
status: TaskStatus.completed,
date: date,
startHour: 20,
startMinute: 15,
endHour: 20,
endMinute: 30,
reward: RewardLevel.medium,
difficulty: DifficultyLevel.medium,
completedAt: _instant(date, 20, 20),
createdAt: createdAt,
),
];
}
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

@ -1,8 +1,12 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Composes the FocusFlow Flutter application around Focus Flow App.
library;
import 'package:flutter/material.dart';
import '../controllers/scheduler_command_controller.dart';
import '../controllers/today_screen_controller.dart';
import '../models/today_screen_models.dart';
import '../theme/focus_flow_theme.dart';
@ -15,11 +19,22 @@ import '../widgets/timeline/timeline_view.dart';
import '../widgets/top_bar.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.
class FocusFlowApp extends StatelessWidget {
/// Creates a FocusFlow app from an application composition.
const FocusFlowApp({required this.composition, super.key});
/// Factory and controller bundle used by the app tree.
final DemoSchedulerComposition composition;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return MaterialApp(
@ -30,141 +45,3 @@ class FocusFlowApp extends StatelessWidget {
);
}
}
class FocusFlowHome extends StatefulWidget {
const FocusFlowHome({required this.composition, super.key});
final DemoSchedulerComposition composition;
@override
State<FocusFlowHome> createState() => _FocusFlowHomeState();
}
class _FocusFlowHomeState extends State<FocusFlowHome> {
late final TodayScreenController controller = widget.composition
.createTodayScreenController();
@override
void initState() {
super.initState();
controller.load();
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@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,
onClearSelection: controller.clearSelection,
),
TodayScreenReady(:final data) => _TodayScreenScaffold(
data: data,
selectedCard: controller.selectedCard,
onSelectCard: controller.selectCard,
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 StatelessWidget {
const _TodayScreenScaffold({
required this.data,
required this.selectedCard,
required this.onSelectCard,
required this.onClearSelection,
});
final TodayScreenData data;
final TimelineCardModel? selectedCard;
final ValueChanged<TimelineCardModel> onSelectCard;
final VoidCallback onClearSelection;
@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: data.dateLabel),
const SizedBox(height: 24),
RequiredBanner(model: data.requiredBanner),
const SizedBox(height: 22),
Expanded(
child: TimelineView(
cards: data.cards,
range: data.timelineRange,
onCardSelected: onSelectCard,
),
),
],
),
),
if (selectedCard != null) ...[
Positioned.fill(
child: GestureDetector(
key: const ValueKey('modal-click-away'),
behavior: HitTestBehavior.opaque,
onTap: onClearSelection,
child: const ColoredBox(color: Colors.transparent),
),
),
Center(
child: TaskSelectionModal(
card: selectedCard!,
onClose: onClearSelection,
),
),
],
],
);
}
}

View file

@ -0,0 +1,18 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
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;
/// Creates the mutable state object used by this stateful widget.
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
@override
State<FocusFlowHome> createState() => _FocusFlowHomeState();
}

View file

@ -0,0 +1,94 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../focus_flow_app.dart';
/// Private implementation type for `_FocusFlowHomeState` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _FocusFlowHomeState extends State<FocusFlowHome> {
/// Stores the `controller` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
late final TodayScreenController controller;
/// Stores the `commandController` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
late final SchedulerCommandController commandController;
/// Initializes state owned by this object before the first build.
/// Use this lifecycle point for controller setup, initial reads, and listener wiring that must happen once per mounted instance.
@override
void initState() {
super.initState();
controller = widget.composition.createTodayScreenController();
commandController = widget.composition.createCommandController(
refreshReads: controller.load,
);
controller.load();
}
/// Releases resources owned by this object before it leaves the tree.
/// Controllers, listeners, and other disposable collaborators should be cleaned up here to avoid stale callbacks.
@override
void dispose() {
commandController.dispose();
controller.dispose();
super.dispose();
}
/// Runs the `_toggleCardCompletion` operation and completes after its asynchronous work finishes.
/// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order.
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,
);
}
}
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@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,25 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../focus_flow_app.dart';
/// Private implementation type for `_PlanIssue` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _PlanIssue extends StatelessWidget {
/// Creates a `_PlanIssue` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _PlanIssue({required this.message});
/// Stores the `message` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final String message;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return Center(
child: Text(message, style: Theme.of(context).textTheme.titleMedium),
);
}
}

View file

@ -0,0 +1,43 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../focus_flow_app.dart';
/// Private implementation type for `_TodayScreenScaffold` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _TodayScreenScaffold extends StatefulWidget {
/// Creates a `_TodayScreenScaffold` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _TodayScreenScaffold({
required this.data,
required this.selectedCard,
required this.onSelectCard,
required this.onCompleteCard,
required this.onClearSelection,
});
/// Stores the `data` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final TodayScreenData data;
/// Stores the `selectedCard` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final TimelineCardModel? selectedCard;
/// Stores the `onSelectCard` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final ValueChanged<TimelineCardModel> onSelectCard;
/// Stores the `onCompleteCard` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Future<void> Function(TimelineCardModel card) onCompleteCard;
/// Stores the `onClearSelection` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final VoidCallback onClearSelection;
/// Creates the mutable state object used by this stateful widget.
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
@override
State<_TodayScreenScaffold> createState() => _TodayScreenScaffoldState();
}

View file

@ -0,0 +1,92 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../focus_flow_app.dart';
/// Private implementation type for `_TodayScreenScaffoldState` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
/// Private state stored as `_timelineScrollTargetCardId` for this implementation.
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
String? _timelineScrollTargetCardId;
/// Private state stored as `_timelineScrollRequest` for this implementation.
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
var _timelineScrollRequest = 0;
/// Runs the `_showUpcomingRequiredTask` helper used inside this library.
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
void _showUpcomingRequiredTask() {
final targetCardId = widget.data.requiredBanner?.timelineCardId;
if (targetCardId == null) {
return;
}
setState(() {
_timelineScrollTargetCardId = targetCardId;
_timelineScrollRequest += 1;
});
}
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@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

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Provides compatibility composition exports for FocusFlow Flutter.
library;

View file

@ -0,0 +1,11 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
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,204 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
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;
/// Private state stored as `_sequence` for this implementation.
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
var _sequence = 0;
/// Private state stored as `_state` for this implementation.
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
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,
);
},
);
}
/// Runs the `_run` operation and completes after its asynchronous work finishes.
/// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order.
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));
}
}
/// Runs the `_nextOperationId` helper used inside this library.
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
String _nextOperationId() {
_sequence += 1;
return 'ui-command-$_sequence';
}
/// Runs the `_setState` helper used inside this library.
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
void _setState(SchedulerCommandState next) {
_state = next;
notifyListeners();
}
/// Runs the `_utcNow` helper used inside this library.
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
static DateTime _utcNow() => DateTime.now().toUtc();
}

View file

@ -0,0 +1,55 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
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

@ -1,155 +1,12 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Coordinates Scheduler Command Controller state for the FocusFlow Flutter UI.
library;
import 'package:flutter/foundation.dart';
import 'package:scheduler_core/scheduler_core.dart';
typedef OperationContextFactory =
ApplicationOperationContext Function(String operationId);
typedef ReadRefresh = Future<void> Function();
sealed class SchedulerCommandState {
const SchedulerCommandState();
}
class SchedulerCommandIdle extends SchedulerCommandState {
const SchedulerCommandIdle();
}
class SchedulerCommandRunning extends SchedulerCommandState {
const SchedulerCommandRunning(this.label);
final String label;
}
class SchedulerCommandSuccess extends SchedulerCommandState {
const SchedulerCommandSuccess(this.message);
final String message;
}
class SchedulerCommandFailure extends SchedulerCommandState {
const SchedulerCommandFailure({this.failure, this.error});
final ApplicationFailure? failure;
final Object? error;
String get codeLabel {
final typed = failure;
if (typed != null) {
return typed.detailCode ?? typed.code.name;
}
return 'unexpected';
}
}
class SchedulerCommandController extends ChangeNotifier {
SchedulerCommandController({
required this.commands,
required this.contextFor,
required this.localDate,
required this.refreshReads,
});
final V1ApplicationCommandUseCases commands;
final OperationContextFactory contextFor;
final CivilDate localDate;
final ReadRefresh refreshReads;
var _sequence = 0;
SchedulerCommandState _state = const SchedulerCommandIdle();
SchedulerCommandState get state => _state;
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',
);
},
);
}
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,
);
},
);
}
Future<void> completeFlexibleTask({
required String taskId,
DateTime? expectedUpdatedAt,
}) async {
await _run(
label: 'Completing task',
successMessage: 'Task completed',
refreshAfterSuccess: true,
action: (operationId) {
return commands.completeFlexibleTask(
context: contextFor(operationId),
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();
}
}
part 'command/scheduler_command_callbacks.dart';
part 'command/scheduler_command_controller_impl.dart';
part 'command/scheduler_command_state.dart';

View file

@ -1,46 +1,72 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Coordinates Scheduler Read Controller state for the FocusFlow Flutter UI.
library;
import 'package:flutter/foundation.dart';
import 'package:scheduler_core/scheduler_core.dart';
/// Reads an application value for a UI surface.
typedef ApplicationReader<T> = Future<ApplicationResult<T>> Function();
/// Determines whether a successful read should be rendered as empty state.
typedef EmptyPredicate<T> = bool Function(T value);
/// Shared interface for read controllers consumed by UI widgets.
abstract class UiReadController<T> extends ChangeNotifier {
/// Current read state.
SchedulerReadState<T> get state;
/// Loads the backing value.
Future<void> load();
/// Retries the latest read.
Future<void> retry();
}
/// Base state for scheduler read operations.
sealed class SchedulerReadState<T> {
/// Creates a scheduler read state.
const SchedulerReadState();
}
/// Indicates that a scheduler read is in progress.
class SchedulerReadLoading<T> extends SchedulerReadState<T> {
/// Creates a loading read state.
const SchedulerReadLoading();
}
/// Indicates that a scheduler read returned renderable data.
class SchedulerReadData<T> extends SchedulerReadState<T> {
/// Creates a data state from [value].
const SchedulerReadData(this.value);
/// Value returned by the read operation.
final T value;
}
/// Indicates that a scheduler read succeeded but has no primary content.
class SchedulerReadEmpty<T> extends SchedulerReadState<T> {
/// Creates an empty state that still carries the read [value].
const SchedulerReadEmpty(this.value);
/// Value returned by the read operation.
final T value;
}
/// Indicates that a scheduler read failed.
class SchedulerReadFailure<T> extends SchedulerReadState<T> {
/// Creates a failure state from an application [failure] or unexpected [error].
const SchedulerReadFailure({this.failure, this.error});
/// Typed application failure returned by the scheduler core.
final ApplicationFailure? failure;
/// Unexpected error thrown while running the read.
final Object? error;
/// Stable label for display and diagnostics.
String get codeLabel {
final typed = failure;
if (typed != null) {
@ -50,17 +76,26 @@ class SchedulerReadFailure<T> extends SchedulerReadState<T> {
}
}
/// Default read controller backed by an [ApplicationReader].
class ApplicationReadController<T> extends UiReadController<T> {
/// Creates a controller from a read callback and empty-state predicate.
ApplicationReadController({required this.read, required this.isEmpty});
/// Callback used to load the application value.
final ApplicationReader<T> read;
/// Predicate used to classify a successful value as empty or populated.
final EmptyPredicate<T> isEmpty;
/// Private state stored as `_state` for this implementation.
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
SchedulerReadState<T> _state = SchedulerReadLoading<T>();
/// Current read state.
@override
SchedulerReadState<T> get state => _state;
/// Loads the current value and updates [state].
@override
Future<void> load() async {
_setState(SchedulerReadLoading<T>());
@ -82,9 +117,12 @@ class ApplicationReadController<T> extends UiReadController<T> {
}
}
/// Retries by running [load] again.
@override
Future<void> retry() => load();
/// Runs the `_setState` helper used inside this library.
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
void _setState(SchedulerReadState<T> next) {
_state = next;
notifyListeners();

View file

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Coordinates Today Screen Controller state for the FocusFlow Flutter UI.
library;
@ -6,44 +9,68 @@ import 'package:scheduler_core/scheduler_core.dart';
import '../models/today_screen_models.dart';
/// Reads Today state from the scheduler application layer.
typedef TodayStateReader = Future<ApplicationResult<TodayState>> Function();
/// Base state for the compact Today screen.
sealed class TodayScreenState {
/// Creates a Today screen state.
const TodayScreenState();
}
/// Indicates that the Today screen is loading.
class TodayScreenLoading extends TodayScreenState {
/// Creates a loading Today screen state.
const TodayScreenLoading();
}
/// Indicates that the Today screen has renderable timeline data.
class TodayScreenReady extends TodayScreenState {
/// Creates a ready state with mapped screen [data].
const TodayScreenReady(this.data);
/// Presentation model for the Today screen.
final TodayScreenData data;
}
/// Indicates that the Today screen loaded successfully with no cards.
class TodayScreenEmpty extends TodayScreenState {
/// Creates an empty Today screen state.
const TodayScreenEmpty();
}
/// Indicates that the Today screen failed to load.
class TodayScreenFailure extends TodayScreenState {
/// Creates a failure state with a displayable [message].
const TodayScreenFailure(this.message);
/// User-facing failure message or code.
final String message;
}
/// Loads Today screen data and tracks selected timeline cards.
class TodayScreenController extends ChangeNotifier {
/// Creates a Today screen controller from a read callback.
TodayScreenController({required this.read});
/// Callback used to read Today state.
final TodayStateReader read;
/// Private state stored as `_state` for this implementation.
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
TodayScreenState _state = const TodayScreenLoading();
/// Private state stored as `_selectedCard` for this implementation.
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
TimelineCardModel? _selectedCard;
/// Current Today screen loading/data/failure state.
TodayScreenState get state => _state;
/// Currently selected timeline card, if a modal should be visible.
TimelineCardModel? get selectedCard => _selectedCard;
/// Loads Today state and maps it into presentation data.
Future<void> load() async {
_state = const TodayScreenLoading();
notifyListeners();
@ -57,6 +84,7 @@ class TodayScreenController extends ChangeNotifier {
}
final data = TodayScreenData.fromTodayState(result.requireValue);
_syncSelectedCard(data);
_state = data.cards.isEmpty
? const TodayScreenEmpty()
: TodayScreenReady(data);
@ -67,6 +95,7 @@ class TodayScreenController extends ChangeNotifier {
}
}
/// Selects [card] when the card allows selection.
void selectCard(TimelineCardModel card) {
if (!card.isSelectable) {
return;
@ -75,6 +104,7 @@ class TodayScreenController extends ChangeNotifier {
notifyListeners();
}
/// Clears the selected timeline card.
void clearSelection() {
if (_selectedCard == null) {
return;
@ -82,4 +112,20 @@ class TodayScreenController extends ChangeNotifier {
_selectedCard = null;
notifyListeners();
}
/// Runs the `_syncSelectedCard` helper used inside this library.
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
void _syncSelectedCard(TodayScreenData data) {
final selected = _selectedCard;
if (selected == null) {
return;
}
for (final card in data.cards) {
if (card.id == selected.id) {
_selectedCard = card;
return;
}
}
_selectedCard = null;
}
}

View file

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Launches the FocusFlow Flutter desktop application.
library;
@ -9,6 +12,7 @@ import 'app/focus_flow_app.dart';
export 'app/demo_scheduler_composition.dart';
export 'app/focus_flow_app.dart';
/// Starts the FocusFlow app with the seeded demo scheduler composition.
void main() {
runApp(FocusFlowApp(composition: DemoSchedulerComposition.seeded()));
}

View file

@ -0,0 +1,23 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
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,22 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
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,46 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../../today_screen_models.dart';
/// Top-level helper that performs the `_dateLabel` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
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}';
}
/// Top-level helper that performs the `_minutesSinceMidnight` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
int _minutesSinceMidnight(DateTime? value) {
if (value == null) {
return 0;
}
return value.hour * 60 + value.minute;
}
/// Top-level helper that performs the `_formatTime` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
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,147 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
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,59 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../today_screen_models.dart';
/// Top-level helper that performs the `_visualKindFor` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
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,
};
}
/// Top-level helper that performs the `_typeLabelFor` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
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',
};
}
/// Top-level helper that performs the `_subtitleFor` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
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,19 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
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,62 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
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

@ -1,240 +1,15 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Defines Today Screen Models models for the FocusFlow Flutter UI.
library;
import 'package:scheduler_core/scheduler_core.dart';
class RequiredBannerModel {
const RequiredBannerModel({required this.title, required this.timeText});
final String title;
final String timeText;
}
class TimelineRangeModel {
const TimelineRangeModel({
required this.startMinutes,
required this.endMinutes,
});
final int startMinutes;
final int endMinutes;
}
enum TaskVisualKind {
flexible,
required,
appointment,
freeSlot,
completedSurprise,
}
class TodayScreenData {
const TodayScreenData({
required this.dateLabel,
required this.timelineRange,
required this.cards,
this.requiredBanner,
});
factory TodayScreenData.empty({required String dateLabel}) {
return TodayScreenData(
dateLabel: dateLabel,
timelineRange: defaultRange,
cards: const [],
);
}
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(
title: nextRequired.item.displayTitle,
timeText: _formatTime(nextRequired.start),
),
cards: List<TimelineCardModel>.unmodifiable(cards),
);
}
static const defaultRange = TimelineRangeModel(
startMinutes: 16 * 60,
endMinutes: 20 * 60 + 45,
);
final String dateLabel;
final RequiredBannerModel? requiredBanner;
final TimelineRangeModel timelineRange;
final List<TimelineCardModel> cards;
}
class TimelineCardModel {
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.visualKind,
required this.rewardIconToken,
required this.difficultyIconToken,
required this.isCompleted,
required this.isSelectable,
required this.showsQuickActions,
this.durationMinutes,
});
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)}';
return TimelineCardModel(
id: item.id,
title: title,
typeLabel: _typeLabelFor(visualKind),
subtitle: _subtitleFor(item, visualKind, timeText),
timeText: timeText,
startMinutes: _minutesSinceMidnight(start),
endMinutes: _minutesSinceMidnight(end),
visualKind: visualKind,
rewardIconToken: item.item.rewardIconToken,
difficultyIconToken: item.item.difficultyIconToken,
durationMinutes: duration,
isCompleted: isCompleted,
isSelectable: true,
showsQuickActions: !isCompleted && visualKind != TaskVisualKind.freeSlot,
);
}
final String id;
final String title;
final String typeLabel;
final String subtitle;
final String timeText;
final int startMinutes;
final int endMinutes;
final int? durationMinutes;
final TaskVisualKind visualKind;
final TimelineRewardIconToken rewardIconToken;
final TimelineDifficultyIconToken difficultyIconToken;
final bool isCompleted;
final bool isSelectable;
final bool showsQuickActions;
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',
};
}
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(TaskVisualKind visualKind) {
return switch (visualKind) {
TaskVisualKind.flexible => 'Flexible',
TaskVisualKind.required => 'Required',
TaskVisualKind.appointment => 'Required',
TaskVisualKind.freeSlot => 'Free Slot',
TaskVisualKind.completedSurprise => 'Surprise task',
};
}
String _subtitleFor(
TodayTimelineItem item,
TaskVisualKind visualKind,
String timeText,
) {
if (visualKind == TaskVisualKind.freeSlot) {
return 'Intentional rest';
}
if (visualKind == TaskVisualKind.completedSurprise) {
final completedAt = _formatTime(item.item.end ?? item.end);
return 'Surprise task - Completed at $completedAt';
}
if (item.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';
}
part 'today/required_banner_model.dart';
part 'today/task_visual_kind.dart';
part 'today/time/time_string_formatter.dart';
part 'today/timeline_card_model.dart';
part 'today/timeline_item_presentation_mapper.dart';
part 'today/timeline_range_model.dart';
part 'today/today_screen_data.dart';

View file

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Defines Focus Flow Theme styling for the FocusFlow Flutter UI.
library;
@ -5,7 +8,9 @@ import 'package:flutter/material.dart';
import 'focus_flow_tokens.dart';
/// Theme factory for the FocusFlow Flutter app.
abstract final class FocusFlowTheme {
/// Builds the dark Material theme used by the desktop app.
static ThemeData dark() {
final scheme = ColorScheme.fromSeed(
seedColor: FocusFlowTokens.accentMagenta,

View file

@ -1,50 +1,124 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Defines Focus Flow Tokens styling for the FocusFlow Flutter UI.
library;
import 'package:flutter/material.dart';
/// Shared visual constants for the FocusFlow Flutter app.
abstract final class FocusFlowTokens {
/// Root application background color.
static const appBackground = Color(0xFF070811);
/// Border color for the app frame.
static const appFrameBorder = Color(0xFF3B3D4E);
/// Sidebar background color.
static const sidebarBackground = Color(0xFF090A14);
/// Standard panel background color.
static const panelBackground = Color(0xFF11121D);
/// Elevated panel background color.
static const elevatedPanel = Color(0xFF181724);
/// Translucent panel color for soft surfaces.
static const glassPanel = Color(0x662A1025);
/// Stronger translucent panel color for selected surfaces.
static const glassPanelStrong = Color(0x992A1025);
/// Timeline grid line color.
static const gridLine = Color(0x243B3D4E);
/// Timeline rail color.
static const rail = Color(0xFF7D7F91);
/// Subtle border color for controls and panels.
static const subtleBorder = Color(0xFF3A3A4A);
/// Active navigation border color.
static const navBorder = Color(0xFFB72D82);
/// Primary accent color.
static const accentMagenta = Color(0xFFFF5BA8);
/// Primary text color.
static const textPrimary = Color(0xFFF7F2FA);
/// Muted text color.
static const textMuted = Color(0xFFBBB5C8);
/// Faint text color.
static const textFaint = Color(0xFF8E889D);
/// Text color for completed task labels.
static const completedText = Color(0xFF8F8A99);
/// Accent color for flexible tasks.
static const flexibleGreen = Color(0xFF4BE064);
/// Accent color for required tasks.
static const requiredPink = Color(0xFFFF4E7D);
/// Accent color for appointment tasks.
static const appointmentBlue = Color(0xFF38A8FF);
/// Accent color for rest and free-slot surfaces.
static const restPurple = Color(0xFFB45CFF);
/// Accent color for completed tasks.
static const completedGray = Color(0xFF777482);
/// Warning accent color.
static const warningYellow = Color(0xFFFFCC66);
/// Fixed sidebar width.
static const sidebarWidth = 250.0;
/// Horizontal gutter for main app content.
static const mainGutter = 32.0;
/// Width of the timeline time rail.
static const timelineRailWidth = 120.0;
/// Horizontal padding inside timeline cards.
static const cardHorizontalPadding = 18.0;
/// Border radius for the outer app frame.
static const appFrameRadius = 8.0;
/// Border radius for navigation items.
static const navRadius = 8.0;
/// Border radius for required-task banners.
static const bannerRadius = 8.0;
static const cardRadius = 8.0;
/// Border radius for timeline cards.
static const cardRadius = 12.0;
/// Border radius for task selection modals.
static const modalRadius = 8.0;
/// Border radius for compact controls.
static const smallButtonRadius = 8.0;
/// Font size for page titles.
static const pageTitleSize = 40.0;
/// Font size for timeline card titles.
static const cardTitleSize = 24.0;
/// Font size for timeline card metadata.
static const cardMetaSize = 16.0;
static const sidebarNavSize = 18.0;
/// Font size for sidebar navigation labels.
static const sidebarNavSize = 16.0;
/// Font size for modal titles.
static const modalTitleSize = 26.0;
/// Font size for modal action buttons.
static const modalButtonSize = 18.0;
}

View file

@ -1,10 +1,15 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Defines Scheduler Visual Tokens styling for the FocusFlow Flutter UI.
library;
import 'package:flutter/material.dart';
import 'package:scheduler_core/scheduler_core.dart';
/// Maps scheduler domain tokens to Flutter colors and icons.
abstract final class SchedulerVisualTokens {
/// Returns the color associated with a project color [token].
static Color projectColor(String token) {
return switch (token) {
'home' => const Color(0xFF68B0AB),
@ -13,6 +18,7 @@ abstract final class SchedulerVisualTokens {
};
}
/// Returns the background color associated with a timeline background token.
static Color backgroundColor(
TimelineBackgroundToken token,
ColorScheme scheme,
@ -27,6 +33,7 @@ abstract final class SchedulerVisualTokens {
};
}
/// Returns the icon associated with a task [type].
static IconData taskIcon(TaskType type) {
return switch (type) {
TaskType.flexible => Icons.task_alt,
@ -38,6 +45,7 @@ abstract final class SchedulerVisualTokens {
};
}
/// Returns the icon associated with a reward token.
static IconData rewardIcon(TimelineRewardIconToken token) {
return switch (token) {
TimelineRewardIconToken.notSet => Icons.remove_circle_outline,
@ -49,6 +57,7 @@ abstract final class SchedulerVisualTokens {
};
}
/// Returns the icon associated with a difficulty token.
static IconData difficultyIcon(TimelineDifficultyIconToken token) {
return switch (token) {
TimelineDifficultyIconToken.notSet => Icons.remove_circle_outline,
@ -60,6 +69,7 @@ abstract final class SchedulerVisualTokens {
};
}
/// Returns the icon associated with a backlog staleness marker.
static IconData stalenessIcon(BacklogStalenessMarker marker) {
return switch (marker) {
BacklogStalenessMarker.green => Icons.circle,
@ -68,6 +78,7 @@ abstract final class SchedulerVisualTokens {
};
}
/// Returns the color associated with a backlog staleness marker.
static Color stalenessColor(
BacklogStalenessMarker marker,
ColorScheme scheme,

View file

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Renders the App Shell widget for the FocusFlow Flutter UI.
library;
@ -5,12 +8,19 @@ import 'package:flutter/material.dart';
import '../theme/focus_flow_tokens.dart';
/// Top-level shell that frames the sidebar and main content area.
class AppShell extends StatelessWidget {
/// Creates an app shell with a [sidebar] and main [child].
const AppShell({required this.sidebar, required this.child, super.key});
/// Sidebar widget displayed at the left edge of the app.
final Widget sidebar;
/// Primary content widget displayed beside the sidebar.
final Widget child;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return ColoredBox(

View file

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Renders the Backlog Pane widget for the FocusFlow Flutter UI.
library;
@ -9,16 +12,23 @@ import '../controllers/scheduler_read_controller.dart';
import 'read_state_view.dart';
import 'schedule_components.dart';
/// Read-driven pane that renders backlog content and command controls.
class BacklogPane extends StatelessWidget {
/// Creates a backlog pane from a read [controller].
const BacklogPane({
required this.controller,
this.commandController,
super.key,
});
/// Controller that loads backlog data.
final UiReadController<BacklogQueryResult> controller;
/// Optional command controller for mutating backlog items.
final SchedulerCommandController? commandController;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return ReadStateView<BacklogQueryResult>(
@ -32,16 +42,23 @@ class BacklogPane extends StatelessWidget {
}
}
/// Populated backlog view for a loaded backlog query result.
class BacklogContent extends StatelessWidget {
/// Creates backlog content for [result].
const BacklogContent({
required this.result,
this.commandController,
super.key,
});
/// Loaded backlog result to display.
final BacklogQueryResult result;
/// Optional command controller for capture and scheduling actions.
final SchedulerCommandController? commandController;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return ListView(
@ -73,24 +90,37 @@ class BacklogContent extends StatelessWidget {
}
}
/// Small form for quickly capturing a task into the backlog.
class QuickCaptureForm extends StatefulWidget {
/// Creates a quick capture form.
const QuickCaptureForm({required this.commandController, super.key});
/// Command controller used to submit captured task titles.
final SchedulerCommandController commandController;
/// Creates the mutable state object used by this stateful widget.
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
@override
State<QuickCaptureForm> createState() => _QuickCaptureFormState();
}
/// Private implementation type for `_QuickCaptureFormState` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _QuickCaptureFormState extends State<QuickCaptureForm> {
/// Stores the `titleController` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final titleController = TextEditingController();
/// Releases resources owned by this object before it leaves the tree.
/// Controllers, listeners, and other disposable collaborators should be cleaned up here to avoid stale callbacks.
@override
void dispose() {
titleController.dispose();
super.dispose();
}
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return Row(
@ -121,11 +151,16 @@ class _QuickCaptureFormState extends State<QuickCaptureForm> {
}
}
/// Displays the latest command state for backlog interactions.
class CommandStatusBanner extends StatelessWidget {
/// Creates a command status banner.
const CommandStatusBanner({required this.controller, super.key});
/// Command controller whose state should be rendered.
final SchedulerCommandController controller;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return AnimatedBuilder(

View file

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Renders the Read State View widget for the FocusFlow Flutter UI.
library;
@ -5,7 +8,9 @@ import 'package:flutter/material.dart';
import '../controllers/scheduler_read_controller.dart';
/// Renders loading, empty, failure, and data states from a read controller.
class ReadStateView<T> extends StatelessWidget {
/// Creates a read-state view.
const ReadStateView({
required this.controller,
required this.title,
@ -15,12 +20,23 @@ class ReadStateView<T> extends StatelessWidget {
super.key,
});
/// Controller that owns the current read state.
final UiReadController<T> controller;
/// Name of the surface being loaded, used in failure copy.
final String title;
/// Title shown when the read succeeds with empty content.
final String emptyTitle;
/// Message shown when the read succeeds with empty content.
final String emptyMessage;
/// Builds the populated content for a read value.
final Widget Function(BuildContext context, T value) dataBuilder;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
@ -52,7 +68,9 @@ class ReadStateView<T> extends StatelessWidget {
}
}
/// Standard empty-state panel with optional surrounding content.
class EmptyStatePanel extends StatelessWidget {
/// Creates an empty-state panel.
const EmptyStatePanel({
required this.title,
required this.message,
@ -60,10 +78,17 @@ class EmptyStatePanel extends StatelessWidget {
super.key,
});
/// Primary empty-state title.
final String title;
/// Supporting empty-state message.
final String message;
/// Optional content shown alongside the empty-state panel.
final Widget? child;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
final panel = Padding(
@ -97,7 +122,9 @@ class EmptyStatePanel extends StatelessWidget {
}
}
/// Standard failure panel with a retry action.
class FailureStatePanel extends StatelessWidget {
/// Creates a failure-state panel.
const FailureStatePanel({
required this.title,
required this.code,
@ -106,11 +133,20 @@ class FailureStatePanel extends StatelessWidget {
super.key,
});
/// Primary failure title.
final String title;
/// Displayable failure code.
final String code;
/// Optional technical detail for unexpected errors.
final String? technicalDetail;
/// Callback invoked when the user retries the read.
final Future<void> Function() onRetry;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return Center(

View file

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Renders the Required Banner widget for the FocusFlow Flutter UI.
library;
@ -6,87 +9,243 @@ import 'package:flutter/material.dart';
import '../models/today_screen_models.dart';
import '../theme/focus_flow_tokens.dart';
/// Banner that highlights the next required task, if one exists.
class RequiredBanner extends StatelessWidget {
const RequiredBanner({this.model, super.key});
/// Creates a required-task banner.
const RequiredBanner({this.model, this.onShowUpcoming, super.key});
/// Optional required task model to render.
final RequiredBannerModel? model;
/// Callback invoked when the highlighted required task should be shown.
final VoidCallback? onShowUpcoming;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
final banner = model;
if (banner == null) {
return const SizedBox(height: 72);
return const SizedBox.shrink();
}
return LayoutBuilder(
builder: (context, constraints) {
final metrics = _RequiredBannerMetrics.fromWidth(constraints.maxWidth);
return Container(
height: 72,
padding: const EdgeInsets.symmetric(horizontal: 18),
height: _RequiredBannerMetrics.referenceHeight,
padding: EdgeInsets.symmetric(horizontal: metrics.horizontalPadding),
decoration: BoxDecoration(
color: FocusFlowTokens.glassPanel,
borderRadius: BorderRadius.circular(FocusFlowTokens.bannerRadius),
borderRadius: BorderRadius.circular(metrics.bannerRadius),
border: Border.all(
color: FocusFlowTokens.navBorder.withValues(alpha: 0.55),
width: metrics.borderWidth,
),
),
child: Row(
children: [
Container(
width: 48,
height: 48,
width: metrics.iconCircleSize,
height: metrics.iconCircleSize,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: FocusFlowTokens.glassPanelStrong,
),
child: const Icon(
child: Icon(
Icons.auto_awesome,
color: FocusFlowTokens.accentMagenta,
size: metrics.iconSize,
),
),
const SizedBox(width: 26),
RichText(
SizedBox(width: metrics.iconGap),
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: FittedBox(
alignment: Alignment.centerLeft,
fit: BoxFit.scaleDown,
child: RichText(
text: TextSpan(
style: const TextStyle(
style: TextStyle(
color: FocusFlowTokens.textPrimary,
fontSize: 19,
fontSize: metrics.textSize,
fontWeight: FontWeight.w700,
),
children: [
const TextSpan(text: 'Next required task: '),
TextSpan(
text: banner.title,
style: const TextStyle(color: FocusFlowTokens.accentMagenta),
style: const TextStyle(
color: FocusFlowTokens.accentMagenta,
),
),
TextSpan(
text: ' at ${banner.timeText}',
style: const TextStyle(fontWeight: FontWeight.w500),
style: TextStyle(
fontWeight: FontWeight.w500,
fontSize: metrics.textSize,
),
),
],
),
),
const Spacer(),
OutlinedButton(
onPressed: () {},
),
),
),
SizedBox(width: metrics.buttonGap),
_UpcomingButton(metrics: metrics, onPressed: onShowUpcoming),
],
),
);
},
);
}
}
/// Private implementation type for `_RequiredBannerMetrics` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _RequiredBannerMetrics {
/// Creates a `_RequiredBannerMetrics` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _RequiredBannerMetrics(this.scale);
/// Shared constant value for `referenceHeight`.
/// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites.
static const referenceHeight = 72.0;
/// Shared constant value for `_referenceWidth`.
/// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites.
static const _referenceWidth = 760.0;
/// Shared constant value for `_minimumScale`.
/// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites.
static const _minimumScale = 0.5;
/// Creates a `_RequiredBannerMetrics.fromWidth` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
factory _RequiredBannerMetrics.fromWidth(double width) {
final safeWidth = width.isFinite ? width : _referenceWidth;
final scale = (safeWidth / _referenceWidth).clamp(_minimumScale, 1.0);
return _RequiredBannerMetrics(scale.toDouble());
}
/// Stores the `scale` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final double scale;
/// Returns the derived `horizontalPadding` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get horizontalPadding => 18 * scale;
/// Returns the derived `bannerRadius` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get bannerRadius => FocusFlowTokens.bannerRadius * scale;
/// Returns the derived `borderWidth` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get borderWidth => scale < 0.7 ? 0.8 : 1;
/// Returns the derived `iconCircleSize` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get iconCircleSize => 48 * scale;
/// Returns the derived `iconSize` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get iconSize => 24 * scale;
/// Returns the derived `iconGap` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get iconGap => 26 * scale;
/// Returns the derived `buttonGap` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get buttonGap => 20 * scale;
/// Returns the derived `textSize` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get textSize => (19 * scale).clamp(13.0, 14.0).toDouble();
/// Returns the derived `buttonWidth` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get buttonWidth => 176 * scale;
/// Returns the derived `buttonHeight` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get buttonHeight => 46 * scale;
/// Returns the derived `buttonRadius` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get buttonRadius => FocusFlowTokens.smallButtonRadius * scale;
/// Returns the derived `buttonTextSize` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get buttonTextSize => (16 * scale).clamp(11.0, 12.5).toDouble();
/// Returns the derived `buttonIconSize` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get buttonIconSize => 20 * scale;
/// Returns the derived `buttonTextGap` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get buttonTextGap => 6 * scale;
/// Returns the derived `buttonPadding` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
EdgeInsets get buttonPadding => EdgeInsets.symmetric(horizontal: 12 * scale);
}
/// Private implementation type for `_UpcomingButton` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _UpcomingButton extends StatelessWidget {
/// Creates a `_UpcomingButton` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _UpcomingButton({required this.metrics, required this.onPressed});
/// Stores the `metrics` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final _RequiredBannerMetrics metrics;
/// Stores the `onPressed` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final VoidCallback? onPressed;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return OutlinedButton(
onPressed: onPressed,
style: OutlinedButton.styleFrom(
foregroundColor: FocusFlowTokens.textPrimary,
side: BorderSide(
color: FocusFlowTokens.navBorder.withValues(alpha: 0.55),
width: metrics.borderWidth,
),
fixedSize: const Size(176, 46),
fixedSize: Size(metrics.buttonWidth, metrics.buttonHeight),
minimumSize: Size.zero,
padding: metrics.buttonPadding,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
FocusFlowTokens.smallButtonRadius,
borderRadius: BorderRadius.circular(metrics.buttonRadius),
),
),
),
child: const FittedBox(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [Text('Show upcoming'), Icon(Icons.chevron_right)],
),
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Show upcoming',
style: TextStyle(
fontSize: metrics.buttonTextSize,
fontWeight: FontWeight.w700,
),
),
SizedBox(width: metrics.buttonTextGap),
Icon(Icons.chevron_right, size: metrics.buttonIconSize),
],
),
),
);
}
}

View file

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Renders the Schedule Components widget for the FocusFlow Flutter UI.
library;
@ -6,11 +9,16 @@ import 'package:scheduler_core/scheduler_core.dart';
import '../theme/scheduler_visual_tokens.dart';
/// Compact summary panel for timeline items.
class CompactPanel extends StatelessWidget {
/// Creates a compact panel for [items].
const CompactPanel({required this.items, super.key});
/// Timeline items to display in compact form.
final List<TodayTimelineItem> items;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
if (items.isEmpty) {
@ -28,12 +36,19 @@ class CompactPanel extends StatelessWidget {
}
}
/// Card row for a single Today timeline item.
class TimelineRow extends StatelessWidget {
/// Creates a timeline row.
const TimelineRow({required this.item, this.onDone, super.key});
/// Timeline item to render.
final TodayTimelineItem item;
/// Optional completion callback for eligible tasks.
final Future<void> Function()? onDone;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
@ -85,25 +100,40 @@ class TimelineRow extends StatelessWidget {
}
}
/// Backlog row with optional scheduling controls.
class BacklogRow extends StatefulWidget {
/// Creates a backlog row.
const BacklogRow({required this.item, this.onSchedule, super.key});
/// Backlog item to render.
final BacklogItemReadModel item;
/// Optional callback that schedules the item for a duration in minutes.
final Future<void> Function(int durationMinutes)? onSchedule;
/// Creates the mutable state object used by this stateful widget.
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
@override
State<BacklogRow> createState() => _BacklogRowState();
}
/// Private implementation type for `_BacklogRowState` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _BacklogRowState extends State<BacklogRow> {
/// Stores the `durationController` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final durationController = TextEditingController();
/// Releases resources owned by this object before it leaves the tree.
/// Controllers, listeners, and other disposable collaborators should be cleaned up here to avoid stale callbacks.
@override
void dispose() {
durationController.dispose();
super.dispose();
}
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
@ -154,11 +184,16 @@ class _BacklogRowState extends State<BacklogRow> {
}
}
/// Icon marker for backlog item staleness.
class StalenessMarker extends StatelessWidget {
/// Creates a staleness marker.
const StalenessMarker({required this.marker, super.key});
/// Staleness marker value to render.
final BacklogStalenessMarker marker;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return Icon(
@ -168,11 +203,16 @@ class StalenessMarker extends StatelessWidget {
}
}
/// Notice banner for pending Today-state messages.
class NoticeBanner extends StatelessWidget {
/// Creates a notice banner.
const NoticeBanner({required this.message, super.key});
/// Notice message to display.
final String message;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return Container(
@ -187,6 +227,7 @@ class NoticeBanner extends StatelessWidget {
}
}
/// Formats a scheduler timeline item as display-ready time text.
String timeText(TimelineItem item) {
final start = item.start;
final end = item.end;
@ -196,6 +237,8 @@ String timeText(TimelineItem item) {
return '${_clockText(start)}-${_clockText(end)}';
}
/// Top-level helper that performs the `_clockText` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
String _clockText(DateTime value) {
final hour = value.hour.toString().padLeft(2, '0');
final minute = value.minute.toString().padLeft(2, '0');

View file

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Renders the Sidebar widget for the FocusFlow Flutter UI.
library;
@ -5,9 +8,13 @@ import 'package:flutter/material.dart';
import '../theme/focus_flow_tokens.dart';
/// Static sidebar for primary FocusFlow navigation.
class Sidebar extends StatelessWidget {
/// Creates the FocusFlow sidebar.
const Sidebar({super.key});
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return DecoratedBox(
@ -44,9 +51,15 @@ class Sidebar extends StatelessWidget {
}
}
/// Private implementation type for `_WindowControls` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _WindowControls extends StatelessWidget {
/// Creates a `_WindowControls` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _WindowControls();
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return const Row(
@ -61,11 +74,19 @@ class _WindowControls extends StatelessWidget {
}
}
/// Private implementation type for `_WindowDot` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _WindowDot extends StatelessWidget {
/// Creates a `_WindowDot` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _WindowDot({required this.color});
/// Stores the `color` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Color color;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return DecoratedBox(
@ -75,9 +96,15 @@ class _WindowDot extends StatelessWidget {
}
}
/// Private implementation type for `_BrandRow` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _BrandRow extends StatelessWidget {
/// Creates a `_BrandRow` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _BrandRow();
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return Row(
@ -109,7 +136,11 @@ class _BrandRow extends StatelessWidget {
}
}
/// Private implementation type for `_NavItem` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _NavItem extends StatelessWidget {
/// Creates a `_NavItem` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _NavItem({
required this.label,
required this.icon,
@ -117,11 +148,24 @@ class _NavItem extends StatelessWidget {
this.active = false,
});
/// Stores the `label` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final String label;
/// Stores the `icon` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final IconData icon;
/// Stores the `onTap` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final VoidCallback onTap;
/// Stores the `active` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final bool active;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
final color = active

View file

@ -0,0 +1,46 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../task_selection_modal.dart';
/// Private implementation type for `_ActionButton` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _ActionButton extends StatelessWidget {
/// Creates a `_ActionButton` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _ActionButton({required this.icon, required this.label});
/// Stores the `icon` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final IconData icon;
/// Stores the `label` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final String label;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@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,46 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../task_selection_modal.dart';
/// Private implementation type for `_HeaderMetadata` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _HeaderMetadata extends StatelessWidget {
/// Creates a `_HeaderMetadata` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _HeaderMetadata({required this.card});
/// Stores the `card` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final TimelineCardModel card;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@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,52 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../task_selection_modal.dart';
/// Private implementation type for `_InfoTile` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _InfoTile extends StatelessWidget {
/// Creates a `_InfoTile` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _InfoTile({required this.child, required this.label});
/// Stores the `child` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Widget child;
/// Stores the `label` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final String label;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@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,16 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../task_selection_modal.dart';
/// Top-level helper that performs the `_accentFor` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
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,71 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../task_selection_modal.dart';
/// Private implementation type for `_ModalStatusButton` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _ModalStatusButton extends StatelessWidget {
/// Creates a `_ModalStatusButton` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _ModalStatusButton({
required this.card,
required this.color,
required this.onPressed,
});
/// Stores the `card` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final TimelineCardModel card;
/// Stores the `color` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Color color;
/// Stores the `onPressed` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final VoidCallback? onPressed;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@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

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Renders the Task Selection Modal widget for the FocusFlow Flutter UI.
library;
@ -8,16 +11,33 @@ import '../theme/focus_flow_tokens.dart';
import 'timeline/difficulty_bars.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.
class TaskSelectionModal extends StatelessWidget {
/// Creates a task selection modal for [card].
const TaskSelectionModal({
required this.card,
required this.onClose,
this.onStatusPressed,
super.key,
});
/// Card model whose details and actions should be displayed.
final TimelineCardModel card;
/// Callback invoked when the modal should close.
final VoidCallback onClose;
/// Callback invoked when the status circle should toggle completion.
final VoidCallback? onStatusPressed;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
final accent = _accentFor(card.visualKind);
@ -38,13 +58,10 @@ class TaskSelectionModal extends StatelessWidget {
children: [
Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: accent, width: 4),
),
_ModalStatusButton(
card: card,
color: accent,
onPressed: onStatusPressed,
),
const SizedBox(width: 28),
Expanded(
@ -60,13 +77,7 @@ class TaskSelectionModal extends StatelessWidget {
),
),
const SizedBox(height: 6),
Text(
'${card.typeLabel} - ${card.timeText}',
style: const TextStyle(
color: FocusFlowTokens.textMuted,
fontSize: 17,
),
),
_HeaderMetadata(card: card),
],
),
),
@ -131,81 +142,3 @@ class TaskSelectionModal extends StatelessWidget {
);
}
}
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,26 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
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;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: _TimelineGridPainter(geometry: geometry, topInset: topInset),
size: Size.infinite,
);
}
}

View file

@ -0,0 +1,46 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../timeline_axis.dart';
/// Private implementation type for `_TimelineGridPainter` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _TimelineGridPainter extends CustomPainter {
/// Creates a `_TimelineGridPainter` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _TimelineGridPainter({required this.geometry, required this.topInset});
/// Stores the `geometry` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final TimelineGeometry geometry;
/// Stores the `topInset` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final double topInset;
/// Performs the `paint` behavior for this scheduler component.
/// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved.
@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,
);
}
}
/// Performs the `shouldRepaint` behavior for this scheduler component.
/// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved.
@override
bool shouldRepaint(covariant _TimelineGridPainter oldDelegate) {
return oldDelegate.geometry != geometry || oldDelegate.topInset != topInset;
}
}

View file

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Renders Difficulty Bars pieces for the FocusFlow Flutter timeline.
library;
@ -6,7 +9,9 @@ import 'package:scheduler_core/scheduler_core.dart';
import '../../theme/focus_flow_tokens.dart';
/// Five-bar effort indicator for a timeline task.
class DifficultyBars extends StatelessWidget {
/// Creates difficulty bars for a scheduler difficulty token.
const DifficultyBars({
required this.difficulty,
this.color = FocusFlowTokens.restPurple,
@ -14,10 +19,16 @@ class DifficultyBars extends StatelessWidget {
super.key,
});
/// Difficulty token that determines how many bars are filled.
final TimelineDifficultyIconToken difficulty;
/// Color used for filled bars.
final Color color;
/// Fixed paint size for the indicator.
final Size size;
/// Converts a timeline difficulty token to a filled bar count.
static int fillCount(TimelineDifficultyIconToken difficulty) {
return switch (difficulty) {
TimelineDifficultyIconToken.notSet => 0,
@ -29,6 +40,7 @@ class DifficultyBars extends StatelessWidget {
};
}
/// Converts a domain difficulty level to a filled bar count.
static int fillCountForLevel(DifficultyLevel difficulty) {
return switch (difficulty) {
DifficultyLevel.notSet => 0,
@ -40,6 +52,8 @@ class DifficultyBars extends StatelessWidget {
};
}
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return CustomPaint(
@ -52,15 +66,26 @@ class DifficultyBars extends StatelessWidget {
}
}
/// Private implementation type for `_DifficultyBarsPainter` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _DifficultyBarsPainter extends CustomPainter {
/// Creates a `_DifficultyBarsPainter` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _DifficultyBarsPainter({
required this.filledCount,
required this.color,
});
/// Stores the `filledCount` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final int filledCount;
/// Stores the `color` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Color color;
/// Performs the `paint` behavior for this scheduler component.
/// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved.
@override
void paint(Canvas canvas, Size size) {
final gap = size.width * 0.08;
@ -89,6 +114,8 @@ class _DifficultyBarsPainter extends CustomPainter {
}
}
/// Performs the `shouldRepaint` behavior for this scheduler component.
/// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved.
@override
bool shouldRepaint(covariant _DifficultyBarsPainter oldDelegate) {
return oldDelegate.filledCount != filledCount || oldDelegate.color != color;

View file

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Renders Reward Icon pieces for the FocusFlow Flutter timeline.
library;
@ -5,16 +8,23 @@ import 'package:flutter/material.dart';
import '../../theme/focus_flow_tokens.dart';
/// Sparkle-style reward indicator for a timeline task.
class RewardIcon extends StatelessWidget {
/// Creates a reward icon.
const RewardIcon({
this.color = FocusFlowTokens.accentMagenta,
this.size = 24,
super.key,
});
/// Fill color used by the icon painter.
final Color color;
/// Square size of the painted icon.
final double size;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return CustomPaint(
@ -24,11 +34,19 @@ class RewardIcon extends StatelessWidget {
}
}
/// Private implementation type for `_RewardIconPainter` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _RewardIconPainter extends CustomPainter {
/// Creates a `_RewardIconPainter` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _RewardIconPainter(this.color);
/// Stores the `color` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Color color;
/// Performs the `paint` behavior for this scheduler component.
/// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved.
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
@ -54,6 +72,8 @@ class _RewardIconPainter extends CustomPainter {
);
}
/// Runs the `_drawSparkle` helper used inside this library.
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
void _drawSparkle(Canvas canvas, Paint paint, Offset center, double radius) {
final path = Path()
..moveTo(center.dx, center.dy - radius)
@ -68,6 +88,8 @@ class _RewardIconPainter extends CustomPainter {
canvas.drawPath(path, paint);
}
/// Performs the `shouldRepaint` behavior for this scheduler component.
/// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved.
@override
bool shouldRepaint(covariant _RewardIconPainter oldDelegate) {
return oldDelegate.color != color;

View file

@ -0,0 +1,60 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../task_timeline_card.dart';
/// Private implementation type for `_CardColors` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _CardColors {
/// Creates a `_CardColors` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _CardColors({
required this.accent,
required this.background,
required this.reward,
});
/// Stores the `accent` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Color accent;
/// Stores the `background` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Color background;
/// Stores the `reward` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Color reward;
/// Creates a `_CardColors.forKind` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
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,180 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../task_timeline_card.dart';
/// Private implementation type for `_CardMetrics` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _CardMetrics {
/// Creates a `_CardMetrics` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _CardMetrics({required this.scale, required this.height});
/// Shared constant value for `_referenceWidth`.
/// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites.
static const _referenceWidth = 640.0;
/// Shared constant value for `_referenceHeight`.
/// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites.
static const _referenceHeight = 88.0;
/// Shared constant value for `_freeSlotReferenceHeight`.
/// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites.
static const _freeSlotReferenceHeight = 108.0;
/// Shared constant value for `_compactHeightThreshold`.
/// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites.
static const _compactHeightThreshold = 56.0;
/// Shared constant value for `_minimumScale`.
/// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites.
static const _minimumScale = 0.16;
/// Creates a `_CardMetrics.fromConstraints` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
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);
}
/// Stores the `scale` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final double scale;
/// Stores the `height` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final double height;
/// Returns the derived `isCompact` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
bool get isCompact => height < _compactHeightThreshold;
/// Returns the derived `cardRadius` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get cardRadius =>
math.min(FocusFlowTokens.cardRadius * scale, height / 2);
/// Returns the derived `borderWidth` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get borderWidth => math.max(0.75, 1.2 * scale);
/// Returns the derived `shadowBlur` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get shadowBlur => isCompact ? 0 : 18 * scale;
/// Returns the derived `shadowSpread` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get shadowSpread => isCompact ? 0 : scale;
/// Returns the derived `statusRingSize` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get statusRingSize {
final availableHeight = math.max(8, height - padding.vertical);
return math.min(24, availableHeight * 0.78).toDouble();
}
/// Returns the derived `statusBorderWidth` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get statusBorderWidth => math.max(1.4, statusRingSize * 0.08);
/// Returns the derived `statusIconSize` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get statusIconSize => statusRingSize * 0.58;
/// Returns the derived `statusGap` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get statusGap => isCompact ? 7 : 22 * scale;
/// Returns the derived `actionButtonSize` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get actionButtonSize => (30 * scale).clamp(14.0, 22.0).toDouble();
/// Returns the derived `actionIconSize` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get actionIconSize => (16 * scale).clamp(9.0, 13.0).toDouble();
/// Returns the derived `actionGap` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get actionGap => math.max(3, 5 * scale);
/// Returns the derived `actionsWidth` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get actionsWidth => actionButtonSize * 4 + actionGap;
/// Returns the derived `indicatorTop` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get indicatorTop => isCompact ? 0 : math.max(1, 2 * scale);
/// Returns the derived `indicatorGap` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get indicatorGap => math.max(3, 5 * scale);
/// Returns the derived `rewardIconSize` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get rewardIconSize => 14;
/// Returns the derived `titleSize` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get titleSize => 10;
/// Returns the derived `metaSize` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get metaSize => 7;
/// Returns the derived `titleMetaGap` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get titleMetaGap => 4 * scale;
/// Returns the derived `freeSlotTimeGap` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get freeSlotTimeGap => 10 * scale;
/// Returns the derived `compactTextGap` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get compactTextGap => math.max(3, 8 * scale);
/// Returns the derived `indicatorWidth` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get indicatorWidth =>
rewardIconSize + indicatorGap + difficultySize.width;
/// Returns the derived `expandedTrailingReserve` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get expandedTrailingReserve =>
indicatorWidth + math.max(8, 12 * scale);
/// Returns the derived `compactTrailingReserve` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get compactTrailingReserve => indicatorWidth + math.max(6, 8 * scale);
/// Returns the derived `difficultySize` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
Size get difficultySize => Size(26, 14);
/// Returns the derived `padding` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
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,96 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../task_timeline_card.dart';
/// Private implementation type for `_CardText` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _CardText extends StatelessWidget {
/// Creates a `_CardText` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _CardText({
required this.card,
required this.color,
required this.metrics,
});
/// Stores the `card` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final TimelineCardModel card;
/// Stores the `color` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Color color;
/// Stores the `metrics` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final _CardMetrics metrics;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@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,118 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../task_timeline_card.dart';
/// Private implementation type for `_CompactCardText` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _CompactCardText extends StatelessWidget {
/// Creates a `_CompactCardText` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _CompactCardText({
required this.card,
required this.color,
required this.metrics,
required this.onStatusPressed,
});
/// Stores the `card` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final TimelineCardModel card;
/// Stores the `color` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Color color;
/// Stores the `metrics` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final _CardMetrics metrics;
/// Stores the `onStatusPressed` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final VoidCallback? onStatusPressed;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@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),
);
},
);
}
/// Runs the `_compactMetaText` helper used inside this library.
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
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,62 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../task_timeline_card.dart';
/// Private implementation type for `_ExpandedCardContent` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _ExpandedCardContent extends StatelessWidget {
/// Creates a `_ExpandedCardContent` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _ExpandedCardContent({
required this.card,
required this.colors,
required this.metrics,
required this.onStatusPressed,
});
/// Stores the `card` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final TimelineCardModel card;
/// Stores the `colors` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final _CardColors colors;
/// Stores the `metrics` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final _CardMetrics metrics;
/// Stores the `onStatusPressed` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final VoidCallback? onStatusPressed;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@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,45 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../task_timeline_card.dart';
/// Private implementation type for `_NoOpIcon` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _NoOpIcon extends StatelessWidget {
/// Creates a `_NoOpIcon` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _NoOpIcon({
required this.icon,
required this.color,
required this.metrics,
});
/// Stores the `icon` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final IconData icon;
/// Stores the `color` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Color color;
/// Stores the `metrics` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final _CardMetrics metrics;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@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,90 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../task_timeline_card.dart';
/// Private implementation type for `_QuickActions` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _QuickActions extends StatelessWidget {
/// Creates a `_QuickActions` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _QuickActions({
required this.color,
required this.backgroundColor,
required this.metrics,
required this.visible,
});
/// Stores the `color` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Color color;
/// Stores the `backgroundColor` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Color backgroundColor;
/// Stores the `metrics` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final _CardMetrics metrics;
/// Stores the `visible` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final bool visible;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@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,77 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../task_timeline_card.dart';
/// Private implementation type for `_StatusRing` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _StatusRing extends StatelessWidget {
/// Creates a `_StatusRing` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _StatusRing({
required this.card,
required this.color,
required this.metrics,
this.onPressed,
});
/// Stores the `card` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final TimelineCardModel card;
/// Stores the `color` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Color color;
/// Stores the `metrics` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final _CardMetrics metrics;
/// Stores the `onPressed` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final VoidCallback? onPressed;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@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,99 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../task_timeline_card.dart';
/// Private implementation type for `_TaskTimelineCardState` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _TaskTimelineCardState extends State<TaskTimelineCard> {
/// Private state stored as `_isHovered` for this implementation.
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
bool _isHovered = false;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@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,58 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../task_timeline_card.dart';
/// Private implementation type for `_TrailingControls` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _TrailingControls extends StatelessWidget {
/// Creates a `_TrailingControls` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _TrailingControls({
required this.card,
required this.colors,
required this.metrics,
required this.actionsVisible,
});
/// Stores the `card` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final TimelineCardModel card;
/// Stores the `colors` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final _CardColors colors;
/// Stores the `metrics` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final _CardMetrics metrics;
/// Stores the `actionsVisible` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final bool actionsVisible;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@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

@ -1,6 +1,11 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Renders Task Timeline Card pieces for the FocusFlow Flutter timeline.
library;
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../models/today_screen_models.dart';
@ -8,227 +13,38 @@ import '../../theme/focus_flow_tokens.dart';
import 'difficulty_bars.dart';
import 'reward_icon.dart';
class TaskTimelineCard extends StatelessWidget {
const TaskTimelineCard({required this.card, required this.onTap, super.key});
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';
final TimelineCardModel card;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colors = _CardColors.forKind(card.visualKind);
return Material(
color: Colors.transparent,
child: InkWell(
key: ValueKey(card.id),
borderRadius: BorderRadius.circular(FocusFlowTokens.cardRadius),
onTap: onTap,
child: Container(
padding: const EdgeInsets.fromLTRB(18, 10, 14, 10),
decoration: BoxDecoration(
color: colors.background,
borderRadius: BorderRadius.circular(FocusFlowTokens.cardRadius),
border: Border.all(color: colors.accent, width: 1.2),
boxShadow: [
BoxShadow(
color: colors.accent.withValues(alpha: 0.12),
blurRadius: 18,
spreadRadius: 1,
),
],
),
child: Row(
children: [
_StatusRing(card: card, color: colors.accent),
const SizedBox(width: 22),
Expanded(
child: _CardText(card: card, color: colors.accent),
),
if (card.showsQuickActions) ...[
_NoOpIcon(icon: Icons.check, color: colors.accent),
_NoOpIcon(icon: Icons.arrow_forward, color: colors.accent),
_NoOpIcon(icon: Icons.calendar_today, color: colors.accent),
_NoOpIcon(icon: Icons.wb_sunny_outlined, color: colors.accent),
const SizedBox(width: 10),
],
_Badge(child: RewardIcon(color: colors.reward, size: 26)),
const SizedBox(width: 8),
_Badge(
child: DifficultyBars(
difficulty: card.difficultyIconToken,
color: colors.reward,
),
),
],
),
),
),
);
}
}
class _StatusRing extends StatelessWidget {
const _StatusRing({required this.card, required this.color});
final TimelineCardModel card;
final Color color;
@override
Widget build(BuildContext context) {
final border = Border.all(
color: color,
width: card.visualKind == TaskVisualKind.freeSlot ? 2 : 3,
style: card.visualKind == TaskVisualKind.freeSlot
? BorderStyle.solid
: BorderStyle.solid,
);
return Container(
width: 34,
height: 34,
decoration: BoxDecoration(shape: BoxShape.circle, border: border),
child: card.isCompleted
? Icon(Icons.check, color: color, size: 24)
: null,
);
}
}
class _CardText extends StatelessWidget {
const _CardText({required this.card, required this.color});
final TimelineCardModel card;
final Color color;
@override
Widget build(BuildContext context) {
final titleStyle = TextStyle(
color: card.isCompleted
? FocusFlowTokens.completedText
: FocusFlowTokens.textPrimary,
fontSize: FocusFlowTokens.cardTitleSize,
fontWeight: FontWeight.w800,
decoration: card.isCompleted ? TextDecoration.lineThrough : null,
decorationColor: FocusFlowTokens.completedText,
decorationThickness: 2,
);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
card.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: titleStyle,
),
const SizedBox(height: 4),
Text(
card.subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: card.isCompleted ? FocusFlowTokens.completedText : color,
fontSize: FocusFlowTokens.cardMetaSize,
fontStyle: card.visualKind == TaskVisualKind.freeSlot
? FontStyle.italic
: FontStyle.normal,
fontWeight: card.visualKind == TaskVisualKind.flexible
? FontWeight.w700
: FontWeight.w500,
),
),
if (card.visualKind == TaskVisualKind.freeSlot) ...[
const SizedBox(height: 10),
Text(
card.timeText,
style: TextStyle(
color: color,
fontSize: FocusFlowTokens.cardMetaSize,
fontWeight: FontWeight.w600,
),
),
],
],
);
}
}
class _NoOpIcon extends StatelessWidget {
const _NoOpIcon({required this.icon, required this.color});
final IconData icon;
final Color color;
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: () {},
color: color,
icon: Icon(icon),
tooltip: 'No-op action',
);
}
}
class _Badge extends StatelessWidget {
const _Badge({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return Container(
width: 62,
height: 48,
alignment: Alignment.center,
decoration: BoxDecoration(
color: FocusFlowTokens.panelBackground.withValues(alpha: 0.84),
borderRadius: BorderRadius.circular(7),
border: Border.all(color: FocusFlowTokens.subtleBorder),
),
child: child,
);
}
}
class _CardColors {
const _CardColors({
required this.accent,
required this.background,
required this.reward,
/// Interactive card that renders a scheduled task on the compact timeline.
class TaskTimelineCard extends StatefulWidget {
/// Creates a task timeline card.
const TaskTimelineCard({
required this.card,
required this.onTap,
this.onStatusPressed,
super.key,
});
final Color accent;
final Color background;
final Color reward;
/// Card model to render.
final TimelineCardModel card;
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,
),
};
}
/// Callback invoked when the card is tapped.
final VoidCallback onTap;
/// Callback invoked when the left status control is clicked.
final VoidCallback? onStatusPressed;
/// Creates the mutable state object used by this stateful widget.
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
@override
State<TaskTimelineCard> createState() => _TaskTimelineCardState();
}

View file

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Renders Timeline Axis pieces for the FocusFlow Flutter timeline.
library;
@ -6,32 +9,46 @@ import 'package:flutter/material.dart';
import '../../theme/focus_flow_tokens.dart';
import 'timeline_geometry.dart';
class TimelineAxis extends StatelessWidget {
const TimelineAxis({required this.geometry, super.key});
part 'axis/timeline_grid.dart';
part 'axis/timeline_grid_painter.dart';
/// Time labels and rail shown beside the compact timeline.
class TimelineAxis extends StatelessWidget {
/// Creates a timeline axis for [geometry].
const TimelineAxis({required this.geometry, this.topInset = 12, super.key});
/// Geometry used to position labels and tick marks.
final TimelineGeometry geometry;
/// Vertical inset before the first timeline tick.
final double topInset;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
final ticks = geometry.ticks();
return SizedBox(
width: FocusFlowTokens.timelineRailWidth,
height: geometry.totalHeight + 24,
height: geometry.totalHeight + topInset + 24,
child: Stack(
children: [
Positioned(
left: 92,
top: 0,
top: topInset,
bottom: 0,
child: Container(width: 2, color: FocusFlowTokens.rail),
),
for (final tick in ticks)
Positioned(
top: tick.y - 10,
top: tick.y + topInset - 10,
left: 0,
width: 86,
child: Text(
tick.label,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.visible,
textAlign: TextAlign.right,
style: TextStyle(
color: tick.major
@ -44,7 +61,7 @@ class TimelineAxis extends StatelessWidget {
),
for (final tick in ticks.where((tick) => tick.major))
Positioned(
top: tick.y - 6,
top: tick.y + topInset - 6,
left: 87,
child: const DecoratedBox(
decoration: BoxDecoration(
@ -59,45 +76,3 @@ class TimelineAxis extends StatelessWidget {
);
}
}
class TimelineGrid extends StatelessWidget {
const TimelineGrid({required this.geometry, super.key});
final TimelineGeometry geometry;
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: _TimelineGridPainter(geometry),
size: Size.infinite,
);
}
}
class _TimelineGridPainter extends CustomPainter {
const _TimelineGridPainter(this.geometry);
final TimelineGeometry geometry;
@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),
Offset(size.width, tick.y),
paint
..color = tick.major
? FocusFlowTokens.gridLine.withValues(alpha: 0.72)
: FocusFlowTokens.gridLine,
);
}
}
@override
bool shouldRepaint(covariant _TimelineGridPainter oldDelegate) {
return oldDelegate.geometry != geometry;
}
}

View file

@ -1,36 +1,52 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Renders Timeline Geometry pieces for the FocusFlow Flutter timeline.
library;
import 'package:flutter/material.dart';
/// Converts timeline clock values into vertical pixel positions.
class TimelineGeometry {
/// Creates geometry for a visible time range.
const TimelineGeometry({
required this.visibleStart,
required this.visibleEnd,
required this.startMinutes,
required this.endMinutes,
required this.pixelsPerMinute,
this.minCardHeight = 72,
});
this.minCardHeight = 0,
}) : assert(startMinutes >= 0),
assert(endMinutes <= 24 * 60),
assert(endMinutes > startMinutes),
assert(minCardHeight >= 0);
final TimeOfDay visibleStart;
final TimeOfDay visibleEnd;
/// First visible minute from midnight.
final int startMinutes;
/// Last visible minute from midnight.
final int endMinutes;
/// Vertical scale used to convert minutes into pixels.
final double pixelsPerMinute;
/// Minimum rendered height for a timeline card.
///
/// Defaults to zero so scheduled cards match their exact duration on the
/// timeline.
final double minCardHeight;
int get startMinutes => visibleStart.hour * 60 + visibleStart.minute;
int get endMinutes => visibleEnd.hour * 60 + visibleEnd.minute;
/// Total vertical height of the visible timeline.
double get totalHeight => (endMinutes - startMinutes) * pixelsPerMinute;
/// Returns the vertical offset for [minutes] since midnight.
double yForMinutesSinceMidnight(int minutes) {
return (minutes - startMinutes) * pixelsPerMinute;
}
/// Returns the rendered height for a duration in minutes.
double heightForDuration(int minutes) {
final height = minutes * pixelsPerMinute;
return height < minCardHeight ? minCardHeight : height;
}
/// Generates timeline ticks at [stepMinutes] intervals.
List<TimelineTick> ticks({int stepMinutes = 15}) {
return [
for (
@ -47,8 +63,34 @@ class TimelineGeometry {
];
}
/// Performs the `operator ==` behavior for this scheduler component.
/// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved.
@override
bool operator ==(Object other) {
return identical(this, other) ||
other is TimelineGeometry &&
other.startMinutes == startMinutes &&
other.endMinutes == endMinutes &&
other.pixelsPerMinute == pixelsPerMinute &&
other.minCardHeight == minCardHeight;
}
/// Returns the derived `hashCode` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
@override
int get hashCode {
return Object.hash(
startMinutes,
endMinutes,
pixelsPerMinute,
minCardHeight,
);
}
/// Runs the `_labelFor` helper used inside this library.
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
static String _labelFor(int minutes) {
final hour24 = minutes ~/ 60;
final hour24 = (minutes ~/ 60) % 24;
final minute = minutes % 60;
final period = hour24 >= 12 ? 'PM' : 'AM';
final rawHour = hour24 % 12;
@ -60,7 +102,9 @@ class TimelineGeometry {
}
}
/// A labeled tick on the compact timeline axis.
class TimelineTick {
/// Creates a timeline tick.
const TimelineTick({
required this.minutesSinceMidnight,
required this.y,
@ -68,8 +112,15 @@ class TimelineTick {
required this.major,
});
/// Minute from midnight represented by this tick.
final int minutesSinceMidnight;
/// Vertical pixel offset for this tick.
final double y;
/// Display label for this tick.
final String label;
/// Whether this tick represents a major interval.
final bool major;
}

View file

@ -1,6 +1,10 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Renders Timeline View pieces for the FocusFlow Flutter timeline.
library;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../../models/today_screen_models.dart';
@ -9,65 +13,47 @@ import 'task_timeline_card.dart';
import 'timeline_axis.dart';
import 'timeline_geometry.dart';
class TimelineView extends StatelessWidget {
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.
class TimelineView extends StatefulWidget {
/// Creates a timeline view.
const TimelineView({
required this.cards,
required this.range,
required this.onCardSelected,
this.onCardCompleted,
this.now,
this.scrollTargetCardId,
this.scrollRequest = 0,
super.key,
});
/// Cards to position on the timeline.
final List<TimelineCardModel> cards;
/// Visible range used to build the timeline geometry.
final TimelineRangeModel range;
/// Callback invoked when a card is selected.
final ValueChanged<TimelineCardModel> onCardSelected;
/// Callback invoked when a task card status control requests completion.
final ValueChanged<TimelineCardModel>? onCardCompleted;
/// Clock used to center the initial scroll position.
final DateTime Function()? now;
/// Card id requested by another surface, such as the required banner.
final String? scrollTargetCardId;
/// Monotonic request number that allows repeated jumps to the same card.
final int scrollRequest;
/// Creates the mutable state object used by this stateful widget.
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
@override
Widget build(BuildContext context) {
final geometry = TimelineGeometry(
visibleStart: TimeOfDay(
hour: range.startMinutes ~/ 60,
minute: range.startMinutes % 60,
),
visibleEnd: TimeOfDay(
hour: range.endMinutes ~/ 60,
minute: range.endMinutes % 60,
),
pixelsPerMinute: 2.45,
minCardHeight: 88,
);
final contentHeight = geometry.totalHeight + 24;
return SingleChildScrollView(
child: SizedBox(
height: contentHeight,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TimelineAxis(geometry: geometry),
const SizedBox(width: 18),
Expanded(
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(child: TimelineGrid(geometry: geometry)),
for (final card in cards)
Positioned(
top: geometry.yForMinutesSinceMidnight(card.startMinutes),
left: FocusFlowTokens.cardHorizontalPadding,
right: 0,
height: geometry.heightForDuration(
card.endMinutes - card.startMinutes,
),
child: TaskTimelineCard(
card: card,
onTap: () => onCardSelected(card),
),
),
],
),
),
],
),
),
);
}
State<TimelineView> createState() => _TimelineViewState();
}

View file

@ -0,0 +1,206 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../timeline_view.dart';
/// Private implementation type for `_TimelineCardPlacement` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _TimelineCardPlacement {
/// Creates a `_TimelineCardPlacement` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _TimelineCardPlacement({
required this.card,
required this.lane,
required this.laneCount,
required this.top,
required this.height,
});
/// Shared constant value for `_laneGap`.
/// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites.
static const _laneGap = 8.0;
/// Stores the `card` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final TimelineCardModel card;
/// Stores the `lane` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final int lane;
/// Stores the `laneCount` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final int laneCount;
/// Stores the `top` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final double top;
/// Stores the `height` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final double height;
/// Performs the `withCard` behavior for this scheduler component.
/// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved.
_TimelineCardPlacement withCard(TimelineCardModel nextCard) {
if (identical(card, nextCard)) {
return this;
}
return _TimelineCardPlacement(
card: nextCard,
lane: lane,
laneCount: laneCount,
top: top,
height: height,
);
}
/// Performs the `pack` behavior for this scheduler component.
/// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved.
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;
}
/// Runs the `_packGroup` helper used inside this library.
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
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,
),
),
];
}
/// Runs the `_visualStart` helper used inside this library.
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
static double _visualStart(
TimelineCardModel card,
TimelineGeometry geometry,
) {
return geometry.yForMinutesSinceMidnight(card.startMinutes);
}
/// Runs the `_visualEnd` helper used inside this library.
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
static double _visualEnd(TimelineCardModel card, TimelineGeometry geometry) {
final duration = card.endMinutes - card.startMinutes;
return _visualStart(card, geometry) + geometry.heightForDuration(duration);
}
/// Performs the `left` behavior for this scheduler component.
/// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved.
double left(double trackWidth) {
final laneWidth = _laneWidth(trackWidth);
final laneGap = _laneGapForWidth(trackWidth);
return FocusFlowTokens.cardHorizontalPadding + lane * (laneWidth + laneGap);
}
/// Performs the `width` behavior for this scheduler component.
/// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved.
double width(double trackWidth) {
return _laneWidth(trackWidth);
}
/// Runs the `_availableWidth` helper used inside this library.
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
double _availableWidth(double trackWidth) {
final availableWidth = trackWidth - FocusFlowTokens.cardHorizontalPadding;
if (availableWidth < 0) {
return 0;
}
return availableWidth;
}
/// Runs the `_laneWidth` helper used inside this library.
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
double _laneWidth(double trackWidth) {
final availableWidth = _availableWidth(trackWidth);
final laneGap = _laneGapForWidth(trackWidth);
return (availableWidth - laneGap * (laneCount - 1)) / laneCount;
}
/// Runs the `_laneGapForWidth` helper used inside this library.
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
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,19 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../timeline_view.dart';
/// Private implementation type for `_TimelineScrollBehavior` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _TimelineScrollBehavior extends MaterialScrollBehavior {
/// Creates a `_TimelineScrollBehavior` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _TimelineScrollBehavior();
/// Returns the derived `dragDevices` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
@override
Set<PointerDeviceKind> get dragDevices {
return {...super.dragDevices, PointerDeviceKind.mouse};
}
}

View file

@ -0,0 +1,246 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../timeline_view.dart';
/// Private implementation type for `_TimelineViewState` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _TimelineViewState extends State<TimelineView> {
/// Shared constant value for `_topInset`.
/// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites.
static const _topInset = 12.0;
/// Private state stored as `_scrollController` for this implementation.
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
final _scrollController = ScrollController();
/// Private state stored as `_didSetInitialScroll` for this implementation.
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
bool _didSetInitialScroll = false;
/// Private state stored as `_handledScrollRequest` for this implementation.
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
var _handledScrollRequest = 0;
/// Private state stored as `_geometry` for this implementation.
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
late TimelineGeometry _geometry;
/// Private state stored as `_contentHeight` for this implementation.
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
late double _contentHeight;
/// Private state stored as `_placements` for this implementation.
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
late List<_TimelineCardPlacement> _placements;
/// Private state stored as `_cardsLayoutHash` for this implementation.
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
late int _cardsLayoutHash;
/// Initializes state owned by this object before the first build.
/// Use this lifecycle point for controller setup, initial reads, and listener wiring that must happen once per mounted instance.
@override
void initState() {
super.initState();
_updateLayoutCache();
}
/// Performs the `didUpdateWidget` behavior for this scheduler component.
/// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved.
@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();
}
}
/// Releases resources owned by this object before it leaves the tree.
/// Controllers, listeners, and other disposable collaborators should be cleaned up here to avoid stale callbacks.
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@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,
),
),
),
),
],
);
},
),
),
],
),
),
),
);
},
);
}
/// Runs the `_updateLayoutCache` helper used inside this library.
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
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);
}
/// Runs the `_layoutHashFor` helper used inside this library.
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
int _layoutHashFor(List<TimelineCardModel> cards) {
return Object.hashAll(
cards.map(
(card) => Object.hash(card.id, card.startMinutes, card.endMinutes),
),
);
}
/// Runs the `_refreshPlacementCards` helper used inside this library.
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
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),
];
}
/// Runs the `_scheduleInitialScroll` helper used inside this library.
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
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;
});
}
/// Runs the `_scheduleTargetScroll` helper used inside this library.
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
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

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Renders the Today Pane widget for the FocusFlow Flutter UI.
library;
@ -9,16 +12,23 @@ import '../controllers/scheduler_read_controller.dart';
import 'read_state_view.dart';
import 'schedule_components.dart';
/// Read-driven pane that renders the Today view and optional commands.
class TodayPane extends StatelessWidget {
/// Creates a Today pane from a read [controller].
const TodayPane({
required this.controller,
this.commandController,
super.key,
});
/// Controller that loads Today state.
final UiReadController<TodayState> controller;
/// Optional command controller for Today actions.
final SchedulerCommandController? commandController;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return ReadStateView<TodayState>(
@ -32,12 +42,19 @@ class TodayPane extends StatelessWidget {
}
}
/// Populated Today view for a loaded Today state.
class TodayContent extends StatelessWidget {
/// Creates Today content for [state].
const TodayContent({required this.state, this.commandController, super.key});
/// Loaded Today state to display.
final TodayState state;
/// Optional command controller for completing tasks.
final SchedulerCommandController? commandController;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return ListView(
@ -65,6 +82,8 @@ class TodayContent extends StatelessWidget {
);
}
/// Runs the `_canComplete` helper used inside this library.
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
bool _canComplete(TodayTimelineItem item) {
return item.taskType == TaskType.flexible &&
item.taskStatus == TaskStatus.planned &&

View file

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Renders the Top Bar widget for the FocusFlow Flutter UI.
library;
@ -5,107 +8,84 @@ import 'package:flutter/material.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.
class TopBar extends StatelessWidget {
/// Creates a top bar with a display-ready [dateLabel].
const TopBar({required this.dateLabel, super.key});
/// Date label shown beside the date navigation controls.
final String dateLabel;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return Row(
children: [
Text('Today', style: Theme.of(context).textTheme.headlineLarge),
const SizedBox(width: 30),
_IconButton(icon: Icons.calendar_today, onPressed: () {}),
_IconButton(icon: Icons.chevron_left, onPressed: () {}),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
dateLabel,
style: const TextStyle(
color: FocusFlowTokens.textPrimary,
fontSize: 17,
),
),
),
_IconButton(icon: Icons.chevron_right, onPressed: () {}),
const Spacer(),
const _ModeToggle(),
const SizedBox(width: 28),
OutlinedButton.icon(
onPressed: () {},
style: OutlinedButton.styleFrom(
foregroundColor: FocusFlowTokens.textPrimary,
side: const BorderSide(color: FocusFlowTokens.subtleBorder),
fixedSize: const Size(136, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
FocusFlowTokens.smallButtonRadius,
),
),
),
icon: const Icon(Icons.wb_sunny_outlined),
label: const Text('Settings'),
),
],
);
}
}
class _IconButton extends StatelessWidget {
const _IconButton({required this.icon, required this.onPressed});
final IconData icon;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: onPressed,
color: FocusFlowTokens.textPrimary,
icon: Icon(icon),
);
}
}
class _ModeToggle extends StatelessWidget {
const _ModeToggle();
@override
Widget build(BuildContext context) {
return Container(
height: 48,
width: 220,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(FocusFlowTokens.smallButtonRadius),
border: Border.all(color: FocusFlowTokens.subtleBorder),
),
return LayoutBuilder(
builder: (context, constraints) {
final metrics = _TopBarMetrics.fromWidth(constraints.maxWidth);
return SizedBox(
height: _TopBarMetrics.referenceHeight,
child: Row(
children: [
Expanded(
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: FocusFlowTokens.glassPanelStrong,
borderRadius: BorderRadius.circular(
FocusFlowTokens.smallButtonRadius,
),
border: Border.all(color: FocusFlowTokens.navBorder),
),
child: const Text('Compact'),
SizedBox(
width: metrics.titleWidth,
height: _TopBarMetrics.referenceHeight,
child: FittedBox(
alignment: Alignment.centerLeft,
fit: BoxFit.scaleDown,
child: Text(
'Today',
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontSize: metrics.titleSize,
),
),
Expanded(
child: TextButton(
),
),
SizedBox(width: metrics.titleGap),
_IconButton(
icon: Icons.calendar_today,
metrics: metrics,
onPressed: () {},
child: const Text(
'Normal',
style: TextStyle(color: FocusFlowTokens.textPrimary),
),
_IconButton(
icon: Icons.chevron_left,
metrics: metrics,
onPressed: () {},
),
SizedBox(
width: metrics.dateWidth,
height: metrics.controlHeight,
child: Center(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
dateLabel,
style: TextStyle(
color: FocusFlowTokens.textPrimary,
fontSize: metrics.dateSize,
),
),
),
),
),
_IconButton(
icon: Icons.chevron_right,
metrics: metrics,
onPressed: () {},
),
const Spacer(),
_ModeToggle(metrics: metrics),
SizedBox(width: metrics.settingsGap),
_SettingsButton(metrics: metrics),
],
),
);
},
);
}
}

View file

@ -0,0 +1,46 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../top_bar.dart';
/// Private implementation type for `_IconButton` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _IconButton extends StatelessWidget {
/// Creates a `_IconButton` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _IconButton({
required this.icon,
required this.metrics,
required this.onPressed,
});
/// Stores the `icon` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final IconData icon;
/// Stores the `metrics` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final _TopBarMetrics metrics;
/// Stores the `onPressed` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final VoidCallback onPressed;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@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,116 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../top_bar.dart';
/// Private implementation type for `_TopBarMetrics` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _TopBarMetrics {
/// Creates a `_TopBarMetrics` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _TopBarMetrics(this.scale);
/// Shared constant value for `referenceHeight`.
/// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites.
static const referenceHeight = 48.0;
/// Shared constant value for `_referenceWidth`.
/// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites.
static const _referenceWidth = 900.0;
/// Shared constant value for `_minimumScale`.
/// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites.
static const _minimumScale = 0.44;
/// Creates a `_TopBarMetrics.fromWidth` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
factory _TopBarMetrics.fromWidth(double width) {
final safeWidth = width.isFinite ? width : _referenceWidth;
final scale = (safeWidth / _referenceWidth).clamp(_minimumScale, 1.0);
return _TopBarMetrics(scale.toDouble());
}
/// Stores the `scale` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final double scale;
/// Returns the derived `titleWidth` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get titleWidth => 112 * scale;
/// Returns the derived `titleSize` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get titleSize => FocusFlowTokens.pageTitleSize * scale;
/// Returns the derived `titleGap` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get titleGap => 30 * scale;
/// Returns the derived `controlHeight` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get controlHeight => referenceHeight * scale;
/// Returns the derived `iconButtonSize` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get iconButtonSize => referenceHeight * scale;
/// Returns the derived `iconSize` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get iconSize => 24 * scale;
/// Returns the derived `dateWidth` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get dateWidth => 144 * scale;
/// Returns the derived `dateSize` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get dateSize => 17 * scale;
/// Returns the derived `modeWidth` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get modeWidth => 220 * scale;
/// Returns the derived `modeHeight` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get modeHeight => referenceHeight * scale;
/// Returns the derived `modeLabelSize` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get modeLabelSize => 17 * scale;
/// Returns the derived `settingsGap` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get settingsGap => 28 * scale;
/// Returns the derived `settingsWidth` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get settingsWidth => 136 * scale;
/// Returns the derived `settingsHeight` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get settingsHeight => referenceHeight * scale;
/// Returns the derived `settingsIconSize` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get settingsIconSize => 20 * scale;
/// Returns the derived `settingsTextSize` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get settingsTextSize => 15 * scale;
/// Returns the derived `settingsTextGap` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get settingsTextGap => 8 * scale;
/// Returns the derived `borderRadius` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get borderRadius => FocusFlowTokens.smallButtonRadius * scale;
/// Returns the derived `borderWidth` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get borderWidth => scale < 0.7 ? 0.8 : 1;
/// Returns the derived `buttonPadding` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
EdgeInsets get buttonPadding => EdgeInsets.symmetric(horizontal: 12 * scale);
}

View file

@ -0,0 +1,77 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../top_bar.dart';
/// Private implementation type for `_ModeToggle` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _ModeToggle extends StatelessWidget {
/// Creates a `_ModeToggle` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _ModeToggle({required this.metrics});
/// Stores the `metrics` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final _TopBarMetrics metrics;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@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,56 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../top_bar.dart';
/// Private implementation type for `_SettingsButton` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _SettingsButton extends StatelessWidget {
/// Creates a `_SettingsButton` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _SettingsButton({required this.metrics});
/// Stores the `metrics` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final _TopBarMetrics metrics;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@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,
),
),
],
),
),
);
}
}

View file

@ -1,3 +1,6 @@
# SPDX-FileCopyrightText: 2026 FocusFlow contributors
# SPDX-License-Identifier: AGPL-3.0-only
name: focus_flow_flutter
description: Provisional Flutter UI foundation for FocusFlow.
publish_to: 'none'

View file

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Tests Forbidden Imports behavior in the FocusFlow Flutter app.
library;
@ -5,6 +8,7 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
/// Runs app package-boundary tests.
void main() {
test('Flutter UI imports stay inside the UI Plan 1 package boundary', () {
final appRoot = Directory.current;
@ -32,6 +36,8 @@ void main() {
});
}
/// Top-level helper that performs the `_dartFiles` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
Iterable<File> _dartFiles(Directory directory) {
if (!directory.existsSync()) {
return const <File>[];
@ -42,6 +48,8 @@ Iterable<File> _dartFiles(Directory directory) {
.where((file) => file.path.endsWith('.dart'));
}
/// Top-level helper that performs the `_forbiddenImportsFor` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
List<_ForbiddenImport> _forbiddenImportsFor(String relativePath) {
return [
const _ForbiddenImport(
@ -68,12 +76,23 @@ List<_ForbiddenImport> _forbiddenImportsFor(String relativePath) {
];
}
/// Private implementation type for `_ForbiddenImport` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _ForbiddenImport {
/// Creates a `_ForbiddenImport` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _ForbiddenImport(this.pattern, this.label);
/// Stores the `pattern` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final String pattern;
/// Stores the `label` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final String label;
/// Performs the `matches` behavior for this scheduler component.
/// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved.
bool matches(String contents) {
return RegExp(
r'''import\s+['"]''' + RegExp.escape(pattern),

View file

@ -1,6 +1,10 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Tests Widget behavior in the FocusFlow Flutter app.
library;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:scheduler_core/scheduler_core.dart';
@ -8,8 +12,17 @@ import 'package:scheduler_core/scheduler_core.dart';
import 'package:focus_flow_flutter/app/demo_scheduler_composition.dart';
import 'package:focus_flow_flutter/app/focus_flow_app.dart';
import 'package:focus_flow_flutter/models/today_screen_models.dart';
import 'package:focus_flow_flutter/theme/focus_flow_theme.dart';
import 'package:focus_flow_flutter/widgets/required_banner.dart';
import 'package:focus_flow_flutter/widgets/timeline/difficulty_bars.dart';
import 'package:focus_flow_flutter/widgets/timeline/reward_icon.dart';
import 'package:focus_flow_flutter/widgets/timeline/task_timeline_card.dart';
import 'package:focus_flow_flutter/widgets/timeline/timeline_axis.dart';
import 'package:focus_flow_flutter/widgets/timeline/timeline_geometry.dart';
import 'package:focus_flow_flutter/widgets/timeline/timeline_view.dart';
import 'package:focus_flow_flutter/widgets/top_bar.dart';
/// Runs FocusFlow widget and visual adapter tests.
void main() {
testWidgets('app shell renders compact Today frame', (tester) async {
await _pumpPlanApp(tester);
@ -36,7 +49,12 @@ void main() {
findsOneWidget,
);
expect(find.text('Clean coffee maker'), findsOneWidget);
expect(find.text('Cleaned kitchen counter'), findsNWidgets(2));
expect(find.text('Cleaned kitchen counter'), findsOneWidget);
expect(find.text('Completed at: 8:05 AM'), findsOneWidget);
expect(find.textContaining('Surprise task'), findsNothing);
expect(find.text('Sort mail'), findsOneWidget);
expect(find.text('Start laundry'), findsOneWidget);
expect(find.text('Water plants'), findsOneWidget);
expect(find.text('Pay bill'), findsOneWidget);
expect(find.text('Doctor appointment'), findsOneWidget);
expect(find.text('Free Slot'), findsOneWidget);
@ -50,11 +68,14 @@ void main() {
final data = TodayScreenData.fromTodayState(state);
expect(_kindFor(data, 'clean-coffee-maker'), TaskVisualKind.flexible);
expect(_kindFor(data, 'sort-mail'), TaskVisualKind.flexible);
expect(_kindFor(data, 'start-laundry'), TaskVisualKind.flexible);
expect(_kindFor(data, 'water-plants'), TaskVisualKind.flexible);
expect(_kindFor(data, 'pay-bill'), TaskVisualKind.required);
expect(_kindFor(data, 'doctor-appointment'), TaskVisualKind.appointment);
expect(_kindFor(data, 'free-slot'), TaskVisualKind.freeSlot);
expect(
_kindFor(data, 'cleaned-kitchen-counter-early'),
_kindFor(data, 'cleaned-kitchen-counter'),
TaskVisualKind.completedSurprise,
);
},
@ -76,6 +97,7 @@ void main() {
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
await _scrollIntoView(tester, const ValueKey('pay-bill'));
await tester.tap(find.byKey(const ValueKey('pay-bill')));
await tester.pumpAndSettle();
@ -100,6 +122,7 @@ void main() {
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
await _scrollIntoView(tester, const ValueKey('pay-bill'));
await tester.tap(find.byKey(const ValueKey('pay-bill')));
await tester.pumpAndSettle();
await tester.tapAt(const Offset(1450, 140));
@ -108,19 +131,525 @@ void main() {
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
expect(find.text('Pay bill'), findsOneWidget);
});
test('Today screen timeline range covers the full day', () {
expect(TodayScreenData.defaultRange.startMinutes, 0);
expect(TodayScreenData.defaultRange.endMinutes, 24 * 60);
});
testWidgets('timeline task bubbles match duration height exactly', (
tester,
) async {
await _pumpConstrainedWidget(
tester,
size: const Size(680, 360),
child: TimelineView(
cards: [
_timelineCard(
id: 'five-minute-task',
title: 'Five minute task',
subtitle: '5 min',
startMinutes: 8 * 60,
endMinutes: 8 * 60 + 5,
),
_timelineCard(
id: 'fifteen-minute-task',
title: 'Fifteen minute task',
subtitle: '15 min',
startMinutes: 8 * 60 + 5,
endMinutes: 8 * 60 + 20,
),
],
range: TodayScreenData.defaultRange,
now: () => DateTime(2026, 6, 30, 8),
onCardSelected: (_) {},
),
);
expect(tester.takeException(), isNull);
final fiveMinute = tester.getRect(
find.byKey(const ValueKey('five-minute-task')),
);
final fifteenMinute = tester.getRect(
find.byKey(const ValueKey('fifteen-minute-task')),
);
expect(fiveMinute.height, closeTo(5 * 2.45, 0.5));
expect(fifteenMinute.height, closeTo(15 * 2.45, 0.5));
expect(fiveMinute.left, closeTo(fifteenMinute.left, 1));
expect(fiveMinute.bottom, closeTo(fifteenMinute.top, 1));
});
testWidgets('required banner jumps timeline to linked task', (tester) async {
await _pumpPlanApp(tester);
final scrollable = tester.state<ScrollableState>(find.byType(Scrollable));
await tester.tap(find.text('Show upcoming'));
await tester.pumpAndSettle();
expect(scrollable.position.pixels, closeTo(2634, 1));
expect(find.byKey(const ValueKey('pay-bill')), findsOneWidget);
});
testWidgets(
'timeline defaults near the current time while keeping full day',
(tester) async {
await _pumpConstrainedWidget(
tester,
size: const Size(680, 400),
child: TimelineView(
cards: const [],
range: TodayScreenData.defaultRange,
now: () => DateTime(2026, 6, 30, 12),
onCardSelected: (_) {},
),
);
expect(tester.takeException(), isNull);
expect(find.text('11:00 PM'), findsOneWidget);
expect(find.text('12:00 AM'), findsWidgets);
final scrollable = tester.state<ScrollableState>(find.byType(Scrollable));
final position = scrollable.position;
expect(position.maxScrollExtent, greaterThan(3000));
expect(position.pixels, closeTo(1576, 1));
},
);
testWidgets('timeline hour labels stay on one line', (tester) async {
await _pumpConstrainedWidget(
tester,
size: const Size(140, 220),
child: const TimelineAxis(
geometry: TimelineGeometry(
startMinutes: 0,
endMinutes: 60,
pixelsPerMinute: 2.45,
),
),
);
expect(tester.takeException(), isNull);
final midnight = tester.widget<Text>(find.text('12:00 AM'));
final oneAm = tester.widget<Text>(find.text('1:00 AM'));
expect(midnight.maxLines, 1);
expect(oneAm.maxLines, 1);
expect(midnight.softWrap, isFalse);
expect(oneAm.softWrap, isFalse);
expect(midnight.overflow, TextOverflow.visible);
expect(oneAm.overflow, TextOverflow.visible);
expect(
tester.getSize(find.text('12:00 AM')).height,
closeTo(tester.getSize(find.text('1:00 AM')).height, 0.1),
);
expect(
tester.getRect(find.text('12:00 AM')).top,
greaterThanOrEqualTo(tester.getRect(find.byType(TimelineAxis)).top),
);
});
testWidgets('timeline scrolls with a mouse drag', (tester) async {
await _pumpConstrainedWidget(
tester,
size: const Size(680, 400),
child: TimelineView(
cards: const [],
range: TodayScreenData.defaultRange,
now: () => DateTime(2026, 6, 30, 12),
onCardSelected: (_) {},
),
);
expect(tester.takeException(), isNull);
final scrollable = tester.state<ScrollableState>(find.byType(Scrollable));
final beforeDrag = scrollable.position.pixels;
await tester.drag(
find.byType(SingleChildScrollView),
const Offset(0, -180),
kind: PointerDeviceKind.mouse,
);
await tester.pumpAndSettle();
expect(scrollable.position.pixels, greaterThan(beforeDrag + 80));
});
testWidgets('timeline splits partial overlaps and reuses empty lanes', (
tester,
) async {
await _pumpConstrainedWidget(
tester,
size: const Size(680, 520),
child: TimelineView(
cards: [
_timelineCard(
id: 'left-first',
title: 'Left first',
startMinutes: 9 * 60,
endMinutes: 10 * 60,
),
_timelineCard(
id: 'right-middle',
title: 'Right middle',
startMinutes: 9 * 60 + 30,
endMinutes: 10 * 60 + 30,
),
_timelineCard(
id: 'left-reused',
title: 'Left reused',
startMinutes: 10 * 60,
endMinutes: 11 * 60,
),
],
range: TodayScreenData.defaultRange,
now: () => DateTime(2026, 6, 30, 9, 30),
onCardSelected: (_) {},
),
);
expect(tester.takeException(), isNull);
final leftFirst = tester.getRect(find.byKey(const ValueKey('left-first')));
final rightMiddle = tester.getRect(
find.byKey(const ValueKey('right-middle')),
);
final leftReused = tester.getRect(
find.byKey(const ValueKey('left-reused')),
);
expect(rightMiddle.left, greaterThan(leftFirst.left));
expect(leftReused.left, closeTo(leftFirst.left, 1));
expect(leftFirst.width, closeTo(rightMiddle.width, 1));
expect(leftReused.width, closeTo(leftFirst.width, 1));
expect(leftFirst.right, lessThan(rightMiddle.left));
expect(leftReused.right, lessThan(rightMiddle.left));
});
testWidgets('seeded demo partial overlaps pack into two lanes', (
tester,
) async {
final state = await _readSeededToday();
final data = TodayScreenData.fromTodayState(state);
await _pumpConstrainedWidget(
tester,
size: const Size(900, 620),
child: TimelineView(
cards: data.cards,
range: data.timelineRange,
now: () => DateTime(2025, 5, 20, 15, 20),
onCardSelected: (_) {},
),
);
expect(tester.takeException(), isNull);
final sortMail = tester.getRect(find.byKey(const ValueKey('sort-mail')));
final startLaundry = tester.getRect(
find.byKey(const ValueKey('start-laundry')),
);
final waterPlants = tester.getRect(
find.byKey(const ValueKey('water-plants')),
);
final cleanCoffee = tester.getRect(
find.byKey(const ValueKey('clean-coffee-maker')),
);
final cleanedKitchenCounter = tester.getRect(
find.byKey(const ValueKey('cleaned-kitchen-counter')),
);
expect(startLaundry.left, greaterThan(sortMail.left));
expect(waterPlants.left, closeTo(sortMail.left, 1));
expect(sortMail.width, closeTo(startLaundry.width, 1));
expect(waterPlants.width, closeTo(sortMail.width, 1));
expect(sortMail.right, lessThan(startLaundry.left));
expect(waterPlants.right, lessThan(startLaundry.left));
expect(cleanCoffee.height, closeTo(15 * 2.45, 0.5));
expect(cleanedKitchenCounter.height, closeTo(15 * 2.45, 0.5));
expect(
data.cards
.singleWhere((card) => card.id == 'cleaned-kitchen-counter')
.startMinutes,
8 * 60 + 15,
);
});
testWidgets('top bar controls scale inside a compact header width', (
tester,
) async {
await _pumpConstrainedWidget(
tester,
size: const Size(520, 72),
child: const TopBar(dateLabel: 'June 30, 2026'),
);
expect(tester.takeException(), isNull);
expect(find.text('Compact'), findsOneWidget);
expect(find.text('Normal'), findsOneWidget);
expect(find.text('Settings'), findsOneWidget);
expect(tester.getSize(find.byType(OutlinedButton)).width, lessThan(136));
});
testWidgets('required banner scales inside a compact header width', (
tester,
) async {
await _pumpConstrainedWidget(
tester,
size: const Size(440, 72),
child: const RequiredBanner(
model: RequiredBannerModel(
timelineCardId: 'pay-bill',
title: 'Pay bill',
timeText: '6:00 PM',
),
),
);
expect(tester.takeException(), isNull);
expect(
find.textContaining('Next required task:', findRichText: true),
findsOneWidget,
);
expect(find.text('Show upcoming'), findsOneWidget);
expect(tester.getSize(find.byType(OutlinedButton)).width, lessThan(176));
});
testWidgets('empty required banner does not reserve vertical space', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(480, 160));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
MaterialApp(
theme: FocusFlowTheme.dark(),
home: const Scaffold(
body: Align(alignment: Alignment.topLeft, child: RequiredBanner()),
),
),
);
await tester.pumpAndSettle();
expect(tester.getSize(find.byType(RequiredBanner)).height, 0);
});
testWidgets('task card controls scale inside a narrow task bubble', (
tester,
) async {
await _pumpTimelineCard(
tester,
card: _timelineCard(
id: 'narrow-actions',
title: 'A task title that would otherwise crowd the controls',
),
size: const Size(320, 88),
);
expect(tester.takeException(), isNull);
expect(
tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity)).opacity,
0,
);
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(
location: tester.getCenter(find.byKey(const ValueKey('narrow-actions'))),
);
await tester.pumpAndSettle();
expect(
tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity)).opacity,
1,
);
final actionBackground = tester.widget<AnimatedContainer>(
find.byType(AnimatedContainer),
);
final decoration = actionBackground.decoration;
expect(decoration, isA<BoxDecoration>());
final boxDecoration = decoration! as BoxDecoration;
expect(boxDecoration.color, const Color(0xFF021A0C));
expect(boxDecoration.border, isNull);
final icon = tester.widget<Icon>(find.byIcon(Icons.arrow_forward));
expect(icon.size, lessThan(18));
expect(
tester.getSize(find.byIcon(Icons.arrow_forward)).width,
lessThan(32),
);
await gesture.removePointer();
});
testWidgets('short task card keeps time inline with title', (tester) async {
await _pumpTimelineCard(
tester,
card: _timelineCard(
id: 'short-inline-time',
title: 'Five minute reset',
subtitle: '5 min',
timeText: '6:00 PM - 6:05 PM',
startMinutes: 18 * 60,
endMinutes: 18 * 60 + 5,
),
size: const Size(360, 24),
);
expect(tester.takeException(), isNull);
final title = tester.getRect(find.text('Five minute reset'));
final time = tester.getRect(find.text('5 min'));
final statusCircle = tester.getRect(
find.byKey(const ValueKey('short-inline-time-status')),
);
expect(statusCircle.height, lessThan(20));
expect(statusCircle.right, lessThan(title.left));
expect(title.right, lessThan(time.left));
expect((title.center.dy - time.center.dy).abs(), lessThan(2));
expect(find.byType(RewardIcon), findsOneWidget);
expect(find.byType(DifficultyBars), findsOneWidget);
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(
location: tester.getCenter(
find.byKey(const ValueKey('short-inline-time')),
),
);
await tester.pumpAndSettle();
expect(
tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity)).opacity,
1,
);
expect(find.text('5 min'), findsOneWidget);
await gesture.removePointer();
});
testWidgets('status circle toggles task completion without opening modal', (
tester,
) async {
final composition = _composition();
await _pumpPlanApp(tester, composition: composition);
await _scrollIntoView(tester, const ValueKey('clean-coffee-maker'));
final beforeClick = DateTime.now().toUtc();
await tester.tap(find.byKey(const ValueKey('clean-coffee-maker-status')));
await tester.pumpAndSettle();
final afterClick = DateTime.now().toUtc();
final completedTask = composition.store.currentTasks.singleWhere(
(task) => task.id == 'clean-coffee-maker',
);
final scheduledStart = completedTask.scheduledStart;
final scheduledEnd = completedTask.scheduledEnd;
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
expect(completedTask.status, TaskStatus.completed);
expect(completedTask.completedAt, isNotNull);
expect(completedTask.completedAt!.isBefore(beforeClick), isFalse);
expect(completedTask.completedAt!.isAfter(afterClick), isFalse);
expect(find.textContaining('Completed at:'), findsWidgets);
await tester.tap(find.byKey(const ValueKey('clean-coffee-maker-status')));
await tester.pumpAndSettle();
final reopenedTask = composition.store.currentTasks.singleWhere(
(task) => task.id == 'clean-coffee-maker',
);
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
expect(reopenedTask.status, TaskStatus.planned);
expect(reopenedTask.scheduledStart, scheduledStart);
expect(reopenedTask.scheduledEnd, scheduledEnd);
expect(reopenedTask.actualStart, isNull);
expect(reopenedTask.actualEnd, isNull);
expect(reopenedTask.completedAt, isNull);
});
testWidgets('task modal status circle toggles and keeps details in sync', (
tester,
) async {
final composition = _composition();
await _pumpPlanApp(tester, composition: composition);
await _scrollIntoView(tester, const ValueKey('water-plants'));
await tester.tap(find.byKey(const ValueKey('water-plants')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
expect(find.text('Flexible - 3:20 PM - 4:00 PM'), findsOneWidget);
expect(find.textContaining('Completed -'), findsNothing);
await tester.tap(find.byKey(const ValueKey('task-modal-status')));
await tester.pumpAndSettle();
final completedTask = composition.store.currentTasks.singleWhere(
(task) => task.id == 'water-plants',
);
expect(completedTask.status, TaskStatus.completed);
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
expect(find.text('Water plants'), findsWidgets);
expect(find.textContaining('Completed -'), findsOneWidget);
expect(find.text('Planned: 3:20 PM - 4:00 PM'), findsOneWidget);
expect(find.byIcon(Icons.check), findsWidgets);
await tester.tap(find.byKey(const ValueKey('task-modal-status')));
await tester.pumpAndSettle();
final reopenedTask = composition.store.currentTasks.singleWhere(
(task) => task.id == 'water-plants',
);
expect(reopenedTask.status, TaskStatus.planned);
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
expect(find.text('Flexible - 3:20 PM - 4:00 PM'), findsOneWidget);
expect(find.textContaining('Completed -'), findsNothing);
});
testWidgets('free slot text scales inside a short task bubble', (
tester,
) async {
await _pumpTimelineCard(
tester,
card: _timelineCard(
id: 'short-free-slot',
title: 'Free Slot',
subtitle: 'Intentional rest',
timeText: '6:15 PM - 6:30 PM',
visualKind: TaskVisualKind.freeSlot,
showsQuickActions: false,
),
size: const Size(360, 88),
);
expect(tester.takeException(), isNull);
expect(find.byKey(const ValueKey('short-free-slot-status')), findsNothing);
expect(find.text('Intentional rest'), findsOneWidget);
expect(find.text('6:15 PM - 6:30 PM'), findsOneWidget);
});
}
Future<void> _pumpPlanApp(WidgetTester tester) async {
/// Top-level helper that performs the `_pumpPlanApp` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
Future<void> _pumpPlanApp(
WidgetTester tester, {
DemoSchedulerComposition? composition,
}) async {
await tester.binding.setSurfaceSize(const Size(1586, 992));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(FocusFlowApp(composition: _composition()));
await tester.pumpWidget(
FocusFlowApp(composition: composition ?? _composition()),
);
await tester.pumpAndSettle();
}
/// Top-level helper that performs the `_composition` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
DemoSchedulerComposition _composition() {
return DemoSchedulerComposition.seeded(selectedDate: CivilDate(2025, 5, 20));
}
/// Top-level helper that performs the `_readSeededToday` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
Future<TodayState> _readSeededToday() async {
final composition = _composition();
final result = await composition.todayQuery.execute(
@ -132,6 +661,109 @@ Future<TodayState> _readSeededToday() async {
return result.requireValue;
}
/// Top-level helper that performs the `_kindFor` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
TaskVisualKind _kindFor(TodayScreenData data, String id) {
return data.cards.singleWhere((card) => card.id == id).visualKind;
}
/// Top-level helper that performs the `_scrollIntoView` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
Future<void> _scrollIntoView(WidgetTester tester, ValueKey<String> key) async {
await tester.ensureVisible(find.byKey(key));
await tester.pumpAndSettle();
}
/// Top-level helper that performs the `_pumpTimelineCard` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
Future<void> _pumpTimelineCard(
WidgetTester tester, {
required TimelineCardModel card,
required Size size,
}) async {
await tester.binding.setSurfaceSize(Size(size.width + 40, size.height + 40));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: SizedBox(
width: size.width,
height: size.height,
child: TaskTimelineCard(
card: card,
onTap: () {},
onStatusPressed: () {},
),
),
),
),
),
);
await tester.pumpAndSettle();
}
/// Top-level helper that performs the `_pumpConstrainedWidget` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
Future<void> _pumpConstrainedWidget(
WidgetTester tester, {
required Size size,
required Widget child,
}) async {
await tester.binding.setSurfaceSize(Size(size.width + 40, size.height + 40));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
MaterialApp(
theme: FocusFlowTheme.dark(),
home: Scaffold(
body: Center(
child: SizedBox(width: size.width, height: size.height, child: child),
),
),
),
);
await tester.pumpAndSettle();
}
/// Top-level helper that performs the `_timelineCard` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
TimelineCardModel _timelineCard({
required String id,
required String title,
String subtitle = '30 min',
String timeText = '6:00 PM - 6:30 PM',
int startMinutes = 18 * 60,
int endMinutes = 18 * 60 + 30,
TaskVisualKind visualKind = TaskVisualKind.flexible,
TaskType? taskType,
bool showsQuickActions = true,
String? completedTimeText,
}) {
final resolvedTaskType =
taskType ??
switch (visualKind) {
TaskVisualKind.flexible => TaskType.flexible,
TaskVisualKind.required => TaskType.critical,
TaskVisualKind.appointment => TaskType.inflexible,
TaskVisualKind.freeSlot => TaskType.freeSlot,
TaskVisualKind.completedSurprise => TaskType.flexible,
};
return TimelineCardModel(
id: id,
title: title,
typeLabel: 'Flexible',
subtitle: subtitle,
timeText: timeText,
startMinutes: startMinutes,
endMinutes: endMinutes,
taskType: resolvedTaskType,
visualKind: visualKind,
rewardIconToken: TimelineRewardIconToken.medium,
difficultyIconToken: TimelineDifficultyIconToken.medium,
completedTimeText: completedTimeText,
isCompleted: false,
isSelectable: true,
showsQuickActions: showsQuickActions,
durationMinutes: endMinutes - startMinutes,
);
}

View file

@ -0,0 +1,6 @@
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# License
This package uses the repository-level license at [../../LICENSE.md](../../LICENSE.md).

View file

@ -1 +1,4 @@
include: package:lints/recommended.yaml
# SPDX-FileCopyrightText: 2026 FocusFlow contributors
# SPDX-License-Identifier: AGPL-3.0-only
include: ../../analysis_options.yaml

View file

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Runs the Backup command for the Scheduler Backup package.
library;
@ -5,6 +8,8 @@ import 'dart:io';
import 'package:scheduler_backup/backup.dart';
/// Entry point for this executable Dart file.
/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API.
Future<void> main(List<String> arguments) async {
final passphrase = _option(arguments, '--passphrase') ??
Platform.environment['SCHEDULER_BACKUP_PASSPHRASE'];
@ -25,6 +30,8 @@ Future<void> main(List<String> arguments) async {
}
}
/// Top-level helper that performs the `_option` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
String? _option(List<String> arguments, String name) {
final index = arguments.indexOf(name);
if (index == -1 || index + 1 >= arguments.length) {

View file

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Encrypted SQLite backup helpers.
library;

View file

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Package-name entry point for scheduler backup helpers.
library;

View file

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Implements Encrypted Backup behavior for the Scheduler Backup package.
library;
@ -7,6 +10,12 @@ import 'dart:math';
import 'dart:typed_data';
import 'package:cryptography/cryptography.dart';
part 'encrypted_backup/errors/backup_decryption_exception.dart';
part 'encrypted_backup/codec/backup_encryption_codec.dart';
part 'encrypted_backup/errors/backup_exception.dart';
part 'encrypted_backup/paths/backup_file_names.dart';
part 'encrypted_backup/paths/backup_paths.dart';
part 'encrypted_backup/operations/encrypted_backup_operations.dart';
/// Default scheduler data directory name under the user's home directory.
const defaultSchedulerDirectoryName = 'ADHD_Scheduler';
@ -17,249 +26,30 @@ const defaultSchedulerDatabaseFileName = 'scheduler.sqlite';
/// Default encrypted backup directory name under the scheduler data directory.
const defaultSchedulerBackupDirectoryName = 'backups';
/// Private file-level value for `_formatMagic`.
/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals.
const _formatMagic = 'ADHD_SCHEDULER_BACKUP_V1';
/// Private file-level value for `_fileExtension`.
/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals.
const _fileExtension = '.db.enc';
/// Private file-level value for `_kdfIterations`.
/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals.
const _kdfIterations = 200000;
/// Private file-level value for `_saltLength`.
/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals.
const _saltLength = 16;
/// Private file-level value for `_nonceLength`.
/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals.
const _nonceLength = 12;
/// Private file-level value for `_keyLength`.
/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals.
const _keyLength = 32;
/// Private file-level value for `_utf8`.
/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals.
final _utf8 = utf8.encoder;
/// Thrown when an encrypted backup cannot be decrypted with the passphrase.
final class BackupDecryptionException implements Exception {
/// Creates a decryption failure with optional diagnostic [message].
const BackupDecryptionException([
this.message = 'Backup could not be decrypted.',
]);
/// Diagnostic text for logs and tests.
final String message;
@override
String toString() => 'BackupDecryptionException: $message';
}
/// Thrown when backup input paths or file contents are invalid.
final class BackupException implements Exception {
/// Creates a backup exception with diagnostic [message].
const BackupException(this.message);
/// Diagnostic text for logs and tests.
final String message;
@override
String toString() => 'BackupException: $message';
}
/// Create an encrypted backup of the scheduler SQLite file.
///
/// Defaults:
/// - Source DB: `~/ADHD_Scheduler/scheduler.sqlite`
/// - Backup dir: `~/ADHD_Scheduler/backups`
/// - File name: `yyyymmdd_hhmm.db.enc`
Future<File> createEncryptedBackup({
required String passphrase,
File? sqliteFile,
Directory? backupDirectory,
DateTime? now,
}) async {
final source = sqliteFile ?? defaultSchedulerSqliteFile();
if (!await source.exists()) {
throw BackupException('SQLite file does not exist: ${source.path}');
}
final destinationDirectory =
backupDirectory ?? defaultSchedulerBackupDirectory();
await destinationDirectory.create(recursive: true);
final timestamp = _backupTimestamp(now ?? DateTime.now());
final destination = await _nextAvailableBackupFile(
destinationDirectory,
'$timestamp$_fileExtension',
);
final plaintext = await source.readAsBytes();
final encoded = await _encryptBytes(
passphrase: passphrase,
plaintext: plaintext,
);
return destination.writeAsBytes(encoded, flush: true);
}
/// Restore an encrypted backup over the scheduler SQLite file.
///
/// Defaults to restoring into `~/ADHD_Scheduler/scheduler.sqlite`. Tests and
/// tools can pass [targetFile] to restore into a temp or alternate database.
Future<void> restoreEncryptedBackup(
File backup,
String passphrase, {
File? targetFile,
}) async {
if (!await backup.exists()) {
throw BackupException('Backup file does not exist: ${backup.path}');
}
final plaintext = await _decryptBytes(
passphrase: passphrase,
encoded: await backup.readAsBytes(),
);
final target = targetFile ?? defaultSchedulerSqliteFile();
await target.parent.create(recursive: true);
final temporary = File('${target.path}.restore.tmp');
await temporary.writeAsBytes(plaintext, flush: true);
if (await target.exists()) {
await target.delete();
}
await temporary.rename(target.path);
}
/// Default scheduler SQLite database path.
File defaultSchedulerSqliteFile() {
return File(_joinPath(
_homeDirectory().path,
defaultSchedulerDirectoryName,
defaultSchedulerDatabaseFileName,
));
}
/// Default encrypted backup output directory.
Directory defaultSchedulerBackupDirectory() {
return Directory(_joinPath(
_homeDirectory().path,
defaultSchedulerDirectoryName,
defaultSchedulerBackupDirectoryName,
));
}
Future<List<int>> _encryptBytes({
required String passphrase,
required List<int> plaintext,
}) async {
final salt = _randomBytes(_saltLength);
final nonce = _randomBytes(_nonceLength);
final key = await _deriveKey(passphrase: passphrase, salt: salt);
final algorithm = AesGcm.with256bits();
final secretBox = await algorithm.encrypt(
plaintext,
secretKey: key,
nonce: nonce,
);
final envelope = <String, Object?>{
'magic': _formatMagic,
'kdf': 'pbkdf2-hmac-sha256',
'iterations': _kdfIterations,
'cipher': 'aes-256-gcm',
'salt': base64Encode(salt),
'nonce': base64Encode(secretBox.nonce),
'ciphertext': base64Encode(secretBox.cipherText),
'mac': base64Encode(secretBox.mac.bytes),
};
return _utf8.convert(jsonEncode(envelope));
}
Future<List<int>> _decryptBytes({
required String passphrase,
required List<int> encoded,
}) async {
try {
final envelope = jsonDecode(utf8.decode(encoded)) as Map<String, Object?>;
if (envelope['magic'] != _formatMagic ||
envelope['kdf'] != 'pbkdf2-hmac-sha256' ||
envelope['iterations'] != _kdfIterations ||
envelope['cipher'] != 'aes-256-gcm') {
throw const BackupDecryptionException('Unsupported backup format.');
}
final salt = base64Decode(envelope['salt'] as String);
final nonce = base64Decode(envelope['nonce'] as String);
final cipherText = base64Decode(envelope['ciphertext'] as String);
final mac = Mac(base64Decode(envelope['mac'] as String));
final key = await _deriveKey(passphrase: passphrase, salt: salt);
final algorithm = AesGcm.with256bits();
return await algorithm.decrypt(
SecretBox(cipherText, nonce: nonce, mac: mac),
secretKey: key,
);
} on BackupDecryptionException {
rethrow;
} on Object {
throw const BackupDecryptionException();
}
}
Future<SecretKey> _deriveKey({
required String passphrase,
required List<int> salt,
}) {
final algorithm = Pbkdf2(
macAlgorithm: Hmac.sha256(),
iterations: _kdfIterations,
bits: _keyLength * 8,
);
return algorithm.deriveKey(
secretKey: SecretKey(utf8.encode(passphrase)),
nonce: salt,
);
}
List<int> _randomBytes(int length) {
final random = Random.secure();
return Uint8List.fromList(
List<int>.generate(length, (_) => random.nextInt(256)),
);
}
Future<File> _nextAvailableBackupFile(Directory directory, String name) async {
final preferred = File(_joinPath(directory.path, name));
if (!await preferred.exists()) {
return preferred;
}
final dot = name.lastIndexOf('.');
final stem = dot == -1 ? name : name.substring(0, dot);
final extension = dot == -1 ? '' : name.substring(dot);
var counter = 2;
while (true) {
final candidate =
File(_joinPath(directory.path, '$stem-$counter$extension'));
if (!await candidate.exists()) {
return candidate;
}
counter += 1;
}
}
String _backupTimestamp(DateTime value) {
final local = value.toLocal();
return '${_four(local.year)}${_two(local.month)}${_two(local.day)}_'
'${_two(local.hour)}${_two(local.minute)}';
}
String _four(int value) => value.toString().padLeft(4, '0');
String _two(int value) => value.toString().padLeft(2, '0');
Directory _homeDirectory() {
final home = Platform.environment['HOME'] ??
Platform.environment['USERPROFILE'] ??
Platform.environment['HOMEDRIVE'];
if (home == null || home.trim().isEmpty) {
throw const BackupException('Home directory could not be resolved.');
}
return Directory(home);
}
String _joinPath(String first, String second, [String? third]) {
final separator = Platform.pathSeparator;
final left = first.endsWith(separator)
? first.substring(0, first.length - separator.length)
: first;
final middle = second.startsWith(separator) ? second.substring(1) : second;
final joined = '$left$separator$middle';
if (third == null) {
return joined;
}
final right = third.startsWith(separator) ? third.substring(1) : third;
return '$joined$separator$right';
}

View file

@ -0,0 +1,90 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../../encrypted_backup.dart';
/// Top-level helper that performs the `_encryptBytes` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
Future<List<int>> _encryptBytes({
required String passphrase,
required List<int> plaintext,
}) async {
final salt = _randomBytes(_saltLength);
final nonce = _randomBytes(_nonceLength);
final key = await _deriveKey(passphrase: passphrase, salt: salt);
final algorithm = AesGcm.with256bits();
final secretBox = await algorithm.encrypt(
plaintext,
secretKey: key,
nonce: nonce,
);
final envelope = <String, Object?>{
'magic': _formatMagic,
'kdf': 'pbkdf2-hmac-sha256',
'iterations': _kdfIterations,
'cipher': 'aes-256-gcm',
'salt': base64Encode(salt),
'nonce': base64Encode(secretBox.nonce),
'ciphertext': base64Encode(secretBox.cipherText),
'mac': base64Encode(secretBox.mac.bytes),
};
return _utf8.convert(jsonEncode(envelope));
}
/// Top-level helper that performs the `_decryptBytes` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
Future<List<int>> _decryptBytes({
required String passphrase,
required List<int> encoded,
}) async {
try {
final envelope = jsonDecode(utf8.decode(encoded)) as Map<String, Object?>;
if (envelope['magic'] != _formatMagic ||
envelope['kdf'] != 'pbkdf2-hmac-sha256' ||
envelope['iterations'] != _kdfIterations ||
envelope['cipher'] != 'aes-256-gcm') {
throw const BackupDecryptionException('Unsupported backup format.');
}
final salt = base64Decode(envelope['salt'] as String);
final nonce = base64Decode(envelope['nonce'] as String);
final cipherText = base64Decode(envelope['ciphertext'] as String);
final mac = Mac(base64Decode(envelope['mac'] as String));
final key = await _deriveKey(passphrase: passphrase, salt: salt);
final algorithm = AesGcm.with256bits();
return await algorithm.decrypt(
SecretBox(cipherText, nonce: nonce, mac: mac),
secretKey: key,
);
} on BackupDecryptionException {
rethrow;
} on Object {
throw const BackupDecryptionException();
}
}
/// Top-level helper that performs the `_deriveKey` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
Future<SecretKey> _deriveKey({
required String passphrase,
required List<int> salt,
}) {
final algorithm = Pbkdf2(
macAlgorithm: Hmac.sha256(),
iterations: _kdfIterations,
bits: _keyLength * 8,
);
return algorithm.deriveKey(
secretKey: SecretKey(utf8.encode(passphrase)),
nonce: salt,
);
}
/// Top-level helper that performs the `_randomBytes` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
List<int> _randomBytes(int length) {
final random = Random.secure();
return Uint8List.fromList(
List<int>.generate(length, (_) => random.nextInt(256)),
);
}

View file

@ -0,0 +1,20 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../../encrypted_backup.dart';
/// Thrown when an encrypted backup cannot be decrypted with the passphrase.
final class BackupDecryptionException implements Exception {
/// Creates a decryption failure with optional diagnostic [message].
const BackupDecryptionException([
this.message = 'Backup could not be decrypted.',
]);
/// Diagnostic text for logs and tests.
final String message;
/// Converts scheduler data for `toString` without changing the source object.
/// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details.
@override
String toString() => 'BackupDecryptionException: $message';
}

View file

@ -0,0 +1,18 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../../encrypted_backup.dart';
/// Thrown when backup input paths or file contents are invalid.
final class BackupException implements Exception {
/// Creates a backup exception with diagnostic [message].
const BackupException(this.message);
/// Diagnostic text for logs and tests.
final String message;
/// Converts scheduler data for `toString` without changing the source object.
/// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details.
@override
String toString() => 'BackupException: $message';
}

View file

@ -0,0 +1,67 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../../encrypted_backup.dart';
/// Create an encrypted backup of the scheduler SQLite file.
///
/// Defaults:
/// - Source DB: `~/ADHD_Scheduler/scheduler.sqlite`
/// - Backup dir: `~/ADHD_Scheduler/backups`
/// - File name: `yyyymmdd_hhmm.db.enc`
Future<File> createEncryptedBackup({
required String passphrase,
File? sqliteFile,
Directory? backupDirectory,
DateTime? now,
}) async {
final source = sqliteFile ?? defaultSchedulerSqliteFile();
if (!await source.exists()) {
throw BackupException('SQLite file does not exist: ${source.path}');
}
final destinationDirectory =
backupDirectory ?? defaultSchedulerBackupDirectory();
await destinationDirectory.create(recursive: true);
final timestamp = _backupTimestamp(now ?? DateTime.now());
final destination = await _nextAvailableBackupFile(
destinationDirectory,
'$timestamp$_fileExtension',
);
final plaintext = await source.readAsBytes();
final encoded = await _encryptBytes(
passphrase: passphrase,
plaintext: plaintext,
);
return destination.writeAsBytes(encoded, flush: true);
}
/// Restore an encrypted backup over the scheduler SQLite file.
///
/// Defaults to restoring into `~/ADHD_Scheduler/scheduler.sqlite`. Tests and
/// tools can pass [targetFile] to restore into a temp or alternate database.
Future<void> restoreEncryptedBackup(
File backup,
String passphrase, {
File? targetFile,
}) async {
if (!await backup.exists()) {
throw BackupException('Backup file does not exist: ${backup.path}');
}
final plaintext = await _decryptBytes(
passphrase: passphrase,
encoded: await backup.readAsBytes(),
);
final target = targetFile ?? defaultSchedulerSqliteFile();
await target.parent.create(recursive: true);
final temporary = File('${target.path}.restore.tmp');
await temporary.writeAsBytes(plaintext, flush: true);
if (await target.exists()) {
await target.delete();
}
await temporary.rename(target.path);
}

View file

@ -0,0 +1,41 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../../encrypted_backup.dart';
/// Top-level helper that performs the `_nextAvailableBackupFile` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
Future<File> _nextAvailableBackupFile(Directory directory, String name) async {
final preferred = File(_joinPath(directory.path, name));
if (!await preferred.exists()) {
return preferred;
}
final dot = name.lastIndexOf('.');
final stem = dot == -1 ? name : name.substring(0, dot);
final extension = dot == -1 ? '' : name.substring(dot);
var counter = 2;
while (true) {
final candidate =
File(_joinPath(directory.path, '$stem-$counter$extension'));
if (!await candidate.exists()) {
return candidate;
}
counter += 1;
}
}
/// Top-level helper that performs the `_backupTimestamp` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
String _backupTimestamp(DateTime value) {
final local = value.toLocal();
return '${_four(local.year)}${_two(local.month)}${_two(local.day)}_'
'${_two(local.hour)}${_two(local.minute)}';
}
/// Top-level helper that performs the `_four` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
String _four(int value) => value.toString().padLeft(4, '0');
/// Top-level helper that performs the `_two` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
String _two(int value) => value.toString().padLeft(2, '0');

View file

@ -0,0 +1,50 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../../encrypted_backup.dart';
/// Default scheduler SQLite database path.
File defaultSchedulerSqliteFile() {
return File(_joinPath(
_homeDirectory().path,
defaultSchedulerDirectoryName,
defaultSchedulerDatabaseFileName,
));
}
/// Default encrypted backup output directory.
Directory defaultSchedulerBackupDirectory() {
return Directory(_joinPath(
_homeDirectory().path,
defaultSchedulerDirectoryName,
defaultSchedulerBackupDirectoryName,
));
}
/// Top-level helper that performs the `_homeDirectory` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
Directory _homeDirectory() {
final home = Platform.environment['HOME'] ??
Platform.environment['USERPROFILE'] ??
Platform.environment['HOMEDRIVE'];
if (home == null || home.trim().isEmpty) {
throw const BackupException('Home directory could not be resolved.');
}
return Directory(home);
}
/// Top-level helper that performs the `_joinPath` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
String _joinPath(String first, String second, [String? third]) {
final separator = Platform.pathSeparator;
final left = first.endsWith(separator)
? first.substring(0, first.length - separator.length)
: first;
final middle = second.startsWith(separator) ? second.substring(1) : second;
final joined = '$left$separator$middle';
if (third == null) {
return joined;
}
final right = third.startsWith(separator) ? third.substring(1) : third;
return '$joined$separator$right';
}

View file

@ -1,3 +1,6 @@
# SPDX-FileCopyrightText: 2026 FocusFlow contributors
# SPDX-License-Identifier: AGPL-3.0-only
name: scheduler_backup
description: Encrypted SQLite backup and restore helpers for the ADHD scheduler.
version: 0.1.0

View file

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Tests Encrypted Backup behavior for the Scheduler Backup package.
library;
@ -7,6 +10,8 @@ import 'dart:io';
import 'package:scheduler_backup/backup.dart';
import 'package:test/test.dart';
/// Entry point for this executable Dart file.
/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API.
void main() {
test('encrypted backup round trips a SQLite file using temp paths', () async {
final temp =

View file

@ -0,0 +1,6 @@
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# License
This package uses the repository-level license at [../../LICENSE.md](../../LICENSE.md).

View file

@ -1,13 +1,4 @@
include: package:lints/recommended.yaml
# SPDX-FileCopyrightText: 2026 FocusFlow contributors
# SPDX-License-Identifier: AGPL-3.0-only
analyzer:
language:
strict-casts: true
strict-inference: true
strict-raw-types: true
linter:
rules:
prefer_final_locals: true
prefer_final_fields: true
avoid_print: true
include: ../../analysis_options.yaml

View file

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Defines the Scheduler Core public library for the Scheduler Core package.
library;
@ -19,28 +22,28 @@ library;
//
// Keep this file small. Implementation details belong in `lib/src/`.
export 'src/models.dart';
export 'src/application_commands.dart';
export 'src/application_layer.dart';
export 'src/application_management.dart';
export 'src/application_recovery.dart';
export 'src/backlog.dart';
export 'src/child_tasks.dart';
export 'src/document_mapping.dart';
export 'src/document_migration.dart';
export 'src/free_slots.dart';
export 'src/locked_time.dart';
export 'src/occupancy_policy.dart';
export 'src/persistence_contract.dart';
export 'src/project_statistics.dart';
export 'src/quick_capture.dart';
export 'src/reminder_policy.dart';
export 'src/repositories.dart';
export 'src/repository_values.dart';
export 'src/scheduling_engine.dart';
export 'src/task_actions.dart';
export 'src/task_lifecycle.dart';
export 'src/task_statistics.dart';
export 'src/time_contracts.dart';
export 'src/today_state.dart';
export 'src/timeline_state.dart';
export 'src/domain/models.dart';
export 'src/application/application_commands.dart';
export 'src/application/application_layer.dart';
export 'src/application/application_management.dart';
export 'src/application/application_recovery.dart';
export 'src/scheduling/backlog.dart';
export 'src/scheduling/child_tasks.dart';
export 'src/persistence/document_mapping.dart';
export 'src/persistence/document_migration.dart';
export 'src/scheduling/free_slots.dart';
export 'src/domain/locked_time.dart';
export 'src/domain/occupancy_policy.dart';
export 'src/persistence/persistence_contract.dart';
export 'src/domain/project_statistics.dart';
export 'src/scheduling/quick_capture.dart';
export 'src/scheduling/reminder_policy.dart';
export 'src/persistence/repositories.dart';
export 'src/persistence/repository_values.dart';
export 'src/scheduling/scheduling_engine.dart';
export 'src/scheduling/task_actions.dart';
export 'src/scheduling/task_lifecycle.dart';
export 'src/domain/task_statistics.dart';
export 'src/domain/time_contracts.dart';
export 'src/scheduling/today_state.dart';
export 'src/scheduling/timeline_state.dart';

View file

@ -0,0 +1,29 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Implements Application Commands behavior for the Scheduler Core package.
library;
// Atomic V1 application commands.
//
// These use cases are the UI-facing mutation boundary. They load authoritative
// repository state, invoke pure domain services, and persist all resulting task,
// activity, project-statistic, and operation-record changes in one unit of work.
import 'application_layer.dart';
import '../scheduling/child_tasks.dart';
import '../scheduling/free_slots.dart';
import '../domain/locked_time.dart';
import '../domain/models.dart';
import '../domain/project_statistics.dart';
import '../scheduling/quick_capture.dart';
import '../scheduling/scheduling_engine.dart';
import '../scheduling/task_actions.dart';
import '../scheduling/task_lifecycle.dart';
import '../domain/time_contracts.dart';
part 'application_commands/codes/application_command_code.dart';
part 'application_commands/messages/application_child_task_draft.dart';
part 'application_commands/messages/application_command_read_hint.dart';
part 'application_commands/messages/application_command_result.dart';
part 'application_commands/use_cases/v1_application_command_use_cases.dart';
part 'application_commands/planning/planning_state.dart';

View file

@ -0,0 +1,75 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../../application_commands.dart';
/// Stable V1 application command identifiers.
enum ApplicationCommandCode {
/// Selects the `quickCaptureToBacklog` option from this enum.
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
quickCaptureToBacklog,
/// Selects the `quickCaptureToNextAvailableSlot` option from this enum.
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
quickCaptureToNextAvailableSlot,
/// Selects the `scheduleBacklogItemToNextAvailableSlot` option from this enum.
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
scheduleBacklogItemToNextAvailableSlot,
/// Selects the `pushFlexibleToNextAvailableSlot` option from this enum.
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
pushFlexibleToNextAvailableSlot,
/// Selects the `pushFlexibleToTomorrowTopOfQueue` option from this enum.
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
pushFlexibleToTomorrowTopOfQueue,
/// Selects the `moveFlexibleToBacklog` option from this enum.
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
moveFlexibleToBacklog,
/// Selects the `completeFlexibleTask` option from this enum.
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
completeFlexibleTask,
/// Selects the `uncompleteTask` option from this enum.
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
uncompleteTask,
/// Selects the `applyRequiredTaskAction` option from this enum.
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
applyRequiredTaskAction,
/// Selects the `logSurpriseTask` option from this enum.
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
logSurpriseTask,
/// Selects the `createProtectedFreeSlot` option from this enum.
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
createProtectedFreeSlot,
/// Selects the `updateProtectedFreeSlot` option from this enum.
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
updateProtectedFreeSlot,
/// Selects the `removeProtectedFreeSlot` option from this enum.
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
removeProtectedFreeSlot,
/// Selects the `breakUpTask` option from this enum.
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
breakUpTask,
/// Selects the `completeChildTask` option from this enum.
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
completeChildTask,
/// Selects the `completeParentTask` option from this enum.
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
completeParentTask,
/// Selects the `completeParentFromChild` option from this enum.
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
completeParentFromChild,
}

View file

@ -0,0 +1,36 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../../application_commands.dart';
/// Draft row for creating one child task from an application command.
class ApplicationChildTaskDraft {
/// Creates a `ApplicationChildTaskDraft` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const ApplicationChildTaskDraft({
required this.title,
this.priority,
this.reward = RewardLevel.notSet,
this.difficulty = DifficultyLevel.notSet,
this.durationMinutes,
this.projectId,
});
/// User-entered child title.
final String title;
/// Optional child priority.
final PriorityLevel? priority;
/// Optional reward estimate.
final RewardLevel reward;
/// Optional difficulty estimate.
final DifficultyLevel difficulty;
/// Optional duration estimate.
final int? durationMinutes;
/// Optional project override. Null inherits from the parent.
final String? projectId;
}

Some files were not shown because too many files have changed in this diff Show more