forked from eva/focus-flow
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.
|
# Runs analyzer, tests, coverage, docs, and tagged desktop packaging.
|
||||||
name: CI
|
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)
|
# 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.
|
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
|
## 7. Branch, Commit & CI
|
||||||
|
|
||||||
* Start each new block from `main` on a block branch named
|
* 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
|
# FocusFlow Flutter
|
||||||
|
|
||||||
Flutter desktop app target for the FocusFlow UI work.
|
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
|
# This file configures the analyzer, which statically analyzes Dart code to
|
||||||
# check for errors, warnings, and lints.
|
# check for errors, warnings, and lints.
|
||||||
#
|
#
|
||||||
|
|
@ -9,6 +12,12 @@
|
||||||
# packages, and plugins designed to encourage good coding practices.
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
include: package:flutter_lints/flutter.yaml
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
analyzer:
|
||||||
|
language:
|
||||||
|
strict-casts: true
|
||||||
|
strict-inference: true
|
||||||
|
strict-raw-types: true
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
# The lint rules applied to this project can be customized in the
|
# The lint rules applied to this project can be customized in the
|
||||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
# 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
|
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||||
# producing the lint.
|
# producing the lint.
|
||||||
rules:
|
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
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` 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.
|
/// Exports the FocusFlow Flutter application entry points.
|
||||||
library;
|
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.
|
/// Composes the FocusFlow Flutter application around Demo Scheduler Composition.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
|
@ -7,7 +10,13 @@ import '../controllers/scheduler_command_controller.dart';
|
||||||
import '../controllers/scheduler_read_controller.dart';
|
import '../controllers/scheduler_read_controller.dart';
|
||||||
import '../controllers/today_screen_controller.dart';
|
import '../controllers/today_screen_controller.dart';
|
||||||
|
|
||||||
|
part 'demo/demo_date_formatter.dart';
|
||||||
|
part 'demo/demo_seed_tasks.dart';
|
||||||
|
|
||||||
|
/// Wires the Flutter demo UI to in-memory scheduler application use cases.
|
||||||
class DemoSchedulerComposition {
|
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._({
|
DemoSchedulerComposition._({
|
||||||
required this.store,
|
required this.store,
|
||||||
required this.todayQuery,
|
required this.todayQuery,
|
||||||
|
|
@ -17,19 +26,37 @@ class DemoSchedulerComposition {
|
||||||
required this.readAt,
|
required this.readAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Fixed owner used by the seeded demo data.
|
||||||
static const ownerId = 'owner-1';
|
static const ownerId = 'owner-1';
|
||||||
|
|
||||||
|
/// Fixed owner time zone used by the seeded demo data.
|
||||||
static const timeZoneId = 'UTC';
|
static const timeZoneId = 'UTC';
|
||||||
|
|
||||||
|
/// Fixed project id used by the seeded demo data.
|
||||||
static const projectId = 'home';
|
static const projectId = 'home';
|
||||||
|
|
||||||
|
/// In-memory application store backing the demo.
|
||||||
final InMemoryApplicationUnitOfWork store;
|
final InMemoryApplicationUnitOfWork store;
|
||||||
|
|
||||||
|
/// Query object used to read the Today state.
|
||||||
final GetTodayStateQuery todayQuery;
|
final GetTodayStateQuery todayQuery;
|
||||||
|
|
||||||
|
/// Management use cases used by backlog-facing UI surfaces.
|
||||||
final V1ApplicationManagementUseCases managementUseCases;
|
final V1ApplicationManagementUseCases managementUseCases;
|
||||||
|
|
||||||
|
/// Command use cases used by interactive UI controls.
|
||||||
final V1ApplicationCommandUseCases commandUseCases;
|
final V1ApplicationCommandUseCases commandUseCases;
|
||||||
|
|
||||||
|
/// Local date represented by the seeded demo.
|
||||||
final CivilDate date;
|
final CivilDate date;
|
||||||
|
|
||||||
|
/// Stable read clock used for deterministic demo operations.
|
||||||
final DateTime readAt;
|
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({
|
factory DemoSchedulerComposition.seeded({
|
||||||
Clock? clock,
|
Clock? clock,
|
||||||
CivilDate? selectedDate,
|
CivilDate? selectedDate,
|
||||||
|
|
@ -87,6 +114,7 @@ class DemoSchedulerComposition {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates the controller used by the compact Today screen mockup.
|
||||||
TodayScreenController createTodayScreenController() {
|
TodayScreenController createTodayScreenController() {
|
||||||
return TodayScreenController(
|
return TodayScreenController(
|
||||||
read: () {
|
read: () {
|
||||||
|
|
@ -101,6 +129,7 @@ class DemoSchedulerComposition {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates a generic read controller for Today-state panes.
|
||||||
UiReadController<TodayState> createTodayController() {
|
UiReadController<TodayState> createTodayController() {
|
||||||
return ApplicationReadController<TodayState>(
|
return ApplicationReadController<TodayState>(
|
||||||
read: () {
|
read: () {
|
||||||
|
|
@ -118,6 +147,7 @@ class DemoSchedulerComposition {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates a generic read controller for backlog panes.
|
||||||
UiReadController<BacklogQueryResult> createBacklogController() {
|
UiReadController<BacklogQueryResult> createBacklogController() {
|
||||||
return ApplicationReadController<BacklogQueryResult>(
|
return ApplicationReadController<BacklogQueryResult>(
|
||||||
read: () {
|
read: () {
|
||||||
|
|
@ -132,6 +162,7 @@ class DemoSchedulerComposition {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates the command controller used by interactive scheduler controls.
|
||||||
SchedulerCommandController createCommandController({
|
SchedulerCommandController createCommandController({
|
||||||
required ReadRefresh refreshReads,
|
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(
|
return ApplicationOperationContext.start(
|
||||||
operationId: operationId,
|
operationId: operationId,
|
||||||
ownerTimeZone: OwnerTimeZoneContext(
|
ownerTimeZone: OwnerTimeZoneContext(
|
||||||
ownerId: ownerId,
|
ownerId: ownerId,
|
||||||
timeZoneId: timeZoneId,
|
timeZoneId: timeZoneId,
|
||||||
),
|
),
|
||||||
clock: FixedClock(readAt),
|
clock: FixedClock(now ?? readAt),
|
||||||
idGenerator: SequentialIdGenerator(prefix: operationId),
|
idGenerator: SequentialIdGenerator(prefix: operationId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
/// Composes the FocusFlow Flutter application around Focus Flow App.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../controllers/scheduler_command_controller.dart';
|
||||||
import '../controllers/today_screen_controller.dart';
|
import '../controllers/today_screen_controller.dart';
|
||||||
import '../models/today_screen_models.dart';
|
import '../models/today_screen_models.dart';
|
||||||
import '../theme/focus_flow_theme.dart';
|
import '../theme/focus_flow_theme.dart';
|
||||||
|
|
@ -15,11 +19,22 @@ import '../widgets/timeline/timeline_view.dart';
|
||||||
import '../widgets/top_bar.dart';
|
import '../widgets/top_bar.dart';
|
||||||
import 'demo_scheduler_composition.dart';
|
import 'demo_scheduler_composition.dart';
|
||||||
|
|
||||||
|
part 'home/focus_flow_home.dart';
|
||||||
|
part 'home/focus_flow_home_state.dart';
|
||||||
|
part 'home/plan_issue.dart';
|
||||||
|
part 'home/today_screen_scaffold.dart';
|
||||||
|
part 'home/today_screen_scaffold_state.dart';
|
||||||
|
|
||||||
|
/// Root Material app for the FocusFlow desktop UI.
|
||||||
class FocusFlowApp extends StatelessWidget {
|
class FocusFlowApp extends StatelessWidget {
|
||||||
|
/// Creates a FocusFlow app from an application composition.
|
||||||
const FocusFlowApp({required this.composition, super.key});
|
const FocusFlowApp({required this.composition, super.key});
|
||||||
|
|
||||||
|
/// Factory and controller bundle used by the app tree.
|
||||||
final DemoSchedulerComposition composition;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
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.
|
/// Provides compatibility composition exports for FocusFlow Flutter.
|
||||||
library;
|
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.
|
/// Coordinates Scheduler Command Controller state for the FocusFlow Flutter UI.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:scheduler_core/scheduler_core.dart';
|
import 'package:scheduler_core/scheduler_core.dart';
|
||||||
|
|
||||||
typedef OperationContextFactory =
|
part 'command/scheduler_command_callbacks.dart';
|
||||||
ApplicationOperationContext Function(String operationId);
|
part 'command/scheduler_command_controller_impl.dart';
|
||||||
|
part 'command/scheduler_command_state.dart';
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
/// Coordinates Scheduler Read Controller state for the FocusFlow Flutter UI.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:scheduler_core/scheduler_core.dart';
|
import 'package:scheduler_core/scheduler_core.dart';
|
||||||
|
|
||||||
|
/// Reads an application value for a UI surface.
|
||||||
typedef ApplicationReader<T> = Future<ApplicationResult<T>> Function();
|
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);
|
typedef EmptyPredicate<T> = bool Function(T value);
|
||||||
|
|
||||||
|
/// Shared interface for read controllers consumed by UI widgets.
|
||||||
abstract class UiReadController<T> extends ChangeNotifier {
|
abstract class UiReadController<T> extends ChangeNotifier {
|
||||||
|
/// Current read state.
|
||||||
SchedulerReadState<T> get state;
|
SchedulerReadState<T> get state;
|
||||||
|
|
||||||
|
/// Loads the backing value.
|
||||||
Future<void> load();
|
Future<void> load();
|
||||||
|
|
||||||
|
/// Retries the latest read.
|
||||||
Future<void> retry();
|
Future<void> retry();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Base state for scheduler read operations.
|
||||||
sealed class SchedulerReadState<T> {
|
sealed class SchedulerReadState<T> {
|
||||||
|
/// Creates a scheduler read state.
|
||||||
const SchedulerReadState();
|
const SchedulerReadState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Indicates that a scheduler read is in progress.
|
||||||
class SchedulerReadLoading<T> extends SchedulerReadState<T> {
|
class SchedulerReadLoading<T> extends SchedulerReadState<T> {
|
||||||
|
/// Creates a loading read state.
|
||||||
const SchedulerReadLoading();
|
const SchedulerReadLoading();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Indicates that a scheduler read returned renderable data.
|
||||||
class SchedulerReadData<T> extends SchedulerReadState<T> {
|
class SchedulerReadData<T> extends SchedulerReadState<T> {
|
||||||
|
/// Creates a data state from [value].
|
||||||
const SchedulerReadData(this.value);
|
const SchedulerReadData(this.value);
|
||||||
|
|
||||||
|
/// Value returned by the read operation.
|
||||||
final T value;
|
final T value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Indicates that a scheduler read succeeded but has no primary content.
|
||||||
class SchedulerReadEmpty<T> extends SchedulerReadState<T> {
|
class SchedulerReadEmpty<T> extends SchedulerReadState<T> {
|
||||||
|
/// Creates an empty state that still carries the read [value].
|
||||||
const SchedulerReadEmpty(this.value);
|
const SchedulerReadEmpty(this.value);
|
||||||
|
|
||||||
|
/// Value returned by the read operation.
|
||||||
final T value;
|
final T value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Indicates that a scheduler read failed.
|
||||||
class SchedulerReadFailure<T> extends SchedulerReadState<T> {
|
class SchedulerReadFailure<T> extends SchedulerReadState<T> {
|
||||||
|
/// Creates a failure state from an application [failure] or unexpected [error].
|
||||||
const SchedulerReadFailure({this.failure, this.error});
|
const SchedulerReadFailure({this.failure, this.error});
|
||||||
|
|
||||||
|
/// Typed application failure returned by the scheduler core.
|
||||||
final ApplicationFailure? failure;
|
final ApplicationFailure? failure;
|
||||||
|
|
||||||
|
/// Unexpected error thrown while running the read.
|
||||||
final Object? error;
|
final Object? error;
|
||||||
|
|
||||||
|
/// Stable label for display and diagnostics.
|
||||||
String get codeLabel {
|
String get codeLabel {
|
||||||
final typed = failure;
|
final typed = failure;
|
||||||
if (typed != null) {
|
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> {
|
class ApplicationReadController<T> extends UiReadController<T> {
|
||||||
|
/// Creates a controller from a read callback and empty-state predicate.
|
||||||
ApplicationReadController({required this.read, required this.isEmpty});
|
ApplicationReadController({required this.read, required this.isEmpty});
|
||||||
|
|
||||||
|
/// Callback used to load the application value.
|
||||||
final ApplicationReader<T> read;
|
final ApplicationReader<T> read;
|
||||||
|
|
||||||
|
/// Predicate used to classify a successful value as empty or populated.
|
||||||
final EmptyPredicate<T> isEmpty;
|
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>();
|
SchedulerReadState<T> _state = SchedulerReadLoading<T>();
|
||||||
|
|
||||||
|
/// Current read state.
|
||||||
@override
|
@override
|
||||||
SchedulerReadState<T> get state => _state;
|
SchedulerReadState<T> get state => _state;
|
||||||
|
|
||||||
|
/// Loads the current value and updates [state].
|
||||||
@override
|
@override
|
||||||
Future<void> load() async {
|
Future<void> load() async {
|
||||||
_setState(SchedulerReadLoading<T>());
|
_setState(SchedulerReadLoading<T>());
|
||||||
|
|
@ -82,9 +117,12 @@ class ApplicationReadController<T> extends UiReadController<T> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retries by running [load] again.
|
||||||
@override
|
@override
|
||||||
Future<void> retry() => load();
|
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) {
|
void _setState(SchedulerReadState<T> next) {
|
||||||
_state = next;
|
_state = next;
|
||||||
notifyListeners();
|
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.
|
/// Coordinates Today Screen Controller state for the FocusFlow Flutter UI.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
|
@ -6,44 +9,68 @@ import 'package:scheduler_core/scheduler_core.dart';
|
||||||
|
|
||||||
import '../models/today_screen_models.dart';
|
import '../models/today_screen_models.dart';
|
||||||
|
|
||||||
|
/// Reads Today state from the scheduler application layer.
|
||||||
typedef TodayStateReader = Future<ApplicationResult<TodayState>> Function();
|
typedef TodayStateReader = Future<ApplicationResult<TodayState>> Function();
|
||||||
|
|
||||||
|
/// Base state for the compact Today screen.
|
||||||
sealed class TodayScreenState {
|
sealed class TodayScreenState {
|
||||||
|
/// Creates a Today screen state.
|
||||||
const TodayScreenState();
|
const TodayScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Indicates that the Today screen is loading.
|
||||||
class TodayScreenLoading extends TodayScreenState {
|
class TodayScreenLoading extends TodayScreenState {
|
||||||
|
/// Creates a loading Today screen state.
|
||||||
const TodayScreenLoading();
|
const TodayScreenLoading();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Indicates that the Today screen has renderable timeline data.
|
||||||
class TodayScreenReady extends TodayScreenState {
|
class TodayScreenReady extends TodayScreenState {
|
||||||
|
/// Creates a ready state with mapped screen [data].
|
||||||
const TodayScreenReady(this.data);
|
const TodayScreenReady(this.data);
|
||||||
|
|
||||||
|
/// Presentation model for the Today screen.
|
||||||
final TodayScreenData data;
|
final TodayScreenData data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Indicates that the Today screen loaded successfully with no cards.
|
||||||
class TodayScreenEmpty extends TodayScreenState {
|
class TodayScreenEmpty extends TodayScreenState {
|
||||||
|
/// Creates an empty Today screen state.
|
||||||
const TodayScreenEmpty();
|
const TodayScreenEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Indicates that the Today screen failed to load.
|
||||||
class TodayScreenFailure extends TodayScreenState {
|
class TodayScreenFailure extends TodayScreenState {
|
||||||
|
/// Creates a failure state with a displayable [message].
|
||||||
const TodayScreenFailure(this.message);
|
const TodayScreenFailure(this.message);
|
||||||
|
|
||||||
|
/// User-facing failure message or code.
|
||||||
final String message;
|
final String message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Loads Today screen data and tracks selected timeline cards.
|
||||||
class TodayScreenController extends ChangeNotifier {
|
class TodayScreenController extends ChangeNotifier {
|
||||||
|
/// Creates a Today screen controller from a read callback.
|
||||||
TodayScreenController({required this.read});
|
TodayScreenController({required this.read});
|
||||||
|
|
||||||
|
/// Callback used to read Today state.
|
||||||
final TodayStateReader read;
|
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();
|
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;
|
TimelineCardModel? _selectedCard;
|
||||||
|
|
||||||
|
/// Current Today screen loading/data/failure state.
|
||||||
TodayScreenState get state => _state;
|
TodayScreenState get state => _state;
|
||||||
|
|
||||||
|
/// Currently selected timeline card, if a modal should be visible.
|
||||||
TimelineCardModel? get selectedCard => _selectedCard;
|
TimelineCardModel? get selectedCard => _selectedCard;
|
||||||
|
|
||||||
|
/// Loads Today state and maps it into presentation data.
|
||||||
Future<void> load() async {
|
Future<void> load() async {
|
||||||
_state = const TodayScreenLoading();
|
_state = const TodayScreenLoading();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
@ -57,6 +84,7 @@ class TodayScreenController extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
final data = TodayScreenData.fromTodayState(result.requireValue);
|
final data = TodayScreenData.fromTodayState(result.requireValue);
|
||||||
|
_syncSelectedCard(data);
|
||||||
_state = data.cards.isEmpty
|
_state = data.cards.isEmpty
|
||||||
? const TodayScreenEmpty()
|
? const TodayScreenEmpty()
|
||||||
: TodayScreenReady(data);
|
: TodayScreenReady(data);
|
||||||
|
|
@ -67,6 +95,7 @@ class TodayScreenController extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Selects [card] when the card allows selection.
|
||||||
void selectCard(TimelineCardModel card) {
|
void selectCard(TimelineCardModel card) {
|
||||||
if (!card.isSelectable) {
|
if (!card.isSelectable) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -75,6 +104,7 @@ class TodayScreenController extends ChangeNotifier {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clears the selected timeline card.
|
||||||
void clearSelection() {
|
void clearSelection() {
|
||||||
if (_selectedCard == null) {
|
if (_selectedCard == null) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -82,4 +112,20 @@ class TodayScreenController extends ChangeNotifier {
|
||||||
_selectedCard = null;
|
_selectedCard = null;
|
||||||
notifyListeners();
|
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.
|
/// Launches the FocusFlow Flutter desktop application.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
|
@ -9,6 +12,7 @@ import 'app/focus_flow_app.dart';
|
||||||
export 'app/demo_scheduler_composition.dart';
|
export 'app/demo_scheduler_composition.dart';
|
||||||
export 'app/focus_flow_app.dart';
|
export 'app/focus_flow_app.dart';
|
||||||
|
|
||||||
|
/// Starts the FocusFlow app with the seeded demo scheduler composition.
|
||||||
void main() {
|
void main() {
|
||||||
runApp(FocusFlowApp(composition: DemoSchedulerComposition.seeded()));
|
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.
|
/// Defines Today Screen Models models for the FocusFlow Flutter UI.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:scheduler_core/scheduler_core.dart';
|
import 'package:scheduler_core/scheduler_core.dart';
|
||||||
|
|
||||||
class RequiredBannerModel {
|
part 'today/required_banner_model.dart';
|
||||||
const RequiredBannerModel({required this.title, required this.timeText});
|
part 'today/task_visual_kind.dart';
|
||||||
|
part 'today/time/time_string_formatter.dart';
|
||||||
final String title;
|
part 'today/timeline_card_model.dart';
|
||||||
final String timeText;
|
part 'today/timeline_item_presentation_mapper.dart';
|
||||||
}
|
part 'today/timeline_range_model.dart';
|
||||||
|
part 'today/today_screen_data.dart';
|
||||||
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';
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
/// Defines Focus Flow Theme styling for the FocusFlow Flutter UI.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
|
@ -5,7 +8,9 @@ import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'focus_flow_tokens.dart';
|
import 'focus_flow_tokens.dart';
|
||||||
|
|
||||||
|
/// Theme factory for the FocusFlow Flutter app.
|
||||||
abstract final class FocusFlowTheme {
|
abstract final class FocusFlowTheme {
|
||||||
|
/// Builds the dark Material theme used by the desktop app.
|
||||||
static ThemeData dark() {
|
static ThemeData dark() {
|
||||||
final scheme = ColorScheme.fromSeed(
|
final scheme = ColorScheme.fromSeed(
|
||||||
seedColor: FocusFlowTokens.accentMagenta,
|
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.
|
/// Defines Focus Flow Tokens styling for the FocusFlow Flutter UI.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Shared visual constants for the FocusFlow Flutter app.
|
||||||
abstract final class FocusFlowTokens {
|
abstract final class FocusFlowTokens {
|
||||||
|
/// Root application background color.
|
||||||
static const appBackground = Color(0xFF070811);
|
static const appBackground = Color(0xFF070811);
|
||||||
|
|
||||||
|
/// Border color for the app frame.
|
||||||
static const appFrameBorder = Color(0xFF3B3D4E);
|
static const appFrameBorder = Color(0xFF3B3D4E);
|
||||||
|
|
||||||
|
/// Sidebar background color.
|
||||||
static const sidebarBackground = Color(0xFF090A14);
|
static const sidebarBackground = Color(0xFF090A14);
|
||||||
|
|
||||||
|
/// Standard panel background color.
|
||||||
static const panelBackground = Color(0xFF11121D);
|
static const panelBackground = Color(0xFF11121D);
|
||||||
|
|
||||||
|
/// Elevated panel background color.
|
||||||
static const elevatedPanel = Color(0xFF181724);
|
static const elevatedPanel = Color(0xFF181724);
|
||||||
|
|
||||||
|
/// Translucent panel color for soft surfaces.
|
||||||
static const glassPanel = Color(0x662A1025);
|
static const glassPanel = Color(0x662A1025);
|
||||||
|
|
||||||
|
/// Stronger translucent panel color for selected surfaces.
|
||||||
static const glassPanelStrong = Color(0x992A1025);
|
static const glassPanelStrong = Color(0x992A1025);
|
||||||
|
|
||||||
|
/// Timeline grid line color.
|
||||||
static const gridLine = Color(0x243B3D4E);
|
static const gridLine = Color(0x243B3D4E);
|
||||||
|
|
||||||
|
/// Timeline rail color.
|
||||||
static const rail = Color(0xFF7D7F91);
|
static const rail = Color(0xFF7D7F91);
|
||||||
|
|
||||||
|
/// Subtle border color for controls and panels.
|
||||||
static const subtleBorder = Color(0xFF3A3A4A);
|
static const subtleBorder = Color(0xFF3A3A4A);
|
||||||
|
|
||||||
|
/// Active navigation border color.
|
||||||
static const navBorder = Color(0xFFB72D82);
|
static const navBorder = Color(0xFFB72D82);
|
||||||
|
|
||||||
|
/// Primary accent color.
|
||||||
static const accentMagenta = Color(0xFFFF5BA8);
|
static const accentMagenta = Color(0xFFFF5BA8);
|
||||||
|
|
||||||
|
/// Primary text color.
|
||||||
static const textPrimary = Color(0xFFF7F2FA);
|
static const textPrimary = Color(0xFFF7F2FA);
|
||||||
|
|
||||||
|
/// Muted text color.
|
||||||
static const textMuted = Color(0xFFBBB5C8);
|
static const textMuted = Color(0xFFBBB5C8);
|
||||||
|
|
||||||
|
/// Faint text color.
|
||||||
static const textFaint = Color(0xFF8E889D);
|
static const textFaint = Color(0xFF8E889D);
|
||||||
|
|
||||||
|
/// Text color for completed task labels.
|
||||||
static const completedText = Color(0xFF8F8A99);
|
static const completedText = Color(0xFF8F8A99);
|
||||||
|
|
||||||
|
/// Accent color for flexible tasks.
|
||||||
static const flexibleGreen = Color(0xFF4BE064);
|
static const flexibleGreen = Color(0xFF4BE064);
|
||||||
|
|
||||||
|
/// Accent color for required tasks.
|
||||||
static const requiredPink = Color(0xFFFF4E7D);
|
static const requiredPink = Color(0xFFFF4E7D);
|
||||||
|
|
||||||
|
/// Accent color for appointment tasks.
|
||||||
static const appointmentBlue = Color(0xFF38A8FF);
|
static const appointmentBlue = Color(0xFF38A8FF);
|
||||||
|
|
||||||
|
/// Accent color for rest and free-slot surfaces.
|
||||||
static const restPurple = Color(0xFFB45CFF);
|
static const restPurple = Color(0xFFB45CFF);
|
||||||
|
|
||||||
|
/// Accent color for completed tasks.
|
||||||
static const completedGray = Color(0xFF777482);
|
static const completedGray = Color(0xFF777482);
|
||||||
|
|
||||||
|
/// Warning accent color.
|
||||||
static const warningYellow = Color(0xFFFFCC66);
|
static const warningYellow = Color(0xFFFFCC66);
|
||||||
|
|
||||||
|
/// Fixed sidebar width.
|
||||||
static const sidebarWidth = 250.0;
|
static const sidebarWidth = 250.0;
|
||||||
|
|
||||||
|
/// Horizontal gutter for main app content.
|
||||||
static const mainGutter = 32.0;
|
static const mainGutter = 32.0;
|
||||||
|
|
||||||
|
/// Width of the timeline time rail.
|
||||||
static const timelineRailWidth = 120.0;
|
static const timelineRailWidth = 120.0;
|
||||||
|
|
||||||
|
/// Horizontal padding inside timeline cards.
|
||||||
static const cardHorizontalPadding = 18.0;
|
static const cardHorizontalPadding = 18.0;
|
||||||
|
|
||||||
|
/// Border radius for the outer app frame.
|
||||||
static const appFrameRadius = 8.0;
|
static const appFrameRadius = 8.0;
|
||||||
|
|
||||||
|
/// Border radius for navigation items.
|
||||||
static const navRadius = 8.0;
|
static const navRadius = 8.0;
|
||||||
|
|
||||||
|
/// Border radius for required-task banners.
|
||||||
static const bannerRadius = 8.0;
|
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;
|
static const modalRadius = 8.0;
|
||||||
|
|
||||||
|
/// Border radius for compact controls.
|
||||||
static const smallButtonRadius = 8.0;
|
static const smallButtonRadius = 8.0;
|
||||||
|
|
||||||
|
/// Font size for page titles.
|
||||||
static const pageTitleSize = 40.0;
|
static const pageTitleSize = 40.0;
|
||||||
|
|
||||||
|
/// Font size for timeline card titles.
|
||||||
static const cardTitleSize = 24.0;
|
static const cardTitleSize = 24.0;
|
||||||
|
|
||||||
|
/// Font size for timeline card metadata.
|
||||||
static const cardMetaSize = 16.0;
|
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;
|
static const modalTitleSize = 26.0;
|
||||||
|
|
||||||
|
/// Font size for modal action buttons.
|
||||||
static const modalButtonSize = 18.0;
|
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.
|
/// Defines Scheduler Visual Tokens styling for the FocusFlow Flutter UI.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:scheduler_core/scheduler_core.dart';
|
import 'package:scheduler_core/scheduler_core.dart';
|
||||||
|
|
||||||
|
/// Maps scheduler domain tokens to Flutter colors and icons.
|
||||||
abstract final class SchedulerVisualTokens {
|
abstract final class SchedulerVisualTokens {
|
||||||
|
/// Returns the color associated with a project color [token].
|
||||||
static Color projectColor(String token) {
|
static Color projectColor(String token) {
|
||||||
return switch (token) {
|
return switch (token) {
|
||||||
'home' => const Color(0xFF68B0AB),
|
'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(
|
static Color backgroundColor(
|
||||||
TimelineBackgroundToken token,
|
TimelineBackgroundToken token,
|
||||||
ColorScheme scheme,
|
ColorScheme scheme,
|
||||||
|
|
@ -27,6 +33,7 @@ abstract final class SchedulerVisualTokens {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the icon associated with a task [type].
|
||||||
static IconData taskIcon(TaskType type) {
|
static IconData taskIcon(TaskType type) {
|
||||||
return switch (type) {
|
return switch (type) {
|
||||||
TaskType.flexible => Icons.task_alt,
|
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) {
|
static IconData rewardIcon(TimelineRewardIconToken token) {
|
||||||
return switch (token) {
|
return switch (token) {
|
||||||
TimelineRewardIconToken.notSet => Icons.remove_circle_outline,
|
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) {
|
static IconData difficultyIcon(TimelineDifficultyIconToken token) {
|
||||||
return switch (token) {
|
return switch (token) {
|
||||||
TimelineDifficultyIconToken.notSet => Icons.remove_circle_outline,
|
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) {
|
static IconData stalenessIcon(BacklogStalenessMarker marker) {
|
||||||
return switch (marker) {
|
return switch (marker) {
|
||||||
BacklogStalenessMarker.green => Icons.circle,
|
BacklogStalenessMarker.green => Icons.circle,
|
||||||
|
|
@ -68,6 +78,7 @@ abstract final class SchedulerVisualTokens {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the color associated with a backlog staleness marker.
|
||||||
static Color stalenessColor(
|
static Color stalenessColor(
|
||||||
BacklogStalenessMarker marker,
|
BacklogStalenessMarker marker,
|
||||||
ColorScheme scheme,
|
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.
|
/// Renders the App Shell widget for the FocusFlow Flutter UI.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
|
@ -5,12 +8,19 @@ import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../theme/focus_flow_tokens.dart';
|
import '../theme/focus_flow_tokens.dart';
|
||||||
|
|
||||||
|
/// Top-level shell that frames the sidebar and main content area.
|
||||||
class AppShell extends StatelessWidget {
|
class AppShell extends StatelessWidget {
|
||||||
|
/// Creates an app shell with a [sidebar] and main [child].
|
||||||
const AppShell({required this.sidebar, required this.child, super.key});
|
const AppShell({required this.sidebar, required this.child, super.key});
|
||||||
|
|
||||||
|
/// Sidebar widget displayed at the left edge of the app.
|
||||||
final Widget sidebar;
|
final Widget sidebar;
|
||||||
|
|
||||||
|
/// Primary content widget displayed beside the sidebar.
|
||||||
final Widget child;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ColoredBox(
|
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.
|
/// Renders the Backlog Pane widget for the FocusFlow Flutter UI.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
|
@ -9,16 +12,23 @@ import '../controllers/scheduler_read_controller.dart';
|
||||||
import 'read_state_view.dart';
|
import 'read_state_view.dart';
|
||||||
import 'schedule_components.dart';
|
import 'schedule_components.dart';
|
||||||
|
|
||||||
|
/// Read-driven pane that renders backlog content and command controls.
|
||||||
class BacklogPane extends StatelessWidget {
|
class BacklogPane extends StatelessWidget {
|
||||||
|
/// Creates a backlog pane from a read [controller].
|
||||||
const BacklogPane({
|
const BacklogPane({
|
||||||
required this.controller,
|
required this.controller,
|
||||||
this.commandController,
|
this.commandController,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Controller that loads backlog data.
|
||||||
final UiReadController<BacklogQueryResult> controller;
|
final UiReadController<BacklogQueryResult> controller;
|
||||||
|
|
||||||
|
/// Optional command controller for mutating backlog items.
|
||||||
final SchedulerCommandController? commandController;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ReadStateView<BacklogQueryResult>(
|
return ReadStateView<BacklogQueryResult>(
|
||||||
|
|
@ -32,16 +42,23 @@ class BacklogPane extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Populated backlog view for a loaded backlog query result.
|
||||||
class BacklogContent extends StatelessWidget {
|
class BacklogContent extends StatelessWidget {
|
||||||
|
/// Creates backlog content for [result].
|
||||||
const BacklogContent({
|
const BacklogContent({
|
||||||
required this.result,
|
required this.result,
|
||||||
this.commandController,
|
this.commandController,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Loaded backlog result to display.
|
||||||
final BacklogQueryResult result;
|
final BacklogQueryResult result;
|
||||||
|
|
||||||
|
/// Optional command controller for capture and scheduling actions.
|
||||||
final SchedulerCommandController? commandController;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListView(
|
return ListView(
|
||||||
|
|
@ -73,24 +90,37 @@ class BacklogContent extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Small form for quickly capturing a task into the backlog.
|
||||||
class QuickCaptureForm extends StatefulWidget {
|
class QuickCaptureForm extends StatefulWidget {
|
||||||
|
/// Creates a quick capture form.
|
||||||
const QuickCaptureForm({required this.commandController, super.key});
|
const QuickCaptureForm({required this.commandController, super.key});
|
||||||
|
|
||||||
|
/// Command controller used to submit captured task titles.
|
||||||
final SchedulerCommandController commandController;
|
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
|
@override
|
||||||
State<QuickCaptureForm> createState() => _QuickCaptureFormState();
|
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> {
|
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();
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
titleController.dispose();
|
titleController.dispose();
|
||||||
super.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Row(
|
return Row(
|
||||||
|
|
@ -121,11 +151,16 @@ class _QuickCaptureFormState extends State<QuickCaptureForm> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Displays the latest command state for backlog interactions.
|
||||||
class CommandStatusBanner extends StatelessWidget {
|
class CommandStatusBanner extends StatelessWidget {
|
||||||
|
/// Creates a command status banner.
|
||||||
const CommandStatusBanner({required this.controller, super.key});
|
const CommandStatusBanner({required this.controller, super.key});
|
||||||
|
|
||||||
|
/// Command controller whose state should be rendered.
|
||||||
final SchedulerCommandController controller;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AnimatedBuilder(
|
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.
|
/// Renders the Read State View widget for the FocusFlow Flutter UI.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
|
@ -5,7 +8,9 @@ import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../controllers/scheduler_read_controller.dart';
|
import '../controllers/scheduler_read_controller.dart';
|
||||||
|
|
||||||
|
/// Renders loading, empty, failure, and data states from a read controller.
|
||||||
class ReadStateView<T> extends StatelessWidget {
|
class ReadStateView<T> extends StatelessWidget {
|
||||||
|
/// Creates a read-state view.
|
||||||
const ReadStateView({
|
const ReadStateView({
|
||||||
required this.controller,
|
required this.controller,
|
||||||
required this.title,
|
required this.title,
|
||||||
|
|
@ -15,12 +20,23 @@ class ReadStateView<T> extends StatelessWidget {
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Controller that owns the current read state.
|
||||||
final UiReadController<T> controller;
|
final UiReadController<T> controller;
|
||||||
|
|
||||||
|
/// Name of the surface being loaded, used in failure copy.
|
||||||
final String title;
|
final String title;
|
||||||
|
|
||||||
|
/// Title shown when the read succeeds with empty content.
|
||||||
final String emptyTitle;
|
final String emptyTitle;
|
||||||
|
|
||||||
|
/// Message shown when the read succeeds with empty content.
|
||||||
final String emptyMessage;
|
final String emptyMessage;
|
||||||
|
|
||||||
|
/// Builds the populated content for a read value.
|
||||||
final Widget Function(BuildContext context, T value) dataBuilder;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
|
|
@ -52,7 +68,9 @@ class ReadStateView<T> extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Standard empty-state panel with optional surrounding content.
|
||||||
class EmptyStatePanel extends StatelessWidget {
|
class EmptyStatePanel extends StatelessWidget {
|
||||||
|
/// Creates an empty-state panel.
|
||||||
const EmptyStatePanel({
|
const EmptyStatePanel({
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.message,
|
required this.message,
|
||||||
|
|
@ -60,10 +78,17 @@ class EmptyStatePanel extends StatelessWidget {
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Primary empty-state title.
|
||||||
final String title;
|
final String title;
|
||||||
|
|
||||||
|
/// Supporting empty-state message.
|
||||||
final String message;
|
final String message;
|
||||||
|
|
||||||
|
/// Optional content shown alongside the empty-state panel.
|
||||||
final Widget? child;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final panel = Padding(
|
final panel = Padding(
|
||||||
|
|
@ -97,7 +122,9 @@ class EmptyStatePanel extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Standard failure panel with a retry action.
|
||||||
class FailureStatePanel extends StatelessWidget {
|
class FailureStatePanel extends StatelessWidget {
|
||||||
|
/// Creates a failure-state panel.
|
||||||
const FailureStatePanel({
|
const FailureStatePanel({
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.code,
|
required this.code,
|
||||||
|
|
@ -106,11 +133,20 @@ class FailureStatePanel extends StatelessWidget {
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Primary failure title.
|
||||||
final String title;
|
final String title;
|
||||||
|
|
||||||
|
/// Displayable failure code.
|
||||||
final String code;
|
final String code;
|
||||||
|
|
||||||
|
/// Optional technical detail for unexpected errors.
|
||||||
final String? technicalDetail;
|
final String? technicalDetail;
|
||||||
|
|
||||||
|
/// Callback invoked when the user retries the read.
|
||||||
final Future<void> Function() onRetry;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Center(
|
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.
|
/// Renders the Required Banner widget for the FocusFlow Flutter UI.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
|
@ -6,87 +9,243 @@ import 'package:flutter/material.dart';
|
||||||
import '../models/today_screen_models.dart';
|
import '../models/today_screen_models.dart';
|
||||||
import '../theme/focus_flow_tokens.dart';
|
import '../theme/focus_flow_tokens.dart';
|
||||||
|
|
||||||
|
/// Banner that highlights the next required task, if one exists.
|
||||||
class RequiredBanner extends StatelessWidget {
|
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;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final banner = model;
|
final banner = model;
|
||||||
if (banner == null) {
|
if (banner == null) {
|
||||||
return const SizedBox(height: 72);
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final metrics = _RequiredBannerMetrics.fromWidth(constraints.maxWidth);
|
||||||
return Container(
|
return Container(
|
||||||
height: 72,
|
height: _RequiredBannerMetrics.referenceHeight,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 18),
|
padding: EdgeInsets.symmetric(horizontal: metrics.horizontalPadding),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: FocusFlowTokens.glassPanel,
|
color: FocusFlowTokens.glassPanel,
|
||||||
borderRadius: BorderRadius.circular(FocusFlowTokens.bannerRadius),
|
borderRadius: BorderRadius.circular(metrics.bannerRadius),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: FocusFlowTokens.navBorder.withValues(alpha: 0.55),
|
color: FocusFlowTokens.navBorder.withValues(alpha: 0.55),
|
||||||
|
width: metrics.borderWidth,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 48,
|
width: metrics.iconCircleSize,
|
||||||
height: 48,
|
height: metrics.iconCircleSize,
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: FocusFlowTokens.glassPanelStrong,
|
color: FocusFlowTokens.glassPanelStrong,
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: Icon(
|
||||||
Icons.auto_awesome,
|
Icons.auto_awesome,
|
||||||
color: FocusFlowTokens.accentMagenta,
|
color: FocusFlowTokens.accentMagenta,
|
||||||
|
size: metrics.iconSize,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 26),
|
SizedBox(width: metrics.iconGap),
|
||||||
RichText(
|
Expanded(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: FittedBox(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: RichText(
|
||||||
text: TextSpan(
|
text: TextSpan(
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: FocusFlowTokens.textPrimary,
|
color: FocusFlowTokens.textPrimary,
|
||||||
fontSize: 19,
|
fontSize: metrics.textSize,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
const TextSpan(text: 'Next required task: '),
|
const TextSpan(text: 'Next required task: '),
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: banner.title,
|
text: banner.title,
|
||||||
style: const TextStyle(color: FocusFlowTokens.accentMagenta),
|
style: const TextStyle(
|
||||||
|
color: FocusFlowTokens.accentMagenta,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: ' at ${banner.timeText}',
|
text: ' at ${banner.timeText}',
|
||||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontSize: metrics.textSize,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
),
|
||||||
OutlinedButton(
|
),
|
||||||
onPressed: () {},
|
),
|
||||||
|
SizedBox(width: metrics.buttonGap),
|
||||||
|
_UpcomingButton(metrics: metrics, onPressed: onShowUpcoming),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Private implementation type for `_RequiredBannerMetrics` in this library.
|
||||||
|
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
|
||||||
|
class _RequiredBannerMetrics {
|
||||||
|
/// Creates a `_RequiredBannerMetrics` instance with the values required by its invariants.
|
||||||
|
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
||||||
|
const _RequiredBannerMetrics(this.scale);
|
||||||
|
|
||||||
|
/// Shared constant value for `referenceHeight`.
|
||||||
|
/// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites.
|
||||||
|
static const referenceHeight = 72.0;
|
||||||
|
|
||||||
|
/// Shared constant value for `_referenceWidth`.
|
||||||
|
/// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites.
|
||||||
|
static const _referenceWidth = 760.0;
|
||||||
|
|
||||||
|
/// Shared constant value for `_minimumScale`.
|
||||||
|
/// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites.
|
||||||
|
static const _minimumScale = 0.5;
|
||||||
|
|
||||||
|
/// Creates a `_RequiredBannerMetrics.fromWidth` instance with the values required by its invariants.
|
||||||
|
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
||||||
|
factory _RequiredBannerMetrics.fromWidth(double width) {
|
||||||
|
final safeWidth = width.isFinite ? width : _referenceWidth;
|
||||||
|
final scale = (safeWidth / _referenceWidth).clamp(_minimumScale, 1.0);
|
||||||
|
return _RequiredBannerMetrics(scale.toDouble());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores the `scale` value for this object.
|
||||||
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
|
final double scale;
|
||||||
|
|
||||||
|
/// Returns the derived `horizontalPadding` value for this object.
|
||||||
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||||
|
double get horizontalPadding => 18 * scale;
|
||||||
|
|
||||||
|
/// Returns the derived `bannerRadius` value for this object.
|
||||||
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||||
|
double get bannerRadius => FocusFlowTokens.bannerRadius * scale;
|
||||||
|
|
||||||
|
/// Returns the derived `borderWidth` value for this object.
|
||||||
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||||
|
double get borderWidth => scale < 0.7 ? 0.8 : 1;
|
||||||
|
|
||||||
|
/// Returns the derived `iconCircleSize` value for this object.
|
||||||
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||||
|
double get iconCircleSize => 48 * scale;
|
||||||
|
|
||||||
|
/// Returns the derived `iconSize` value for this object.
|
||||||
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||||
|
double get iconSize => 24 * scale;
|
||||||
|
|
||||||
|
/// Returns the derived `iconGap` value for this object.
|
||||||
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||||
|
double get iconGap => 26 * scale;
|
||||||
|
|
||||||
|
/// Returns the derived `buttonGap` value for this object.
|
||||||
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||||
|
double get buttonGap => 20 * scale;
|
||||||
|
|
||||||
|
/// Returns the derived `textSize` value for this object.
|
||||||
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||||
|
double get textSize => (19 * scale).clamp(13.0, 14.0).toDouble();
|
||||||
|
|
||||||
|
/// Returns the derived `buttonWidth` value for this object.
|
||||||
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||||
|
double get buttonWidth => 176 * scale;
|
||||||
|
|
||||||
|
/// Returns the derived `buttonHeight` value for this object.
|
||||||
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||||
|
double get buttonHeight => 46 * scale;
|
||||||
|
|
||||||
|
/// Returns the derived `buttonRadius` value for this object.
|
||||||
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||||
|
double get buttonRadius => FocusFlowTokens.smallButtonRadius * scale;
|
||||||
|
|
||||||
|
/// Returns the derived `buttonTextSize` value for this object.
|
||||||
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||||
|
double get buttonTextSize => (16 * scale).clamp(11.0, 12.5).toDouble();
|
||||||
|
|
||||||
|
/// Returns the derived `buttonIconSize` value for this object.
|
||||||
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||||
|
double get buttonIconSize => 20 * scale;
|
||||||
|
|
||||||
|
/// Returns the derived `buttonTextGap` value for this object.
|
||||||
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||||
|
double get buttonTextGap => 6 * scale;
|
||||||
|
|
||||||
|
/// Returns the derived `buttonPadding` value for this object.
|
||||||
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||||
|
EdgeInsets get buttonPadding => EdgeInsets.symmetric(horizontal: 12 * scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Private implementation type for `_UpcomingButton` in this library.
|
||||||
|
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
|
||||||
|
class _UpcomingButton extends StatelessWidget {
|
||||||
|
/// Creates a `_UpcomingButton` instance with the values required by its invariants.
|
||||||
|
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
||||||
|
const _UpcomingButton({required this.metrics, required this.onPressed});
|
||||||
|
|
||||||
|
/// Stores the `metrics` value for this object.
|
||||||
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
|
final _RequiredBannerMetrics metrics;
|
||||||
|
|
||||||
|
/// Stores the `onPressed` value for this object.
|
||||||
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
|
||||||
|
/// Builds the widget subtree for this component.
|
||||||
|
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return OutlinedButton(
|
||||||
|
onPressed: onPressed,
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: FocusFlowTokens.textPrimary,
|
foregroundColor: FocusFlowTokens.textPrimary,
|
||||||
side: BorderSide(
|
side: BorderSide(
|
||||||
color: FocusFlowTokens.navBorder.withValues(alpha: 0.55),
|
color: FocusFlowTokens.navBorder.withValues(alpha: 0.55),
|
||||||
|
width: metrics.borderWidth,
|
||||||
),
|
),
|
||||||
fixedSize: const Size(176, 46),
|
fixedSize: Size(metrics.buttonWidth, metrics.buttonHeight),
|
||||||
|
minimumSize: Size.zero,
|
||||||
|
padding: metrics.buttonPadding,
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(
|
borderRadius: BorderRadius.circular(metrics.buttonRadius),
|
||||||
FocusFlowTokens.smallButtonRadius,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
child: FittedBox(
|
||||||
child: const FittedBox(
|
|
||||||
fit: BoxFit.scaleDown,
|
fit: BoxFit.scaleDown,
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [Text('Show upcoming'), Icon(Icons.chevron_right)],
|
children: [
|
||||||
),
|
Text(
|
||||||
|
'Show upcoming',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: metrics.buttonTextSize,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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.
|
/// Renders the Schedule Components widget for the FocusFlow Flutter UI.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
|
@ -6,11 +9,16 @@ import 'package:scheduler_core/scheduler_core.dart';
|
||||||
|
|
||||||
import '../theme/scheduler_visual_tokens.dart';
|
import '../theme/scheduler_visual_tokens.dart';
|
||||||
|
|
||||||
|
/// Compact summary panel for timeline items.
|
||||||
class CompactPanel extends StatelessWidget {
|
class CompactPanel extends StatelessWidget {
|
||||||
|
/// Creates a compact panel for [items].
|
||||||
const CompactPanel({required this.items, super.key});
|
const CompactPanel({required this.items, super.key});
|
||||||
|
|
||||||
|
/// Timeline items to display in compact form.
|
||||||
final List<TodayTimelineItem> items;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (items.isEmpty) {
|
if (items.isEmpty) {
|
||||||
|
|
@ -28,12 +36,19 @@ class CompactPanel extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Card row for a single Today timeline item.
|
||||||
class TimelineRow extends StatelessWidget {
|
class TimelineRow extends StatelessWidget {
|
||||||
|
/// Creates a timeline row.
|
||||||
const TimelineRow({required this.item, this.onDone, super.key});
|
const TimelineRow({required this.item, this.onDone, super.key});
|
||||||
|
|
||||||
|
/// Timeline item to render.
|
||||||
final TodayTimelineItem item;
|
final TodayTimelineItem item;
|
||||||
|
|
||||||
|
/// Optional completion callback for eligible tasks.
|
||||||
final Future<void> Function()? onDone;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
|
@ -85,25 +100,40 @@ class TimelineRow extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Backlog row with optional scheduling controls.
|
||||||
class BacklogRow extends StatefulWidget {
|
class BacklogRow extends StatefulWidget {
|
||||||
|
/// Creates a backlog row.
|
||||||
const BacklogRow({required this.item, this.onSchedule, super.key});
|
const BacklogRow({required this.item, this.onSchedule, super.key});
|
||||||
|
|
||||||
|
/// Backlog item to render.
|
||||||
final BacklogItemReadModel item;
|
final BacklogItemReadModel item;
|
||||||
|
|
||||||
|
/// Optional callback that schedules the item for a duration in minutes.
|
||||||
final Future<void> Function(int durationMinutes)? onSchedule;
|
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
|
@override
|
||||||
State<BacklogRow> createState() => _BacklogRowState();
|
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> {
|
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();
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
durationController.dispose();
|
durationController.dispose();
|
||||||
super.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
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 {
|
class StalenessMarker extends StatelessWidget {
|
||||||
|
/// Creates a staleness marker.
|
||||||
const StalenessMarker({required this.marker, super.key});
|
const StalenessMarker({required this.marker, super.key});
|
||||||
|
|
||||||
|
/// Staleness marker value to render.
|
||||||
final BacklogStalenessMarker marker;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Icon(
|
return Icon(
|
||||||
|
|
@ -168,11 +203,16 @@ class StalenessMarker extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Notice banner for pending Today-state messages.
|
||||||
class NoticeBanner extends StatelessWidget {
|
class NoticeBanner extends StatelessWidget {
|
||||||
|
/// Creates a notice banner.
|
||||||
const NoticeBanner({required this.message, super.key});
|
const NoticeBanner({required this.message, super.key});
|
||||||
|
|
||||||
|
/// Notice message to display.
|
||||||
final String message;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
|
|
@ -187,6 +227,7 @@ class NoticeBanner extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Formats a scheduler timeline item as display-ready time text.
|
||||||
String timeText(TimelineItem item) {
|
String timeText(TimelineItem item) {
|
||||||
final start = item.start;
|
final start = item.start;
|
||||||
final end = item.end;
|
final end = item.end;
|
||||||
|
|
@ -196,6 +237,8 @@ String timeText(TimelineItem item) {
|
||||||
return '${_clockText(start)}-${_clockText(end)}';
|
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) {
|
String _clockText(DateTime value) {
|
||||||
final hour = value.hour.toString().padLeft(2, '0');
|
final hour = value.hour.toString().padLeft(2, '0');
|
||||||
final minute = value.minute.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.
|
/// Renders the Sidebar widget for the FocusFlow Flutter UI.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
|
@ -5,9 +8,13 @@ import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../theme/focus_flow_tokens.dart';
|
import '../theme/focus_flow_tokens.dart';
|
||||||
|
|
||||||
|
/// Static sidebar for primary FocusFlow navigation.
|
||||||
class Sidebar extends StatelessWidget {
|
class Sidebar extends StatelessWidget {
|
||||||
|
/// Creates the FocusFlow sidebar.
|
||||||
const Sidebar({super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DecoratedBox(
|
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 {
|
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();
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const Row(
|
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 {
|
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});
|
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;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DecoratedBox(
|
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 {
|
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();
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Row(
|
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 {
|
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({
|
const _NavItem({
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
|
|
@ -117,11 +148,24 @@ class _NavItem extends StatelessWidget {
|
||||||
this.active = false,
|
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;
|
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;
|
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;
|
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;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final color = active
|
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.
|
/// Renders the Task Selection Modal widget for the FocusFlow Flutter UI.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
|
@ -8,16 +11,33 @@ import '../theme/focus_flow_tokens.dart';
|
||||||
import 'timeline/difficulty_bars.dart';
|
import 'timeline/difficulty_bars.dart';
|
||||||
import 'timeline/reward_icon.dart';
|
import 'timeline/reward_icon.dart';
|
||||||
|
|
||||||
|
part 'task_selection/action_button.dart';
|
||||||
|
part 'task_selection/header_metadata.dart';
|
||||||
|
part 'task_selection/info_tile.dart';
|
||||||
|
part 'task_selection/modal_accent.dart';
|
||||||
|
part 'task_selection/modal_status_button.dart';
|
||||||
|
|
||||||
|
/// Modal shown when a selectable timeline task card is opened.
|
||||||
class TaskSelectionModal extends StatelessWidget {
|
class TaskSelectionModal extends StatelessWidget {
|
||||||
|
/// Creates a task selection modal for [card].
|
||||||
const TaskSelectionModal({
|
const TaskSelectionModal({
|
||||||
required this.card,
|
required this.card,
|
||||||
required this.onClose,
|
required this.onClose,
|
||||||
|
this.onStatusPressed,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Card model whose details and actions should be displayed.
|
||||||
final TimelineCardModel card;
|
final TimelineCardModel card;
|
||||||
|
|
||||||
|
/// Callback invoked when the modal should close.
|
||||||
final VoidCallback onClose;
|
final VoidCallback onClose;
|
||||||
|
|
||||||
|
/// Callback invoked when the status circle should toggle completion.
|
||||||
|
final VoidCallback? onStatusPressed;
|
||||||
|
|
||||||
|
/// 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final accent = _accentFor(card.visualKind);
|
final accent = _accentFor(card.visualKind);
|
||||||
|
|
@ -38,13 +58,10 @@ class TaskSelectionModal extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
_ModalStatusButton(
|
||||||
width: 44,
|
card: card,
|
||||||
height: 44,
|
color: accent,
|
||||||
decoration: BoxDecoration(
|
onPressed: onStatusPressed,
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(color: accent, width: 4),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 28),
|
const SizedBox(width: 28),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -60,13 +77,7 @@ class TaskSelectionModal extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
_HeaderMetadata(card: card),
|
||||||
'${card.typeLabel} - ${card.timeText}',
|
|
||||||
style: const TextStyle(
|
|
||||||
color: FocusFlowTokens.textMuted,
|
|
||||||
fontSize: 17,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -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.
|
/// Renders Difficulty Bars pieces for the FocusFlow Flutter timeline.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
|
@ -6,7 +9,9 @@ import 'package:scheduler_core/scheduler_core.dart';
|
||||||
|
|
||||||
import '../../theme/focus_flow_tokens.dart';
|
import '../../theme/focus_flow_tokens.dart';
|
||||||
|
|
||||||
|
/// Five-bar effort indicator for a timeline task.
|
||||||
class DifficultyBars extends StatelessWidget {
|
class DifficultyBars extends StatelessWidget {
|
||||||
|
/// Creates difficulty bars for a scheduler difficulty token.
|
||||||
const DifficultyBars({
|
const DifficultyBars({
|
||||||
required this.difficulty,
|
required this.difficulty,
|
||||||
this.color = FocusFlowTokens.restPurple,
|
this.color = FocusFlowTokens.restPurple,
|
||||||
|
|
@ -14,10 +19,16 @@ class DifficultyBars extends StatelessWidget {
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Difficulty token that determines how many bars are filled.
|
||||||
final TimelineDifficultyIconToken difficulty;
|
final TimelineDifficultyIconToken difficulty;
|
||||||
|
|
||||||
|
/// Color used for filled bars.
|
||||||
final Color color;
|
final Color color;
|
||||||
|
|
||||||
|
/// Fixed paint size for the indicator.
|
||||||
final Size size;
|
final Size size;
|
||||||
|
|
||||||
|
/// Converts a timeline difficulty token to a filled bar count.
|
||||||
static int fillCount(TimelineDifficultyIconToken difficulty) {
|
static int fillCount(TimelineDifficultyIconToken difficulty) {
|
||||||
return switch (difficulty) {
|
return switch (difficulty) {
|
||||||
TimelineDifficultyIconToken.notSet => 0,
|
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) {
|
static int fillCountForLevel(DifficultyLevel difficulty) {
|
||||||
return switch (difficulty) {
|
return switch (difficulty) {
|
||||||
DifficultyLevel.notSet => 0,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return CustomPaint(
|
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 {
|
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({
|
const _DifficultyBarsPainter({
|
||||||
required this.filledCount,
|
required this.filledCount,
|
||||||
required this.color,
|
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;
|
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;
|
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
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
final gap = size.width * 0.08;
|
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
|
@override
|
||||||
bool shouldRepaint(covariant _DifficultyBarsPainter oldDelegate) {
|
bool shouldRepaint(covariant _DifficultyBarsPainter oldDelegate) {
|
||||||
return oldDelegate.filledCount != filledCount || oldDelegate.color != color;
|
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.
|
/// Renders Reward Icon pieces for the FocusFlow Flutter timeline.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
|
@ -5,16 +8,23 @@ import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../theme/focus_flow_tokens.dart';
|
import '../../theme/focus_flow_tokens.dart';
|
||||||
|
|
||||||
|
/// Sparkle-style reward indicator for a timeline task.
|
||||||
class RewardIcon extends StatelessWidget {
|
class RewardIcon extends StatelessWidget {
|
||||||
|
/// Creates a reward icon.
|
||||||
const RewardIcon({
|
const RewardIcon({
|
||||||
this.color = FocusFlowTokens.accentMagenta,
|
this.color = FocusFlowTokens.accentMagenta,
|
||||||
this.size = 24,
|
this.size = 24,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Fill color used by the icon painter.
|
||||||
final Color color;
|
final Color color;
|
||||||
|
|
||||||
|
/// Square size of the painted icon.
|
||||||
final double size;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return CustomPaint(
|
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 {
|
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);
|
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;
|
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
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
final paint = Paint()
|
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) {
|
void _drawSparkle(Canvas canvas, Paint paint, Offset center, double radius) {
|
||||||
final path = Path()
|
final path = Path()
|
||||||
..moveTo(center.dx, center.dy - radius)
|
..moveTo(center.dx, center.dy - radius)
|
||||||
|
|
@ -68,6 +88,8 @@ class _RewardIconPainter extends CustomPainter {
|
||||||
canvas.drawPath(path, paint);
|
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
|
@override
|
||||||
bool shouldRepaint(covariant _RewardIconPainter oldDelegate) {
|
bool shouldRepaint(covariant _RewardIconPainter oldDelegate) {
|
||||||
return oldDelegate.color != color;
|
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.
|
/// Renders Task Timeline Card pieces for the FocusFlow Flutter timeline.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../models/today_screen_models.dart';
|
import '../../models/today_screen_models.dart';
|
||||||
|
|
@ -8,227 +13,38 @@ import '../../theme/focus_flow_tokens.dart';
|
||||||
import 'difficulty_bars.dart';
|
import 'difficulty_bars.dart';
|
||||||
import 'reward_icon.dart';
|
import 'reward_icon.dart';
|
||||||
|
|
||||||
class TaskTimelineCard extends StatelessWidget {
|
part 'task_card/card_colors.dart';
|
||||||
const TaskTimelineCard({required this.card, required this.onTap, super.key});
|
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;
|
/// Interactive card that renders a scheduled task on the compact timeline.
|
||||||
final VoidCallback onTap;
|
class TaskTimelineCard extends StatefulWidget {
|
||||||
|
/// Creates a task timeline card.
|
||||||
@override
|
const TaskTimelineCard({
|
||||||
Widget build(BuildContext context) {
|
required this.card,
|
||||||
final colors = _CardColors.forKind(card.visualKind);
|
required this.onTap,
|
||||||
return Material(
|
this.onStatusPressed,
|
||||||
color: Colors.transparent,
|
super.key,
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final Color accent;
|
/// Card model to render.
|
||||||
final Color background;
|
final TimelineCardModel card;
|
||||||
final Color reward;
|
|
||||||
|
|
||||||
factory _CardColors.forKind(TaskVisualKind kind) {
|
/// Callback invoked when the card is tapped.
|
||||||
return switch (kind) {
|
final VoidCallback onTap;
|
||||||
TaskVisualKind.flexible => const _CardColors(
|
|
||||||
accent: FocusFlowTokens.flexibleGreen,
|
/// Callback invoked when the left status control is clicked.
|
||||||
background: Color(0xCC021A0C),
|
final VoidCallback? onStatusPressed;
|
||||||
reward: FocusFlowTokens.flexibleGreen,
|
|
||||||
),
|
/// Creates the mutable state object used by this stateful widget.
|
||||||
TaskVisualKind.required => const _CardColors(
|
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
|
||||||
accent: FocusFlowTokens.requiredPink,
|
@override
|
||||||
background: Color(0xCC230814),
|
State<TaskTimelineCard> createState() => _TaskTimelineCardState();
|
||||||
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,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
/// Renders Timeline Axis pieces for the FocusFlow Flutter timeline.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
|
@ -6,32 +9,46 @@ import 'package:flutter/material.dart';
|
||||||
import '../../theme/focus_flow_tokens.dart';
|
import '../../theme/focus_flow_tokens.dart';
|
||||||
import 'timeline_geometry.dart';
|
import 'timeline_geometry.dart';
|
||||||
|
|
||||||
class TimelineAxis extends StatelessWidget {
|
part 'axis/timeline_grid.dart';
|
||||||
const TimelineAxis({required this.geometry, super.key});
|
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;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final ticks = geometry.ticks();
|
final ticks = geometry.ticks();
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: FocusFlowTokens.timelineRailWidth,
|
width: FocusFlowTokens.timelineRailWidth,
|
||||||
height: geometry.totalHeight + 24,
|
height: geometry.totalHeight + topInset + 24,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 92,
|
left: 92,
|
||||||
top: 0,
|
top: topInset,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
child: Container(width: 2, color: FocusFlowTokens.rail),
|
child: Container(width: 2, color: FocusFlowTokens.rail),
|
||||||
),
|
),
|
||||||
for (final tick in ticks)
|
for (final tick in ticks)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: tick.y - 10,
|
top: tick.y + topInset - 10,
|
||||||
left: 0,
|
left: 0,
|
||||||
width: 86,
|
width: 86,
|
||||||
child: Text(
|
child: Text(
|
||||||
tick.label,
|
tick.label,
|
||||||
|
maxLines: 1,
|
||||||
|
softWrap: false,
|
||||||
|
overflow: TextOverflow.visible,
|
||||||
textAlign: TextAlign.right,
|
textAlign: TextAlign.right,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: tick.major
|
color: tick.major
|
||||||
|
|
@ -44,7 +61,7 @@ class TimelineAxis extends StatelessWidget {
|
||||||
),
|
),
|
||||||
for (final tick in ticks.where((tick) => tick.major))
|
for (final tick in ticks.where((tick) => tick.major))
|
||||||
Positioned(
|
Positioned(
|
||||||
top: tick.y - 6,
|
top: tick.y + topInset - 6,
|
||||||
left: 87,
|
left: 87,
|
||||||
child: const DecoratedBox(
|
child: const DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -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.
|
/// Renders Timeline Geometry pieces for the FocusFlow Flutter timeline.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
/// Converts timeline clock values into vertical pixel positions.
|
||||||
|
|
||||||
class TimelineGeometry {
|
class TimelineGeometry {
|
||||||
|
/// Creates geometry for a visible time range.
|
||||||
const TimelineGeometry({
|
const TimelineGeometry({
|
||||||
required this.visibleStart,
|
required this.startMinutes,
|
||||||
required this.visibleEnd,
|
required this.endMinutes,
|
||||||
required this.pixelsPerMinute,
|
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;
|
/// First visible minute from midnight.
|
||||||
final TimeOfDay visibleEnd;
|
final int startMinutes;
|
||||||
|
|
||||||
|
/// Last visible minute from midnight.
|
||||||
|
final int endMinutes;
|
||||||
|
|
||||||
|
/// Vertical scale used to convert minutes into pixels.
|
||||||
final double pixelsPerMinute;
|
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;
|
final double minCardHeight;
|
||||||
|
|
||||||
int get startMinutes => visibleStart.hour * 60 + visibleStart.minute;
|
/// Total vertical height of the visible timeline.
|
||||||
|
|
||||||
int get endMinutes => visibleEnd.hour * 60 + visibleEnd.minute;
|
|
||||||
|
|
||||||
double get totalHeight => (endMinutes - startMinutes) * pixelsPerMinute;
|
double get totalHeight => (endMinutes - startMinutes) * pixelsPerMinute;
|
||||||
|
|
||||||
|
/// Returns the vertical offset for [minutes] since midnight.
|
||||||
double yForMinutesSinceMidnight(int minutes) {
|
double yForMinutesSinceMidnight(int minutes) {
|
||||||
return (minutes - startMinutes) * pixelsPerMinute;
|
return (minutes - startMinutes) * pixelsPerMinute;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the rendered height for a duration in minutes.
|
||||||
double heightForDuration(int minutes) {
|
double heightForDuration(int minutes) {
|
||||||
final height = minutes * pixelsPerMinute;
|
final height = minutes * pixelsPerMinute;
|
||||||
return height < minCardHeight ? minCardHeight : height;
|
return height < minCardHeight ? minCardHeight : height;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generates timeline ticks at [stepMinutes] intervals.
|
||||||
List<TimelineTick> ticks({int stepMinutes = 15}) {
|
List<TimelineTick> ticks({int stepMinutes = 15}) {
|
||||||
return [
|
return [
|
||||||
for (
|
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) {
|
static String _labelFor(int minutes) {
|
||||||
final hour24 = minutes ~/ 60;
|
final hour24 = (minutes ~/ 60) % 24;
|
||||||
final minute = minutes % 60;
|
final minute = minutes % 60;
|
||||||
final period = hour24 >= 12 ? 'PM' : 'AM';
|
final period = hour24 >= 12 ? 'PM' : 'AM';
|
||||||
final rawHour = hour24 % 12;
|
final rawHour = hour24 % 12;
|
||||||
|
|
@ -60,7 +102,9 @@ class TimelineGeometry {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A labeled tick on the compact timeline axis.
|
||||||
class TimelineTick {
|
class TimelineTick {
|
||||||
|
/// Creates a timeline tick.
|
||||||
const TimelineTick({
|
const TimelineTick({
|
||||||
required this.minutesSinceMidnight,
|
required this.minutesSinceMidnight,
|
||||||
required this.y,
|
required this.y,
|
||||||
|
|
@ -68,8 +112,15 @@ class TimelineTick {
|
||||||
required this.major,
|
required this.major,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Minute from midnight represented by this tick.
|
||||||
final int minutesSinceMidnight;
|
final int minutesSinceMidnight;
|
||||||
|
|
||||||
|
/// Vertical pixel offset for this tick.
|
||||||
final double y;
|
final double y;
|
||||||
|
|
||||||
|
/// Display label for this tick.
|
||||||
final String label;
|
final String label;
|
||||||
|
|
||||||
|
/// Whether this tick represents a major interval.
|
||||||
final bool major;
|
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.
|
/// Renders Timeline View pieces for the FocusFlow Flutter timeline.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../models/today_screen_models.dart';
|
import '../../models/today_screen_models.dart';
|
||||||
|
|
@ -9,65 +13,47 @@ import 'task_timeline_card.dart';
|
||||||
import 'timeline_axis.dart';
|
import 'timeline_axis.dart';
|
||||||
import 'timeline_geometry.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({
|
const TimelineView({
|
||||||
required this.cards,
|
required this.cards,
|
||||||
required this.range,
|
required this.range,
|
||||||
required this.onCardSelected,
|
required this.onCardSelected,
|
||||||
|
this.onCardCompleted,
|
||||||
|
this.now,
|
||||||
|
this.scrollTargetCardId,
|
||||||
|
this.scrollRequest = 0,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Cards to position on the timeline.
|
||||||
final List<TimelineCardModel> cards;
|
final List<TimelineCardModel> cards;
|
||||||
|
|
||||||
|
/// Visible range used to build the timeline geometry.
|
||||||
final TimelineRangeModel range;
|
final TimelineRangeModel range;
|
||||||
|
|
||||||
|
/// Callback invoked when a card is selected.
|
||||||
final ValueChanged<TimelineCardModel> onCardSelected;
|
final ValueChanged<TimelineCardModel> onCardSelected;
|
||||||
|
|
||||||
|
/// Callback invoked when a task card status control requests completion.
|
||||||
|
final ValueChanged<TimelineCardModel>? onCardCompleted;
|
||||||
|
|
||||||
|
/// Clock used to center the initial scroll position.
|
||||||
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
State<TimelineView> createState() => _TimelineViewState();
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
/// Renders the Today Pane widget for the FocusFlow Flutter UI.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
|
@ -9,16 +12,23 @@ import '../controllers/scheduler_read_controller.dart';
|
||||||
import 'read_state_view.dart';
|
import 'read_state_view.dart';
|
||||||
import 'schedule_components.dart';
|
import 'schedule_components.dart';
|
||||||
|
|
||||||
|
/// Read-driven pane that renders the Today view and optional commands.
|
||||||
class TodayPane extends StatelessWidget {
|
class TodayPane extends StatelessWidget {
|
||||||
|
/// Creates a Today pane from a read [controller].
|
||||||
const TodayPane({
|
const TodayPane({
|
||||||
required this.controller,
|
required this.controller,
|
||||||
this.commandController,
|
this.commandController,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Controller that loads Today state.
|
||||||
final UiReadController<TodayState> controller;
|
final UiReadController<TodayState> controller;
|
||||||
|
|
||||||
|
/// Optional command controller for Today actions.
|
||||||
final SchedulerCommandController? commandController;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ReadStateView<TodayState>(
|
return ReadStateView<TodayState>(
|
||||||
|
|
@ -32,12 +42,19 @@ class TodayPane extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Populated Today view for a loaded Today state.
|
||||||
class TodayContent extends StatelessWidget {
|
class TodayContent extends StatelessWidget {
|
||||||
|
/// Creates Today content for [state].
|
||||||
const TodayContent({required this.state, this.commandController, super.key});
|
const TodayContent({required this.state, this.commandController, super.key});
|
||||||
|
|
||||||
|
/// Loaded Today state to display.
|
||||||
final TodayState state;
|
final TodayState state;
|
||||||
|
|
||||||
|
/// Optional command controller for completing tasks.
|
||||||
final SchedulerCommandController? commandController;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListView(
|
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) {
|
bool _canComplete(TodayTimelineItem item) {
|
||||||
return item.taskType == TaskType.flexible &&
|
return item.taskType == TaskType.flexible &&
|
||||||
item.taskStatus == TaskStatus.planned &&
|
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.
|
/// Renders the Top Bar widget for the FocusFlow Flutter UI.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
|
@ -5,107 +8,84 @@ import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../theme/focus_flow_tokens.dart';
|
import '../theme/focus_flow_tokens.dart';
|
||||||
|
|
||||||
|
part 'top_bar/top_bar_icon_button.dart';
|
||||||
|
part 'top_bar/top_bar_metrics.dart';
|
||||||
|
part 'top_bar/top_bar_mode_toggle.dart';
|
||||||
|
part 'top_bar/top_bar_settings_button.dart';
|
||||||
|
|
||||||
|
/// Header bar for the compact Today view.
|
||||||
class TopBar extends StatelessWidget {
|
class TopBar extends StatelessWidget {
|
||||||
|
/// Creates a top bar with a display-ready [dateLabel].
|
||||||
const TopBar({required this.dateLabel, super.key});
|
const TopBar({required this.dateLabel, super.key});
|
||||||
|
|
||||||
|
/// Date label shown beside the date navigation controls.
|
||||||
final String dateLabel;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Row(
|
return LayoutBuilder(
|
||||||
children: [
|
builder: (context, constraints) {
|
||||||
Text('Today', style: Theme.of(context).textTheme.headlineLarge),
|
final metrics = _TopBarMetrics.fromWidth(constraints.maxWidth);
|
||||||
const SizedBox(width: 30),
|
return SizedBox(
|
||||||
_IconButton(icon: Icons.calendar_today, onPressed: () {}),
|
height: _TopBarMetrics.referenceHeight,
|
||||||
_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(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
SizedBox(
|
||||||
child: Container(
|
width: metrics.titleWidth,
|
||||||
alignment: Alignment.center,
|
height: _TopBarMetrics.referenceHeight,
|
||||||
decoration: BoxDecoration(
|
child: FittedBox(
|
||||||
color: FocusFlowTokens.glassPanelStrong,
|
alignment: Alignment.centerLeft,
|
||||||
borderRadius: BorderRadius.circular(
|
fit: BoxFit.scaleDown,
|
||||||
FocusFlowTokens.smallButtonRadius,
|
child: Text(
|
||||||
),
|
'Today',
|
||||||
border: Border.all(color: FocusFlowTokens.navBorder),
|
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
|
||||||
),
|
fontSize: metrics.titleSize,
|
||||||
child: const Text('Compact'),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
),
|
||||||
child: TextButton(
|
),
|
||||||
|
SizedBox(width: metrics.titleGap),
|
||||||
|
_IconButton(
|
||||||
|
icon: Icons.calendar_today,
|
||||||
|
metrics: metrics,
|
||||||
onPressed: () {},
|
onPressed: () {},
|
||||||
child: const Text(
|
),
|
||||||
'Normal',
|
_IconButton(
|
||||||
style: TextStyle(color: FocusFlowTokens.textPrimary),
|
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
|
name: focus_flow_flutter
|
||||||
description: Provisional Flutter UI foundation for FocusFlow.
|
description: Provisional Flutter UI foundation for FocusFlow.
|
||||||
publish_to: 'none'
|
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.
|
/// Tests Forbidden Imports behavior in the FocusFlow Flutter app.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
|
@ -5,6 +8,7 @@ import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
/// Runs app package-boundary tests.
|
||||||
void main() {
|
void main() {
|
||||||
test('Flutter UI imports stay inside the UI Plan 1 package boundary', () {
|
test('Flutter UI imports stay inside the UI Plan 1 package boundary', () {
|
||||||
final appRoot = Directory.current;
|
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) {
|
Iterable<File> _dartFiles(Directory directory) {
|
||||||
if (!directory.existsSync()) {
|
if (!directory.existsSync()) {
|
||||||
return const <File>[];
|
return const <File>[];
|
||||||
|
|
@ -42,6 +48,8 @@ Iterable<File> _dartFiles(Directory directory) {
|
||||||
.where((file) => file.path.endsWith('.dart'));
|
.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) {
|
List<_ForbiddenImport> _forbiddenImportsFor(String relativePath) {
|
||||||
return [
|
return [
|
||||||
const _ForbiddenImport(
|
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 {
|
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);
|
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;
|
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;
|
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) {
|
bool matches(String contents) {
|
||||||
return RegExp(
|
return RegExp(
|
||||||
r'''import\s+['"]''' + RegExp.escape(pattern),
|
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.
|
/// Tests Widget behavior in the FocusFlow Flutter app.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:scheduler_core/scheduler_core.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/demo_scheduler_composition.dart';
|
||||||
import 'package:focus_flow_flutter/app/focus_flow_app.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/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/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() {
|
void main() {
|
||||||
testWidgets('app shell renders compact Today frame', (tester) async {
|
testWidgets('app shell renders compact Today frame', (tester) async {
|
||||||
await _pumpPlanApp(tester);
|
await _pumpPlanApp(tester);
|
||||||
|
|
@ -36,7 +49,12 @@ void main() {
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(find.text('Clean coffee maker'), findsOneWidget);
|
expect(find.text('Clean coffee maker'), findsOneWidget);
|
||||||
expect(find.text('Cleaned kitchen counter'), findsNWidgets(2));
|
expect(find.text('Cleaned kitchen counter'), findsOneWidget);
|
||||||
|
expect(find.text('Completed at: 8:05 AM'), findsOneWidget);
|
||||||
|
expect(find.textContaining('Surprise task'), findsNothing);
|
||||||
|
expect(find.text('Sort mail'), findsOneWidget);
|
||||||
|
expect(find.text('Start laundry'), findsOneWidget);
|
||||||
|
expect(find.text('Water plants'), findsOneWidget);
|
||||||
expect(find.text('Pay bill'), findsOneWidget);
|
expect(find.text('Pay bill'), findsOneWidget);
|
||||||
expect(find.text('Doctor appointment'), findsOneWidget);
|
expect(find.text('Doctor appointment'), findsOneWidget);
|
||||||
expect(find.text('Free Slot'), findsOneWidget);
|
expect(find.text('Free Slot'), findsOneWidget);
|
||||||
|
|
@ -50,11 +68,14 @@ void main() {
|
||||||
final data = TodayScreenData.fromTodayState(state);
|
final data = TodayScreenData.fromTodayState(state);
|
||||||
|
|
||||||
expect(_kindFor(data, 'clean-coffee-maker'), TaskVisualKind.flexible);
|
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, 'pay-bill'), TaskVisualKind.required);
|
||||||
expect(_kindFor(data, 'doctor-appointment'), TaskVisualKind.appointment);
|
expect(_kindFor(data, 'doctor-appointment'), TaskVisualKind.appointment);
|
||||||
expect(_kindFor(data, 'free-slot'), TaskVisualKind.freeSlot);
|
expect(_kindFor(data, 'free-slot'), TaskVisualKind.freeSlot);
|
||||||
expect(
|
expect(
|
||||||
_kindFor(data, 'cleaned-kitchen-counter-early'),
|
_kindFor(data, 'cleaned-kitchen-counter'),
|
||||||
TaskVisualKind.completedSurprise,
|
TaskVisualKind.completedSurprise,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -76,6 +97,7 @@ void main() {
|
||||||
|
|
||||||
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
|
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.tap(find.byKey(const ValueKey('pay-bill')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
|
@ -100,6 +122,7 @@ void main() {
|
||||||
|
|
||||||
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
|
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.tap(find.byKey(const ValueKey('pay-bill')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tapAt(const Offset(1450, 140));
|
await tester.tapAt(const Offset(1450, 140));
|
||||||
|
|
@ -108,19 +131,525 @@ void main() {
|
||||||
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
|
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
|
||||||
expect(find.text('Pay bill'), findsOneWidget);
|
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));
|
await tester.binding.setSurfaceSize(const Size(1586, 992));
|
||||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
await tester.pumpWidget(FocusFlowApp(composition: _composition()));
|
await tester.pumpWidget(
|
||||||
|
FocusFlowApp(composition: composition ?? _composition()),
|
||||||
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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() {
|
DemoSchedulerComposition _composition() {
|
||||||
return DemoSchedulerComposition.seeded(selectedDate: CivilDate(2025, 5, 20));
|
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 {
|
Future<TodayState> _readSeededToday() async {
|
||||||
final composition = _composition();
|
final composition = _composition();
|
||||||
final result = await composition.todayQuery.execute(
|
final result = await composition.todayQuery.execute(
|
||||||
|
|
@ -132,6 +661,109 @@ Future<TodayState> _readSeededToday() async {
|
||||||
return result.requireValue;
|
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) {
|
TaskVisualKind _kindFor(TodayScreenData data, String id) {
|
||||||
return data.cards.singleWhere((card) => card.id == id).visualKind;
|
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.
|
/// Runs the Backup command for the Scheduler Backup package.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
|
@ -5,6 +8,8 @@ import 'dart:io';
|
||||||
|
|
||||||
import 'package:scheduler_backup/backup.dart';
|
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 {
|
Future<void> main(List<String> arguments) async {
|
||||||
final passphrase = _option(arguments, '--passphrase') ??
|
final passphrase = _option(arguments, '--passphrase') ??
|
||||||
Platform.environment['SCHEDULER_BACKUP_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) {
|
String? _option(List<String> arguments, String name) {
|
||||||
final index = arguments.indexOf(name);
|
final index = arguments.indexOf(name);
|
||||||
if (index == -1 || index + 1 >= arguments.length) {
|
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.
|
/// Encrypted SQLite backup helpers.
|
||||||
library;
|
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.
|
/// Package-name entry point for scheduler backup helpers.
|
||||||
library;
|
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.
|
/// Implements Encrypted Backup behavior for the Scheduler Backup package.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
|
@ -7,6 +10,12 @@ import 'dart:math';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:cryptography/cryptography.dart';
|
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.
|
/// Default scheduler data directory name under the user's home directory.
|
||||||
const defaultSchedulerDirectoryName = 'ADHD_Scheduler';
|
const defaultSchedulerDirectoryName = 'ADHD_Scheduler';
|
||||||
|
|
@ -17,249 +26,30 @@ const defaultSchedulerDatabaseFileName = 'scheduler.sqlite';
|
||||||
/// Default encrypted backup directory name under the scheduler data directory.
|
/// Default encrypted backup directory name under the scheduler data directory.
|
||||||
const defaultSchedulerBackupDirectoryName = 'backups';
|
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';
|
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';
|
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;
|
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;
|
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;
|
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;
|
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;
|
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
|
name: scheduler_backup
|
||||||
description: Encrypted SQLite backup and restore helpers for the ADHD scheduler.
|
description: Encrypted SQLite backup and restore helpers for the ADHD scheduler.
|
||||||
version: 0.1.0
|
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.
|
/// Tests Encrypted Backup behavior for the Scheduler Backup package.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
|
@ -7,6 +10,8 @@ import 'dart:io';
|
||||||
import 'package:scheduler_backup/backup.dart';
|
import 'package:scheduler_backup/backup.dart';
|
||||||
import 'package:test/test.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() {
|
void main() {
|
||||||
test('encrypted backup round trips a SQLite file using temp paths', () async {
|
test('encrypted backup round trips a SQLite file using temp paths', () async {
|
||||||
final temp =
|
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:
|
include: ../../analysis_options.yaml
|
||||||
language:
|
|
||||||
strict-casts: true
|
|
||||||
strict-inference: true
|
|
||||||
strict-raw-types: true
|
|
||||||
|
|
||||||
linter:
|
|
||||||
rules:
|
|
||||||
prefer_final_locals: true
|
|
||||||
prefer_final_fields: true
|
|
||||||
avoid_print: true
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
/// Defines the Scheduler Core public library for the Scheduler Core package.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
|
@ -19,28 +22,28 @@ library;
|
||||||
//
|
//
|
||||||
// Keep this file small. Implementation details belong in `lib/src/`.
|
// Keep this file small. Implementation details belong in `lib/src/`.
|
||||||
|
|
||||||
export 'src/models.dart';
|
export 'src/domain/models.dart';
|
||||||
export 'src/application_commands.dart';
|
export 'src/application/application_commands.dart';
|
||||||
export 'src/application_layer.dart';
|
export 'src/application/application_layer.dart';
|
||||||
export 'src/application_management.dart';
|
export 'src/application/application_management.dart';
|
||||||
export 'src/application_recovery.dart';
|
export 'src/application/application_recovery.dart';
|
||||||
export 'src/backlog.dart';
|
export 'src/scheduling/backlog.dart';
|
||||||
export 'src/child_tasks.dart';
|
export 'src/scheduling/child_tasks.dart';
|
||||||
export 'src/document_mapping.dart';
|
export 'src/persistence/document_mapping.dart';
|
||||||
export 'src/document_migration.dart';
|
export 'src/persistence/document_migration.dart';
|
||||||
export 'src/free_slots.dart';
|
export 'src/scheduling/free_slots.dart';
|
||||||
export 'src/locked_time.dart';
|
export 'src/domain/locked_time.dart';
|
||||||
export 'src/occupancy_policy.dart';
|
export 'src/domain/occupancy_policy.dart';
|
||||||
export 'src/persistence_contract.dart';
|
export 'src/persistence/persistence_contract.dart';
|
||||||
export 'src/project_statistics.dart';
|
export 'src/domain/project_statistics.dart';
|
||||||
export 'src/quick_capture.dart';
|
export 'src/scheduling/quick_capture.dart';
|
||||||
export 'src/reminder_policy.dart';
|
export 'src/scheduling/reminder_policy.dart';
|
||||||
export 'src/repositories.dart';
|
export 'src/persistence/repositories.dart';
|
||||||
export 'src/repository_values.dart';
|
export 'src/persistence/repository_values.dart';
|
||||||
export 'src/scheduling_engine.dart';
|
export 'src/scheduling/scheduling_engine.dart';
|
||||||
export 'src/task_actions.dart';
|
export 'src/scheduling/task_actions.dart';
|
||||||
export 'src/task_lifecycle.dart';
|
export 'src/scheduling/task_lifecycle.dart';
|
||||||
export 'src/task_statistics.dart';
|
export 'src/domain/task_statistics.dart';
|
||||||
export 'src/time_contracts.dart';
|
export 'src/domain/time_contracts.dart';
|
||||||
export 'src/today_state.dart';
|
export 'src/scheduling/today_state.dart';
|
||||||
export 'src/timeline_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