merge: block 29 app dart docs
This commit is contained in:
commit
d65ebf0bd1
584 changed files with 30098 additions and 19246 deletions
15
.githooks/pre-commit
Executable file
15
.githooks/pre-commit
Executable 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
|
||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
14
AGENTS.md
14
AGENTS.md
|
|
@ -1,3 +1,6 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# AGENTS.md — Codex Project Rules (SQLite‑First, 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
|
||||
|
|
|
|||
9
Focus Flow Contributors.md
Normal file
9
Focus Flow Contributors.md
Normal 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
17
REUSE.toml
Normal 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
17
analysis_options.yaml
Normal 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
|
||||
6
apps/focus_flow_flutter/LICENSE.md
Normal file
6
apps/focus_flow_flutter/LICENSE.md
Normal 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).
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Exports the FocusFlow Flutter application entry points.
|
||||
library;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
163
apps/focus_flow_flutter/lib/app/demo/demo_seed_tasks.dart
Normal file
163
apps/focus_flow_flutter/lib/app/demo/demo_seed_tasks.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
apps/focus_flow_flutter/lib/app/home/focus_flow_home.dart
Normal file
18
apps/focus_flow_flutter/lib/app/home/focus_flow_home.dart
Normal 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();
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
};
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
25
apps/focus_flow_flutter/lib/app/home/plan_issue.dart
Normal file
25
apps/focus_flow_flutter/lib/app/home/plan_issue.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,6 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Provides compatibility composition exports for FocusFlow Flutter.
|
||||
library;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,86 +9,242 @@ 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 Container(
|
||||
height: 72,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18),
|
||||
decoration: BoxDecoration(
|
||||
color: FocusFlowTokens.glassPanel,
|
||||
borderRadius: BorderRadius.circular(FocusFlowTokens.bannerRadius),
|
||||
border: Border.all(
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final metrics = _RequiredBannerMetrics.fromWidth(constraints.maxWidth);
|
||||
return Container(
|
||||
height: _RequiredBannerMetrics.referenceHeight,
|
||||
padding: EdgeInsets.symmetric(horizontal: metrics.horizontalPadding),
|
||||
decoration: BoxDecoration(
|
||||
color: FocusFlowTokens.glassPanel,
|
||||
borderRadius: BorderRadius.circular(metrics.bannerRadius),
|
||||
border: Border.all(
|
||||
color: FocusFlowTokens.navBorder.withValues(alpha: 0.55),
|
||||
width: metrics.borderWidth,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: metrics.iconCircleSize,
|
||||
height: metrics.iconCircleSize,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: FocusFlowTokens.glassPanelStrong,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.auto_awesome,
|
||||
color: FocusFlowTokens.accentMagenta,
|
||||
size: metrics.iconSize,
|
||||
),
|
||||
),
|
||||
SizedBox(width: metrics.iconGap),
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FittedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
fit: BoxFit.scaleDown,
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
style: TextStyle(
|
||||
color: FocusFlowTokens.textPrimary,
|
||||
fontSize: metrics.textSize,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
children: [
|
||||
const TextSpan(text: 'Next required task: '),
|
||||
TextSpan(
|
||||
text: banner.title,
|
||||
style: const TextStyle(
|
||||
color: FocusFlowTokens.accentMagenta,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' at ${banner.timeText}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: metrics.textSize,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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: Size(metrics.buttonWidth, metrics.buttonHeight),
|
||||
minimumSize: Size.zero,
|
||||
padding: metrics.buttonPadding,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(metrics.buttonRadius),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: FocusFlowTokens.glassPanelStrong,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.auto_awesome,
|
||||
color: FocusFlowTokens.accentMagenta,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 26),
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
style: const TextStyle(
|
||||
color: FocusFlowTokens.textPrimary,
|
||||
fontSize: 19,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Show upcoming',
|
||||
style: TextStyle(
|
||||
fontSize: metrics.buttonTextSize,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
children: [
|
||||
const TextSpan(text: 'Next required task: '),
|
||||
TextSpan(
|
||||
text: banner.title,
|
||||
style: const TextStyle(color: FocusFlowTokens.accentMagenta),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' at ${banner.timeText}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
OutlinedButton(
|
||||
onPressed: () {},
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: FocusFlowTokens.textPrimary,
|
||||
side: BorderSide(
|
||||
color: FocusFlowTokens.navBorder.withValues(alpha: 0.55),
|
||||
),
|
||||
fixedSize: const Size(176, 46),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
FocusFlowTokens.smallButtonRadius,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [Text('Show upcoming'), Icon(Icons.chevron_right)],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
SizedBox(width: metrics.buttonTextGap),
|
||||
Icon(Icons.chevron_right, size: metrics.buttonIconSize),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: FocusFlowTokens.glassPanelStrong,
|
||||
borderRadius: BorderRadius.circular(
|
||||
FocusFlowTokens.smallButtonRadius,
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final metrics = _TopBarMetrics.fromWidth(constraints.maxWidth);
|
||||
return SizedBox(
|
||||
height: _TopBarMetrics.referenceHeight,
|
||||
child: Row(
|
||||
children: [
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
border: Border.all(color: FocusFlowTokens.navBorder),
|
||||
),
|
||||
child: const Text('Compact'),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text(
|
||||
'Normal',
|
||||
style: TextStyle(color: FocusFlowTokens.textPrimary),
|
||||
SizedBox(width: metrics.titleGap),
|
||||
_IconButton(
|
||||
icon: Icons.calendar_today,
|
||||
metrics: metrics,
|
||||
onPressed: () {},
|
||||
),
|
||||
),
|
||||
_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),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
116
apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_metrics.dart
Normal file
116
apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_metrics.dart
Normal 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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
6
packages/scheduler_backup/LICENSE.md
Normal file
6
packages/scheduler_backup/LICENSE.md
Normal 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).
|
||||
|
|
@ -1 +1,4 @@
|
|||
include: package:lints/recommended.yaml
|
||||
# SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
include: ../../analysis_options.yaml
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Encrypted SQLite backup helpers.
|
||||
library;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
6
packages/scheduler_core/LICENSE.md
Normal file
6
packages/scheduler_core/LICENSE.md
Normal 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).
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue