merge: task pushing controls
Some checks failed
CI / test (macos-latest) (push) Has been cancelled
CI / test (ubuntu-latest) (push) Has been cancelled
CI / test (windows-latest) (push) Has been cancelled
CI / package (macos-latest, macos) (push) Has been cancelled
CI / package (ubuntu-latest, linux) (push) Has been cancelled
CI / package (windows-latest, windows) (push) Has been cancelled
Some checks failed
CI / test (macos-latest) (push) Has been cancelled
CI / test (ubuntu-latest) (push) Has been cancelled
CI / test (windows-latest) (push) Has been cancelled
CI / package (macos-latest, macos) (push) Has been cancelled
CI / package (ubuntu-latest, linux) (push) Has been cancelled
CI / package (windows-latest, windows) (push) Has been cancelled
This commit is contained in:
commit
a488d1a5f5
132 changed files with 6420 additions and 352 deletions
|
|
@ -16,6 +16,9 @@ Included documents cover:
|
||||||
normal Flutter startup and close/reopen task lifecycle proof.
|
normal Flutter startup and close/reopen task lifecycle proof.
|
||||||
- Date Selection 01, the completed day navigation and date picker plan for the
|
- Date Selection 01, the completed day navigation and date picker plan for the
|
||||||
persistence-backed Flutter Today timeline.
|
persistence-backed Flutter Today timeline.
|
||||||
|
- Task Pushing 01, the completed past-task push controls plan for planned
|
||||||
|
flexible tasks, including backend scheduling semantics and Backlog freshness
|
||||||
|
reset behavior.
|
||||||
|
|
||||||
Excluded documents include superseded originals, MongoDB-targeted persistence or
|
Excluded documents include superseded originals, MongoDB-targeted persistence or
|
||||||
runtime plans, planned/blocked UI handoff plans, old archive indexes, and review
|
runtime plans, planned/blocked UI handoff plans, old archive indexes, and review
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
# Task Pushing 01 — Past Task Push Controls
|
# Task Pushing 01 — Past Task Push Controls
|
||||||
|
|
||||||
**Status:** Planned. Execute after Date Selection 01.
|
**Status:** Complete. Executed after Date Selection 01.
|
||||||
**Scope level:** XHIGH implementation plan.
|
**Scope level:** XHIGH implementation plan.
|
||||||
|
|
||||||
This plan adds the push affordance for scheduled tasks that are in the past and
|
This plan adds the push affordance for scheduled tasks that are in the past and
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
# Task Pushing 01 Block 01 — Scope and Domain Semantics
|
# Task Pushing 01 Block 01 — Scope and Domain Semantics
|
||||||
|
|
||||||
**Status:** Planned.
|
**Status:** Complete.
|
||||||
**Goal:** Confirm the exact pushable task scope and lock destination semantics
|
**Goal:** Confirm the exact pushable task scope and lock destination semantics
|
||||||
before adding visible controls.
|
before adding visible controls.
|
||||||
|
|
||||||
|
|
@ -15,6 +15,21 @@ before adding visible controls.
|
||||||
After this block, the implementation should know which cards can show `Push`,
|
After this block, the implementation should know which cards can show `Push`,
|
||||||
what `past` means, and what each destination must do.
|
what `past` means, and what each destination must do.
|
||||||
|
|
||||||
|
## Implementation notes
|
||||||
|
|
||||||
|
1. Task Pushing 01 keeps V1 support limited to planned flexible tasks. Required,
|
||||||
|
locked, surprise, and free-slot cards must not show the full Push button.
|
||||||
|
2. `past incomplete` is evaluated against owner-local current time. Historical
|
||||||
|
selected dates can make an incomplete scheduled task pushable, but future
|
||||||
|
selected dates do not make tasks pushable before their scheduled end.
|
||||||
|
3. `Push to next` targets the next valid no-overlap slot after real current
|
||||||
|
owner-local time. Later-today placement is preferred when a fit exists.
|
||||||
|
4. `Push to tomorrow` targets the owner-local day after real current time, not
|
||||||
|
the day after the selected review date.
|
||||||
|
5. `Push to backlog` preserves `createdAt` and resets freshness through the
|
||||||
|
existing `backlogEnteredAt` persistence field, promoted into domain/read
|
||||||
|
behavior by this plan.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Chunk 1.1 — Confirm prerequisites
|
## Chunk 1.1 — Confirm prerequisites
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
# Task Pushing 01 Block 02 — Past Task Read Model
|
# Task Pushing 01 Block 02 — Past Task Read Model
|
||||||
|
|
||||||
**Status:** Planned.
|
**Status:** Complete.
|
||||||
**Goal:** Add presentation-model state that tells widgets when to show the full
|
**Goal:** Add presentation-model state that tells widgets when to show the full
|
||||||
Push button.
|
Push button.
|
||||||
|
|
||||||
|
|
@ -15,6 +15,17 @@ Push button.
|
||||||
After this block, `TimelineCardModel` or its successor exposes a clear push-state
|
After this block, `TimelineCardModel` or its successor exposes a clear push-state
|
||||||
field. Leaf widgets should not recompute past/incomplete scheduling conditions.
|
field. Leaf widgets should not recompute past/incomplete scheduling conditions.
|
||||||
|
|
||||||
|
## Implementation notes
|
||||||
|
|
||||||
|
1. `TimelineItem` carries the source task `updatedAt` timestamp for stale-write
|
||||||
|
protection.
|
||||||
|
2. `TodayScreenData` and `TimelineCardModel` receive an injected clock for
|
||||||
|
deterministic past/incomplete detection.
|
||||||
|
3. `TimelineCardModel.showPastTaskPushButton` identifies past incomplete planned
|
||||||
|
flexible cards and suppresses hover quick actions while true.
|
||||||
|
4. Mapper tests cover earlier today, previous day, future day, completed,
|
||||||
|
unsupported task types, and missing schedule placement.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Chunk 2.1 — Add push-state presentation fields
|
## Chunk 2.1 — Add push-state presentation fields
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
# Task Pushing 01 Block 03 — Push Button Card UI
|
# Task Pushing 01 Block 03 — Push Button Card UI
|
||||||
|
|
||||||
**Status:** Planned.
|
**Status:** Complete.
|
||||||
**Goal:** Render the full right-side Push button on past incomplete cards.
|
**Goal:** Render the full right-side Push button on past incomplete cards.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -15,6 +15,16 @@ After this block, past incomplete planned flexible cards visibly show a `Push`
|
||||||
button beside the reward/difficulty icons. The menu may still be stubbed until
|
button beside the reward/difficulty icons. The menu may still be stubbed until
|
||||||
Block 04.
|
Block 04.
|
||||||
|
|
||||||
|
## Implementation notes
|
||||||
|
|
||||||
|
1. `TaskTimelineCard` renders a full Push button beside reward/difficulty
|
||||||
|
controls when `TimelineCardModel.showPastTaskPushButton` is true.
|
||||||
|
2. Text reserves additional right-side space while Push is visible, and trailing
|
||||||
|
controls remain above the text layer.
|
||||||
|
3. Hover quick actions are not built while the full Push button is visible.
|
||||||
|
4. Widget tests cover render state, completed/future exclusions, hover
|
||||||
|
suppression, and callback activation.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Chunk 3.1 — Place the Push button in trailing controls
|
## Chunk 3.1 — Place the Push button in trailing controls
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
# Task Pushing 01 Block 04 — Push Menu Destinations
|
# Task Pushing 01 Block 04 — Push Menu Destinations
|
||||||
|
|
||||||
**Status:** Planned.
|
**Status:** Complete.
|
||||||
**Goal:** Open a destination menu from the Push button and route each choice to a
|
**Goal:** Open a destination menu from the Push button and route each choice to a
|
||||||
callback.
|
callback.
|
||||||
|
|
||||||
|
|
@ -16,6 +16,17 @@ After this block, clicking `Push` opens a menu with the three requested choices.
|
||||||
The choices may call stubbed/controller callbacks until Block 05 wires real
|
The choices may call stubbed/controller callbacks until Block 05 wires real
|
||||||
commands.
|
commands.
|
||||||
|
|
||||||
|
## Implementation notes
|
||||||
|
|
||||||
|
1. The Push button opens a compact popup menu with exactly `Push to next`,
|
||||||
|
`Push to tomorrow`, and `Push to backlog`.
|
||||||
|
2. Destination-specific callbacks are threaded from the home scaffold through
|
||||||
|
`TimelineView` to `TaskTimelineCard`.
|
||||||
|
3. The card prevents duplicate destination submission while a push callback is
|
||||||
|
still running.
|
||||||
|
4. Widget tests cover menu labels, destination callbacks, outside-click
|
||||||
|
dismissal, and one-shot running behavior.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Chunk 4.1 — Add destination menu UI
|
## Chunk 4.1 — Add destination menu UI
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
# Task Pushing 01 Block 05 — Command Wiring and Persistence
|
# Task Pushing 01 Block 05 — Command Wiring and Persistence
|
||||||
|
|
||||||
**Status:** Planned.
|
**Status:** Complete.
|
||||||
**Goal:** Connect push menu destinations to application commands and prove their
|
**Goal:** Connect push menu destinations to application commands and prove their
|
||||||
results persist.
|
results persist.
|
||||||
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
# Task Pushing 01 Block 06 — Destination Scheduling Rules
|
# Task Pushing 01 Block 06 — Destination Scheduling Rules
|
||||||
|
|
||||||
**Status:** Planned.
|
**Status:** Complete.
|
||||||
**Goal:** Verify or adjust backend push scheduling so destination behavior exactly
|
**Goal:** Verify or adjust backend push scheduling so destination behavior exactly
|
||||||
matches the requested product rules.
|
matches the requested product rules.
|
||||||
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
# Task Pushing 01 Block 07 — Backlog Freshness Reset
|
# Task Pushing 01 Block 07 — Backlog Freshness Reset
|
||||||
|
|
||||||
**Status:** Planned.
|
**Status:** Complete.
|
||||||
**Goal:** Make Push to backlog reset the backlog freshness timer without losing
|
**Goal:** Make Push to backlog reset the backlog freshness timer without losing
|
||||||
original task creation history.
|
original task creation history.
|
||||||
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
# Task Pushing 01 Block 08 — Validation and Handoff
|
# Task Pushing 01 Block 08 — Validation and Handoff
|
||||||
|
|
||||||
**Status:** Planned.
|
**Status:** Complete.
|
||||||
**Goal:** Prove task pushing works end to end and close the plan cleanly.
|
**Goal:** Prove task pushing works end to end and close the plan cleanly.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -113,3 +113,35 @@ flutter test
|
||||||
- Push results persist through SQLite.
|
- Push results persist through SQLite.
|
||||||
- Push to Backlog resets freshness without rewriting creation history.
|
- Push to Backlog resets freshness without rewriting creation history.
|
||||||
- Validation status is documented.
|
- Validation status is documented.
|
||||||
|
|
||||||
|
## Handoff notes
|
||||||
|
|
||||||
|
Implemented changes:
|
||||||
|
|
||||||
|
1. Added past-task push presentation state, button/menu UI, and command
|
||||||
|
callbacks.
|
||||||
|
2. Routed all three destinations through `SchedulerCommandController` and
|
||||||
|
`V1ApplicationCommandUseCases`.
|
||||||
|
3. Added scheduler/application support for `Push to next` after command time,
|
||||||
|
including overdue tasks from previous dates.
|
||||||
|
4. Added `Task.backlogEnteredAt` and `backlogEnteredAtProvenance`; Backlog age
|
||||||
|
filtering and staleness markers now use that freshness anchor with
|
||||||
|
`createdAt` fallback.
|
||||||
|
5. Added on-disk SQLite close/reopen coverage for push-next, push-tomorrow,
|
||||||
|
push-backlog, and completion after push.
|
||||||
|
|
||||||
|
Validation completed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd packages/scheduler_core
|
||||||
|
dart test test/document_mapping_test.dart test/scheduling_engine_test.dart test/application_commands_test.dart test/persistence_edge_cases_test.dart test/edge_case_regression_test.dart test/task_lifecycle_test.dart
|
||||||
|
cd ../../apps/focus_flow_flutter
|
||||||
|
flutter test test/models/past_task_push_presentation_test.dart test/widget_test.dart test/persistent_composition_test.dart
|
||||||
|
flutter test test/persistent_composition_test.dart
|
||||||
|
```
|
||||||
|
|
||||||
|
Unverified gates:
|
||||||
|
|
||||||
|
1. Full `scripts/test.sh`, full package `dart analyze`, and full Flutter
|
||||||
|
analyze/test were not rerun for this closeout. Earlier hook/analyzer runs in
|
||||||
|
this branch hit the known Dart analysis server `Too many open files` failure.
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
# Task Pushing 01 Execution Order
|
# Task Pushing 01 Execution Order
|
||||||
|
|
||||||
**Status:** Planned.
|
**Status:** Complete.
|
||||||
|
|
||||||
Run these files in order. All chunks are intended for `XHIGH` execution unless a
|
Run these files in order. All chunks are intended for `XHIGH` execution unless a
|
||||||
chunk explicitly says otherwise.
|
chunk explicitly says otherwise.
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
# Task Pushing 01 Summary — Past Task Push Controls
|
# Task Pushing 01 Summary — Past Task Push Controls
|
||||||
|
|
||||||
**Status:** Planned.
|
**Status:** Complete.
|
||||||
**Scope level:** XHIGH implementation plan.
|
**Scope level:** XHIGH implementation plan.
|
||||||
**Primary outcome:** Past, incomplete scheduled tasks expose a persistent push
|
**Primary outcome:** Past, incomplete scheduled tasks expose a persistent push
|
||||||
control that can move the task to the next available slot, tomorrow's first open
|
control that can move the task to the next available slot, tomorrow's first open
|
||||||
|
|
@ -91,6 +91,30 @@ Task Pushing 01 is complete when all of the following are true:
|
||||||
11. UI widgets do not contain scheduling, Drift, SQL, or repository logic.
|
11. UI widgets do not contain scheduling, Drift, SQL, or repository logic.
|
||||||
12. Validation gates pass, or unavailable commands are documented.
|
12. Validation gates pass, or unavailable commands are documented.
|
||||||
|
|
||||||
|
## Completion notes
|
||||||
|
|
||||||
|
Implemented on branch `task-pushing-01-past-task-controls`.
|
||||||
|
|
||||||
|
Key decisions:
|
||||||
|
|
||||||
|
1. Push support remains limited to planned flexible tasks in V1.
|
||||||
|
2. `Push to next` uses the real current owner-local date/time, not the selected
|
||||||
|
review date, and never schedules before that instant.
|
||||||
|
3. `Push to tomorrow` targets the next owner-local civil day relative to command
|
||||||
|
time.
|
||||||
|
4. `Push to backlog` records `backlogEnteredAt` and provenance while preserving
|
||||||
|
original `createdAt`.
|
||||||
|
|
||||||
|
Validation completed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd packages/scheduler_core
|
||||||
|
dart test test/document_mapping_test.dart test/scheduling_engine_test.dart test/application_commands_test.dart test/persistence_edge_cases_test.dart test/edge_case_regression_test.dart test/task_lifecycle_test.dart
|
||||||
|
cd ../../apps/focus_flow_flutter
|
||||||
|
flutter test test/models/past_task_push_presentation_test.dart test/widget_test.dart test/persistent_composition_test.dart
|
||||||
|
flutter test test/persistent_composition_test.dart
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Non-goals
|
## Non-goals
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
Ordered implementation queue:
|
Ordered implementation queue:
|
||||||
|
|
||||||
1. `Task Pushing 01 - Past Task Push Controls/` — Next incomplete plan.
|
No active implementation plans remain in this folder.
|
||||||
|
|
||||||
Execute only the first incomplete plan.
|
Execute only the first incomplete plan.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||||
|
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||||
|
|
||||||
|
# Deleted Task Statistics
|
||||||
|
|
||||||
|
Consider how the system should remember lightweight statistics for tasks that
|
||||||
|
have been deleted, while still allowing deleted tasks to be fully removed from
|
||||||
|
task persistence.
|
||||||
|
|
||||||
|
The likely direction is a small, separate deletion-statistics record rather than
|
||||||
|
keeping deleted task rows. One possible shape is a table that stores only the
|
||||||
|
deleted task name and the timestamp when it was deleted. This needs more design
|
||||||
|
work because it has wider implications for stats, privacy, export, backup, and
|
||||||
|
future cleanup behavior.
|
||||||
355
LOGGING_RULES.md
Normal file
355
LOGGING_RULES.md
Normal file
|
|
@ -0,0 +1,355 @@
|
||||||
|
# FocusFlow Logging Rules
|
||||||
|
|
||||||
|
These instructions are for adding logging calls to the FocusFlow codebase.
|
||||||
|
The goal is to add useful diagnostics without moving logging policy into
|
||||||
|
individual services, controllers, repositories, or widgets.
|
||||||
|
|
||||||
|
## Hard Requirements
|
||||||
|
|
||||||
|
1. Use the shared logger only.
|
||||||
|
Do not create service-specific logger classes, log enums, log entries, helper
|
||||||
|
sinks, caller-capture code, stack-frame parsing, or message-formatting code in
|
||||||
|
feature files.
|
||||||
|
|
||||||
|
2. Call logging directly from feature code.
|
||||||
|
Correct examples:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
logger.debug(() => 'Applying flexible quick action. taskId=${task.id}');
|
||||||
|
logger.warn(() => 'Duplicate operation ignored. operationId=$operationId');
|
||||||
|
logger.error(() => 'SQLite bootstrap failed. code=${failure.code.name}');
|
||||||
|
```
|
||||||
|
|
||||||
|
Incorrect examples:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
if (logger.wouldWrite(FocusFlowLogLevel.debug)) {
|
||||||
|
logger.debug('Applying action. taskId=${task.id}');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final message = 'Expensive state dump. tasks=${tasks.map(...).join(',')}';
|
||||||
|
logger.finest(message);
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Lazy messages are required for anything with interpolation, collection
|
||||||
|
formatting, object dumps, date formatting, joins, maps, or stack-related
|
||||||
|
values. Pass a closure: `logger.debug(() => '...')`. The logger will not
|
||||||
|
evaluate the closure unless that level is enabled.
|
||||||
|
|
||||||
|
4. Do not manually capture caller file, method, line, or stack traces for normal
|
||||||
|
log calls. The logging layer automatically captures caller information when
|
||||||
|
the configured level requires it:
|
||||||
|
- `finest` configuration captures caller information for every written log.
|
||||||
|
- `fine`, `finer`, or `finest` configuration captures caller information for
|
||||||
|
`warn` and `error` logs.
|
||||||
|
- `debug`, `info`, `warn`, or `error` configuration does not capture caller
|
||||||
|
information for normal lower-detail logs.
|
||||||
|
|
||||||
|
5. If a caught exception already provides an error or stack trace, pass it to
|
||||||
|
the logger. Do not create `StackTrace.current` just to satisfy logging.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
} on Object catch (error, stackTrace) {
|
||||||
|
logger.error(
|
||||||
|
() => 'Startup failed.',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Do not change runtime behavior to add logging. Logging must not affect return
|
||||||
|
values, operation IDs, scheduling results, persistence results, widget state,
|
||||||
|
retry behavior, or exception behavior.
|
||||||
|
|
||||||
|
7. Preserve existing SPDX headers, library docs, Dartdoc, imports, and part-file
|
||||||
|
structure. New Dart files need SPDX metadata and Dartdoc, but this pass should
|
||||||
|
not need new Dart files.
|
||||||
|
|
||||||
|
8. Use ASCII only unless the edited file already requires non-ASCII.
|
||||||
|
|
||||||
|
## Import Rules
|
||||||
|
|
||||||
|
Use the shared scheduler logger from:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:scheduler_core/scheduler_core.dart' show logger;
|
||||||
|
```
|
||||||
|
|
||||||
|
If a file already has a local `logger` name or a conflict, use an alias:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:scheduler_core/scheduler_core.dart' as scheduler_core;
|
||||||
|
|
||||||
|
scheduler_core.logger.info(() => '...');
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside `packages/scheduler_core/lib/src/...`, prefer existing local library
|
||||||
|
patterns. Some scheduler core files are `part of` files. A `part of` file cannot
|
||||||
|
add imports. For those files, add the import to the parent library file instead.
|
||||||
|
Example: `flexible_task_action_service.dart` is a part of
|
||||||
|
`task_actions.dart`, so `task_actions.dart` imports the logging layer and the
|
||||||
|
part file can call `logger.debug(...)`.
|
||||||
|
|
||||||
|
Do not import the Flutter app file logger into scheduler core or package code.
|
||||||
|
The app file logger is only the configured sink. Feature code should use the
|
||||||
|
shared `logger`.
|
||||||
|
|
||||||
|
## Message Shape
|
||||||
|
|
||||||
|
Prefer concise, structured text:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
logger.debug(() => 'Scheduling backlog item. taskId=$taskId '
|
||||||
|
'durationMinutes=$durationMinutes windowStart=${window.start.toIso8601String()}');
|
||||||
|
```
|
||||||
|
|
||||||
|
Use:
|
||||||
|
- A short action phrase first.
|
||||||
|
- Stable key names in `key=value` form.
|
||||||
|
- IDs, enum names, dates, counts, status, outcome codes, and operation IDs.
|
||||||
|
- `toIso8601String()` for dates when logging dates.
|
||||||
|
|
||||||
|
Avoid:
|
||||||
|
- Full user-authored task titles, descriptions, notes, or free-form content at
|
||||||
|
`info`, `debug`, `fine`, `warn`, or `error`.
|
||||||
|
- Large object dumps outside `finest`.
|
||||||
|
- Vague messages like `Something went wrong`.
|
||||||
|
- Messages that blame the user.
|
||||||
|
- Duplicate logs for the same event at multiple levels.
|
||||||
|
|
||||||
|
## Level Rules
|
||||||
|
|
||||||
|
### `finest`
|
||||||
|
|
||||||
|
Use for extremely granular diagnostics only.
|
||||||
|
|
||||||
|
Good uses:
|
||||||
|
- Full input or output dumps after level gating.
|
||||||
|
- Raw decoded payloads or document maps when debugging mapping/migration.
|
||||||
|
- Complete task lists, scheduling inputs, scheduling results, or repository
|
||||||
|
state snapshots.
|
||||||
|
- Per-candidate loop details only when needed to reconstruct a scheduling
|
||||||
|
decision.
|
||||||
|
|
||||||
|
Do not use for:
|
||||||
|
- Normal app flow.
|
||||||
|
- High-level operation starts or finishes.
|
||||||
|
- Any message that would be useful in normal debugging at `debug`.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `finest` configuration automatically prepends caller information for every
|
||||||
|
written log.
|
||||||
|
- Always use a lazy closure.
|
||||||
|
|
||||||
|
### `finer`
|
||||||
|
|
||||||
|
Use for intra-method minor actions and small state changes.
|
||||||
|
|
||||||
|
Good uses:
|
||||||
|
- Branches selected inside a larger operation.
|
||||||
|
- Resolved operation IDs, generated IDs, or normalized inputs.
|
||||||
|
- A candidate accepted/rejected inside a scheduling method.
|
||||||
|
- A repository deciding which query path to use.
|
||||||
|
- A controller clearing or preserving local selection as a secondary state
|
||||||
|
change.
|
||||||
|
|
||||||
|
Do not use for:
|
||||||
|
- Method entry/exit in every method.
|
||||||
|
- Normal high-level actions that belong at `debug`.
|
||||||
|
- Large data dumps that belong at `finest`.
|
||||||
|
|
||||||
|
### `fine`
|
||||||
|
|
||||||
|
Use for secondary detail and "possible issue" logging.
|
||||||
|
|
||||||
|
Good uses:
|
||||||
|
- Extra detail that explains a `warn` or `error` path.
|
||||||
|
- Detailed result summaries, notice lists, change lists, or stack traces when
|
||||||
|
already caught.
|
||||||
|
- A handled unexpected or non-typical value that could indicate a real issue,
|
||||||
|
but the code safely handled it.
|
||||||
|
|
||||||
|
Possible issue rule:
|
||||||
|
- A "possible issue" is an unexpected or non-typical value that was handled but
|
||||||
|
may indicate bad upstream state or a missed assumption.
|
||||||
|
- Only use this when there is a real reason to suspect an issue.
|
||||||
|
- Start the message with `Possible issue:` when using this concept.
|
||||||
|
|
||||||
|
Good possible issue example:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
logger.fine(() => 'Possible issue: completion transition did not apply. '
|
||||||
|
'taskId=${task.id} outcome=${transition.outcomeCode.name}');
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not use `fine` for:
|
||||||
|
- Normal control flow.
|
||||||
|
- Expected empty states.
|
||||||
|
- User choices such as canceling, closing a modal, or choosing no date.
|
||||||
|
- Anything that should clearly be a handled issue at `warn`.
|
||||||
|
|
||||||
|
### `debug`
|
||||||
|
|
||||||
|
Use for high-level debug flow and useful state values.
|
||||||
|
|
||||||
|
Good uses:
|
||||||
|
- A command/action starts with important input IDs and state.
|
||||||
|
- A scheduling operation finishes with outcome, change count, notice count.
|
||||||
|
- A task is pushed and the earliest candidate time or destination used.
|
||||||
|
- A controller submits an action and refreshes data.
|
||||||
|
- A repository save/load operation begins or finishes with IDs and counts.
|
||||||
|
|
||||||
|
Do not use for:
|
||||||
|
- Healthy app lifecycle milestones that belong at `info`.
|
||||||
|
- Handled failures that belong at `warn`.
|
||||||
|
- Extremely detailed dumps that belong at `finest`.
|
||||||
|
- Per-frame or every-build Flutter widget logs.
|
||||||
|
|
||||||
|
### `info`
|
||||||
|
|
||||||
|
Use for very high-level healthy lifecycle checkpoints.
|
||||||
|
|
||||||
|
Good uses:
|
||||||
|
- App startup started/completed.
|
||||||
|
- Persistent runtime opened/closed.
|
||||||
|
- SQLite connected or persistence backend opened.
|
||||||
|
- Backup/export started/completed.
|
||||||
|
- A major recovery routine completed with a compact summary.
|
||||||
|
|
||||||
|
Do not use for:
|
||||||
|
- Per-task operations.
|
||||||
|
- UI taps.
|
||||||
|
- Scheduler internal decisions.
|
||||||
|
- Warnings, handled issues, or noisy repeated operations.
|
||||||
|
|
||||||
|
### `warn`
|
||||||
|
|
||||||
|
Use when something did go wrong, but the app handled it and kept going.
|
||||||
|
|
||||||
|
Good uses:
|
||||||
|
- Duplicate operation ignored.
|
||||||
|
- Task not found and a typed no-op/not-found result is returned.
|
||||||
|
- Invalid task state handled with a typed result.
|
||||||
|
- Config file unreadable and defaults are used.
|
||||||
|
- Recovery performed a fallback and continued.
|
||||||
|
- Repository compare-and-set conflict handled without corrupting state.
|
||||||
|
|
||||||
|
Do not use for:
|
||||||
|
- Expected user choices.
|
||||||
|
- Empty query results.
|
||||||
|
- Normal no-op behavior that is part of the happy path.
|
||||||
|
- "Possible issues" that are only unusual and safely handled; those are `fine`.
|
||||||
|
|
||||||
|
### `error`
|
||||||
|
|
||||||
|
Use when state may be corrupted, startup cannot continue, persistence may be in a
|
||||||
|
bad state, data may require review, or an invariant is being violated.
|
||||||
|
|
||||||
|
Good uses:
|
||||||
|
- Throwing because a service received an impossible or invalid task type.
|
||||||
|
- Startup/bootstrap failure.
|
||||||
|
- Repository commit failure.
|
||||||
|
- Migration or mapping failure that prevents safe loading.
|
||||||
|
- Backup encryption/decryption failure.
|
||||||
|
- Unhandled command failure requiring review.
|
||||||
|
|
||||||
|
Do not use for:
|
||||||
|
- Handled typed no-op results.
|
||||||
|
- Validation failures that are expected user input paths.
|
||||||
|
- Missing optional configuration.
|
||||||
|
- Anything recovered cleanly without review; use `warn`.
|
||||||
|
|
||||||
|
## Where Logging Belongs
|
||||||
|
|
||||||
|
Add logs to imperative boundaries and domain operations:
|
||||||
|
- `main.dart` and app composition/open/close paths.
|
||||||
|
- Controllers that submit commands, refresh data, or handle failures.
|
||||||
|
- Application use cases.
|
||||||
|
- Scheduling services and engines.
|
||||||
|
- Repository and persistence adapters.
|
||||||
|
- Backup/export/import operations.
|
||||||
|
- Recovery, migration, mapping, and command orchestration.
|
||||||
|
|
||||||
|
Avoid logs in:
|
||||||
|
- Pure data classes, enums, constants, token files, and simple value objects.
|
||||||
|
- `copyWith`, equality, validation constructors, and simple formatting helpers
|
||||||
|
unless they throw or handle a real issue.
|
||||||
|
- Flutter `build` methods, painters, layout helpers, and frequently called
|
||||||
|
visual functions unless there is a rare handled error. Build methods can run
|
||||||
|
often; logging there can make logs unusable.
|
||||||
|
|
||||||
|
For loops:
|
||||||
|
- Prefer one `debug` summary before/after the loop.
|
||||||
|
- Use `finer` for important branch decisions inside the loop only when useful.
|
||||||
|
- Use `finest` for per-item dumps only when the details are essential.
|
||||||
|
|
||||||
|
For caught exceptions:
|
||||||
|
- `warn` if recovered and state is safe.
|
||||||
|
- `error` if state may be bad, operation failed, or review is needed.
|
||||||
|
- Pass `error:` and `stackTrace:` if already available.
|
||||||
|
|
||||||
|
## Performance Rules
|
||||||
|
|
||||||
|
The logger already gates work by level. Call sites should rely on that.
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
logger.finest(() => 'Scheduling input dump. input=$input tasks=${input.tasks}');
|
||||||
|
```
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final dump = 'Scheduling input dump. input=$input tasks=${input.tasks}';
|
||||||
|
logger.finest(dump);
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not add `logger.wouldWrite(...)` checks around normal calls. If message
|
||||||
|
creation is expensive, put that expensive work inside the closure. Only consider
|
||||||
|
`wouldWrite` for rare cases where a large temporary structure must be built
|
||||||
|
before a logger call and cannot reasonably be built inside the closure.
|
||||||
|
|
||||||
|
## Validation Rules
|
||||||
|
|
||||||
|
After adding logs:
|
||||||
|
|
||||||
|
1. Run formatter on edited Dart files.
|
||||||
|
2. Run package analysis and tests for touched packages.
|
||||||
|
3. At minimum for scheduler core changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/scheduler_core
|
||||||
|
dart analyze
|
||||||
|
dart test
|
||||||
|
```
|
||||||
|
|
||||||
|
4. At minimum for Flutter app changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/focus_flow_flutter
|
||||||
|
dart analyze
|
||||||
|
flutter test
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Do not introduce new analyzer warnings. Existing unrelated info-level lints
|
||||||
|
may remain, but do not add new ones.
|
||||||
|
6. Logging changes must not change functional test expectations except tests
|
||||||
|
that directly assert logging behavior.
|
||||||
|
|
||||||
|
## Zip Replacement Instructions
|
||||||
|
|
||||||
|
This zip is intended to be edited externally and then extracted over the repo
|
||||||
|
root. It contains this rules file at the top level and source files under their
|
||||||
|
repo-relative paths, such as `apps/...` and `packages/...`.
|
||||||
|
|
||||||
|
When returning edited files:
|
||||||
|
- Keep the same paths.
|
||||||
|
- Do not rename files.
|
||||||
|
- Do not remove files from the archive unless they are intentionally deleted in
|
||||||
|
the repository.
|
||||||
|
- Do not add generated files, build output, `.dart_tool`, or dependency caches.
|
||||||
|
- Preserve the shared logging layer design.
|
||||||
|
|
@ -4,6 +4,13 @@
|
||||||
include: package:lints/recommended.yaml
|
include: package:lints/recommended.yaml
|
||||||
|
|
||||||
analyzer:
|
analyzer:
|
||||||
|
exclude:
|
||||||
|
- .dart_tool/**
|
||||||
|
- archive/**
|
||||||
|
- builds/**
|
||||||
|
- coverage/**
|
||||||
|
- doc/api/**
|
||||||
|
- logging_handoff_with_logging/**
|
||||||
language:
|
language:
|
||||||
strict-casts: true
|
strict-casts: true
|
||||||
strict-inference: true
|
strict-inference: true
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,58 @@ It is not inserted during normal startup.
|
||||||
Widgets consume `TodayScreenData` from `lib/models/today_screen_models.dart`.
|
Widgets consume `TodayScreenData` from `lib/models/today_screen_models.dart`.
|
||||||
Scheduler core remains the source of truth for task state and Today ordering.
|
Scheduler core remains the source of truth for task state and Today ordering.
|
||||||
|
|
||||||
|
## Runtime Config
|
||||||
|
|
||||||
|
The app optionally reads a JSON config file from:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/ADHD_Scheduler/config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Override the config path with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
flutter run -d linux --dart-define=FOCUS_FLOW_CONFIG_PATH=/tmp/focus_flow_config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported optional keys:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Timezone": "UTC",
|
||||||
|
"LogLevel": "fine",
|
||||||
|
"LogfileLocation": "~/ADHD_Scheduler/debug/"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Timezone` accepts a three-letter time-zone code without regard to character
|
||||||
|
case, such as `UTC`, `GMT`, `PST`, or `PDT`. Common daylight-saving pairs such
|
||||||
|
as `PST`/`PDT`, `MST`/`MDT`, `CST`/`CDT`, and `EST`/`EDT` are resolved once when
|
||||||
|
the config loads, so either seasonal abbreviation uses the correct current
|
||||||
|
offset for that zone during the app session. The setting is an app/UI reference
|
||||||
|
only: it controls wall-clock timeline display, selected-day startup, and
|
||||||
|
time-relative actions such as pushing a task to the next slot or tomorrow. Task
|
||||||
|
instants and all persistence data remain stored as UTC/GMT values.
|
||||||
|
|
||||||
|
`LogLevel` accepts `finest`, `finer`, `fine`, `debug`, `info`, `warn`, or
|
||||||
|
`error`.
|
||||||
|
`LogfileLocation` is treated as a directory, and the app writes
|
||||||
|
`focus_flow.log` inside it. If either key is absent or invalid, file logging is
|
||||||
|
not enabled and the app ignores that setting.
|
||||||
|
|
||||||
|
Verbose log levels can include automatic caller information without requiring
|
||||||
|
each call site to pass it manually. `finest` includes caller frames for every
|
||||||
|
written message. `fine`, `finer`, and `finest` include caller frames for
|
||||||
|
warnings and errors. This caller lookup is skipped when the configured level
|
||||||
|
does not require it.
|
||||||
|
|
||||||
|
For expensive debug/detail messages, pass a lazy closure so work is skipped when
|
||||||
|
the configured level would not write the log:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
logger.debug(() => 'state=${expensiveStateDump()}');
|
||||||
|
```
|
||||||
|
|
||||||
## Package Boundary
|
## Package Boundary
|
||||||
|
|
||||||
Allowed app imports include public scheduler APIs such as:
|
Allowed app imports include public scheduler APIs such as:
|
||||||
|
|
|
||||||
5
apps/focus_flow_flutter/config.example.json
Normal file
5
apps/focus_flow_flutter/config.example.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"Timezone": "PST",
|
||||||
|
"LogLevel": "fine",
|
||||||
|
"LogfileLocation": "~/ADHD_Scheduler/debug/"
|
||||||
|
}
|
||||||
|
|
@ -145,6 +145,7 @@ class DemoSchedulerComposition implements SchedulerAppComposition {
|
||||||
return TodayScreenController(
|
return TodayScreenController(
|
||||||
selectedDates: selectedDates,
|
selectedDates: selectedDates,
|
||||||
dateLabelFor: dateLabelFor,
|
dateLabelFor: dateLabelFor,
|
||||||
|
now: () => readAt,
|
||||||
read: (date) {
|
read: (date) {
|
||||||
return todayQuery.execute(
|
return todayQuery.execute(
|
||||||
GetTodayStateRequest(
|
GetTodayStateRequest(
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
logger.debug(() => 'FocusFlow home initializing controllers.');
|
||||||
selectedDateController = widget.composition.createSelectedDateController();
|
selectedDateController = widget.composition.createSelectedDateController();
|
||||||
controller = widget.composition.createTodayScreenController(
|
controller = widget.composition.createTodayScreenController(
|
||||||
selectedDates: selectedDateController,
|
selectedDates: selectedDateController,
|
||||||
|
|
@ -31,6 +32,7 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
||||||
refreshReads: controller.load,
|
refreshReads: controller.load,
|
||||||
selectedDate: () => selectedDateController.date,
|
selectedDate: () => selectedDateController.date,
|
||||||
);
|
);
|
||||||
|
logger.finer(() => 'FocusFlow home triggering initial Today load.');
|
||||||
controller.load();
|
controller.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,6 +40,7 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
||||||
/// Controllers, listeners, and other disposable collaborators should be cleaned up here to avoid stale callbacks.
|
/// Controllers, listeners, and other disposable collaborators should be cleaned up here to avoid stale callbacks.
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
logger.debug(() => 'FocusFlow home disposing controllers.');
|
||||||
commandController.dispose();
|
commandController.dispose();
|
||||||
controller.dispose();
|
controller.dispose();
|
||||||
selectedDateController.dispose();
|
selectedDateController.dispose();
|
||||||
|
|
@ -49,8 +52,16 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
||||||
/// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order.
|
/// 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 {
|
Future<void> _toggleCardCompletion(TimelineCardModel card) async {
|
||||||
if (!card.canToggleCompletion) {
|
if (!card.canToggleCompletion) {
|
||||||
|
logger.finer(
|
||||||
|
() => 'Timeline completion toggle ignored. cardId=${card.id}',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
logger.debug(
|
||||||
|
() =>
|
||||||
|
'Timeline completion toggle requested. '
|
||||||
|
'cardId=${card.id} taskType=${card.taskType.name} isCompleted=${card.isCompleted}',
|
||||||
|
);
|
||||||
if (card.isCompleted) {
|
if (card.isCompleted) {
|
||||||
await commandController.uncompleteTask(taskId: card.id);
|
await commandController.uncompleteTask(taskId: card.id);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -63,16 +74,58 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
||||||
|
|
||||||
/// Adds a generic filler task from the timeline context menu.
|
/// Adds a generic filler task from the timeline context menu.
|
||||||
Future<void> _addGenericFlexibleTask() async {
|
Future<void> _addGenericFlexibleTask() async {
|
||||||
|
logger.debug(() => 'Timeline add generic flexible task requested.');
|
||||||
await commandController.addGenericFlexibleTask();
|
await commandController.addGenericFlexibleTask();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pushes [card] to the next available slot after the current clock time.
|
||||||
|
Future<void> _pushCardToNext(TimelineCardModel card) async {
|
||||||
|
logger.debug(() => 'Timeline push-to-next requested. cardId=${card.id}');
|
||||||
|
await commandController.pushTaskToNextAvailableSlot(
|
||||||
|
taskId: card.id,
|
||||||
|
expectedUpdatedAt: card.expectedUpdatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pushes [card] to tomorrow's first available slot.
|
||||||
|
Future<void> _pushCardToTomorrow(TimelineCardModel card) async {
|
||||||
|
logger.debug(
|
||||||
|
() => 'Timeline push-to-tomorrow requested. cardId=${card.id}',
|
||||||
|
);
|
||||||
|
await commandController.pushTaskToTomorrow(
|
||||||
|
taskId: card.id,
|
||||||
|
expectedUpdatedAt: card.expectedUpdatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pushes [card] back to Backlog.
|
||||||
|
Future<void> _pushCardToBacklog(TimelineCardModel card) async {
|
||||||
|
logger.debug(() => 'Timeline push-to-backlog requested. cardId=${card.id}');
|
||||||
|
await commandController.pushTaskToBacklog(
|
||||||
|
taskId: card.id,
|
||||||
|
expectedUpdatedAt: card.expectedUpdatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Permanently removes [card] from persistence.
|
||||||
|
Future<void> _removeCard(TimelineCardModel card) async {
|
||||||
|
logger.debug(() => 'Timeline remove requested. cardId=${card.id}');
|
||||||
|
await commandController.removeTask(
|
||||||
|
taskId: card.id,
|
||||||
|
expectedUpdatedAt: card.expectedUpdatedAt,
|
||||||
|
);
|
||||||
|
controller.clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
/// Moves the visible planning date backward by one day.
|
/// Moves the visible planning date backward by one day.
|
||||||
void _selectPreviousDate() {
|
void _selectPreviousDate() {
|
||||||
|
logger.finer(() => 'Previous date action requested.');
|
||||||
selectedDateController.selectPreviousDay();
|
selectedDateController.selectPreviousDay();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Moves the visible planning date forward by one day.
|
/// Moves the visible planning date forward by one day.
|
||||||
void _selectNextDate() {
|
void _selectNextDate() {
|
||||||
|
logger.finer(() => 'Next date action requested.');
|
||||||
selectedDateController.selectNextDay();
|
selectedDateController.selectNextDay();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,9 +139,14 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
||||||
builder: _buildDatePickerTheme,
|
builder: _buildDatePickerTheme,
|
||||||
);
|
);
|
||||||
if (picked == null) {
|
if (picked == null) {
|
||||||
|
logger.finer(() => 'Date picker dismissed without selection.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
selectedDateController.selectDate(_civilDateForDateTime(picked));
|
final selected = _civilDateForDateTime(picked);
|
||||||
|
logger.debug(
|
||||||
|
() => 'Date picker selected date. date=${selected.toIsoString()}',
|
||||||
|
);
|
||||||
|
selectedDateController.selectDate(selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds the widget subtree for this component.
|
/// Builds the widget subtree for this component.
|
||||||
|
|
@ -114,6 +172,10 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
||||||
selectedCard: controller.selectedCard,
|
selectedCard: controller.selectedCard,
|
||||||
onSelectCard: controller.selectCard,
|
onSelectCard: controller.selectCard,
|
||||||
onCompleteCard: _toggleCardCompletion,
|
onCompleteCard: _toggleCardCompletion,
|
||||||
|
onPushToNext: _pushCardToNext,
|
||||||
|
onPushToTomorrow: _pushCardToTomorrow,
|
||||||
|
onPushToBacklog: _pushCardToBacklog,
|
||||||
|
onRemoveCard: _removeCard,
|
||||||
onAddTask: _addGenericFlexibleTask,
|
onAddTask: _addGenericFlexibleTask,
|
||||||
onClearSelection: controller.clearSelection,
|
onClearSelection: controller.clearSelection,
|
||||||
onPreviousDate: _selectPreviousDate,
|
onPreviousDate: _selectPreviousDate,
|
||||||
|
|
@ -127,6 +189,10 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
||||||
selectedCard: controller.selectedCard,
|
selectedCard: controller.selectedCard,
|
||||||
onSelectCard: controller.selectCard,
|
onSelectCard: controller.selectCard,
|
||||||
onCompleteCard: _toggleCardCompletion,
|
onCompleteCard: _toggleCardCompletion,
|
||||||
|
onPushToNext: _pushCardToNext,
|
||||||
|
onPushToTomorrow: _pushCardToTomorrow,
|
||||||
|
onPushToBacklog: _pushCardToBacklog,
|
||||||
|
onRemoveCard: _removeCard,
|
||||||
onAddTask: _addGenericFlexibleTask,
|
onAddTask: _addGenericFlexibleTask,
|
||||||
onClearSelection: controller.clearSelection,
|
onClearSelection: controller.clearSelection,
|
||||||
onPreviousDate: _selectPreviousDate,
|
onPreviousDate: _selectPreviousDate,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ class _TodayScreenScaffold extends StatefulWidget {
|
||||||
required this.selectedCard,
|
required this.selectedCard,
|
||||||
required this.onSelectCard,
|
required this.onSelectCard,
|
||||||
required this.onCompleteCard,
|
required this.onCompleteCard,
|
||||||
|
required this.onPushToNext,
|
||||||
|
required this.onPushToTomorrow,
|
||||||
|
required this.onPushToBacklog,
|
||||||
|
required this.onRemoveCard,
|
||||||
required this.onAddTask,
|
required this.onAddTask,
|
||||||
required this.onClearSelection,
|
required this.onClearSelection,
|
||||||
required this.onPreviousDate,
|
required this.onPreviousDate,
|
||||||
|
|
@ -36,6 +40,22 @@ class _TodayScreenScaffold extends StatefulWidget {
|
||||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
/// 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;
|
final Future<void> Function(TimelineCardModel card) onCompleteCard;
|
||||||
|
|
||||||
|
/// Stores the `onPushToNext` 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) onPushToNext;
|
||||||
|
|
||||||
|
/// Stores the `onPushToTomorrow` 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) onPushToTomorrow;
|
||||||
|
|
||||||
|
/// Stores the `onPushToBacklog` 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) onPushToBacklog;
|
||||||
|
|
||||||
|
/// Stores the `onRemoveCard` 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) onRemoveCard;
|
||||||
|
|
||||||
/// Stores the `onAddTask` value for this object.
|
/// Stores the `onAddTask` value for this object.
|
||||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
final Future<void> Function() onAddTask;
|
final Future<void> Function() onAddTask;
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
|
||||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||||
var _timelineScrollRequest = 0;
|
var _timelineScrollRequest = 0;
|
||||||
|
|
||||||
|
/// Private state stored as `_timelineScrollToNowRequest` 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 _timelineScrollToNowRequest = 0;
|
||||||
|
|
||||||
/// Runs the `_showUpcomingRequiredTask` helper used inside this library.
|
/// 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.
|
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||||
void _showUpcomingRequiredTask() {
|
void _showUpcomingRequiredTask() {
|
||||||
|
|
@ -31,6 +35,15 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs the `_scrollTimelineToCurrentTime` 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 _scrollTimelineToCurrentTime() {
|
||||||
|
setState(() {
|
||||||
|
_timelineScrollTargetCardId = null;
|
||||||
|
_timelineScrollToNowRequest += 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Performs the `didUpdateWidget` behavior for this scheduler component.
|
/// 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.
|
/// 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
|
||||||
|
|
@ -41,6 +54,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
|
||||||
}
|
}
|
||||||
_timelineScrollTargetCardId = null;
|
_timelineScrollTargetCardId = null;
|
||||||
_timelineScrollRequest = 0;
|
_timelineScrollRequest = 0;
|
||||||
|
_timelineScrollToNowRequest = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds the widget subtree for this component.
|
/// Builds the widget subtree for this component.
|
||||||
|
|
@ -64,6 +78,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
|
||||||
onPreviousDate: widget.onPreviousDate,
|
onPreviousDate: widget.onPreviousDate,
|
||||||
onNextDate: widget.onNextDate,
|
onNextDate: widget.onNextDate,
|
||||||
onDatePressed: widget.onChooseDate,
|
onDatePressed: widget.onChooseDate,
|
||||||
|
onDateLabelPressed: _scrollTimelineToCurrentTime,
|
||||||
),
|
),
|
||||||
if (widget.data.requiredBanner == null)
|
if (widget.data.requiredBanner == null)
|
||||||
const SizedBox(height: 16)
|
const SizedBox(height: 16)
|
||||||
|
|
@ -81,9 +96,15 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
|
||||||
range: widget.data.timelineRange,
|
range: widget.data.timelineRange,
|
||||||
scrollTargetCardId: _timelineScrollTargetCardId,
|
scrollTargetCardId: _timelineScrollTargetCardId,
|
||||||
scrollRequest: _timelineScrollRequest,
|
scrollRequest: _timelineScrollRequest,
|
||||||
|
scrollToNowRequest: _timelineScrollToNowRequest,
|
||||||
onCardSelected: widget.onSelectCard,
|
onCardSelected: widget.onSelectCard,
|
||||||
onAddTask: widget.onAddTask,
|
onAddTask: widget.onAddTask,
|
||||||
onCardCompleted: widget.onCompleteCard,
|
onCardCompleted: widget.onCompleteCard,
|
||||||
|
onPushToNext: widget.onPushToNext,
|
||||||
|
onPushToTomorrow: widget.onPushToTomorrow,
|
||||||
|
onPushToBacklog: widget.onPushToBacklog,
|
||||||
|
now: () =>
|
||||||
|
DateTime.now().toUtc().add(widget.data.timeZoneOffset),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -109,6 +130,10 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
|
||||||
onStatusPressed: widget.selectedCard!.canToggleCompletion
|
onStatusPressed: widget.selectedCard!.canToggleCompletion
|
||||||
? () => widget.onCompleteCard(widget.selectedCard!)
|
? () => widget.onCompleteCard(widget.selectedCard!)
|
||||||
: null,
|
: null,
|
||||||
|
onPushToNext: widget.onPushToNext,
|
||||||
|
onPushToTomorrow: widget.onPushToTomorrow,
|
||||||
|
onPushToBacklog: widget.onPushToBacklog,
|
||||||
|
onRemove: widget.onRemoveCard,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,17 @@ library;
|
||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:scheduler_core/scheduler_core.dart';
|
import 'package:scheduler_core/scheduler_core.dart' hide logger;
|
||||||
|
import 'package:scheduler_core/scheduler_core.dart'
|
||||||
|
as scheduler_core
|
||||||
|
show logger;
|
||||||
import 'package:scheduler_persistence_sqlite/sqlite.dart';
|
import 'package:scheduler_persistence_sqlite/sqlite.dart';
|
||||||
|
|
||||||
import '../controllers/scheduler_command_controller.dart';
|
import '../controllers/scheduler_command_controller.dart';
|
||||||
import '../controllers/selected_date_controller.dart';
|
import '../controllers/selected_date_controller.dart';
|
||||||
import '../controllers/today_screen_controller.dart';
|
import '../controllers/today_screen_controller.dart';
|
||||||
|
import 'runtime/focus_flow_file_logger.dart';
|
||||||
|
import 'runtime/focus_flow_runtime_config.dart';
|
||||||
import 'demo_scheduler_composition.dart' show formatSchedulerDateLabel;
|
import 'demo_scheduler_composition.dart' show formatSchedulerDateLabel;
|
||||||
import 'scheduler_app_composition.dart';
|
import 'scheduler_app_composition.dart';
|
||||||
|
|
||||||
|
|
@ -25,6 +30,10 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
||||||
required this.commandUseCases,
|
required this.commandUseCases,
|
||||||
required this.date,
|
required this.date,
|
||||||
required this.readAt,
|
required this.readAt,
|
||||||
|
required this.logger,
|
||||||
|
required this.uiTimeZone,
|
||||||
|
required this.clock,
|
||||||
|
required this.timeZoneResolver,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Opens the configured SQLite runtime and bootstraps required owner state.
|
/// Opens the configured SQLite runtime and bootstraps required owner state.
|
||||||
|
|
@ -32,36 +41,65 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
||||||
String? sqlitePath,
|
String? sqlitePath,
|
||||||
Clock? clock,
|
Clock? clock,
|
||||||
CivilDate? selectedDate,
|
CivilDate? selectedDate,
|
||||||
|
FocusFlowFileLogger? logger,
|
||||||
|
FocusFlowUiTimeZone? uiTimeZone,
|
||||||
}) async {
|
}) async {
|
||||||
|
final resolvedLogger = logger ?? FocusFlowFileLogger.disabled();
|
||||||
|
final resolvedTimeZone = uiTimeZone ?? FocusFlowUiTimeZone.utc;
|
||||||
|
final resolvedSqlitePath = sqlitePath ?? sqlitePathFromEnvironment();
|
||||||
|
scheduler_core.logger.info(
|
||||||
|
() =>
|
||||||
|
'Opening SQLite runtime. sqlitePath=$resolvedSqlitePath '
|
||||||
|
'uiTimeZone=${resolvedTimeZone.code}',
|
||||||
|
);
|
||||||
final runtime = await SqliteApplicationRuntime.open(
|
final runtime = await SqliteApplicationRuntime.open(
|
||||||
path: sqlitePath ?? sqlitePathFromEnvironment(),
|
path: resolvedSqlitePath,
|
||||||
);
|
);
|
||||||
final bootstrap = await runtime.bootstrap();
|
final bootstrap = await runtime.bootstrap();
|
||||||
if (bootstrap.isFailure) {
|
if (bootstrap.isFailure) {
|
||||||
await runtime.close();
|
await runtime.close();
|
||||||
|
scheduler_core.logger.error(
|
||||||
|
() =>
|
||||||
|
'SQLite bootstrap failed. failureCode=${bootstrap.failure!.code.name}',
|
||||||
|
error: bootstrap.failure!.code.name,
|
||||||
|
);
|
||||||
throw StateError(
|
throw StateError(
|
||||||
'SQLite bootstrap failed: ${bootstrap.failure!.code.name}',
|
'SQLite bootstrap failed: ${bootstrap.failure!.code.name}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
scheduler_core.logger.debug(() => 'SQLite bootstrap completed.');
|
||||||
final resolvedClock = clock ?? const SystemClock();
|
final resolvedClock = clock ?? const SystemClock();
|
||||||
final date = selectedDate ?? CivilDate.fromDateTime(resolvedClock.now());
|
final openedAt = resolvedClock.now();
|
||||||
final readAt = resolvedClock.now().toUtc();
|
final date = selectedDate ?? resolvedTimeZone.toLocalCivilDate(openedAt);
|
||||||
|
final readAt = openedAt.toUtc();
|
||||||
|
final resolver = FixedOffsetTimeZoneResolver(
|
||||||
|
offset: resolvedTimeZone.utcOffset,
|
||||||
|
);
|
||||||
|
scheduler_core.logger.info(
|
||||||
|
() =>
|
||||||
|
'Persistent scheduler composition opened. date=${date.toIsoString()} '
|
||||||
|
'readAt=${readAt.toIso8601String()} uiTimeZone=${resolvedTimeZone.code}',
|
||||||
|
);
|
||||||
|
|
||||||
return PersistentSchedulerComposition._(
|
return PersistentSchedulerComposition._(
|
||||||
runtime: runtime,
|
runtime: runtime,
|
||||||
todayQuery: GetTodayStateQuery(
|
todayQuery: GetTodayStateQuery(
|
||||||
applicationStore: runtime.applicationStore,
|
applicationStore: runtime.applicationStore,
|
||||||
timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(),
|
timeZoneResolver: resolver,
|
||||||
),
|
),
|
||||||
managementUseCases: V1ApplicationManagementUseCases(
|
managementUseCases: V1ApplicationManagementUseCases(
|
||||||
applicationStore: runtime.applicationStore,
|
applicationStore: runtime.applicationStore,
|
||||||
),
|
),
|
||||||
commandUseCases: V1ApplicationCommandUseCases(
|
commandUseCases: V1ApplicationCommandUseCases(
|
||||||
applicationStore: runtime.applicationStore,
|
applicationStore: runtime.applicationStore,
|
||||||
timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(),
|
timeZoneResolver: resolver,
|
||||||
),
|
),
|
||||||
date: date,
|
date: date,
|
||||||
readAt: readAt,
|
readAt: readAt,
|
||||||
|
logger: resolvedLogger,
|
||||||
|
uiTimeZone: resolvedTimeZone,
|
||||||
|
clock: resolvedClock,
|
||||||
|
timeZoneResolver: resolver,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,6 +136,18 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
||||||
/// Stable read clock instant for operation contexts.
|
/// Stable read clock instant for operation contexts.
|
||||||
final DateTime readAt;
|
final DateTime readAt;
|
||||||
|
|
||||||
|
/// Optional app runtime logger.
|
||||||
|
final FocusFlowFileLogger logger;
|
||||||
|
|
||||||
|
/// UI-local fixed-offset time-zone reference for wall-clock behavior.
|
||||||
|
final FocusFlowUiTimeZone uiTimeZone;
|
||||||
|
|
||||||
|
/// Runtime clock used to derive current UTC instants.
|
||||||
|
final Clock clock;
|
||||||
|
|
||||||
|
/// Resolver configured from [uiTimeZone].
|
||||||
|
final FixedOffsetTimeZoneResolver timeZoneResolver;
|
||||||
|
|
||||||
/// Pending close operation, if disposal has started.
|
/// Pending close operation, if disposal has started.
|
||||||
Future<void>? _disposeFuture;
|
Future<void>? _disposeFuture;
|
||||||
|
|
||||||
|
|
@ -122,6 +172,10 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
||||||
/// Creates the controller that owns the visible planning date.
|
/// Creates the controller that owns the visible planning date.
|
||||||
@override
|
@override
|
||||||
SelectedDateController createSelectedDateController() {
|
SelectedDateController createSelectedDateController() {
|
||||||
|
scheduler_core.logger.finer(
|
||||||
|
() =>
|
||||||
|
'Creating selected date controller. date=${initialDate.toIsoString()}',
|
||||||
|
);
|
||||||
return SelectedDateController(initialDate: initialDate);
|
return SelectedDateController(initialDate: initialDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,9 +184,14 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
||||||
TodayScreenController createTodayScreenController({
|
TodayScreenController createTodayScreenController({
|
||||||
required SelectedDateController selectedDates,
|
required SelectedDateController selectedDates,
|
||||||
}) {
|
}) {
|
||||||
|
scheduler_core.logger.finer(
|
||||||
|
() => 'Creating today screen controller. date=${date.toIsoString()}',
|
||||||
|
);
|
||||||
return TodayScreenController(
|
return TodayScreenController(
|
||||||
selectedDates: selectedDates,
|
selectedDates: selectedDates,
|
||||||
dateLabelFor: dateLabelFor,
|
dateLabelFor: dateLabelFor,
|
||||||
|
now: _utcNow,
|
||||||
|
timeZoneOffset: uiTimeZone.utcOffset,
|
||||||
read: (date) {
|
read: (date) {
|
||||||
return todayQuery.execute(
|
return todayQuery.execute(
|
||||||
GetTodayStateRequest(
|
GetTodayStateRequest(
|
||||||
|
|
@ -151,6 +210,10 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
||||||
required ReadRefresh refreshReads,
|
required ReadRefresh refreshReads,
|
||||||
required SelectedDateReader selectedDate,
|
required SelectedDateReader selectedDate,
|
||||||
}) {
|
}) {
|
||||||
|
scheduler_core.logger.finer(
|
||||||
|
() =>
|
||||||
|
'Creating scheduler command controller. operationIdPrefix=ui-command-${readAt.microsecondsSinceEpoch}',
|
||||||
|
);
|
||||||
return SchedulerCommandController(
|
return SchedulerCommandController(
|
||||||
commands: commandUseCases,
|
commands: commandUseCases,
|
||||||
contextFor: context,
|
contextFor: context,
|
||||||
|
|
@ -158,6 +221,8 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
||||||
refreshReads: refreshReads,
|
refreshReads: refreshReads,
|
||||||
quickCaptureProjectId: defaultProjectId,
|
quickCaptureProjectId: defaultProjectId,
|
||||||
operationIdPrefix: 'ui-command-${readAt.microsecondsSinceEpoch}',
|
operationIdPrefix: 'ui-command-${readAt.microsecondsSinceEpoch}',
|
||||||
|
currentDateFor: uiTimeZone.toLocalCivilDate,
|
||||||
|
now: _utcNow,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -167,9 +232,9 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
||||||
operationId: operationId,
|
operationId: operationId,
|
||||||
ownerTimeZone: OwnerTimeZoneContext(
|
ownerTimeZone: OwnerTimeZoneContext(
|
||||||
ownerId: SqliteApplicationRuntime.defaultV1OwnerId,
|
ownerId: SqliteApplicationRuntime.defaultV1OwnerId,
|
||||||
timeZoneId: SqliteApplicationRuntime.defaultV1TimeZoneId,
|
timeZoneId: uiTimeZone.code,
|
||||||
),
|
),
|
||||||
clock: FixedClock(now ?? readAt),
|
clock: FixedClock((now ?? _utcNow()).toUtc()),
|
||||||
idGenerator: SequentialIdGenerator(prefix: operationId),
|
idGenerator: SequentialIdGenerator(prefix: operationId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -177,6 +242,18 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
||||||
/// Closes the persistent runtime.
|
/// Closes the persistent runtime.
|
||||||
@override
|
@override
|
||||||
Future<void> dispose() {
|
Future<void> dispose() {
|
||||||
return _disposeFuture ??= runtime.close();
|
return _disposeFuture ??= _dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes runtime resources and records shutdown diagnostics when enabled.
|
||||||
|
Future<void> _dispose() async {
|
||||||
|
scheduler_core.logger.debug(() => 'Closing SQLite runtime.');
|
||||||
|
await runtime.close();
|
||||||
|
scheduler_core.logger.info(() => 'SQLite runtime closed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current runtime instant normalized to UTC.
|
||||||
|
DateTime _utcNow() {
|
||||||
|
return clock.now().toUtc();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,174 @@
|
||||||
|
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
/// Provides optional FocusFlow runtime file logging.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:scheduler_core/scheduler_core.dart' as scheduler_core;
|
||||||
|
|
||||||
|
import 'focus_flow_runtime_config.dart';
|
||||||
|
|
||||||
|
/// Minimal append-only file logger for FocusFlow runtime diagnostics.
|
||||||
|
final class FocusFlowFileLogger {
|
||||||
|
/// Creates a logger with explicit runtime state.
|
||||||
|
FocusFlowFileLogger._({
|
||||||
|
required this.minimumLevel,
|
||||||
|
required this.logFilePath,
|
||||||
|
required this._logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Additional caller-frame marker for this app wrapper.
|
||||||
|
static const List<String> _callerFrameIgnores = <String>[
|
||||||
|
'focus_flow_file_logger.dart',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Creates a disabled logger that drops every message.
|
||||||
|
factory FocusFlowFileLogger.disabled({DateTime Function()? now}) {
|
||||||
|
scheduler_core.logger.disable(now: now);
|
||||||
|
return FocusFlowFileLogger._(
|
||||||
|
minimumLevel: null,
|
||||||
|
logFilePath: null,
|
||||||
|
logger: scheduler_core.FocusFlowLogger.disabled(now: now),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens a logger from [config] when both logging settings are present.
|
||||||
|
static Future<FocusFlowFileLogger> open(
|
||||||
|
FocusFlowRuntimeConfig config, {
|
||||||
|
DateTime Function()? now,
|
||||||
|
String fileName = 'focus_flow.log',
|
||||||
|
}) async {
|
||||||
|
final level = config.logLevel;
|
||||||
|
final location = config.logFileLocation;
|
||||||
|
if (level == null || location == null) {
|
||||||
|
return FocusFlowFileLogger.disabled(now: now);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final directory = Directory(location);
|
||||||
|
await directory.create(recursive: true);
|
||||||
|
final logFilePath = _joinPath(directory.path, fileName);
|
||||||
|
final resolvedNow = now ?? _utcNow;
|
||||||
|
final sink = _fileSink(logFilePath);
|
||||||
|
scheduler_core.logger.configure(
|
||||||
|
minimumLevel: level,
|
||||||
|
sink: sink,
|
||||||
|
now: resolvedNow,
|
||||||
|
callerFrameIgnores: _callerFrameIgnores,
|
||||||
|
);
|
||||||
|
scheduler_core.logger.info(
|
||||||
|
() =>
|
||||||
|
'FocusFlow file logging configured. level=${level.name} path=$logFilePath',
|
||||||
|
);
|
||||||
|
return FocusFlowFileLogger._(
|
||||||
|
minimumLevel: level,
|
||||||
|
logFilePath: logFilePath,
|
||||||
|
logger: scheduler_core.FocusFlowLogger(
|
||||||
|
minimumLevel: level,
|
||||||
|
sink: sink,
|
||||||
|
now: resolvedNow,
|
||||||
|
callerFrameIgnores: _callerFrameIgnores,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} on Object catch (error, stackTrace) {
|
||||||
|
scheduler_core.logger.error(
|
||||||
|
() => 'FocusFlow file logging setup failed; disabling file logging.',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
return FocusFlowFileLogger.disabled(now: now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimum severity that will be written, or null when disabled.
|
||||||
|
final FocusFlowLogLevel? minimumLevel;
|
||||||
|
|
||||||
|
/// Concrete file path receiving appended log lines.
|
||||||
|
final String? logFilePath;
|
||||||
|
|
||||||
|
/// Shared logger implementation that owns gating and caller capture.
|
||||||
|
final scheduler_core.FocusFlowLogger _logger;
|
||||||
|
|
||||||
|
/// Whether this logger currently writes to a file.
|
||||||
|
bool get isEnabled => logFilePath != null && _logger.isEnabled;
|
||||||
|
|
||||||
|
/// Writes a finest-level [message] when enabled for finest logging.
|
||||||
|
Future<void> finest(Object? message) {
|
||||||
|
return write(scheduler_core.FocusFlowLogLevel.finest, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a finer-level [message] when enabled for finer logging.
|
||||||
|
Future<void> finer(Object? message) {
|
||||||
|
return write(scheduler_core.FocusFlowLogLevel.finer, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a fine-level [message] when enabled for fine logging.
|
||||||
|
Future<void> fine(Object? message) {
|
||||||
|
return write(scheduler_core.FocusFlowLogLevel.fine, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a debug-level [message] when enabled for debug or lower.
|
||||||
|
Future<void> debug(Object? message) {
|
||||||
|
return write(scheduler_core.FocusFlowLogLevel.debug, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes an info-level [message] when enabled for info or lower.
|
||||||
|
Future<void> info(Object? message) {
|
||||||
|
return write(scheduler_core.FocusFlowLogLevel.info, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a warning-level [message] when enabled for warning or lower.
|
||||||
|
Future<void> warn(Object? message, {Object? error, StackTrace? stackTrace}) {
|
||||||
|
return write(
|
||||||
|
scheduler_core.FocusFlowLogLevel.warn,
|
||||||
|
message,
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes an error-level [message] when file logging is enabled.
|
||||||
|
Future<void> error(Object? message, {Object? error, StackTrace? stackTrace}) {
|
||||||
|
return write(
|
||||||
|
scheduler_core.FocusFlowLogLevel.error,
|
||||||
|
message,
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes [message] at [level] when it passes the configured threshold.
|
||||||
|
Future<void> write(
|
||||||
|
scheduler_core.FocusFlowLogLevel level,
|
||||||
|
Object? message, {
|
||||||
|
Object? error,
|
||||||
|
StackTrace? stackTrace,
|
||||||
|
}) {
|
||||||
|
_logger.write(level, message, error: error, stackTrace: stackTrace);
|
||||||
|
return Future<void>.value();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current UTC timestamp.
|
||||||
|
DateTime _utcNow() => DateTime.now().toUtc();
|
||||||
|
|
||||||
|
/// Creates a file sink that appends formatted log entries.
|
||||||
|
scheduler_core.FocusFlowLogSink _fileSink(String path) {
|
||||||
|
return (entry) {
|
||||||
|
File(path).writeAsStringSync(
|
||||||
|
'${entry.toSingleLine()}\n',
|
||||||
|
mode: FileMode.append,
|
||||||
|
flush: true,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Joins [base] and [child] with the platform path separator.
|
||||||
|
String _joinPath(String base, String child) {
|
||||||
|
if (base.endsWith('/') || base.endsWith(r'\')) {
|
||||||
|
return '$base$child';
|
||||||
|
}
|
||||||
|
return '$base${Platform.pathSeparator}$child';
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,515 @@
|
||||||
|
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
/// Loads optional FocusFlow runtime configuration from a local JSON file.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:scheduler_core/scheduler_core.dart'
|
||||||
|
show CivilDate, FocusFlowLogLevel;
|
||||||
|
import 'package:scheduler_core/scheduler_core.dart' show logger;
|
||||||
|
|
||||||
|
export 'package:scheduler_core/scheduler_core.dart' show FocusFlowLogLevel;
|
||||||
|
|
||||||
|
/// File-backed runtime configuration for optional developer diagnostics.
|
||||||
|
final class FocusFlowRuntimeConfig {
|
||||||
|
/// Creates a runtime configuration with optional UI and logging controls.
|
||||||
|
const FocusFlowRuntimeConfig({
|
||||||
|
this.logLevel,
|
||||||
|
this.logFileLocation,
|
||||||
|
this.timeZone,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Loads runtime configuration from [path] or the configured default path.
|
||||||
|
///
|
||||||
|
/// [now] is evaluated once and used to resolve DST-sensitive time-zone codes
|
||||||
|
/// into one fixed offset for this app session.
|
||||||
|
static Future<FocusFlowRuntimeConfig> load({
|
||||||
|
String? path,
|
||||||
|
DateTime Function()? now,
|
||||||
|
}) async {
|
||||||
|
final configPath = path ?? configPathFromEnvironment();
|
||||||
|
final file = File(_expandHome(configPath));
|
||||||
|
if (!await file.exists()) {
|
||||||
|
logger.finer(() => 'Runtime config file not found. path=${file.path}');
|
||||||
|
return const FocusFlowRuntimeConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.debug(() => 'Loading runtime config. path=${file.path}');
|
||||||
|
final decoded = jsonDecode(await file.readAsString());
|
||||||
|
if (decoded is Map<String, Object?>) {
|
||||||
|
final config = fromJson(
|
||||||
|
decoded,
|
||||||
|
referenceInstant: (now ?? DateTime.now).call().toUtc(),
|
||||||
|
);
|
||||||
|
logger.debug(
|
||||||
|
() =>
|
||||||
|
'Runtime config loaded. hasLogLevel=${config.logLevel != null} '
|
||||||
|
'hasLogFileLocation=${config.logFileLocation != null} '
|
||||||
|
'hasTimeZone=${config.timeZone != null}',
|
||||||
|
);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
logger.warn(
|
||||||
|
() =>
|
||||||
|
'Runtime config ignored because decoded JSON was not an object. '
|
||||||
|
'path=${file.path} decodedType=${decoded.runtimeType}',
|
||||||
|
);
|
||||||
|
} on Object catch (error, stackTrace) {
|
||||||
|
logger.warn(
|
||||||
|
() => 'Runtime config unreadable; using defaults. path=${file.path}',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
return const FocusFlowRuntimeConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
return const FocusFlowRuntimeConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the config file path from a Dart define or the local default.
|
||||||
|
static String configPathFromEnvironment() {
|
||||||
|
const configured = String.fromEnvironment('FOCUS_FLOW_CONFIG_PATH');
|
||||||
|
final trimmed = configured.trim();
|
||||||
|
if (trimmed.isNotEmpty) {
|
||||||
|
logger.finer(
|
||||||
|
() => 'Runtime config path resolved from environment. path=$trimmed',
|
||||||
|
);
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
final path = defaultConfigPath();
|
||||||
|
logger.finer(() => 'Runtime config path resolved from default. path=$path');
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the default config file path.
|
||||||
|
static String defaultConfigPath() {
|
||||||
|
return _joinPath(_schedulerHomePath(), 'config.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a runtime configuration from decoded JSON fields.
|
||||||
|
///
|
||||||
|
/// [referenceInstant] resolves DST-sensitive time zones once while reading the
|
||||||
|
/// config so normal app conversions can use the resulting fixed offset.
|
||||||
|
static FocusFlowRuntimeConfig fromJson(
|
||||||
|
Map<String, Object?> json, {
|
||||||
|
DateTime? referenceInstant,
|
||||||
|
}) {
|
||||||
|
final level = FocusFlowLogLevel.tryParse(_optionalString(json['LogLevel']));
|
||||||
|
final logLocation = _optionalString(json['LogfileLocation']);
|
||||||
|
final timeZone = FocusFlowUiTimeZone.tryParse(
|
||||||
|
_optionalString(json['Timezone'] ?? json['TimeZone'] ?? json['timezone']),
|
||||||
|
referenceInstant: referenceInstant,
|
||||||
|
);
|
||||||
|
return FocusFlowRuntimeConfig(
|
||||||
|
logLevel: level,
|
||||||
|
logFileLocation: logLocation == null ? null : _expandHome(logLocation),
|
||||||
|
timeZone: timeZone,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optional minimum level for file logging.
|
||||||
|
final FocusFlowLogLevel? logLevel;
|
||||||
|
|
||||||
|
/// Optional directory where the app should append its debug log file.
|
||||||
|
final String? logFileLocation;
|
||||||
|
|
||||||
|
/// Optional UI-local time-zone reference for wall-clock display and actions.
|
||||||
|
final FocusFlowUiTimeZone? timeZone;
|
||||||
|
|
||||||
|
/// Whether this configuration has enough settings to enable file logging.
|
||||||
|
bool get enablesFileLogging => logLevel != null && logFileLocation != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UI-local fixed-offset time-zone reference loaded from runtime config.
|
||||||
|
final class FocusFlowUiTimeZone {
|
||||||
|
/// Creates a UI time-zone reference from a normalized code and UTC offset.
|
||||||
|
const FocusFlowUiTimeZone._({required this.code, required this.utcOffset});
|
||||||
|
|
||||||
|
/// Default UI time-zone when no valid config value is provided.
|
||||||
|
static const utc = FocusFlowUiTimeZone._(
|
||||||
|
code: 'UTC',
|
||||||
|
utcOffset: Duration.zero,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Parses a three-letter time-zone code without regard to character case.
|
||||||
|
///
|
||||||
|
/// [referenceInstant] resolves supported daylight-saving aliases, such as
|
||||||
|
/// `PST`/`PDT`, into the currently active code and offset up front.
|
||||||
|
static FocusFlowUiTimeZone? tryParse(
|
||||||
|
String? value, {
|
||||||
|
DateTime? referenceInstant,
|
||||||
|
}) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final code = value.trim().toUpperCase();
|
||||||
|
if (!_threeLetterTimeZoneCode.hasMatch(code)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final resolvedCode = _resolveDaylightSavingCode(
|
||||||
|
code,
|
||||||
|
referenceInstant ?? DateTime.now().toUtc(),
|
||||||
|
);
|
||||||
|
final offsetMinutes = _timeZoneOffsetMinutesByCode[resolvedCode];
|
||||||
|
if (offsetMinutes == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return FocusFlowUiTimeZone._(
|
||||||
|
code: resolvedCode,
|
||||||
|
utcOffset: Duration(minutes: offsetMinutes),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalized three-letter time-zone code.
|
||||||
|
final String code;
|
||||||
|
|
||||||
|
/// Fixed offset from UTC used for UI-local wall-clock interpretation.
|
||||||
|
final Duration utcOffset;
|
||||||
|
|
||||||
|
/// Converts [instant] into a UI-local display `DateTime`.
|
||||||
|
DateTime toLocalDateTime(DateTime instant) {
|
||||||
|
return instant.toUtc().add(utcOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts [instant] into the UI-local civil date.
|
||||||
|
CivilDate toLocalCivilDate(DateTime instant) {
|
||||||
|
return CivilDate.fromDateTime(toLocalDateTime(instant));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accepted time-zone code shape for manual UI config values.
|
||||||
|
final _threeLetterTimeZoneCode = RegExp(r'^[A-Z]{3}$');
|
||||||
|
|
||||||
|
/// Number of days in one calendar week.
|
||||||
|
const _daysPerWeek = 7;
|
||||||
|
|
||||||
|
/// Rule family for North American zones with standard/daylight abbreviations.
|
||||||
|
const _northAmericaDstFamily = 'northAmerica';
|
||||||
|
|
||||||
|
/// Rule family for British Summer Time.
|
||||||
|
const _britishDstFamily = 'british';
|
||||||
|
|
||||||
|
/// DST-linked code groups that should resolve to the active seasonal code.
|
||||||
|
const _daylightSavingTimeZoneRules =
|
||||||
|
<
|
||||||
|
String,
|
||||||
|
({
|
||||||
|
String standardCode,
|
||||||
|
String daylightCode,
|
||||||
|
int standardOffsetMinutes,
|
||||||
|
int daylightOffsetMinutes,
|
||||||
|
String family,
|
||||||
|
})
|
||||||
|
>{
|
||||||
|
'AST': (
|
||||||
|
standardCode: 'AST',
|
||||||
|
daylightCode: 'ADT',
|
||||||
|
standardOffsetMinutes: -4 * 60,
|
||||||
|
daylightOffsetMinutes: -3 * 60,
|
||||||
|
family: _northAmericaDstFamily,
|
||||||
|
),
|
||||||
|
'ADT': (
|
||||||
|
standardCode: 'AST',
|
||||||
|
daylightCode: 'ADT',
|
||||||
|
standardOffsetMinutes: -4 * 60,
|
||||||
|
daylightOffsetMinutes: -3 * 60,
|
||||||
|
family: _northAmericaDstFamily,
|
||||||
|
),
|
||||||
|
'CST': (
|
||||||
|
standardCode: 'CST',
|
||||||
|
daylightCode: 'CDT',
|
||||||
|
standardOffsetMinutes: -6 * 60,
|
||||||
|
daylightOffsetMinutes: -5 * 60,
|
||||||
|
family: _northAmericaDstFamily,
|
||||||
|
),
|
||||||
|
'CDT': (
|
||||||
|
standardCode: 'CST',
|
||||||
|
daylightCode: 'CDT',
|
||||||
|
standardOffsetMinutes: -6 * 60,
|
||||||
|
daylightOffsetMinutes: -5 * 60,
|
||||||
|
family: _northAmericaDstFamily,
|
||||||
|
),
|
||||||
|
'EST': (
|
||||||
|
standardCode: 'EST',
|
||||||
|
daylightCode: 'EDT',
|
||||||
|
standardOffsetMinutes: -5 * 60,
|
||||||
|
daylightOffsetMinutes: -4 * 60,
|
||||||
|
family: _northAmericaDstFamily,
|
||||||
|
),
|
||||||
|
'EDT': (
|
||||||
|
standardCode: 'EST',
|
||||||
|
daylightCode: 'EDT',
|
||||||
|
standardOffsetMinutes: -5 * 60,
|
||||||
|
daylightOffsetMinutes: -4 * 60,
|
||||||
|
family: _northAmericaDstFamily,
|
||||||
|
),
|
||||||
|
'MST': (
|
||||||
|
standardCode: 'MST',
|
||||||
|
daylightCode: 'MDT',
|
||||||
|
standardOffsetMinutes: -7 * 60,
|
||||||
|
daylightOffsetMinutes: -6 * 60,
|
||||||
|
family: _northAmericaDstFamily,
|
||||||
|
),
|
||||||
|
'MDT': (
|
||||||
|
standardCode: 'MST',
|
||||||
|
daylightCode: 'MDT',
|
||||||
|
standardOffsetMinutes: -7 * 60,
|
||||||
|
daylightOffsetMinutes: -6 * 60,
|
||||||
|
family: _northAmericaDstFamily,
|
||||||
|
),
|
||||||
|
'NST': (
|
||||||
|
standardCode: 'NST',
|
||||||
|
daylightCode: 'NDT',
|
||||||
|
standardOffsetMinutes: -(3 * 60 + 30),
|
||||||
|
daylightOffsetMinutes: -(2 * 60 + 30),
|
||||||
|
family: _northAmericaDstFamily,
|
||||||
|
),
|
||||||
|
'NDT': (
|
||||||
|
standardCode: 'NST',
|
||||||
|
daylightCode: 'NDT',
|
||||||
|
standardOffsetMinutes: -(3 * 60 + 30),
|
||||||
|
daylightOffsetMinutes: -(2 * 60 + 30),
|
||||||
|
family: _northAmericaDstFamily,
|
||||||
|
),
|
||||||
|
'PST': (
|
||||||
|
standardCode: 'PST',
|
||||||
|
daylightCode: 'PDT',
|
||||||
|
standardOffsetMinutes: -8 * 60,
|
||||||
|
daylightOffsetMinutes: -7 * 60,
|
||||||
|
family: _northAmericaDstFamily,
|
||||||
|
),
|
||||||
|
'PDT': (
|
||||||
|
standardCode: 'PST',
|
||||||
|
daylightCode: 'PDT',
|
||||||
|
standardOffsetMinutes: -8 * 60,
|
||||||
|
daylightOffsetMinutes: -7 * 60,
|
||||||
|
family: _northAmericaDstFamily,
|
||||||
|
),
|
||||||
|
'BST': (
|
||||||
|
standardCode: 'GMT',
|
||||||
|
daylightCode: 'BST',
|
||||||
|
standardOffsetMinutes: 0,
|
||||||
|
daylightOffsetMinutes: 60,
|
||||||
|
family: _britishDstFamily,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Fixed UTC offsets, in minutes, for recognized three-letter zone codes.
|
||||||
|
const _timeZoneOffsetMinutesByCode = <String, int>{
|
||||||
|
'ADT': -3 * 60,
|
||||||
|
'AFT': 4 * 60 + 30,
|
||||||
|
'AST': -4 * 60,
|
||||||
|
'BDT': 6 * 60,
|
||||||
|
'BST': 1 * 60,
|
||||||
|
'CAT': 2 * 60,
|
||||||
|
'CET': 1 * 60,
|
||||||
|
'CDT': -5 * 60,
|
||||||
|
'CST': -6 * 60,
|
||||||
|
'EAT': 3 * 60,
|
||||||
|
'EDT': -4 * 60,
|
||||||
|
'EET': 2 * 60,
|
||||||
|
'EST': -5 * 60,
|
||||||
|
'GMT': 0,
|
||||||
|
'GST': 4 * 60,
|
||||||
|
'HDT': -9 * 60,
|
||||||
|
'HKT': 8 * 60,
|
||||||
|
'HST': -10 * 60,
|
||||||
|
'ICT': 7 * 60,
|
||||||
|
'IST': 5 * 60 + 30,
|
||||||
|
'JST': 9 * 60,
|
||||||
|
'KST': 9 * 60,
|
||||||
|
'MDT': -6 * 60,
|
||||||
|
'MMT': 6 * 60 + 30,
|
||||||
|
'MSK': 3 * 60,
|
||||||
|
'MST': -7 * 60,
|
||||||
|
'NDT': -(2 * 60 + 30),
|
||||||
|
'NPT': 5 * 60 + 45,
|
||||||
|
'NST': -(3 * 60 + 30),
|
||||||
|
'PDT': -7 * 60,
|
||||||
|
'PHT': 8 * 60,
|
||||||
|
'PKT': 5 * 60,
|
||||||
|
'PST': -8 * 60,
|
||||||
|
'SGT': 8 * 60,
|
||||||
|
'TRT': 3 * 60,
|
||||||
|
'UCT': 0,
|
||||||
|
'UTC': 0,
|
||||||
|
'WAT': 1 * 60,
|
||||||
|
'WET': 0,
|
||||||
|
'WIB': 7 * 60,
|
||||||
|
'WIT': 9 * 60,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Resolves [code] to the active seasonal code for DST-linked zones.
|
||||||
|
String _resolveDaylightSavingCode(String code, DateTime referenceInstant) {
|
||||||
|
final rule = _daylightSavingTimeZoneRules[code];
|
||||||
|
if (rule == null) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
final instant = referenceInstant.toUtc();
|
||||||
|
final usesDaylight = _usesDaylightSavingTime(rule, instant);
|
||||||
|
return usesDaylight ? rule.daylightCode : rule.standardCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reports whether [rule] is in daylight-saving time at [instant].
|
||||||
|
bool _usesDaylightSavingTime(
|
||||||
|
({
|
||||||
|
String standardCode,
|
||||||
|
String daylightCode,
|
||||||
|
int standardOffsetMinutes,
|
||||||
|
int daylightOffsetMinutes,
|
||||||
|
String family,
|
||||||
|
})
|
||||||
|
rule,
|
||||||
|
DateTime instant,
|
||||||
|
) {
|
||||||
|
return switch (rule.family) {
|
||||||
|
_northAmericaDstFamily => _isNorthAmericaDaylightSavingTime(
|
||||||
|
instant: instant,
|
||||||
|
standardOffsetMinutes: rule.standardOffsetMinutes,
|
||||||
|
daylightOffsetMinutes: rule.daylightOffsetMinutes,
|
||||||
|
),
|
||||||
|
_britishDstFamily => _isBritishSummerTime(instant),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reports whether [instant] falls inside North American daylight saving time.
|
||||||
|
bool _isNorthAmericaDaylightSavingTime({
|
||||||
|
required DateTime instant,
|
||||||
|
required int standardOffsetMinutes,
|
||||||
|
required int daylightOffsetMinutes,
|
||||||
|
}) {
|
||||||
|
final utc = instant.toUtc();
|
||||||
|
final year = utc.year;
|
||||||
|
final startDay = _nthWeekdayOfMonth(
|
||||||
|
year: year,
|
||||||
|
month: DateTime.march,
|
||||||
|
weekday: DateTime.sunday,
|
||||||
|
occurrence: 2,
|
||||||
|
);
|
||||||
|
final endDay = _nthWeekdayOfMonth(
|
||||||
|
year: year,
|
||||||
|
month: DateTime.november,
|
||||||
|
weekday: DateTime.sunday,
|
||||||
|
occurrence: 1,
|
||||||
|
);
|
||||||
|
final startsAtUtc = DateTime.utc(
|
||||||
|
year,
|
||||||
|
DateTime.march,
|
||||||
|
startDay,
|
||||||
|
2,
|
||||||
|
).subtract(Duration(minutes: standardOffsetMinutes));
|
||||||
|
final endsAtUtc = DateTime.utc(
|
||||||
|
year,
|
||||||
|
DateTime.november,
|
||||||
|
endDay,
|
||||||
|
2,
|
||||||
|
).subtract(Duration(minutes: daylightOffsetMinutes));
|
||||||
|
return !utc.isBefore(startsAtUtc) && utc.isBefore(endsAtUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reports whether [instant] falls inside British Summer Time.
|
||||||
|
bool _isBritishSummerTime(DateTime instant) {
|
||||||
|
final utc = instant.toUtc();
|
||||||
|
final year = utc.year;
|
||||||
|
final startDay = _lastWeekdayOfMonth(
|
||||||
|
year: year,
|
||||||
|
month: DateTime.march,
|
||||||
|
weekday: DateTime.sunday,
|
||||||
|
);
|
||||||
|
final endDay = _lastWeekdayOfMonth(
|
||||||
|
year: year,
|
||||||
|
month: DateTime.october,
|
||||||
|
weekday: DateTime.sunday,
|
||||||
|
);
|
||||||
|
final startsAtUtc = DateTime.utc(year, DateTime.march, startDay, 1);
|
||||||
|
final endsAtUtc = DateTime.utc(year, DateTime.october, endDay, 1);
|
||||||
|
return !utc.isBefore(startsAtUtc) && utc.isBefore(endsAtUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the day number for the [occurrence] of [weekday] in a month.
|
||||||
|
int _nthWeekdayOfMonth({
|
||||||
|
required int year,
|
||||||
|
required int month,
|
||||||
|
required int weekday,
|
||||||
|
required int occurrence,
|
||||||
|
}) {
|
||||||
|
final firstDay = DateTime.utc(year, month);
|
||||||
|
final daysUntilWeekday = (weekday - firstDay.weekday) % _daysPerWeek;
|
||||||
|
return firstDay
|
||||||
|
.add(Duration(days: daysUntilWeekday + (_daysPerWeek * (occurrence - 1))))
|
||||||
|
.day;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the day number for the last [weekday] in a month.
|
||||||
|
int _lastWeekdayOfMonth({
|
||||||
|
required int year,
|
||||||
|
required int month,
|
||||||
|
required int weekday,
|
||||||
|
}) {
|
||||||
|
final lastDay = DateTime.utc(year, month + 1, 0);
|
||||||
|
final daysSinceWeekday = (lastDay.weekday - weekday) % _daysPerWeek;
|
||||||
|
return lastDay.subtract(Duration(days: daysSinceWeekday)).day;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads [value] as a non-empty string when present.
|
||||||
|
String? _optionalString(Object? value) {
|
||||||
|
if (value is! String) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final trimmed = value.trim();
|
||||||
|
return trimmed.isEmpty ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the local scheduler home directory path.
|
||||||
|
String _schedulerHomePath() {
|
||||||
|
final configured = Platform.environment['SCHEDULER_HOME']?.trim();
|
||||||
|
if (configured != null && configured.isNotEmpty) {
|
||||||
|
return _expandHome(configured);
|
||||||
|
}
|
||||||
|
final home = _homePath();
|
||||||
|
if (home == null) {
|
||||||
|
return 'ADHD_Scheduler';
|
||||||
|
}
|
||||||
|
return _joinPath(home, 'ADHD_Scheduler');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the current user's home directory when the platform exposes it.
|
||||||
|
String? _homePath() {
|
||||||
|
final home = Platform.environment['HOME']?.trim();
|
||||||
|
if (home != null && home.isNotEmpty) {
|
||||||
|
return home;
|
||||||
|
}
|
||||||
|
final userProfile = Platform.environment['USERPROFILE']?.trim();
|
||||||
|
if (userProfile != null && userProfile.isNotEmpty) {
|
||||||
|
return userProfile;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expands a leading `~` in [path] using the platform home directory.
|
||||||
|
String _expandHome(String path) {
|
||||||
|
if (path == '~') {
|
||||||
|
return _homePath() ?? path;
|
||||||
|
}
|
||||||
|
if (path.startsWith('~/') || path.startsWith(r'~\')) {
|
||||||
|
final home = _homePath();
|
||||||
|
if (home == null) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
return _joinPath(home, path.substring(2));
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Joins [base] and [child] with the platform path separator.
|
||||||
|
String _joinPath(String base, String child) {
|
||||||
|
if (base.endsWith('/') || base.endsWith(r'\')) {
|
||||||
|
return '$base$child';
|
||||||
|
}
|
||||||
|
return '$base${Platform.pathSeparator}$child';
|
||||||
|
}
|
||||||
|
|
@ -12,3 +12,6 @@ typedef ReadRefresh = Future<void> Function();
|
||||||
|
|
||||||
/// Reads the owner-local date that commands should target at execution time.
|
/// Reads the owner-local date that commands should target at execution time.
|
||||||
typedef SchedulerSelectedDateReader = CivilDate Function();
|
typedef SchedulerSelectedDateReader = CivilDate Function();
|
||||||
|
|
||||||
|
/// Resolves the owner-local civil date for an absolute command instant.
|
||||||
|
typedef SchedulerCurrentDateResolver = CivilDate Function(DateTime instant);
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,10 @@ class SchedulerCommandController extends ChangeNotifier {
|
||||||
required this.selectedDate,
|
required this.selectedDate,
|
||||||
required this.refreshReads,
|
required this.refreshReads,
|
||||||
required this.quickCaptureProjectId,
|
required this.quickCaptureProjectId,
|
||||||
String operationIdPrefix = 'ui-command',
|
this.operationIdPrefix = 'ui-command',
|
||||||
|
SchedulerCurrentDateResolver? currentDateFor,
|
||||||
DateTime Function()? now,
|
DateTime Function()? now,
|
||||||
}) : _operationIdPrefix = operationIdPrefix,
|
}) : currentDateFor = currentDateFor ?? _utcCivilDateFor,
|
||||||
now = now ?? _utcNow;
|
now = now ?? _utcNow;
|
||||||
|
|
||||||
/// Scheduler write use cases invoked by this controller.
|
/// Scheduler write use cases invoked by this controller.
|
||||||
|
|
@ -32,13 +33,15 @@ class SchedulerCommandController extends ChangeNotifier {
|
||||||
/// Project id used by quick-capture commands.
|
/// Project id used by quick-capture commands.
|
||||||
final String quickCaptureProjectId;
|
final String quickCaptureProjectId;
|
||||||
|
|
||||||
/// Private state stored as `_operationIdPrefix` for this implementation.
|
/// Prefix used to generate unique operation ids for scheduler commands.
|
||||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
final String operationIdPrefix;
|
||||||
final String _operationIdPrefix;
|
|
||||||
|
|
||||||
/// Clock used by direct UI commands such as marking a task complete.
|
/// Clock used by direct UI commands such as marking a task complete.
|
||||||
final DateTime Function() now;
|
final DateTime Function() now;
|
||||||
|
|
||||||
|
/// Resolves command instants to the owner-local current civil date.
|
||||||
|
final SchedulerCurrentDateResolver currentDateFor;
|
||||||
|
|
||||||
/// Filler title used by the temporary timeline add-task action.
|
/// Filler title used by the temporary timeline add-task action.
|
||||||
static const _genericFlexibleTaskTitle = 'New flexible task';
|
static const _genericFlexibleTaskTitle = 'New flexible task';
|
||||||
|
|
||||||
|
|
@ -58,6 +61,11 @@ class SchedulerCommandController extends ChangeNotifier {
|
||||||
|
|
||||||
/// Captures [title] as a backlog task.
|
/// Captures [title] as a backlog task.
|
||||||
Future<void> quickCaptureToBacklog(String title) async {
|
Future<void> quickCaptureToBacklog(String title) async {
|
||||||
|
logger.debug(
|
||||||
|
() =>
|
||||||
|
'UI command requested: quick capture to backlog. '
|
||||||
|
'projectId=$quickCaptureProjectId hasTitle=${title.trim().isNotEmpty}',
|
||||||
|
);
|
||||||
await _run(
|
await _run(
|
||||||
label: 'Capturing task',
|
label: 'Capturing task',
|
||||||
successMessage: 'Task captured',
|
successMessage: 'Task captured',
|
||||||
|
|
@ -75,6 +83,11 @@ class SchedulerCommandController extends ChangeNotifier {
|
||||||
|
|
||||||
/// Adds a generic flexible task to the next available Today slot.
|
/// Adds a generic flexible task to the next available Today slot.
|
||||||
Future<void> addGenericFlexibleTask() async {
|
Future<void> addGenericFlexibleTask() async {
|
||||||
|
logger.debug(
|
||||||
|
() =>
|
||||||
|
'UI command requested: add generic flexible task. '
|
||||||
|
'durationMinutes=$_genericFlexibleTaskDurationMinutes projectId=$quickCaptureProjectId',
|
||||||
|
);
|
||||||
await _run(
|
await _run(
|
||||||
label: 'Adding task',
|
label: 'Adding task',
|
||||||
successMessage: 'Task added',
|
successMessage: 'Task added',
|
||||||
|
|
@ -98,6 +111,12 @@ class SchedulerCommandController extends ChangeNotifier {
|
||||||
required int durationMinutes,
|
required int durationMinutes,
|
||||||
required DateTime expectedUpdatedAt,
|
required DateTime expectedUpdatedAt,
|
||||||
}) async {
|
}) async {
|
||||||
|
logger.debug(
|
||||||
|
() =>
|
||||||
|
'UI command requested: schedule backlog item. '
|
||||||
|
'taskId=$taskId durationMinutes=$durationMinutes '
|
||||||
|
'expectedUpdatedAt=${expectedUpdatedAt.toIso8601String()}',
|
||||||
|
);
|
||||||
await _run(
|
await _run(
|
||||||
label: 'Scheduling task',
|
label: 'Scheduling task',
|
||||||
successMessage: 'Task scheduled',
|
successMessage: 'Task scheduled',
|
||||||
|
|
@ -134,6 +153,13 @@ class SchedulerCommandController extends ChangeNotifier {
|
||||||
DateTime? expectedUpdatedAt,
|
DateTime? expectedUpdatedAt,
|
||||||
}) async {
|
}) async {
|
||||||
final completedAt = now();
|
final completedAt = now();
|
||||||
|
logger.debug(
|
||||||
|
() =>
|
||||||
|
'UI command requested: complete task. '
|
||||||
|
'taskId=$taskId taskType=${taskType.name} '
|
||||||
|
'hasExpectedUpdatedAt=${expectedUpdatedAt != null} '
|
||||||
|
'occurredAt=${completedAt.toIso8601String()}',
|
||||||
|
);
|
||||||
await _run(
|
await _run(
|
||||||
label: 'Completing task',
|
label: 'Completing task',
|
||||||
successMessage: 'Task completed',
|
successMessage: 'Task completed',
|
||||||
|
|
@ -177,6 +203,12 @@ class SchedulerCommandController extends ChangeNotifier {
|
||||||
DateTime? expectedUpdatedAt,
|
DateTime? expectedUpdatedAt,
|
||||||
}) async {
|
}) async {
|
||||||
final occurredAt = now();
|
final occurredAt = now();
|
||||||
|
logger.debug(
|
||||||
|
() =>
|
||||||
|
'UI command requested: uncomplete task. '
|
||||||
|
'taskId=$taskId hasExpectedUpdatedAt=${expectedUpdatedAt != null} '
|
||||||
|
'occurredAt=${occurredAt.toIso8601String()}',
|
||||||
|
);
|
||||||
await _run(
|
await _run(
|
||||||
label: 'Reopening task',
|
label: 'Reopening task',
|
||||||
successMessage: 'Task marked as not done',
|
successMessage: 'Task marked as not done',
|
||||||
|
|
@ -192,6 +224,112 @@ class SchedulerCommandController extends ChangeNotifier {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pushes a past flexible task to the next available slot after now.
|
||||||
|
Future<void> pushTaskToNextAvailableSlot({
|
||||||
|
required String taskId,
|
||||||
|
DateTime? expectedUpdatedAt,
|
||||||
|
}) async {
|
||||||
|
final commandAt = now();
|
||||||
|
logger.debug(
|
||||||
|
() =>
|
||||||
|
'UI command requested: push task to next available slot. '
|
||||||
|
'taskId=$taskId hasExpectedUpdatedAt=${expectedUpdatedAt != null} '
|
||||||
|
'occurredAt=${commandAt.toIso8601String()}',
|
||||||
|
);
|
||||||
|
await _run(
|
||||||
|
label: 'Pushing task',
|
||||||
|
successMessage: 'Task pushed',
|
||||||
|
refreshAfterSuccess: true,
|
||||||
|
action: (operationId) {
|
||||||
|
return commands.pushFlexibleToNextAvailableSlot(
|
||||||
|
context: contextFor(operationId, now: commandAt),
|
||||||
|
localDate: currentDateFor(commandAt),
|
||||||
|
taskId: taskId,
|
||||||
|
expectedUpdatedAt: expectedUpdatedAt,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pushes a past flexible task to the first available slot tomorrow.
|
||||||
|
Future<void> pushTaskToTomorrow({
|
||||||
|
required String taskId,
|
||||||
|
DateTime? expectedUpdatedAt,
|
||||||
|
}) async {
|
||||||
|
final commandAt = now();
|
||||||
|
logger.debug(
|
||||||
|
() =>
|
||||||
|
'UI command requested: push task to tomorrow. '
|
||||||
|
'taskId=$taskId hasExpectedUpdatedAt=${expectedUpdatedAt != null} '
|
||||||
|
'occurredAt=${commandAt.toIso8601String()}',
|
||||||
|
);
|
||||||
|
await _run(
|
||||||
|
label: 'Pushing task',
|
||||||
|
successMessage: 'Task moved to tomorrow',
|
||||||
|
refreshAfterSuccess: true,
|
||||||
|
action: (operationId) {
|
||||||
|
return commands.pushFlexibleToTomorrowTopOfQueue(
|
||||||
|
context: contextFor(operationId, now: commandAt),
|
||||||
|
targetDate: currentDateFor(commandAt).addDays(1),
|
||||||
|
taskId: taskId,
|
||||||
|
expectedUpdatedAt: expectedUpdatedAt,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pushes a past flexible task back to Backlog.
|
||||||
|
Future<void> pushTaskToBacklog({
|
||||||
|
required String taskId,
|
||||||
|
DateTime? expectedUpdatedAt,
|
||||||
|
}) async {
|
||||||
|
final commandAt = now();
|
||||||
|
logger.debug(
|
||||||
|
() =>
|
||||||
|
'UI command requested: push task to backlog. '
|
||||||
|
'taskId=$taskId hasExpectedUpdatedAt=${expectedUpdatedAt != null} '
|
||||||
|
'occurredAt=${commandAt.toIso8601String()}',
|
||||||
|
);
|
||||||
|
await _run(
|
||||||
|
label: 'Moving task to backlog',
|
||||||
|
successMessage: 'Task moved to backlog',
|
||||||
|
refreshAfterSuccess: true,
|
||||||
|
action: (operationId) {
|
||||||
|
return commands.moveFlexibleToBacklog(
|
||||||
|
context: contextFor(operationId, now: commandAt),
|
||||||
|
taskId: taskId,
|
||||||
|
expectedUpdatedAt: expectedUpdatedAt,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Permanently removes a task from persistence.
|
||||||
|
Future<void> removeTask({
|
||||||
|
required String taskId,
|
||||||
|
DateTime? expectedUpdatedAt,
|
||||||
|
}) async {
|
||||||
|
final commandAt = now();
|
||||||
|
logger.debug(
|
||||||
|
() =>
|
||||||
|
'UI command requested: remove task. '
|
||||||
|
'taskId=$taskId hasExpectedUpdatedAt=${expectedUpdatedAt != null} '
|
||||||
|
'occurredAt=${commandAt.toIso8601String()}',
|
||||||
|
);
|
||||||
|
await _run(
|
||||||
|
label: 'Removing task',
|
||||||
|
successMessage: 'Task removed',
|
||||||
|
refreshAfterSuccess: true,
|
||||||
|
action: (operationId) {
|
||||||
|
return commands.removeTask(
|
||||||
|
context: contextFor(operationId, now: commandAt),
|
||||||
|
taskId: taskId,
|
||||||
|
expectedUpdatedAt: expectedUpdatedAt,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Runs the `_run` operation and completes after its asynchronous work finishes.
|
/// 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.
|
/// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order.
|
||||||
Future<void> _run({
|
Future<void> _run({
|
||||||
|
|
@ -204,19 +342,44 @@ class SchedulerCommandController extends ChangeNotifier {
|
||||||
action,
|
action,
|
||||||
}) async {
|
}) async {
|
||||||
final operationId = _nextOperationId();
|
final operationId = _nextOperationId();
|
||||||
|
logger.debug(
|
||||||
|
() => 'Scheduler command started. operationId=$operationId label=$label',
|
||||||
|
);
|
||||||
_setState(SchedulerCommandRunning(label));
|
_setState(SchedulerCommandRunning(label));
|
||||||
try {
|
try {
|
||||||
final result = await action(operationId);
|
final result = await action(operationId);
|
||||||
final failure = result.failure;
|
final failure = result.failure;
|
||||||
if (failure != null) {
|
if (failure != null) {
|
||||||
|
logger.warn(
|
||||||
|
() =>
|
||||||
|
'Scheduler command returned failure. '
|
||||||
|
'operationId=$operationId code=${failure.code.name} '
|
||||||
|
'detailCode=${failure.detailCode} entityId=${failure.entityId}',
|
||||||
|
);
|
||||||
_setState(SchedulerCommandFailure(failure: failure));
|
_setState(SchedulerCommandFailure(failure: failure));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
logger.debug(
|
||||||
|
() =>
|
||||||
|
'Scheduler command succeeded. operationId=$operationId '
|
||||||
|
'refreshAfterSuccess=$refreshAfterSuccess',
|
||||||
|
);
|
||||||
if (refreshAfterSuccess) {
|
if (refreshAfterSuccess) {
|
||||||
|
logger.finer(
|
||||||
|
() =>
|
||||||
|
'Refreshing reads after scheduler command. '
|
||||||
|
'operationId=$operationId',
|
||||||
|
);
|
||||||
await refreshReads();
|
await refreshReads();
|
||||||
}
|
}
|
||||||
_setState(SchedulerCommandSuccess(successMessage));
|
_setState(SchedulerCommandSuccess(successMessage));
|
||||||
} on Object catch (error) {
|
} on Object catch (error, stackTrace) {
|
||||||
|
logger.error(
|
||||||
|
() =>
|
||||||
|
'Scheduler command threw unexpectedly. operationId=$operationId label=$label',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
_setState(SchedulerCommandFailure(error: error));
|
_setState(SchedulerCommandFailure(error: error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -225,7 +388,7 @@ class SchedulerCommandController extends ChangeNotifier {
|
||||||
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||||
String _nextOperationId() {
|
String _nextOperationId() {
|
||||||
_sequence += 1;
|
_sequence += 1;
|
||||||
return '$_operationIdPrefix-$_sequence';
|
return '$operationIdPrefix-$_sequence';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs the `_setState` helper used inside this library.
|
/// Runs the `_setState` helper used inside this library.
|
||||||
|
|
@ -238,4 +401,11 @@ class SchedulerCommandController extends ChangeNotifier {
|
||||||
/// Runs the `_utcNow` helper used inside this library.
|
/// 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.
|
/// 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();
|
static DateTime _utcNow() => DateTime.now().toUtc();
|
||||||
|
|
||||||
|
/// Runs the `_utcCivilDateFor` 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 CivilDate _utcCivilDateFor(DateTime instant) {
|
||||||
|
final utc = instant.toUtc();
|
||||||
|
return CivilDate(utc.year, utc.month, utc.day);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -98,21 +98,35 @@ class ApplicationReadController<T> extends UiReadController<T> {
|
||||||
/// Loads the current value and updates [state].
|
/// Loads the current value and updates [state].
|
||||||
@override
|
@override
|
||||||
Future<void> load() async {
|
Future<void> load() async {
|
||||||
|
logger.debug(() => 'Scheduler read started. valueType=$T');
|
||||||
_setState(SchedulerReadLoading<T>());
|
_setState(SchedulerReadLoading<T>());
|
||||||
try {
|
try {
|
||||||
final result = await read();
|
final result = await read();
|
||||||
final failure = result.failure;
|
final failure = result.failure;
|
||||||
if (failure != null) {
|
if (failure != null) {
|
||||||
|
logger.warn(
|
||||||
|
() =>
|
||||||
|
'Scheduler read returned failure. valueType=$T '
|
||||||
|
'code=${failure.code.name} detailCode=${failure.detailCode} '
|
||||||
|
'entityId=${failure.entityId}',
|
||||||
|
);
|
||||||
_setState(SchedulerReadFailure<T>(failure: failure));
|
_setState(SchedulerReadFailure<T>(failure: failure));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final value = result.requireValue;
|
final value = result.requireValue;
|
||||||
_setState(
|
final empty = isEmpty(value);
|
||||||
isEmpty(value)
|
logger.debug(
|
||||||
? SchedulerReadEmpty<T>(value)
|
() => 'Scheduler read finished. valueType=$T isEmpty=$empty',
|
||||||
: SchedulerReadData<T>(value),
|
);
|
||||||
|
_setState(
|
||||||
|
empty ? SchedulerReadEmpty<T>(value) : SchedulerReadData<T>(value),
|
||||||
|
);
|
||||||
|
} on Object catch (error, stackTrace) {
|
||||||
|
logger.error(
|
||||||
|
() => 'Scheduler read threw unexpectedly. valueType=$T',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
);
|
);
|
||||||
} on Object catch (error) {
|
|
||||||
_setState(SchedulerReadFailure<T>(error: error));
|
_setState(SchedulerReadFailure<T>(error: error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,13 @@ class SelectedDateController extends ChangeNotifier {
|
||||||
/// Selects [date] as the visible planning day.
|
/// Selects [date] as the visible planning day.
|
||||||
bool selectDate(CivilDate date) {
|
bool selectDate(CivilDate date) {
|
||||||
if (date == _date) {
|
if (date == _date) {
|
||||||
|
logger.finer(() => 'Selected date unchanged. date=${date.toIsoString()}');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
logger.debug(
|
||||||
|
() =>
|
||||||
|
'Selected date changed. previous=${_date.toIsoString()} next=${date.toIsoString()}',
|
||||||
|
);
|
||||||
_date = date;
|
_date = date;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,10 @@ class TodayScreenController extends ChangeNotifier {
|
||||||
required this.read,
|
required this.read,
|
||||||
required this.selectedDates,
|
required this.selectedDates,
|
||||||
required this.dateLabelFor,
|
required this.dateLabelFor,
|
||||||
}) : _state = TodayScreenLoading(dateLabelFor(selectedDates.date)) {
|
this.timeZoneOffset = Duration.zero,
|
||||||
|
DateTime Function()? now,
|
||||||
|
}) : now = now ?? _utcNow,
|
||||||
|
_state = TodayScreenLoading(dateLabelFor(selectedDates.date)) {
|
||||||
selectedDates.addListener(_handleSelectedDateChanged);
|
selectedDates.addListener(_handleSelectedDateChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,6 +87,12 @@ class TodayScreenController extends ChangeNotifier {
|
||||||
/// Formats dates before a backend read has returned screen data.
|
/// Formats dates before a backend read has returned screen data.
|
||||||
final TodayDateLabelFormatter dateLabelFor;
|
final TodayDateLabelFormatter dateLabelFor;
|
||||||
|
|
||||||
|
/// Clock used while mapping time-sensitive presentation state.
|
||||||
|
final DateTime Function() now;
|
||||||
|
|
||||||
|
/// UI-local offset used to format timeline instants as wall-clock values.
|
||||||
|
final Duration timeZoneOffset;
|
||||||
|
|
||||||
/// Private state stored as `_state` for this implementation.
|
/// 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.
|
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||||
TodayScreenState _state;
|
TodayScreenState _state;
|
||||||
|
|
@ -113,15 +122,30 @@ class TodayScreenController extends ChangeNotifier {
|
||||||
Future<void> load() async {
|
Future<void> load() async {
|
||||||
final date = selectedDates.date;
|
final date = selectedDates.date;
|
||||||
final sequence = _nextLoadSequence();
|
final sequence = _nextLoadSequence();
|
||||||
|
logger.debug(
|
||||||
|
() =>
|
||||||
|
'Today screen load started. date=${date.toIsoString()} sequence=$sequence',
|
||||||
|
);
|
||||||
_state = TodayScreenLoading(dateLabelFor(date));
|
_state = TodayScreenLoading(dateLabelFor(date));
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
try {
|
try {
|
||||||
final result = await read(date);
|
final result = await read(date);
|
||||||
if (_isStale(sequence)) {
|
if (_isStale(sequence)) {
|
||||||
|
logger.finer(
|
||||||
|
() =>
|
||||||
|
'Ignoring stale Today screen load result. '
|
||||||
|
'date=${date.toIsoString()} sequence=$sequence currentSequence=$_loadSequence',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final failure = result.failure;
|
final failure = result.failure;
|
||||||
if (failure != null) {
|
if (failure != null) {
|
||||||
|
logger.warn(
|
||||||
|
() =>
|
||||||
|
'Today screen load returned failure. '
|
||||||
|
'date=${date.toIsoString()} code=${failure.code.name} '
|
||||||
|
'detailCode=${failure.detailCode} entityId=${failure.entityId}',
|
||||||
|
);
|
||||||
_state = TodayScreenFailure(
|
_state = TodayScreenFailure(
|
||||||
message: failure.detailCode ?? failure.code.name,
|
message: failure.detailCode ?? failure.code.name,
|
||||||
data: _emptyDataFor(date),
|
data: _emptyDataFor(date),
|
||||||
|
|
@ -130,16 +154,36 @@ class TodayScreenController extends ChangeNotifier {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final data = TodayScreenData.fromTodayState(result.requireValue);
|
final data = TodayScreenData.fromTodayState(
|
||||||
|
result.requireValue,
|
||||||
|
now: now(),
|
||||||
|
timeZoneOffset: timeZoneOffset,
|
||||||
|
);
|
||||||
_syncSelectedCard(data);
|
_syncSelectedCard(data);
|
||||||
_state = data.cards.isEmpty
|
_state = data.cards.isEmpty
|
||||||
? TodayScreenEmpty(data)
|
? TodayScreenEmpty(data)
|
||||||
: TodayScreenReady(data);
|
: TodayScreenReady(data);
|
||||||
|
logger.debug(
|
||||||
|
() =>
|
||||||
|
'Today screen load finished. date=${date.toIsoString()} '
|
||||||
|
'sequence=$sequence cardCount=${data.cards.length}',
|
||||||
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} on Object catch (error) {
|
} on Object catch (error, stackTrace) {
|
||||||
if (_isStale(sequence)) {
|
if (_isStale(sequence)) {
|
||||||
|
logger.finer(
|
||||||
|
() =>
|
||||||
|
'Ignoring stale Today screen load exception. '
|
||||||
|
'date=${date.toIsoString()} sequence=$sequence currentSequence=$_loadSequence',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
logger.error(
|
||||||
|
() =>
|
||||||
|
'Today screen load threw unexpectedly. date=${date.toIsoString()} sequence=$sequence',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
_state = TodayScreenFailure(
|
_state = TodayScreenFailure(
|
||||||
message: error.toString(),
|
message: error.toString(),
|
||||||
data: _emptyDataFor(date),
|
data: _emptyDataFor(date),
|
||||||
|
|
@ -151,8 +195,12 @@ class TodayScreenController extends ChangeNotifier {
|
||||||
/// Selects [card] when the card allows selection.
|
/// Selects [card] when the card allows selection.
|
||||||
void selectCard(TimelineCardModel card) {
|
void selectCard(TimelineCardModel card) {
|
||||||
if (!card.isSelectable) {
|
if (!card.isSelectable) {
|
||||||
|
logger.finer(
|
||||||
|
() => 'Today screen card selection ignored. cardId=${card.id}',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
logger.finer(() => 'Today screen card selected. cardId=${card.id}');
|
||||||
_selectedCard = card;
|
_selectedCard = card;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
@ -162,6 +210,9 @@ class TodayScreenController extends ChangeNotifier {
|
||||||
if (_selectedCard == null) {
|
if (_selectedCard == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
logger.finer(
|
||||||
|
() => 'Today screen card selection cleared. cardId=${_selectedCard!.id}',
|
||||||
|
);
|
||||||
_selectedCard = null;
|
_selectedCard = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
@ -177,6 +228,10 @@ class TodayScreenController extends ChangeNotifier {
|
||||||
/// Runs the `_handleSelectedDateChanged` helper used inside this library.
|
/// Runs the `_handleSelectedDateChanged` helper used inside this library.
|
||||||
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||||
void _handleSelectedDateChanged() {
|
void _handleSelectedDateChanged() {
|
||||||
|
logger.debug(
|
||||||
|
() =>
|
||||||
|
'Today screen selected date changed. date=${selectedDates.date.toIsoString()}',
|
||||||
|
);
|
||||||
_selectedCard = null;
|
_selectedCard = null;
|
||||||
unawaited(load());
|
unawaited(load());
|
||||||
}
|
}
|
||||||
|
|
@ -197,7 +252,10 @@ class TodayScreenController extends ChangeNotifier {
|
||||||
/// Runs the `_emptyDataFor` helper used inside this library.
|
/// Runs the `_emptyDataFor` helper used inside this library.
|
||||||
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||||
TodayScreenData _emptyDataFor(CivilDate date) {
|
TodayScreenData _emptyDataFor(CivilDate date) {
|
||||||
return TodayScreenData.empty(dateLabel: dateLabelFor(date));
|
return TodayScreenData.empty(
|
||||||
|
dateLabel: dateLabelFor(date),
|
||||||
|
timeZoneOffset: timeZoneOffset,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs the `_syncSelectedCard` helper used inside this library.
|
/// Runs the `_syncSelectedCard` helper used inside this library.
|
||||||
|
|
@ -213,6 +271,14 @@ class TodayScreenController extends ChangeNotifier {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
logger.finer(
|
||||||
|
() =>
|
||||||
|
'Today screen selected card no longer present. cardId=${selected.id}',
|
||||||
|
);
|
||||||
_selectedCard = null;
|
_selectedCard = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,14 @@
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:scheduler_core/scheduler_core.dart'
|
||||||
|
as scheduler_core
|
||||||
|
show logger;
|
||||||
|
|
||||||
import 'app/focus_flow_app.dart';
|
import 'app/focus_flow_app.dart';
|
||||||
import 'app/persistent_scheduler_composition.dart';
|
import 'app/persistent_scheduler_composition.dart';
|
||||||
|
import 'app/runtime/focus_flow_file_logger.dart';
|
||||||
|
import 'app/runtime/focus_flow_runtime_config.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';
|
||||||
|
|
@ -16,10 +21,26 @@ export 'app/persistent_scheduler_composition.dart';
|
||||||
/// Starts the FocusFlow app with the persistent SQLite scheduler composition.
|
/// Starts the FocusFlow app with the persistent SQLite scheduler composition.
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
var logger = FocusFlowFileLogger.disabled();
|
||||||
try {
|
try {
|
||||||
final composition = await PersistentSchedulerComposition.open();
|
final config = await FocusFlowRuntimeConfig.load();
|
||||||
|
logger = await FocusFlowFileLogger.open(config);
|
||||||
|
scheduler_core.logger.info(() => 'FocusFlow startup started.');
|
||||||
|
scheduler_core.logger.debug(
|
||||||
|
() => 'Runtime config loaded. fileLogging=${logger.isEnabled}',
|
||||||
|
);
|
||||||
|
final composition = await PersistentSchedulerComposition.open(
|
||||||
|
logger: logger,
|
||||||
|
uiTimeZone: config.timeZone,
|
||||||
|
);
|
||||||
|
scheduler_core.logger.info(() => 'FocusFlow startup completed.');
|
||||||
runApp(FocusFlowApp(composition: composition));
|
runApp(FocusFlowApp(composition: composition));
|
||||||
} on Object catch (error) {
|
} on Object catch (error, stackTrace) {
|
||||||
|
scheduler_core.logger.error(
|
||||||
|
() => 'FocusFlow startup failed.',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
runApp(_StartupFailureApp(message: error.toString()));
|
runApp(_StartupFailureApp(message: error.toString()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,11 @@ String _dateLabel(CivilDate date) {
|
||||||
|
|
||||||
/// Top-level helper that performs the `_shortDateTimeLabel` operation for this file.
|
/// Top-level helper that performs the `_shortDateTimeLabel` operation for this file.
|
||||||
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
|
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
|
||||||
String _shortDateTimeLabel(DateTime value) {
|
String _shortDateTimeLabel(
|
||||||
|
DateTime value, {
|
||||||
|
Duration timeZoneOffset = Duration.zero,
|
||||||
|
}) {
|
||||||
|
final localValue = _displayDateTime(value, timeZoneOffset);
|
||||||
const months = [
|
const months = [
|
||||||
'Jan',
|
'Jan',
|
||||||
'Feb',
|
'Feb',
|
||||||
|
|
@ -40,36 +44,52 @@ String _shortDateTimeLabel(DateTime value) {
|
||||||
'Nov',
|
'Nov',
|
||||||
'Dec',
|
'Dec',
|
||||||
];
|
];
|
||||||
return '${months[value.month - 1]} ${value.day}, ${_formatTime(value)}';
|
return '${months[localValue.month - 1]} ${localValue.day}, '
|
||||||
|
'${_formatTime(value, timeZoneOffset: timeZoneOffset)}';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Top-level helper that performs the `_civilDateForDateTime` operation for this file.
|
/// Top-level helper that performs the `_civilDateForDateTime` operation for this file.
|
||||||
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
|
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
|
||||||
CivilDate? _civilDateForDateTime(DateTime? value) {
|
CivilDate? _civilDateForDateTime(
|
||||||
|
DateTime? value, {
|
||||||
|
Duration timeZoneOffset = Duration.zero,
|
||||||
|
}) {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return CivilDate(value.year, value.month, value.day);
|
final localValue = _displayDateTime(value, timeZoneOffset);
|
||||||
|
return CivilDate(localValue.year, localValue.month, localValue.day);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Top-level helper that performs the `_minutesSinceMidnight` operation for this file.
|
/// 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.
|
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
|
||||||
int _minutesSinceMidnight(DateTime? value) {
|
int _minutesSinceMidnight(
|
||||||
|
DateTime? value, {
|
||||||
|
Duration timeZoneOffset = Duration.zero,
|
||||||
|
}) {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
return value.hour * 60 + value.minute;
|
final localValue = _displayDateTime(value, timeZoneOffset);
|
||||||
|
return localValue.hour * 60 + localValue.minute;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Top-level helper that performs the `_formatTime` operation for this file.
|
/// 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.
|
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
|
||||||
String _formatTime(DateTime? value) {
|
String _formatTime(DateTime? value, {Duration timeZoneOffset = Duration.zero}) {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
final period = value.hour >= 12 ? 'PM' : 'AM';
|
final localValue = _displayDateTime(value, timeZoneOffset);
|
||||||
final rawHour = value.hour % 12;
|
final period = localValue.hour >= 12 ? 'PM' : 'AM';
|
||||||
|
final rawHour = localValue.hour % 12;
|
||||||
final hour = rawHour == 0 ? 12 : rawHour;
|
final hour = rawHour == 0 ? 12 : rawHour;
|
||||||
final minute = value.minute.toString().padLeft(2, '0');
|
final minute = localValue.minute.toString().padLeft(2, '0');
|
||||||
return '$hour:$minute $period';
|
return '$hour:$minute $period';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Top-level helper that performs the `_displayDateTime` 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 _displayDateTime(DateTime value, Duration timeZoneOffset) {
|
||||||
|
return value.toUtc().add(timeZoneOffset);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,26 +21,35 @@ class TimelineCardModel {
|
||||||
required this.isCompleted,
|
required this.isCompleted,
|
||||||
required this.isSelectable,
|
required this.isSelectable,
|
||||||
required this.showsQuickActions,
|
required this.showsQuickActions,
|
||||||
|
this.showPastTaskPushButton = false,
|
||||||
this.completionDateContextRequired = false,
|
this.completionDateContextRequired = false,
|
||||||
this.completedTimeText,
|
this.completedTimeText,
|
||||||
|
this.expectedUpdatedAt,
|
||||||
this.durationMinutes,
|
this.durationMinutes,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Maps a scheduler timeline item into card presentation data.
|
/// Maps a scheduler timeline item into card presentation data.
|
||||||
factory TimelineCardModel.fromItem(TodayTimelineItem item) {
|
factory TimelineCardModel.fromItem(
|
||||||
|
TodayTimelineItem item, {
|
||||||
|
required DateTime now,
|
||||||
|
Duration timeZoneOffset = Duration.zero,
|
||||||
|
}) {
|
||||||
final start = item.start;
|
final start = item.start;
|
||||||
final end = item.end;
|
final end = item.end;
|
||||||
final visualKind = _visualKindFor(item);
|
final visualKind = _visualKindFor(item);
|
||||||
final isCompleted = item.taskStatus == TaskStatus.completed;
|
final isCompleted = item.taskStatus == TaskStatus.completed;
|
||||||
|
final showPastTaskPushButton = _showPastTaskPushButton(item, now: now);
|
||||||
final title = item.item.displayTitle;
|
final title = item.item.displayTitle;
|
||||||
final duration = item.item.durationMinutes;
|
final duration = item.item.durationMinutes;
|
||||||
final timeText = start == null || end == null
|
final timeText = start == null || end == null
|
||||||
? duration == null
|
? duration == null
|
||||||
? ''
|
? ''
|
||||||
: '$duration min'
|
: '$duration min'
|
||||||
: '${_formatTime(start)} - ${_formatTime(end)}';
|
: '${_formatTime(start, timeZoneOffset: timeZoneOffset)} - '
|
||||||
|
'${_formatTime(end, timeZoneOffset: timeZoneOffset)}';
|
||||||
final completionDateContextRequired =
|
final completionDateContextRequired =
|
||||||
isCompleted && _completionDateContextRequired(item);
|
isCompleted &&
|
||||||
|
_completionDateContextRequired(item, timeZoneOffset: timeZoneOffset);
|
||||||
return TimelineCardModel(
|
return TimelineCardModel(
|
||||||
id: item.id,
|
id: item.id,
|
||||||
title: title,
|
title: title,
|
||||||
|
|
@ -50,10 +59,14 @@ class TimelineCardModel {
|
||||||
visualKind,
|
visualKind,
|
||||||
timeText,
|
timeText,
|
||||||
completionDateContextRequired: completionDateContextRequired,
|
completionDateContextRequired: completionDateContextRequired,
|
||||||
|
timeZoneOffset: timeZoneOffset,
|
||||||
),
|
),
|
||||||
timeText: timeText,
|
timeText: timeText,
|
||||||
startMinutes: _minutesSinceMidnight(start),
|
startMinutes: _minutesSinceMidnight(
|
||||||
endMinutes: _minutesSinceMidnight(end),
|
start,
|
||||||
|
timeZoneOffset: timeZoneOffset,
|
||||||
|
),
|
||||||
|
endMinutes: _minutesSinceMidnight(end, timeZoneOffset: timeZoneOffset),
|
||||||
taskType: item.taskType,
|
taskType: item.taskType,
|
||||||
visualKind: visualKind,
|
visualKind: visualKind,
|
||||||
rewardIconToken: item.item.rewardIconToken,
|
rewardIconToken: item.item.rewardIconToken,
|
||||||
|
|
@ -64,11 +77,17 @@ class TimelineCardModel {
|
||||||
? _completionDisplayText(
|
? _completionDisplayText(
|
||||||
item,
|
item,
|
||||||
includeDate: completionDateContextRequired,
|
includeDate: completionDateContextRequired,
|
||||||
|
timeZoneOffset: timeZoneOffset,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
|
expectedUpdatedAt: item.item.updatedAt,
|
||||||
isCompleted: isCompleted,
|
isCompleted: isCompleted,
|
||||||
isSelectable: true,
|
isSelectable: true,
|
||||||
showsQuickActions: !isCompleted && visualKind != TaskVisualKind.freeSlot,
|
showPastTaskPushButton: showPastTaskPushButton,
|
||||||
|
showsQuickActions:
|
||||||
|
!showPastTaskPushButton &&
|
||||||
|
!isCompleted &&
|
||||||
|
visualKind != TaskVisualKind.freeSlot,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,6 +109,9 @@ class TimelineCardModel {
|
||||||
/// Display-ready completion time or date-time, if the task has been completed.
|
/// Display-ready completion time or date-time, if the task has been completed.
|
||||||
final String? completedTimeText;
|
final String? completedTimeText;
|
||||||
|
|
||||||
|
/// Expected update timestamp for stale-write protection.
|
||||||
|
final DateTime? expectedUpdatedAt;
|
||||||
|
|
||||||
/// Whether completion text includes date context because completion crossed dates.
|
/// Whether completion text includes date context because completion crossed dates.
|
||||||
final bool completionDateContextRequired;
|
final bool completionDateContextRequired;
|
||||||
|
|
||||||
|
|
@ -123,6 +145,12 @@ class TimelineCardModel {
|
||||||
/// Whether the card should show compact quick-action controls.
|
/// Whether the card should show compact quick-action controls.
|
||||||
final bool showsQuickActions;
|
final bool showsQuickActions;
|
||||||
|
|
||||||
|
/// Whether the card should show the full overdue-task push control.
|
||||||
|
final bool showPastTaskPushButton;
|
||||||
|
|
||||||
|
/// Accessible label for the full overdue-task push control.
|
||||||
|
String get pastTaskPushSemanticLabel => 'Push overdue task';
|
||||||
|
|
||||||
/// Whether the left status control can toggle this task's completion.
|
/// Whether the left status control can toggle this task's completion.
|
||||||
bool get canToggleCompletion {
|
bool get canToggleCompletion {
|
||||||
return switch (taskType) {
|
return switch (taskType) {
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ String _subtitleFor(
|
||||||
TaskVisualKind visualKind,
|
TaskVisualKind visualKind,
|
||||||
String timeText, {
|
String timeText, {
|
||||||
required bool completionDateContextRequired,
|
required bool completionDateContextRequired,
|
||||||
|
required Duration timeZoneOffset,
|
||||||
}) {
|
}) {
|
||||||
if (visualKind == TaskVisualKind.freeSlot) {
|
if (visualKind == TaskVisualKind.freeSlot) {
|
||||||
return 'Intentional rest';
|
return 'Intentional rest';
|
||||||
|
|
@ -53,6 +54,7 @@ String _subtitleFor(
|
||||||
final completedText = _completionDisplayText(
|
final completedText = _completionDisplayText(
|
||||||
item,
|
item,
|
||||||
includeDate: completionDateContextRequired,
|
includeDate: completionDateContextRequired,
|
||||||
|
timeZoneOffset: timeZoneOffset,
|
||||||
);
|
);
|
||||||
if (completedText == null || completedText.isEmpty) {
|
if (completedText == null || completedText.isEmpty) {
|
||||||
return 'Completed';
|
return 'Completed';
|
||||||
|
|
@ -70,15 +72,19 @@ String _subtitleFor(
|
||||||
String? _completionDisplayText(
|
String? _completionDisplayText(
|
||||||
TodayTimelineItem item, {
|
TodayTimelineItem item, {
|
||||||
required bool includeDate,
|
required bool includeDate,
|
||||||
|
required Duration timeZoneOffset,
|
||||||
}) {
|
}) {
|
||||||
final completionInstant = _completionDisplayInstant(item);
|
final completionInstant = _completionDisplayInstant(item);
|
||||||
if (completionInstant == null) {
|
if (completionInstant == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (includeDate) {
|
if (includeDate) {
|
||||||
return _shortDateTimeLabel(completionInstant);
|
return _shortDateTimeLabel(
|
||||||
|
completionInstant,
|
||||||
|
timeZoneOffset: timeZoneOffset,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return _formatTime(completionInstant);
|
return _formatTime(completionInstant, timeZoneOffset: timeZoneOffset);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Top-level helper that performs the `_completionDisplayInstant` operation for this file.
|
/// Top-level helper that performs the `_completionDisplayInstant` operation for this file.
|
||||||
|
|
@ -89,15 +95,46 @@ DateTime? _completionDisplayInstant(TodayTimelineItem item) {
|
||||||
|
|
||||||
/// Top-level helper that performs the `_completionDateContextRequired` operation for this file.
|
/// Top-level helper that performs the `_completionDateContextRequired` operation for this file.
|
||||||
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
|
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
|
||||||
bool _completionDateContextRequired(TodayTimelineItem item) {
|
bool _completionDateContextRequired(
|
||||||
final scheduledDate = _civilDateForDateTime(item.item.start ?? item.start);
|
TodayTimelineItem item, {
|
||||||
|
required Duration timeZoneOffset,
|
||||||
|
}) {
|
||||||
|
final scheduledDate = _civilDateForDateTime(
|
||||||
|
item.item.start ?? item.start,
|
||||||
|
timeZoneOffset: timeZoneOffset,
|
||||||
|
);
|
||||||
if (scheduledDate == null) {
|
if (scheduledDate == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
final completedAtDate = _civilDateForDateTime(item.item.completedAt);
|
final completedAtDate = _civilDateForDateTime(
|
||||||
final actualStartDate = _civilDateForDateTime(item.item.actualStart);
|
item.item.completedAt,
|
||||||
final actualEndDate = _civilDateForDateTime(item.item.actualEnd);
|
timeZoneOffset: timeZoneOffset,
|
||||||
|
);
|
||||||
|
final actualStartDate = _civilDateForDateTime(
|
||||||
|
item.item.actualStart,
|
||||||
|
timeZoneOffset: timeZoneOffset,
|
||||||
|
);
|
||||||
|
final actualEndDate = _civilDateForDateTime(
|
||||||
|
item.item.actualEnd,
|
||||||
|
timeZoneOffset: timeZoneOffset,
|
||||||
|
);
|
||||||
return completedAtDate != null && completedAtDate != scheduledDate ||
|
return completedAtDate != null && completedAtDate != scheduledDate ||
|
||||||
actualStartDate != null && actualStartDate != scheduledDate ||
|
actualStartDate != null && actualStartDate != scheduledDate ||
|
||||||
actualEndDate != null && actualEndDate != scheduledDate;
|
actualEndDate != null && actualEndDate != scheduledDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Top-level helper that performs the `_showPastTaskPushButton` operation for this file.
|
||||||
|
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
|
||||||
|
bool _showPastTaskPushButton(TodayTimelineItem item, {required DateTime now}) {
|
||||||
|
if (item.source != TodayTimelineItemSource.task) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (item.taskType != TaskType.flexible) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (item.taskStatus != TaskStatus.planned) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final end = item.end;
|
||||||
|
return end != null && end.isBefore(now);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,22 +10,36 @@ class TodayScreenData {
|
||||||
required this.dateLabel,
|
required this.dateLabel,
|
||||||
required this.timelineRange,
|
required this.timelineRange,
|
||||||
required this.cards,
|
required this.cards,
|
||||||
|
this.timeZoneOffset = Duration.zero,
|
||||||
this.requiredBanner,
|
this.requiredBanner,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Creates an empty Today screen model for [dateLabel].
|
/// Creates an empty Today screen model for [dateLabel].
|
||||||
factory TodayScreenData.empty({required String dateLabel}) {
|
factory TodayScreenData.empty({
|
||||||
|
required String dateLabel,
|
||||||
|
Duration timeZoneOffset = Duration.zero,
|
||||||
|
}) {
|
||||||
return TodayScreenData(
|
return TodayScreenData(
|
||||||
dateLabel: dateLabel,
|
dateLabel: dateLabel,
|
||||||
timelineRange: defaultRange,
|
timelineRange: defaultRange,
|
||||||
cards: const [],
|
cards: const [],
|
||||||
|
timeZoneOffset: timeZoneOffset,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maps scheduler core [TodayState] into UI presentation data.
|
/// Maps scheduler core [TodayState] into UI presentation data.
|
||||||
factory TodayScreenData.fromTodayState(TodayState state) {
|
factory TodayScreenData.fromTodayState(
|
||||||
|
TodayState state, {
|
||||||
|
required DateTime now,
|
||||||
|
Duration timeZoneOffset = Duration.zero,
|
||||||
|
}) {
|
||||||
final cards = [
|
final cards = [
|
||||||
for (final item in state.timelineItems) TimelineCardModel.fromItem(item),
|
for (final item in state.timelineItems)
|
||||||
|
TimelineCardModel.fromItem(
|
||||||
|
item,
|
||||||
|
now: now,
|
||||||
|
timeZoneOffset: timeZoneOffset,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
final nextRequired = state.nextRequiredItem;
|
final nextRequired = state.nextRequiredItem;
|
||||||
return TodayScreenData(
|
return TodayScreenData(
|
||||||
|
|
@ -36,9 +50,13 @@ class TodayScreenData {
|
||||||
: RequiredBannerModel(
|
: RequiredBannerModel(
|
||||||
timelineCardId: nextRequired.id,
|
timelineCardId: nextRequired.id,
|
||||||
title: nextRequired.item.displayTitle,
|
title: nextRequired.item.displayTitle,
|
||||||
timeText: _formatTime(nextRequired.start),
|
timeText: _formatTime(
|
||||||
|
nextRequired.start,
|
||||||
|
timeZoneOffset: timeZoneOffset,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
cards: List<TimelineCardModel>.unmodifiable(cards),
|
cards: List<TimelineCardModel>.unmodifiable(cards),
|
||||||
|
timeZoneOffset: timeZoneOffset,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,6 +75,9 @@ class TodayScreenData {
|
||||||
/// Visible timeline range.
|
/// Visible timeline range.
|
||||||
final TimelineRangeModel timelineRange;
|
final TimelineRangeModel timelineRange;
|
||||||
|
|
||||||
|
/// UI-local offset used for wall-clock timeline labels and positions.
|
||||||
|
final Duration timeZoneOffset;
|
||||||
|
|
||||||
/// Timeline cards to render.
|
/// Timeline cards to render.
|
||||||
final List<TimelineCardModel> cards;
|
final List<TimelineCardModel> cards;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
125
apps/focus_flow_flutter/lib/widgets/status_circle_button.dart
Normal file
125
apps/focus_flow_flutter/lib/widgets/status_circle_button.dart
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
/// Shared status-circle affordance for timeline and modal task controls.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Circular completion control with shared hover feedback.
|
||||||
|
class StatusCircleButton extends StatefulWidget {
|
||||||
|
/// Creates a status circle button.
|
||||||
|
const StatusCircleButton({
|
||||||
|
required this.color,
|
||||||
|
required this.checkColor,
|
||||||
|
required this.isCompleted,
|
||||||
|
required this.size,
|
||||||
|
required this.borderWidth,
|
||||||
|
required this.iconSize,
|
||||||
|
required this.semanticLabel,
|
||||||
|
this.onPressed,
|
||||||
|
this.circleKey,
|
||||||
|
this.hoverBlurRadius = 10,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Key assigned to the animated circle surface.
|
||||||
|
final Key? circleKey;
|
||||||
|
|
||||||
|
/// Accent color used for the border, fill, and hover glow.
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
/// Color used for the completed check icon.
|
||||||
|
final Color checkColor;
|
||||||
|
|
||||||
|
/// Whether the circle is in its completed state.
|
||||||
|
final bool isCompleted;
|
||||||
|
|
||||||
|
/// Square size of the rendered circle.
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
/// Border width for the circle outline.
|
||||||
|
final double borderWidth;
|
||||||
|
|
||||||
|
/// Size of the completed check icon.
|
||||||
|
final double iconSize;
|
||||||
|
|
||||||
|
/// Semantic label used when the control is interactive.
|
||||||
|
final String semanticLabel;
|
||||||
|
|
||||||
|
/// Callback invoked when the circle is tapped.
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
|
||||||
|
/// Blur radius used for the hover shadow.
|
||||||
|
final double hoverBlurRadius;
|
||||||
|
|
||||||
|
/// 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<StatusCircleButton> createState() => _StatusCircleButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mutable state for [StatusCircleButton].
|
||||||
|
class _StatusCircleButtonState extends State<StatusCircleButton> {
|
||||||
|
/// Whether the pointer is hovering this status circle.
|
||||||
|
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 ring = AnimatedContainer(
|
||||||
|
key: widget.circleKey,
|
||||||
|
width: widget.size,
|
||||||
|
height: widget.size,
|
||||||
|
duration: const Duration(milliseconds: 120),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: widget.isCompleted
|
||||||
|
? widget.color
|
||||||
|
: _isHovered
|
||||||
|
? widget.color.withValues(alpha: 0.12)
|
||||||
|
: Colors.transparent,
|
||||||
|
border: Border.all(color: widget.color, width: widget.borderWidth),
|
||||||
|
boxShadow: _isHovered && widget.onPressed != null
|
||||||
|
? [
|
||||||
|
BoxShadow(
|
||||||
|
color: widget.color.withValues(alpha: 0.38),
|
||||||
|
blurRadius: widget.hoverBlurRadius,
|
||||||
|
spreadRadius: 1,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: const [],
|
||||||
|
),
|
||||||
|
child: widget.isCompleted
|
||||||
|
? Icon(Icons.check, color: widget.checkColor, size: widget.iconSize)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
if (widget.onPressed == null) {
|
||||||
|
return ring;
|
||||||
|
}
|
||||||
|
return Semantics(
|
||||||
|
button: true,
|
||||||
|
label: widget.semanticLabel,
|
||||||
|
child: MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.click,
|
||||||
|
onEnter: (_) {
|
||||||
|
setState(() {
|
||||||
|
_isHovered = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onExit: (_) {
|
||||||
|
setState(() {
|
||||||
|
_isHovered = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: widget.onPressed,
|
||||||
|
child: ring,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,12 +3,24 @@
|
||||||
|
|
||||||
part of '../task_selection_modal.dart';
|
part of '../task_selection_modal.dart';
|
||||||
|
|
||||||
|
/// Menu destinations exposed by the modal Push button.
|
||||||
|
enum _ModalPushDestination {
|
||||||
|
/// Pushes the task to the next available slot.
|
||||||
|
next,
|
||||||
|
|
||||||
|
/// Pushes the task to tomorrow's first available slot.
|
||||||
|
tomorrow,
|
||||||
|
|
||||||
|
/// Pushes the task to Backlog.
|
||||||
|
backlog,
|
||||||
|
}
|
||||||
|
|
||||||
/// Private implementation type for `_ActionButton` in this library.
|
/// 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.
|
/// 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 {
|
class _ActionButton extends StatefulWidget {
|
||||||
/// Creates a `_ActionButton` instance with the values required by its invariants.
|
/// 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.
|
/// 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});
|
const _ActionButton({required this.icon, required this.label, this.onTap});
|
||||||
|
|
||||||
/// Stores the `icon` value for this object.
|
/// 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.
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
|
|
@ -18,27 +30,307 @@ class _ActionButton extends StatelessWidget {
|
||||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
/// 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;
|
||||||
|
|
||||||
|
/// Callback invoked when the action is tapped.
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
/// 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<_ActionButton> createState() => _ActionButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Private implementation type for `_ActionButtonState` in this library.
|
||||||
|
/// It owns hover feedback for compact modal action buttons.
|
||||||
|
class _ActionButtonState extends State<_ActionButton> {
|
||||||
|
/// Whether the pointer is hovering this action button.
|
||||||
|
bool _isHovered = false;
|
||||||
|
|
||||||
/// Builds the widget subtree for this component.
|
/// Builds the widget subtree for this component.
|
||||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
/// 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 OutlinedButton.icon(
|
return _ModalActionSurface(
|
||||||
onPressed: () {},
|
icon: widget.icon,
|
||||||
style: OutlinedButton.styleFrom(
|
label: widget.label,
|
||||||
foregroundColor: FocusFlowTokens.textPrimary,
|
isHovered: _isHovered,
|
||||||
side: const BorderSide(color: FocusFlowTokens.subtleBorder),
|
onHoverChanged: (value) {
|
||||||
shape: RoundedRectangleBorder(
|
setState(() {
|
||||||
borderRadius: BorderRadius.circular(
|
_isHovered = value;
|
||||||
FocusFlowTokens.smallButtonRadius,
|
});
|
||||||
|
},
|
||||||
|
onTap: widget.onTap ?? () {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Private implementation type for `_PushActionButton` in this library.
|
||||||
|
/// It exposes push destinations from the clicked-task modal.
|
||||||
|
class _PushActionButton extends StatefulWidget {
|
||||||
|
/// Creates a `_PushActionButton` 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 _PushActionButton({
|
||||||
|
required this.card,
|
||||||
|
this.onPushToNext,
|
||||||
|
this.onPushToTomorrow,
|
||||||
|
this.onPushToBacklog,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
|
||||||
|
/// Callback invoked when Push to next is selected.
|
||||||
|
final Future<void> Function(TimelineCardModel card)? onPushToNext;
|
||||||
|
|
||||||
|
/// Callback invoked when Push to tomorrow is selected.
|
||||||
|
final Future<void> Function(TimelineCardModel card)? onPushToTomorrow;
|
||||||
|
|
||||||
|
/// Callback invoked when Push to backlog is selected.
|
||||||
|
final Future<void> Function(TimelineCardModel card)? onPushToBacklog;
|
||||||
|
|
||||||
|
/// 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<_PushActionButton> createState() => _PushActionButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Private implementation type for `_PushActionButtonState` in this library.
|
||||||
|
/// It owns hover and running state for modal push destinations.
|
||||||
|
class _PushActionButtonState extends State<_PushActionButton> {
|
||||||
|
/// Whether the pointer is hovering this action button.
|
||||||
|
bool _isHovered = false;
|
||||||
|
|
||||||
|
/// Whether a push destination command is in flight.
|
||||||
|
bool _isRunning = 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 options = _modalPushDestinationOptions();
|
||||||
|
final enabled =
|
||||||
|
!_isRunning && options.any((option) => option.callback != null);
|
||||||
|
return PopupMenuButton<_ModalPushDestination>(
|
||||||
|
key: ValueKey('${widget.card.id}-modal-push-button'),
|
||||||
|
tooltip: '',
|
||||||
|
enabled: enabled,
|
||||||
|
color: const Color(0xFF122018),
|
||||||
|
elevation: 8,
|
||||||
|
position: PopupMenuPosition.under,
|
||||||
|
onSelected: (destination) {
|
||||||
|
for (final option in options) {
|
||||||
|
if (option.destination != destination) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final callback = option.callback;
|
||||||
|
if (callback != null) {
|
||||||
|
unawaited(_runPush(callback));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
for (final option in options)
|
||||||
|
PopupMenuItem<_ModalPushDestination>(
|
||||||
|
value: option.destination,
|
||||||
|
enabled: option.callback != null,
|
||||||
|
child: Text(option.label),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
|
child: _ModalActionSurface(
|
||||||
|
icon: Icons.arrow_forward,
|
||||||
|
label: 'Push',
|
||||||
|
isHovered: _isHovered,
|
||||||
|
isEnabled: enabled,
|
||||||
|
onHoverChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_isHovered = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
),
|
),
|
||||||
icon: Icon(icon),
|
);
|
||||||
label: Text(
|
}
|
||||||
label,
|
|
||||||
style: const TextStyle(
|
/// Builds the push destination option list for this button.
|
||||||
fontSize: FocusFlowTokens.modalButtonSize,
|
List<_ModalPushDestinationOption> _modalPushDestinationOptions() {
|
||||||
fontWeight: FontWeight.w700,
|
return [
|
||||||
|
_ModalPushDestinationOption(
|
||||||
|
destination: _ModalPushDestination.next,
|
||||||
|
label: 'Push to next',
|
||||||
|
callback: widget.onPushToNext == null
|
||||||
|
? null
|
||||||
|
: () => widget.onPushToNext!(widget.card),
|
||||||
|
),
|
||||||
|
_ModalPushDestinationOption(
|
||||||
|
destination: _ModalPushDestination.tomorrow,
|
||||||
|
label: 'Push to tomorrow',
|
||||||
|
callback: widget.onPushToTomorrow == null
|
||||||
|
? null
|
||||||
|
: () => widget.onPushToTomorrow!(widget.card),
|
||||||
|
),
|
||||||
|
_ModalPushDestinationOption(
|
||||||
|
destination: _ModalPushDestination.backlog,
|
||||||
|
label: 'Push to backlog',
|
||||||
|
callback: widget.onPushToBacklog == null
|
||||||
|
? null
|
||||||
|
: () => widget.onPushToBacklog!(widget.card),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs [callback] while guarding against duplicate push commands.
|
||||||
|
Future<void> _runPush(Future<void> Function() callback) async {
|
||||||
|
if (_isRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_isRunning = true;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await callback();
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isRunning = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Private implementation type for `_ModalPushDestinationOption` in this library.
|
||||||
|
/// It stores one modal push menu destination.
|
||||||
|
class _ModalPushDestinationOption {
|
||||||
|
/// Creates a `_ModalPushDestinationOption` 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 _ModalPushDestinationOption({
|
||||||
|
required this.destination,
|
||||||
|
required this.label,
|
||||||
|
required this.callback,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Stores the `destination` value for this object.
|
||||||
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
|
final _ModalPushDestination destination;
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
|
||||||
|
/// Callback invoked when this option is selected.
|
||||||
|
final Future<void> Function()? callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Private implementation type for `_ModalActionSurface` in this library.
|
||||||
|
/// It draws compact modal action button chrome shared by push and placeholder actions.
|
||||||
|
class _ModalActionSurface extends StatelessWidget {
|
||||||
|
/// Creates a `_ModalActionSurface` 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 _ModalActionSurface({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.isHovered,
|
||||||
|
required this.onHoverChanged,
|
||||||
|
this.isEnabled = true,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
|
||||||
|
/// Stores the `isHovered` 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 isHovered;
|
||||||
|
|
||||||
|
/// Stores the `isEnabled` 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 isEnabled;
|
||||||
|
|
||||||
|
/// Callback invoked when the hover state changes.
|
||||||
|
final ValueChanged<bool> onHoverChanged;
|
||||||
|
|
||||||
|
/// Callback invoked when the action surface is tapped.
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
/// 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 foreground = isEnabled
|
||||||
|
? FocusFlowTokens.textPrimary
|
||||||
|
: FocusFlowTokens.textMuted;
|
||||||
|
final hoverColor = FocusFlowTokens.textPrimary.withValues(alpha: 0.08);
|
||||||
|
return Semantics(
|
||||||
|
button: true,
|
||||||
|
enabled: isEnabled,
|
||||||
|
label: label,
|
||||||
|
child: MouseRegion(
|
||||||
|
cursor: isEnabled ? SystemMouseCursors.click : SystemMouseCursors.basic,
|
||||||
|
onEnter: (_) => onHoverChanged(true),
|
||||||
|
onExit: (_) => onHoverChanged(false),
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: isEnabled ? onTap : null,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
height: 42,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
|
duration: const Duration(milliseconds: 120),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isHovered && isEnabled
|
||||||
|
? hoverColor
|
||||||
|
: FocusFlowTokens.glassPanelStrong.withValues(alpha: 0.26),
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
FocusFlowTokens.smallButtonRadius,
|
||||||
|
),
|
||||||
|
border: Border.all(
|
||||||
|
color: isHovered && isEnabled
|
||||||
|
? FocusFlowTokens.textPrimary.withValues(alpha: 0.22)
|
||||||
|
: FocusFlowTokens.subtleBorder.withValues(alpha: 0.72),
|
||||||
|
),
|
||||||
|
boxShadow: isHovered && isEnabled
|
||||||
|
? [
|
||||||
|
BoxShadow(
|
||||||
|
color: FocusFlowTokens.textPrimary.withValues(
|
||||||
|
alpha: 0.12,
|
||||||
|
),
|
||||||
|
blurRadius: 12,
|
||||||
|
spreadRadius: 1,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: const [],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 28,
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Icon(icon, color: foreground, size: 18),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: foreground,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,50 +3,71 @@
|
||||||
|
|
||||||
part of '../task_selection_modal.dart';
|
part of '../task_selection_modal.dart';
|
||||||
|
|
||||||
/// Private implementation type for `_InfoTile` in this library.
|
/// Private implementation type for `_HeaderMetricIcons` 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.
|
/// It keeps task effort and reward metadata near the modal close action.
|
||||||
class _InfoTile extends StatelessWidget {
|
class _HeaderMetricIcons extends StatelessWidget {
|
||||||
/// Creates a `_InfoTile` instance with the values required by its invariants.
|
/// Creates a `_HeaderMetricIcons` 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.
|
/// 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});
|
const _HeaderMetricIcons({required this.card});
|
||||||
|
|
||||||
/// Stores the `child` value for this object.
|
/// 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.
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
final Widget child;
|
final TimelineCardModel card;
|
||||||
|
|
||||||
/// 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.
|
/// Builds the widget subtree for this component.
|
||||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
/// 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 Column(
|
||||||
height: 58,
|
mainAxisSize: MainAxisSize.min,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
children: [
|
||||||
decoration: BoxDecoration(
|
_HeaderMetricIcon(
|
||||||
borderRadius: BorderRadius.circular(FocusFlowTokens.smallButtonRadius),
|
tooltip: card.effortLabel == 'Effort not set'
|
||||||
border: Border.all(color: FocusFlowTokens.subtleBorder),
|
? 'Not Set'
|
||||||
),
|
: card.effortLabel,
|
||||||
child: Row(
|
child: DifficultyBars(
|
||||||
children: [
|
difficulty: card.difficultyIconToken,
|
||||||
child,
|
color: FocusFlowTokens.restPurple,
|
||||||
const SizedBox(width: 18),
|
size: const Size(28, 16),
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: FocusFlowTokens.textPrimary,
|
|
||||||
fontSize: 17,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
const SizedBox(height: 8),
|
||||||
|
_HeaderMetricIcon(
|
||||||
|
tooltip: card.rewardLabel == 'Reward not set'
|
||||||
|
? 'Not Set'
|
||||||
|
: card.rewardLabel,
|
||||||
|
child: const RewardIcon(
|
||||||
|
color: FocusFlowTokens.accentMagenta,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Private implementation type for `_HeaderMetricIcon` in this library.
|
||||||
|
/// It keeps metric tooltips scoped to icon-only modal affordances.
|
||||||
|
class _HeaderMetricIcon extends StatelessWidget {
|
||||||
|
/// Creates a `_HeaderMetricIcon` 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 _HeaderMetricIcon({required this.tooltip, required this.child});
|
||||||
|
|
||||||
|
/// Stores the `tooltip` 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 tooltip;
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
|
||||||
|
/// 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: tooltip,
|
||||||
|
child: SizedBox.square(dimension: 28, child: Center(child: child)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
part of '../task_selection_modal.dart';
|
part of '../task_selection_modal.dart';
|
||||||
|
|
||||||
/// Private implementation type for `_ModalStatusButton` in this library.
|
/// 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.
|
/// It adapts modal sizing and labels to the shared status-circle button.
|
||||||
class _ModalStatusButton extends StatelessWidget {
|
class _ModalStatusButton extends StatelessWidget {
|
||||||
/// Creates a `_ModalStatusButton` instance with the values required by its invariants.
|
/// 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.
|
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
||||||
|
|
@ -30,42 +30,19 @@ class _ModalStatusButton extends StatelessWidget {
|
||||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
/// 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 ring = Container(
|
return StatusCircleButton(
|
||||||
key: const ValueKey('task-modal-status'),
|
circleKey: const ValueKey('task-modal-status'),
|
||||||
width: 44,
|
color: color,
|
||||||
height: 44,
|
checkColor: FocusFlowTokens.appBackground,
|
||||||
decoration: BoxDecoration(
|
isCompleted: card.isCompleted,
|
||||||
shape: BoxShape.circle,
|
size: 44,
|
||||||
color: card.isCompleted ? color : Colors.transparent,
|
borderWidth: 4,
|
||||||
border: Border.all(color: color, width: 4),
|
iconSize: 26,
|
||||||
),
|
hoverBlurRadius: 12,
|
||||||
child: card.isCompleted
|
semanticLabel: card.isCompleted
|
||||||
? const Icon(
|
? 'Mark ${card.title} not done'
|
||||||
Icons.check,
|
: 'Mark ${card.title} complete',
|
||||||
color: FocusFlowTokens.appBackground,
|
onPressed: onPressed,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,13 @@
|
||||||
/// Renders the Task Selection Modal widget for the FocusFlow Flutter UI.
|
/// Renders the Task Selection Modal widget for the FocusFlow Flutter UI.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
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';
|
||||||
|
import 'status_circle_button.dart';
|
||||||
import 'timeline/difficulty_bars.dart';
|
import 'timeline/difficulty_bars.dart';
|
||||||
import 'timeline/reward_icon.dart';
|
import 'timeline/reward_icon.dart';
|
||||||
|
|
||||||
|
|
@ -24,6 +27,10 @@ class TaskSelectionModal extends StatelessWidget {
|
||||||
required this.card,
|
required this.card,
|
||||||
required this.onClose,
|
required this.onClose,
|
||||||
this.onStatusPressed,
|
this.onStatusPressed,
|
||||||
|
this.onPushToNext,
|
||||||
|
this.onPushToTomorrow,
|
||||||
|
this.onPushToBacklog,
|
||||||
|
this.onRemove,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -36,6 +43,18 @@ class TaskSelectionModal extends StatelessWidget {
|
||||||
/// Callback invoked when the status circle should toggle completion.
|
/// Callback invoked when the status circle should toggle completion.
|
||||||
final VoidCallback? onStatusPressed;
|
final VoidCallback? onStatusPressed;
|
||||||
|
|
||||||
|
/// Callback invoked when Push to next is selected.
|
||||||
|
final Future<void> Function(TimelineCardModel card)? onPushToNext;
|
||||||
|
|
||||||
|
/// Callback invoked when Push to tomorrow is selected.
|
||||||
|
final Future<void> Function(TimelineCardModel card)? onPushToTomorrow;
|
||||||
|
|
||||||
|
/// Callback invoked when Push to backlog is selected.
|
||||||
|
final Future<void> Function(TimelineCardModel card)? onPushToBacklog;
|
||||||
|
|
||||||
|
/// Callback invoked when Remove is selected.
|
||||||
|
final Future<void> Function(TimelineCardModel card)? onRemove;
|
||||||
|
|
||||||
/// Builds the widget subtree for this component.
|
/// Builds the widget subtree for this component.
|
||||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||||
@override
|
@override
|
||||||
|
|
@ -81,6 +100,9 @@ class TaskSelectionModal extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
_HeaderMetricIcons(card: card),
|
||||||
|
const SizedBox(width: 14),
|
||||||
IconButton(
|
IconButton(
|
||||||
key: const ValueKey('close-task-modal'),
|
key: const ValueKey('close-task-modal'),
|
||||||
onPressed: onClose,
|
onPressed: onClose,
|
||||||
|
|
@ -92,43 +114,35 @@ class TaskSelectionModal extends StatelessWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 26),
|
const SizedBox(height: 24),
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: _InfoTile(
|
|
||||||
label: card.rewardLabel,
|
|
||||||
child: RewardIcon(color: FocusFlowTokens.accentMagenta),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 28),
|
|
||||||
Expanded(
|
|
||||||
child: _InfoTile(
|
|
||||||
label: card.effortLabel,
|
|
||||||
child: DifficultyBars(
|
|
||||||
difficulty: card.difficultyIconToken,
|
|
||||||
color: FocusFlowTokens.restPurple,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 30),
|
|
||||||
GridView.count(
|
GridView.count(
|
||||||
crossAxisCount: 2,
|
crossAxisCount: 2,
|
||||||
crossAxisSpacing: 26,
|
crossAxisSpacing: 18,
|
||||||
mainAxisSpacing: 16,
|
mainAxisSpacing: 12,
|
||||||
childAspectRatio: 4.1,
|
childAspectRatio: 5.3,
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
children: const [
|
children: [
|
||||||
_ActionButton(icon: Icons.check, label: 'Done'),
|
_PushActionButton(
|
||||||
_ActionButton(icon: Icons.arrow_forward, label: 'Push'),
|
card: card,
|
||||||
|
onPushToNext: onPushToNext,
|
||||||
|
onPushToTomorrow: onPushToTomorrow,
|
||||||
|
onPushToBacklog: onPushToBacklog,
|
||||||
|
),
|
||||||
_ActionButton(
|
_ActionButton(
|
||||||
icon: Icons.inventory_2_outlined,
|
icon: Icons.inventory_2_outlined,
|
||||||
label: 'Move to Backlog',
|
label: 'Backlog',
|
||||||
|
),
|
||||||
|
const _ActionButton(icon: Icons.apps, label: 'Break up'),
|
||||||
|
_ActionButton(
|
||||||
|
icon: Icons.delete_outline,
|
||||||
|
label: 'Remove',
|
||||||
|
onTap: onRemove == null
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
unawaited(onRemove!(card));
|
||||||
|
},
|
||||||
),
|
),
|
||||||
_ActionButton(icon: Icons.apps, label: 'Break up'),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,19 @@ class _CardMetrics {
|
||||||
|
|
||||||
/// Returns the derived `actionsWidth` value for this object.
|
/// 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.
|
/// 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;
|
double get actionsWidth => actionButtonSize * 3 + actionGap;
|
||||||
|
|
||||||
|
/// Returns the derived `pushButtonHeight` 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 pushButtonHeight => (28 * scale).clamp(18.0, 24.0).toDouble();
|
||||||
|
|
||||||
|
/// Returns the derived `pushButtonWidth` 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 pushButtonWidth => (62 * scale).clamp(42.0, 58.0).toDouble();
|
||||||
|
|
||||||
|
/// Returns the derived `pushButtonGap` 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 pushButtonGap => math.max(5, 8 * scale);
|
||||||
|
|
||||||
/// Returns the derived `indicatorTop` value for this object.
|
/// 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.
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||||
|
|
@ -158,10 +170,23 @@ class _CardMetrics {
|
||||||
double get expandedTrailingReserve =>
|
double get expandedTrailingReserve =>
|
||||||
indicatorWidth + math.max(8, 12 * scale);
|
indicatorWidth + math.max(8, 12 * scale);
|
||||||
|
|
||||||
|
/// Returns the derived `expandedPushTrailingReserve` 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 expandedPushTrailingReserve =>
|
||||||
|
indicatorWidth +
|
||||||
|
pushButtonGap +
|
||||||
|
pushButtonWidth +
|
||||||
|
math.max(8, 12 * scale);
|
||||||
|
|
||||||
/// Returns the derived `compactTrailingReserve` value for this object.
|
/// 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.
|
/// 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);
|
double get compactTrailingReserve => indicatorWidth + math.max(6, 8 * scale);
|
||||||
|
|
||||||
|
/// Returns the derived `compactPushTrailingReserve` 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 compactPushTrailingReserve =>
|
||||||
|
indicatorWidth + pushButtonGap + pushButtonWidth + math.max(6, 8 * scale);
|
||||||
|
|
||||||
/// Returns the derived `difficultySize` value for this object.
|
/// 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.
|
/// 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);
|
Size get difficultySize => Size(26, 14);
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,11 @@ class _CompactCardText extends StatelessWidget {
|
||||||
style: metaStyle,
|
style: metaStyle,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(width: metrics.compactTrailingReserve),
|
SizedBox(
|
||||||
|
width: card.showPastTaskPushButton
|
||||||
|
? metrics.compactPushTrailingReserve
|
||||||
|
: metrics.compactTrailingReserve,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) {
|
if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) {
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,11 @@ class _ExpandedCardContent extends StatelessWidget {
|
||||||
],
|
],
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.only(right: metrics.expandedTrailingReserve),
|
padding: EdgeInsets.only(
|
||||||
|
right: card.showPastTaskPushButton
|
||||||
|
? metrics.expandedPushTrailingReserve
|
||||||
|
: metrics.expandedTrailingReserve,
|
||||||
|
),
|
||||||
child: _CardText(
|
child: _CardText(
|
||||||
card: card,
|
card: card,
|
||||||
color: colors.accent,
|
color: colors.accent,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ class _NoOpIcon extends StatelessWidget {
|
||||||
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
/// 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({
|
const _NoOpIcon({
|
||||||
required this.icon,
|
required this.icon,
|
||||||
|
required this.tooltip,
|
||||||
required this.color,
|
required this.color,
|
||||||
required this.metrics,
|
required this.metrics,
|
||||||
});
|
});
|
||||||
|
|
@ -18,6 +19,9 @@ class _NoOpIcon extends StatelessWidget {
|
||||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
/// 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;
|
||||||
|
|
||||||
|
/// Tooltip message for this inactive icon affordance.
|
||||||
|
final String tooltip;
|
||||||
|
|
||||||
/// Stores the `color` value for this object.
|
/// 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.
|
/// 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;
|
||||||
|
|
@ -31,7 +35,7 @@ class _NoOpIcon extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Tooltip(
|
return Tooltip(
|
||||||
message: 'No-op action',
|
message: tooltip,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () {},
|
onTap: () {},
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
|
|
@ -43,3 +47,80 @@ class _NoOpIcon extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Private implementation type for `_QuickPushIcon` in this library.
|
||||||
|
/// It keeps the timeline hover push action visually compact while reusing the same destination menu as the full overdue Push button.
|
||||||
|
class _QuickPushIcon extends StatelessWidget {
|
||||||
|
/// Creates a `_QuickPushIcon` 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 _QuickPushIcon({
|
||||||
|
required this.card,
|
||||||
|
required this.color,
|
||||||
|
required this.metrics,
|
||||||
|
required this.isRunning,
|
||||||
|
this.onPushToNext,
|
||||||
|
this.onPushToTomorrow,
|
||||||
|
this.onPushToBacklog,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 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 `isRunning` 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 isRunning;
|
||||||
|
|
||||||
|
/// Callback invoked when Push to next is selected.
|
||||||
|
final Future<void> Function()? onPushToNext;
|
||||||
|
|
||||||
|
/// Callback invoked when Push to tomorrow is selected.
|
||||||
|
final Future<void> Function()? onPushToTomorrow;
|
||||||
|
|
||||||
|
/// Callback invoked when Push to backlog is selected.
|
||||||
|
final Future<void> Function()? onPushToBacklog;
|
||||||
|
|
||||||
|
/// 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 options = _pushDestinationOptions(
|
||||||
|
onPushToNext: onPushToNext,
|
||||||
|
onPushToTomorrow: onPushToTomorrow,
|
||||||
|
onPushToBacklog: onPushToBacklog,
|
||||||
|
);
|
||||||
|
final enabled = !isRunning && _hasEnabledPushDestination(options);
|
||||||
|
return PopupMenuButton<_PastTaskPushDestination>(
|
||||||
|
key: ValueKey('${card.id}-quick-push-button'),
|
||||||
|
tooltip: 'Push task',
|
||||||
|
enabled: enabled,
|
||||||
|
color: const Color(0xFF122018),
|
||||||
|
elevation: 8,
|
||||||
|
position: PopupMenuPosition.under,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
onSelected: (destination) => _selectPushDestination(destination, options),
|
||||||
|
itemBuilder: (context) => _pushDestinationMenuItems(options),
|
||||||
|
child: Semantics(
|
||||||
|
button: true,
|
||||||
|
enabled: enabled,
|
||||||
|
label: 'Push ${card.title}',
|
||||||
|
child: SizedBox.square(
|
||||||
|
dimension: metrics.actionButtonSize,
|
||||||
|
child: Icon(
|
||||||
|
Icons.arrow_forward,
|
||||||
|
color: color,
|
||||||
|
size: metrics.actionIconSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,21 @@ class _QuickActions extends StatelessWidget {
|
||||||
/// Creates a `_QuickActions` instance with the values required by its invariants.
|
/// 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.
|
/// 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({
|
const _QuickActions({
|
||||||
|
required this.card,
|
||||||
required this.color,
|
required this.color,
|
||||||
required this.backgroundColor,
|
required this.backgroundColor,
|
||||||
required this.metrics,
|
required this.metrics,
|
||||||
required this.visible,
|
required this.visible,
|
||||||
|
required this.pushRunning,
|
||||||
|
this.onPushToNext,
|
||||||
|
this.onPushToTomorrow,
|
||||||
|
this.onPushToBacklog,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// 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.
|
/// 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.
|
/// 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;
|
||||||
|
|
@ -31,6 +40,19 @@ class _QuickActions extends StatelessWidget {
|
||||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
final bool visible;
|
final bool visible;
|
||||||
|
|
||||||
|
/// Stores the `pushRunning` 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 pushRunning;
|
||||||
|
|
||||||
|
/// Callback invoked when Push to next is selected.
|
||||||
|
final Future<void> Function()? onPushToNext;
|
||||||
|
|
||||||
|
/// Callback invoked when Push to tomorrow is selected.
|
||||||
|
final Future<void> Function()? onPushToTomorrow;
|
||||||
|
|
||||||
|
/// Callback invoked when Push to backlog is selected.
|
||||||
|
final Future<void> Function()? onPushToBacklog;
|
||||||
|
|
||||||
/// Builds the widget subtree for this component.
|
/// Builds the widget subtree for this component.
|
||||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||||
@override
|
@override
|
||||||
|
|
@ -41,6 +63,7 @@ class _QuickActions extends StatelessWidget {
|
||||||
ignoring: !visible,
|
ignoring: !visible,
|
||||||
child: ClipRect(
|
child: ClipRect(
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
|
key: ValueKey('${card.id}-quick-actions-background'),
|
||||||
width: visible ? metrics.actionsWidth : 0,
|
width: visible ? metrics.actionsWidth : 0,
|
||||||
height: metrics.actionButtonSize,
|
height: metrics.actionButtonSize,
|
||||||
decoration: BoxDecoration(color: backgroundColor),
|
decoration: BoxDecoration(color: backgroundColor),
|
||||||
|
|
@ -51,29 +74,31 @@ class _QuickActions extends StatelessWidget {
|
||||||
maxWidth: metrics.actionsWidth,
|
maxWidth: metrics.actionsWidth,
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
|
key: ValueKey('${card.id}-quick-actions-opacity'),
|
||||||
opacity: visible ? 1 : 0,
|
opacity: visible ? 1 : 0,
|
||||||
duration: const Duration(milliseconds: 90),
|
duration: const Duration(milliseconds: 90),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_NoOpIcon(
|
_QuickPushIcon(
|
||||||
icon: Icons.check,
|
card: card,
|
||||||
color: color,
|
|
||||||
metrics: metrics,
|
|
||||||
),
|
|
||||||
_NoOpIcon(
|
|
||||||
icon: Icons.arrow_forward,
|
|
||||||
color: color,
|
color: color,
|
||||||
metrics: metrics,
|
metrics: metrics,
|
||||||
|
isRunning: pushRunning,
|
||||||
|
onPushToNext: onPushToNext,
|
||||||
|
onPushToTomorrow: onPushToTomorrow,
|
||||||
|
onPushToBacklog: onPushToBacklog,
|
||||||
),
|
),
|
||||||
_NoOpIcon(
|
_NoOpIcon(
|
||||||
icon: Icons.calendar_today,
|
icon: Icons.calendar_today,
|
||||||
|
tooltip: 'Schedule',
|
||||||
color: color,
|
color: color,
|
||||||
metrics: metrics,
|
metrics: metrics,
|
||||||
),
|
),
|
||||||
_NoOpIcon(
|
_NoOpIcon(
|
||||||
icon: Icons.wb_sunny_outlined,
|
icon: Icons.wb_sunny_outlined,
|
||||||
|
tooltip: 'Protect time',
|
||||||
color: color,
|
color: color,
|
||||||
metrics: metrics,
|
metrics: metrics,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
part of '../task_timeline_card.dart';
|
part of '../task_timeline_card.dart';
|
||||||
|
|
||||||
/// Private implementation type for `_StatusRing` in this library.
|
/// 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.
|
/// It adapts timeline card sizing and labels to the shared status-circle button.
|
||||||
class _StatusRing extends StatelessWidget {
|
class _StatusRing extends StatelessWidget {
|
||||||
/// Creates a `_StatusRing` instance with the values required by its invariants.
|
/// 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.
|
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
||||||
|
|
@ -35,43 +35,18 @@ class _StatusRing extends StatelessWidget {
|
||||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
/// 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 border = Border.all(color: color, width: metrics.statusBorderWidth);
|
return StatusCircleButton(
|
||||||
final ring = Container(
|
circleKey: ValueKey('${card.id}-status'),
|
||||||
key: ValueKey('${card.id}-status'),
|
color: color,
|
||||||
width: metrics.statusRingSize,
|
checkColor: FocusFlowTokens.appBackground,
|
||||||
height: metrics.statusRingSize,
|
isCompleted: card.isCompleted,
|
||||||
decoration: BoxDecoration(
|
size: metrics.statusRingSize,
|
||||||
shape: BoxShape.circle,
|
borderWidth: metrics.statusBorderWidth,
|
||||||
color: card.isCompleted ? color : Colors.transparent,
|
iconSize: metrics.statusIconSize,
|
||||||
border: border,
|
semanticLabel: card.isCompleted
|
||||||
),
|
? 'Mark ${card.title} not done'
|
||||||
child: card.isCompleted
|
: 'Mark ${card.title} complete',
|
||||||
? Icon(
|
onPressed: onPressed,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ class _TaskTimelineCardState extends State<TaskTimelineCard> {
|
||||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||||
bool _isHovered = false;
|
bool _isHovered = false;
|
||||||
|
|
||||||
|
/// Private state stored as `_isPushRunning` 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 _isPushRunning = false;
|
||||||
|
|
||||||
/// Builds the widget subtree for this component.
|
/// Builds the widget subtree for this component.
|
||||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||||
@override
|
@override
|
||||||
|
|
@ -48,6 +52,16 @@ class _TaskTimelineCardState extends State<TaskTimelineCard> {
|
||||||
colors: colors,
|
colors: colors,
|
||||||
metrics: metrics,
|
metrics: metrics,
|
||||||
actionsVisible: card.showsQuickActions && _isHovered,
|
actionsVisible: card.showsQuickActions && _isHovered,
|
||||||
|
pushRunning: _isPushRunning,
|
||||||
|
onPushToNext: widget.onPushToNext == null
|
||||||
|
? null
|
||||||
|
: () => _runPush(widget.onPushToNext!),
|
||||||
|
onPushToTomorrow: widget.onPushToTomorrow == null
|
||||||
|
? null
|
||||||
|
: () => _runPush(widget.onPushToTomorrow!),
|
||||||
|
onPushToBacklog: widget.onPushToBacklog == null
|
||||||
|
? null
|
||||||
|
: () => _runPush(widget.onPushToBacklog!),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -96,4 +110,38 @@ class _TaskTimelineCardState extends State<TaskTimelineCard> {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs the `_runPush` 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> _runPush(
|
||||||
|
Future<void> Function(TimelineCardModel card) callback,
|
||||||
|
) async {
|
||||||
|
if (_isPushRunning) {
|
||||||
|
logger.warn(
|
||||||
|
() => 'Duplicate card push action ignored. cardId=${widget.card.id}',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.debug(() => 'Card push action started. cardId=${widget.card.id}');
|
||||||
|
setState(() {
|
||||||
|
_isPushRunning = true;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await callback(widget.card);
|
||||||
|
logger.debug(() => 'Card push action finished. cardId=${widget.card.id}');
|
||||||
|
} on Object catch (error, stackTrace) {
|
||||||
|
logger.error(
|
||||||
|
() => 'Card push action failed. cardId=${widget.card.id}',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
rethrow;
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isPushRunning = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ class _TrailingControls extends StatelessWidget {
|
||||||
required this.colors,
|
required this.colors,
|
||||||
required this.metrics,
|
required this.metrics,
|
||||||
required this.actionsVisible,
|
required this.actionsVisible,
|
||||||
|
required this.pushRunning,
|
||||||
|
this.onPushToNext,
|
||||||
|
this.onPushToTomorrow,
|
||||||
|
this.onPushToBacklog,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Stores the `card` value for this object.
|
/// Stores the `card` value for this object.
|
||||||
|
|
@ -31,6 +35,19 @@ class _TrailingControls extends StatelessWidget {
|
||||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
final bool actionsVisible;
|
final bool actionsVisible;
|
||||||
|
|
||||||
|
/// Stores the `pushRunning` 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 pushRunning;
|
||||||
|
|
||||||
|
/// Callback invoked when Push to next is selected.
|
||||||
|
final Future<void> Function()? onPushToNext;
|
||||||
|
|
||||||
|
/// Callback invoked when Push to tomorrow is selected.
|
||||||
|
final Future<void> Function()? onPushToTomorrow;
|
||||||
|
|
||||||
|
/// Callback invoked when Push to backlog is selected.
|
||||||
|
final Future<void> Function()? onPushToBacklog;
|
||||||
|
|
||||||
/// Builds the widget subtree for this component.
|
/// Builds the widget subtree for this component.
|
||||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||||
@override
|
@override
|
||||||
|
|
@ -40,11 +57,29 @@ class _TrailingControls extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
if (card.showsQuickActions)
|
if (card.showsQuickActions)
|
||||||
_QuickActions(
|
_QuickActions(
|
||||||
|
card: card,
|
||||||
color: colors.accent,
|
color: colors.accent,
|
||||||
backgroundColor: colors.background.withValues(alpha: 1),
|
backgroundColor: colors.background.withValues(alpha: 1),
|
||||||
metrics: metrics,
|
metrics: metrics,
|
||||||
visible: actionsVisible,
|
visible: actionsVisible,
|
||||||
|
pushRunning: pushRunning,
|
||||||
|
onPushToNext: onPushToNext,
|
||||||
|
onPushToTomorrow: onPushToTomorrow,
|
||||||
|
onPushToBacklog: onPushToBacklog,
|
||||||
),
|
),
|
||||||
|
if (card.showPastTaskPushButton) ...[
|
||||||
|
_PastTaskPushButton(
|
||||||
|
card: card,
|
||||||
|
color: colors.accent,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
metrics: metrics,
|
||||||
|
isRunning: pushRunning,
|
||||||
|
onPushToNext: onPushToNext,
|
||||||
|
onPushToTomorrow: onPushToTomorrow,
|
||||||
|
onPushToBacklog: onPushToBacklog,
|
||||||
|
),
|
||||||
|
SizedBox(width: metrics.pushButtonGap),
|
||||||
|
],
|
||||||
RewardIcon(color: colors.reward, size: metrics.rewardIconSize),
|
RewardIcon(color: colors.reward, size: metrics.rewardIconSize),
|
||||||
SizedBox(width: metrics.indicatorGap),
|
SizedBox(width: metrics.indicatorGap),
|
||||||
DifficultyBars(
|
DifficultyBars(
|
||||||
|
|
@ -56,3 +91,189 @@ class _TrailingControls extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Menu destinations exposed by the overdue-task Push button.
|
||||||
|
enum _PastTaskPushDestination {
|
||||||
|
/// Pushes the task to the next available slot.
|
||||||
|
next,
|
||||||
|
|
||||||
|
/// Pushes the task to tomorrow's first available slot.
|
||||||
|
tomorrow,
|
||||||
|
|
||||||
|
/// Pushes the task to Backlog.
|
||||||
|
backlog,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Menu item model for push destination options.
|
||||||
|
class _PushDestinationOption {
|
||||||
|
/// Creates one push destination menu option.
|
||||||
|
const _PushDestinationOption({
|
||||||
|
required this.destination,
|
||||||
|
required this.label,
|
||||||
|
required this.callback,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Destination selected by this option.
|
||||||
|
final _PastTaskPushDestination destination;
|
||||||
|
|
||||||
|
/// Visible label for this option.
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// Callback invoked when this option is selected.
|
||||||
|
final Future<void> Function()? callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs [callback] for [destination] when that destination has a callback.
|
||||||
|
void _selectPushDestination(
|
||||||
|
_PastTaskPushDestination destination,
|
||||||
|
List<_PushDestinationOption> options,
|
||||||
|
) {
|
||||||
|
for (final option in options) {
|
||||||
|
if (option.destination != destination) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final callback = option.callback;
|
||||||
|
if (callback != null) {
|
||||||
|
unawaited(callback());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds push destination menu options.
|
||||||
|
List<_PushDestinationOption> _pushDestinationOptions({
|
||||||
|
required Future<void> Function()? onPushToNext,
|
||||||
|
required Future<void> Function()? onPushToTomorrow,
|
||||||
|
required Future<void> Function()? onPushToBacklog,
|
||||||
|
}) {
|
||||||
|
return [
|
||||||
|
_PushDestinationOption(
|
||||||
|
destination: _PastTaskPushDestination.next,
|
||||||
|
label: 'Push to next',
|
||||||
|
callback: onPushToNext,
|
||||||
|
),
|
||||||
|
_PushDestinationOption(
|
||||||
|
destination: _PastTaskPushDestination.tomorrow,
|
||||||
|
label: 'Push to tomorrow',
|
||||||
|
callback: onPushToTomorrow,
|
||||||
|
),
|
||||||
|
_PushDestinationOption(
|
||||||
|
destination: _PastTaskPushDestination.backlog,
|
||||||
|
label: 'Push to backlog',
|
||||||
|
callback: onPushToBacklog,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether any push destination option has an active callback.
|
||||||
|
bool _hasEnabledPushDestination(List<_PushDestinationOption> options) {
|
||||||
|
return options.any((option) => option.callback != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds popup menu entries for push destination options.
|
||||||
|
List<PopupMenuEntry<_PastTaskPushDestination>> _pushDestinationMenuItems(
|
||||||
|
List<_PushDestinationOption> options,
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
for (final option in options)
|
||||||
|
PopupMenuItem<_PastTaskPushDestination>(
|
||||||
|
value: option.destination,
|
||||||
|
enabled: option.callback != null,
|
||||||
|
child: Text(option.label),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Private implementation type for `_PastTaskPushButton` 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 _PastTaskPushButton extends StatelessWidget {
|
||||||
|
/// Creates a `_PastTaskPushButton` 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 _PastTaskPushButton({
|
||||||
|
required this.card,
|
||||||
|
required this.color,
|
||||||
|
required this.backgroundColor,
|
||||||
|
required this.metrics,
|
||||||
|
required this.isRunning,
|
||||||
|
this.onPushToNext,
|
||||||
|
this.onPushToTomorrow,
|
||||||
|
this.onPushToBacklog,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 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 `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 `isRunning` 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 isRunning;
|
||||||
|
|
||||||
|
/// Callback invoked when Push to next is selected.
|
||||||
|
final Future<void> Function()? onPushToNext;
|
||||||
|
|
||||||
|
/// Callback invoked when Push to tomorrow is selected.
|
||||||
|
final Future<void> Function()? onPushToTomorrow;
|
||||||
|
|
||||||
|
/// Callback invoked when Push to backlog is selected.
|
||||||
|
final Future<void> Function()? onPushToBacklog;
|
||||||
|
|
||||||
|
/// 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 options = _pushDestinationOptions(
|
||||||
|
onPushToNext: onPushToNext,
|
||||||
|
onPushToTomorrow: onPushToTomorrow,
|
||||||
|
onPushToBacklog: onPushToBacklog,
|
||||||
|
);
|
||||||
|
final enabled = !isRunning && _hasEnabledPushDestination(options);
|
||||||
|
return PopupMenuButton<_PastTaskPushDestination>(
|
||||||
|
key: ValueKey('${card.id}-push-button'),
|
||||||
|
tooltip: card.pastTaskPushSemanticLabel,
|
||||||
|
enabled: enabled,
|
||||||
|
color: const Color(0xFF122018),
|
||||||
|
elevation: 8,
|
||||||
|
position: PopupMenuPosition.under,
|
||||||
|
onSelected: (destination) => _selectPushDestination(destination, options),
|
||||||
|
itemBuilder: (context) => _pushDestinationMenuItems(options),
|
||||||
|
child: Semantics(
|
||||||
|
button: true,
|
||||||
|
enabled: enabled,
|
||||||
|
label: card.pastTaskPushSemanticLabel,
|
||||||
|
child: Container(
|
||||||
|
width: metrics.pushButtonWidth,
|
||||||
|
height: metrics.pushButtonHeight,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor.withValues(alpha: 0.96),
|
||||||
|
border: Border.all(color: color, width: 1),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Push',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: color,
|
||||||
|
fontSize: (11 * metrics.scale).clamp(8.0, 11.0).toDouble(),
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
height: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,15 @@
|
||||||
/// Renders Task Timeline Card pieces for the FocusFlow Flutter timeline.
|
/// Renders Task Timeline Card pieces for the FocusFlow Flutter timeline.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:scheduler_core/scheduler_core.dart' show logger;
|
||||||
|
|
||||||
import '../../models/today_screen_models.dart';
|
import '../../models/today_screen_models.dart';
|
||||||
import '../../theme/focus_flow_tokens.dart';
|
import '../../theme/focus_flow_tokens.dart';
|
||||||
|
import '../status_circle_button.dart';
|
||||||
import 'difficulty_bars.dart';
|
import 'difficulty_bars.dart';
|
||||||
import 'reward_icon.dart';
|
import 'reward_icon.dart';
|
||||||
|
|
||||||
|
|
@ -31,6 +34,9 @@ class TaskTimelineCard extends StatefulWidget {
|
||||||
required this.card,
|
required this.card,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
this.onStatusPressed,
|
this.onStatusPressed,
|
||||||
|
this.onPushToNext,
|
||||||
|
this.onPushToTomorrow,
|
||||||
|
this.onPushToBacklog,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -43,6 +49,15 @@ class TaskTimelineCard extends StatefulWidget {
|
||||||
/// Callback invoked when the left status control is clicked.
|
/// Callback invoked when the left status control is clicked.
|
||||||
final VoidCallback? onStatusPressed;
|
final VoidCallback? onStatusPressed;
|
||||||
|
|
||||||
|
/// Callback invoked when the card requests Push to next.
|
||||||
|
final Future<void> Function(TimelineCardModel card)? onPushToNext;
|
||||||
|
|
||||||
|
/// Callback invoked when the card requests Push to tomorrow.
|
||||||
|
final Future<void> Function(TimelineCardModel card)? onPushToTomorrow;
|
||||||
|
|
||||||
|
/// Callback invoked when the card requests Push to backlog.
|
||||||
|
final Future<void> Function(TimelineCardModel card)? onPushToBacklog;
|
||||||
|
|
||||||
/// Creates the mutable state object used by this stateful widget.
|
/// 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.
|
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,13 @@ class TimelineView extends StatefulWidget {
|
||||||
required this.onCardSelected,
|
required this.onCardSelected,
|
||||||
this.onAddTask,
|
this.onAddTask,
|
||||||
this.onCardCompleted,
|
this.onCardCompleted,
|
||||||
|
this.onPushToNext,
|
||||||
|
this.onPushToTomorrow,
|
||||||
|
this.onPushToBacklog,
|
||||||
this.now,
|
this.now,
|
||||||
this.scrollTargetCardId,
|
this.scrollTargetCardId,
|
||||||
this.scrollRequest = 0,
|
this.scrollRequest = 0,
|
||||||
|
this.scrollToNowRequest = 0,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -47,6 +51,15 @@ class TimelineView extends StatefulWidget {
|
||||||
/// Callback invoked when a task card status control requests completion.
|
/// Callback invoked when a task card status control requests completion.
|
||||||
final ValueChanged<TimelineCardModel>? onCardCompleted;
|
final ValueChanged<TimelineCardModel>? onCardCompleted;
|
||||||
|
|
||||||
|
/// Callback invoked when a task card requests Push to next.
|
||||||
|
final Future<void> Function(TimelineCardModel card)? onPushToNext;
|
||||||
|
|
||||||
|
/// Callback invoked when a task card requests Push to tomorrow.
|
||||||
|
final Future<void> Function(TimelineCardModel card)? onPushToTomorrow;
|
||||||
|
|
||||||
|
/// Callback invoked when a task card requests Push to backlog.
|
||||||
|
final Future<void> Function(TimelineCardModel card)? onPushToBacklog;
|
||||||
|
|
||||||
/// Clock used to center the initial scroll position.
|
/// Clock used to center the initial scroll position.
|
||||||
final DateTime Function()? now;
|
final DateTime Function()? now;
|
||||||
|
|
||||||
|
|
@ -56,6 +69,9 @@ class TimelineView extends StatefulWidget {
|
||||||
/// Monotonic request number that allows repeated jumps to the same card.
|
/// Monotonic request number that allows repeated jumps to the same card.
|
||||||
final int scrollRequest;
|
final int scrollRequest;
|
||||||
|
|
||||||
|
/// Monotonic request number that allows repeated jumps to current time.
|
||||||
|
final int scrollToNowRequest;
|
||||||
|
|
||||||
/// Creates the mutable state object used by this stateful widget.
|
/// 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.
|
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,10 @@ class _TimelineViewState extends State<TimelineView> {
|
||||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||||
var _handledScrollRequest = 0;
|
var _handledScrollRequest = 0;
|
||||||
|
|
||||||
|
/// Private state stored as `_handledScrollToNowRequest` 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 _handledScrollToNowRequest = 0;
|
||||||
|
|
||||||
/// Private state stored as `_geometry` for this implementation.
|
/// 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.
|
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||||
late TimelineGeometry _geometry;
|
late TimelineGeometry _geometry;
|
||||||
|
|
@ -86,6 +90,7 @@ class _TimelineViewState extends State<TimelineView> {
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
_scheduleInitialScroll(_geometry, constraints.maxHeight);
|
_scheduleInitialScroll(_geometry, constraints.maxHeight);
|
||||||
|
_scheduleCurrentTimeScroll(_geometry, constraints.maxHeight);
|
||||||
_scheduleTargetScroll(_geometry);
|
_scheduleTargetScroll(_geometry);
|
||||||
return ScrollConfiguration(
|
return ScrollConfiguration(
|
||||||
behavior: const _TimelineScrollBehavior(),
|
behavior: const _TimelineScrollBehavior(),
|
||||||
|
|
@ -140,6 +145,9 @@ class _TimelineViewState extends State<TimelineView> {
|
||||||
: () => widget.onCardCompleted!(
|
: () => widget.onCardCompleted!(
|
||||||
placement.card,
|
placement.card,
|
||||||
),
|
),
|
||||||
|
onPushToNext: widget.onPushToNext,
|
||||||
|
onPushToTomorrow: widget.onPushToTomorrow,
|
||||||
|
onPushToBacklog: widget.onPushToBacklog,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -234,21 +242,31 @@ class _TimelineViewState extends State<TimelineView> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final now = widget.now?.call() ?? DateTime.now();
|
final now = widget.now?.call() ?? DateTime.now();
|
||||||
final currentMinute = now.hour * 60 + now.minute;
|
_jumpToCurrentTime(geometry, viewportHeight, now);
|
||||||
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;
|
_didSetInitialScroll = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs the `_scheduleCurrentTimeScroll` 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 _scheduleCurrentTimeScroll(
|
||||||
|
TimelineGeometry geometry,
|
||||||
|
double viewportHeight,
|
||||||
|
) {
|
||||||
|
if (widget.scrollToNowRequest == _handledScrollToNowRequest ||
|
||||||
|
!viewportHeight.isFinite) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted || !_scrollController.hasClients) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final now = widget.now?.call() ?? DateTime.now();
|
||||||
|
_jumpToCurrentTime(geometry, viewportHeight, now);
|
||||||
|
_handledScrollToNowRequest = widget.scrollToNowRequest;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Runs the `_scheduleTargetScroll` helper used inside this library.
|
/// 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.
|
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||||
void _scheduleTargetScroll(TimelineGeometry geometry) {
|
void _scheduleTargetScroll(TimelineGeometry geometry) {
|
||||||
|
|
@ -281,4 +299,40 @@ class _TimelineViewState extends State<TimelineView> {
|
||||||
_handledScrollRequest = widget.scrollRequest;
|
_handledScrollRequest = widget.scrollRequest;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs the `_jumpToCurrentTime` 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 _jumpToCurrentTime(
|
||||||
|
TimelineGeometry geometry,
|
||||||
|
double viewportHeight,
|
||||||
|
DateTime now,
|
||||||
|
) {
|
||||||
|
final currentMinute = now.hour * 60 + now.minute;
|
||||||
|
final clampedMinute = currentMinute
|
||||||
|
.clamp(geometry.startMinutes, geometry.endMinutes)
|
||||||
|
.toInt();
|
||||||
|
final maxOffset = _scrollController.position.maxScrollExtent;
|
||||||
|
final offset = _scrollOffsetForMinute(
|
||||||
|
geometry: geometry,
|
||||||
|
minute: clampedMinute,
|
||||||
|
viewportHeight: viewportHeight,
|
||||||
|
maxOffset: maxOffset,
|
||||||
|
);
|
||||||
|
_scrollController.jumpTo(offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the `_scrollOffsetForMinute` 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 _scrollOffsetForMinute({
|
||||||
|
required TimelineGeometry geometry,
|
||||||
|
required int minute,
|
||||||
|
required double viewportHeight,
|
||||||
|
required double maxOffset,
|
||||||
|
}) {
|
||||||
|
final targetOffset =
|
||||||
|
geometry.yForMinutesSinceMidnight(minute) +
|
||||||
|
_topInset -
|
||||||
|
viewportHeight * 0.25;
|
||||||
|
return targetOffset.clamp(0, maxOffset).toDouble();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ class TopBar extends StatelessWidget {
|
||||||
this.onPreviousDate,
|
this.onPreviousDate,
|
||||||
this.onNextDate,
|
this.onNextDate,
|
||||||
this.onDatePressed,
|
this.onDatePressed,
|
||||||
|
this.onDateLabelPressed,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -36,6 +37,9 @@ class TopBar extends StatelessWidget {
|
||||||
/// Callback invoked when the choose-date control is activated.
|
/// Callback invoked when the choose-date control is activated.
|
||||||
final VoidCallback? onDatePressed;
|
final VoidCallback? onDatePressed;
|
||||||
|
|
||||||
|
/// Callback invoked when the displayed date label is activated.
|
||||||
|
final VoidCallback? onDateLabelPressed;
|
||||||
|
|
||||||
/// Builds the widget subtree for this component.
|
/// Builds the widget subtree for this component.
|
||||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||||
@override
|
@override
|
||||||
|
|
@ -78,13 +82,13 @@ class TopBar extends StatelessWidget {
|
||||||
width: metrics.dateWidth,
|
width: metrics.dateWidth,
|
||||||
height: metrics.controlHeight,
|
height: metrics.controlHeight,
|
||||||
child: Tooltip(
|
child: Tooltip(
|
||||||
message: 'Choose date',
|
message: 'Scroll to current time',
|
||||||
child: Semantics(
|
child: Semantics(
|
||||||
button: true,
|
button: true,
|
||||||
label: 'Choose date, $dateLabel',
|
label: 'Scroll to current time, $dateLabel',
|
||||||
child: TextButton(
|
child: TextButton(
|
||||||
key: const ValueKey('top-bar-date-button'),
|
key: const ValueKey('top-bar-date-button'),
|
||||||
onPressed: onDatePressed,
|
onPressed: onDateLabelPressed,
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
foregroundColor: FocusFlowTokens.textPrimary,
|
foregroundColor: FocusFlowTokens.textPrimary,
|
||||||
padding: EdgeInsets.symmetric(
|
padding: EdgeInsets.symmetric(
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ void main() {
|
||||||
testWidgets('date picker jumps directly to the chosen date', (tester) async {
|
testWidgets('date picker jumps directly to the chosen date', (tester) async {
|
||||||
await _pumpPlanApp(tester);
|
await _pumpPlanApp(tester);
|
||||||
|
|
||||||
await tester.tap(find.byKey(const ValueKey('top-bar-date-button')));
|
await tester.tap(find.byTooltip('Choose date'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byType(DatePickerDialog), findsOneWidget);
|
expect(find.byType(DatePickerDialog), findsOneWidget);
|
||||||
|
|
@ -49,6 +49,18 @@ void main() {
|
||||||
expect(find.text('Clean coffee maker'), findsNothing);
|
expect(find.text('Clean coffee maker'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('date label scrolls timeline instead of opening picker', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _pumpPlanApp(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const ValueKey('top-bar-date-button')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(DatePickerDialog), findsNothing);
|
||||||
|
expect(find.text('May 20, 2025'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('open modal closes when changing dates', (tester) async {
|
testWidgets('open modal closes when changing dates', (tester) async {
|
||||||
await _pumpPlanApp(tester);
|
await _pumpPlanApp(tester);
|
||||||
|
|
||||||
|
|
|
||||||
329
apps/focus_flow_flutter/test/app/runtime_config_test.dart
Normal file
329
apps/focus_flow_flutter/test/app/runtime_config_test.dart
Normal file
|
|
@ -0,0 +1,329 @@
|
||||||
|
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
/// Tests FocusFlow runtime config and file logging behavior.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:focus_flow_flutter/app/runtime/focus_flow_file_logger.dart';
|
||||||
|
import 'package:focus_flow_flutter/app/runtime/focus_flow_runtime_config.dart';
|
||||||
|
import 'package:scheduler_core/scheduler_core.dart' as scheduler_core;
|
||||||
|
|
||||||
|
/// Runs runtime config tests.
|
||||||
|
void main() {
|
||||||
|
tearDown(scheduler_core.logger.disable);
|
||||||
|
|
||||||
|
group('FocusFlowRuntimeConfig', () {
|
||||||
|
test('missing config file leaves optional settings unset', () async {
|
||||||
|
final directory = await Directory.systemTemp.createTemp(
|
||||||
|
'focus-flow-config-test-',
|
||||||
|
);
|
||||||
|
addTearDown(() => directory.delete(recursive: true));
|
||||||
|
|
||||||
|
final config = await FocusFlowRuntimeConfig.load(
|
||||||
|
path: '${directory.path}${Platform.pathSeparator}missing.json',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(config.logLevel, isNull);
|
||||||
|
expect(config.logFileLocation, isNull);
|
||||||
|
expect(config.enablesFileLogging, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loads supported settings from a config file', () async {
|
||||||
|
final directory = await Directory.systemTemp.createTemp(
|
||||||
|
'focus-flow-config-file-test-',
|
||||||
|
);
|
||||||
|
addTearDown(() => directory.delete(recursive: true));
|
||||||
|
final configFile = File(
|
||||||
|
'${directory.path}${Platform.pathSeparator}config.json',
|
||||||
|
);
|
||||||
|
await configFile.writeAsString(
|
||||||
|
jsonEncode({
|
||||||
|
'LogLevel': 'finest',
|
||||||
|
'LogfileLocation': directory.path,
|
||||||
|
'Timezone': 'pDt',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
final config = await FocusFlowRuntimeConfig.load(
|
||||||
|
path: configFile.path,
|
||||||
|
now: () => DateTime.utc(2026, 7, 3, 12),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(config.logLevel, FocusFlowLogLevel.finest);
|
||||||
|
expect(config.logFileLocation, directory.path);
|
||||||
|
expect(config.timeZone?.code, 'PDT');
|
||||||
|
expect(config.timeZone?.utcOffset, const Duration(hours: -7));
|
||||||
|
expect(config.enablesFileLogging, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolves daylight-saving aliases from the reference instant', () {
|
||||||
|
final pstInSummer = FocusFlowUiTimeZone.tryParse(
|
||||||
|
'PST',
|
||||||
|
referenceInstant: DateTime.utc(2026, 7, 5, 2),
|
||||||
|
);
|
||||||
|
final pdtInWinter = FocusFlowUiTimeZone.tryParse(
|
||||||
|
'pdt',
|
||||||
|
referenceInstant: DateTime.utc(2026, 1, 5, 12),
|
||||||
|
);
|
||||||
|
final cstInSummer = FocusFlowUiTimeZone.tryParse(
|
||||||
|
'CST',
|
||||||
|
referenceInstant: DateTime.utc(2026, 7, 5, 12),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(pstInSummer?.code, 'PDT');
|
||||||
|
expect(pstInSummer?.utcOffset, const Duration(hours: -7));
|
||||||
|
expect(pdtInWinter?.code, 'PST');
|
||||||
|
expect(pdtInWinter?.utcOffset, const Duration(hours: -8));
|
||||||
|
expect(cstInSummer?.code, 'CDT');
|
||||||
|
expect(cstInSummer?.utcOffset, const Duration(hours: -5));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps non-DST timezone codes fixed without regard to case', () {
|
||||||
|
final gmt = FocusFlowUiTimeZone.tryParse(
|
||||||
|
'GmT',
|
||||||
|
referenceInstant: DateTime.utc(2026, 7, 5, 12),
|
||||||
|
);
|
||||||
|
final jst = FocusFlowUiTimeZone.tryParse(
|
||||||
|
'jst',
|
||||||
|
referenceInstant: DateTime.utc(2026, 7, 5, 12),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(gmt?.code, 'GMT');
|
||||||
|
expect(gmt?.utcOffset, Duration.zero);
|
||||||
|
expect(jst?.code, 'JST');
|
||||||
|
expect(jst?.utcOffset, const Duration(hours: 9));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolves daylight-saving boundaries at config parse time', () {
|
||||||
|
final beforePacificSpring = FocusFlowUiTimeZone.tryParse(
|
||||||
|
'PST',
|
||||||
|
referenceInstant: DateTime.utc(2026, 3, 8, 9, 59),
|
||||||
|
);
|
||||||
|
final afterPacificSpring = FocusFlowUiTimeZone.tryParse(
|
||||||
|
'PST',
|
||||||
|
referenceInstant: DateTime.utc(2026, 3, 8, 10),
|
||||||
|
);
|
||||||
|
final beforePacificFall = FocusFlowUiTimeZone.tryParse(
|
||||||
|
'PDT',
|
||||||
|
referenceInstant: DateTime.utc(2026, 11, 1, 8, 59),
|
||||||
|
);
|
||||||
|
final afterPacificFall = FocusFlowUiTimeZone.tryParse(
|
||||||
|
'PDT',
|
||||||
|
referenceInstant: DateTime.utc(2026, 11, 1, 9),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(beforePacificSpring?.code, 'PST');
|
||||||
|
expect(afterPacificSpring?.code, 'PDT');
|
||||||
|
expect(beforePacificFall?.code, 'PDT');
|
||||||
|
expect(afterPacificFall?.code, 'PST');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores timezone values that are not valid three-letter codes', () {
|
||||||
|
expect(FocusFlowUiTimeZone.tryParse('America/Los_Angeles'), isNull);
|
||||||
|
expect(FocusFlowUiTimeZone.tryParse('PT'), isNull);
|
||||||
|
expect(FocusFlowUiTimeZone.tryParse('XYZ'), isNull);
|
||||||
|
expect(FocusFlowUiTimeZone.tryParse(' '), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses every supported log level', () {
|
||||||
|
expect(FocusFlowLogLevel.tryParse('finest'), FocusFlowLogLevel.finest);
|
||||||
|
expect(FocusFlowLogLevel.tryParse('finer'), FocusFlowLogLevel.finer);
|
||||||
|
expect(FocusFlowLogLevel.tryParse('fine'), FocusFlowLogLevel.fine);
|
||||||
|
expect(FocusFlowLogLevel.tryParse('debug'), FocusFlowLogLevel.debug);
|
||||||
|
expect(FocusFlowLogLevel.tryParse('info'), FocusFlowLogLevel.info);
|
||||||
|
expect(FocusFlowLogLevel.tryParse('warn'), FocusFlowLogLevel.warn);
|
||||||
|
expect(FocusFlowLogLevel.tryParse('error'), FocusFlowLogLevel.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loads supported logging settings from JSON', () {
|
||||||
|
final config = FocusFlowRuntimeConfig.fromJson({
|
||||||
|
'LogLevel': 'debug',
|
||||||
|
'LogfileLocation': '/tmp/focus-flow-logs',
|
||||||
|
'Timezone': 'GMT',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(config.logLevel, FocusFlowLogLevel.debug);
|
||||||
|
expect(config.logFileLocation, '/tmp/focus-flow-logs');
|
||||||
|
expect(config.timeZone?.code, 'GMT');
|
||||||
|
expect(config.enablesFileLogging, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores missing and unsupported optional settings', () {
|
||||||
|
final config = FocusFlowRuntimeConfig.fromJson({
|
||||||
|
'LogLevel': 'verbose',
|
||||||
|
'LogfileLocation': ' ',
|
||||||
|
'Timezone': 'Pacific',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(config.logLevel, isNull);
|
||||||
|
expect(config.logFileLocation, isNull);
|
||||||
|
expect(config.timeZone, isNull);
|
||||||
|
expect(config.enablesFileLogging, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('FocusFlowFileLogger', () {
|
||||||
|
test('does nothing unless both logging settings are set', () async {
|
||||||
|
final logger = await FocusFlowFileLogger.open(
|
||||||
|
const FocusFlowRuntimeConfig(logLevel: FocusFlowLogLevel.debug),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(logger.isEnabled, isFalse);
|
||||||
|
await logger.info('ignored');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writes messages at or above the configured level', () async {
|
||||||
|
final directory = await Directory.systemTemp.createTemp(
|
||||||
|
'focus-flow-log-test-',
|
||||||
|
);
|
||||||
|
addTearDown(() => directory.delete(recursive: true));
|
||||||
|
final logger = await FocusFlowFileLogger.open(
|
||||||
|
FocusFlowRuntimeConfig(
|
||||||
|
logLevel: FocusFlowLogLevel.warn,
|
||||||
|
logFileLocation: directory.path,
|
||||||
|
),
|
||||||
|
now: () => DateTime.utc(2026, 7, 3, 12),
|
||||||
|
);
|
||||||
|
|
||||||
|
await logger.info('not written');
|
||||||
|
await logger.warn('written warning');
|
||||||
|
await logger.error('written error', error: 'boom');
|
||||||
|
|
||||||
|
final logFile = File(
|
||||||
|
'${directory.path}${Platform.pathSeparator}focus_flow.log',
|
||||||
|
);
|
||||||
|
final contents = await logFile.readAsString();
|
||||||
|
expect(contents, isNot(contains('not written')));
|
||||||
|
expect(contents, contains('2026-07-03T12:00:00.000Z WARN'));
|
||||||
|
expect(contents, contains('written warning'));
|
||||||
|
expect(contents, contains('ERROR written error | error=boom'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips lazy message work below the configured level', () async {
|
||||||
|
final directory = await Directory.systemTemp.createTemp(
|
||||||
|
'focus-flow-lazy-skip-test-',
|
||||||
|
);
|
||||||
|
addTearDown(() => directory.delete(recursive: true));
|
||||||
|
final logger = await FocusFlowFileLogger.open(
|
||||||
|
FocusFlowRuntimeConfig(
|
||||||
|
logLevel: FocusFlowLogLevel.info,
|
||||||
|
logFileLocation: directory.path,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
var evaluated = false;
|
||||||
|
|
||||||
|
await logger.debug(() {
|
||||||
|
evaluated = true;
|
||||||
|
return 'expensive debug state';
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(evaluated, isFalse);
|
||||||
|
final contents = await File(
|
||||||
|
'${directory.path}${Platform.pathSeparator}focus_flow.log',
|
||||||
|
).readAsString();
|
||||||
|
expect(contents, isNot(contains('expensive debug state')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('evaluates lazy messages once the level is enabled', () async {
|
||||||
|
final directory = await Directory.systemTemp.createTemp(
|
||||||
|
'focus-flow-lazy-write-test-',
|
||||||
|
);
|
||||||
|
addTearDown(() => directory.delete(recursive: true));
|
||||||
|
final logger = await FocusFlowFileLogger.open(
|
||||||
|
FocusFlowRuntimeConfig(
|
||||||
|
logLevel: FocusFlowLogLevel.debug,
|
||||||
|
logFileLocation: directory.path,
|
||||||
|
),
|
||||||
|
now: () => DateTime.utc(2026, 7, 3, 12),
|
||||||
|
);
|
||||||
|
var evaluations = 0;
|
||||||
|
|
||||||
|
await logger.debug(() {
|
||||||
|
evaluations += 1;
|
||||||
|
return 'computed debug state';
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(evaluations, 1);
|
||||||
|
final contents = await File(
|
||||||
|
'${directory.path}${Platform.pathSeparator}focus_flow.log',
|
||||||
|
).readAsString();
|
||||||
|
expect(contents, contains('DEBUG computed debug state'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'finest captures caller information for every written level',
|
||||||
|
() async {
|
||||||
|
final directory = await Directory.systemTemp.createTemp(
|
||||||
|
'focus-flow-finest-caller-test-',
|
||||||
|
);
|
||||||
|
addTearDown(() => directory.delete(recursive: true));
|
||||||
|
final logger = await FocusFlowFileLogger.open(
|
||||||
|
FocusFlowRuntimeConfig(
|
||||||
|
logLevel: FocusFlowLogLevel.finest,
|
||||||
|
logFileLocation: directory.path,
|
||||||
|
),
|
||||||
|
now: () => DateTime.utc(2026, 7, 3, 12),
|
||||||
|
);
|
||||||
|
|
||||||
|
await logger.info('high level message');
|
||||||
|
|
||||||
|
final contents = await File(
|
||||||
|
'${directory.path}${Platform.pathSeparator}focus_flow.log',
|
||||||
|
).readAsString();
|
||||||
|
expect(contents, contains('INFO caller='));
|
||||||
|
expect(contents, contains('runtime_config_test.dart'));
|
||||||
|
expect(contents, contains('high level message'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('fine captures caller information for warnings and errors', () async {
|
||||||
|
final directory = await Directory.systemTemp.createTemp(
|
||||||
|
'focus-flow-fine-caller-test-',
|
||||||
|
);
|
||||||
|
addTearDown(() => directory.delete(recursive: true));
|
||||||
|
final logger = await FocusFlowFileLogger.open(
|
||||||
|
FocusFlowRuntimeConfig(
|
||||||
|
logLevel: FocusFlowLogLevel.fine,
|
||||||
|
logFileLocation: directory.path,
|
||||||
|
),
|
||||||
|
now: () => DateTime.utc(2026, 7, 3, 12),
|
||||||
|
);
|
||||||
|
|
||||||
|
await logger.warn('handled warning');
|
||||||
|
|
||||||
|
final contents = await File(
|
||||||
|
'${directory.path}${Platform.pathSeparator}focus_flow.log',
|
||||||
|
).readAsString();
|
||||||
|
expect(contents, contains('WARN caller='));
|
||||||
|
expect(contents, contains('runtime_config_test.dart'));
|
||||||
|
expect(contents, contains('handled warning'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('info does not capture caller information for warnings', () async {
|
||||||
|
final directory = await Directory.systemTemp.createTemp(
|
||||||
|
'focus-flow-info-caller-test-',
|
||||||
|
);
|
||||||
|
addTearDown(() => directory.delete(recursive: true));
|
||||||
|
final logger = await FocusFlowFileLogger.open(
|
||||||
|
FocusFlowRuntimeConfig(
|
||||||
|
logLevel: FocusFlowLogLevel.info,
|
||||||
|
logFileLocation: directory.path,
|
||||||
|
),
|
||||||
|
now: () => DateTime.utc(2026, 7, 3, 12),
|
||||||
|
);
|
||||||
|
|
||||||
|
await logger.warn('minimal warning');
|
||||||
|
|
||||||
|
final contents = await File(
|
||||||
|
'${directory.path}${Platform.pathSeparator}focus_flow.log',
|
||||||
|
).readAsString();
|
||||||
|
expect(contents, contains('WARN minimal warning'));
|
||||||
|
expect(contents, isNot(contains('caller=')));
|
||||||
|
expect(contents, isNot(contains('runtime_config_test.dart')));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -77,7 +77,8 @@ List<_ForbiddenImport> _forbiddenImportsFor(String relativePath) {
|
||||||
),
|
),
|
||||||
if (relativePath != 'test/forbidden_imports_test.dart' &&
|
if (relativePath != 'test/forbidden_imports_test.dart' &&
|
||||||
relativePath != 'test/persistent_composition_test.dart' &&
|
relativePath != 'test/persistent_composition_test.dart' &&
|
||||||
!_isPersistentComposition(relativePath))
|
relativePath != 'test/app/runtime_config_test.dart' &&
|
||||||
|
!_isApprovedRuntimeBoundary(relativePath))
|
||||||
const _ForbiddenImport("dart:io", 'dart:io'),
|
const _ForbiddenImport("dart:io", 'dart:io'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -87,6 +88,12 @@ bool _isPersistentComposition(String relativePath) {
|
||||||
return relativePath == 'lib/app/persistent_scheduler_composition.dart';
|
return relativePath == 'lib/app/persistent_scheduler_composition.dart';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether [relativePath] is approved to touch local runtime files directly.
|
||||||
|
bool _isApprovedRuntimeBoundary(String relativePath) {
|
||||||
|
return _isPersistentComposition(relativePath) ||
|
||||||
|
relativePath.startsWith('lib/app/runtime/');
|
||||||
|
}
|
||||||
|
|
||||||
/// Private implementation type for `_ForbiddenImport` in this library.
|
/// 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.
|
/// 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 {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ void main() {
|
||||||
scheduledEnd: DateTime.utc(2026, 1, 3, 9, 30),
|
scheduledEnd: DateTime.utc(2026, 1, 3, 9, 30),
|
||||||
completedAt: DateTime.utc(2026, 1, 3, 9, 25),
|
completedAt: DateTime.utc(2026, 1, 3, 9, 25),
|
||||||
),
|
),
|
||||||
|
now: DateTime.utc(2026, 1, 3, 10),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(card.completionDateContextRequired, isFalse);
|
expect(card.completionDateContextRequired, isFalse);
|
||||||
|
|
@ -32,6 +33,7 @@ void main() {
|
||||||
scheduledEnd: DateTime.utc(2026, 1, 3, 9, 30),
|
scheduledEnd: DateTime.utc(2026, 1, 3, 9, 30),
|
||||||
completedAt: DateTime.utc(2026, 1, 4, 8, 5),
|
completedAt: DateTime.utc(2026, 1, 4, 8, 5),
|
||||||
),
|
),
|
||||||
|
now: DateTime.utc(2026, 1, 4, 9),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(card.completionDateContextRequired, isTrue);
|
expect(card.completionDateContextRequired, isTrue);
|
||||||
|
|
@ -48,6 +50,7 @@ void main() {
|
||||||
actualStart: DateTime.utc(2026, 1, 4, 0, 10),
|
actualStart: DateTime.utc(2026, 1, 4, 0, 10),
|
||||||
actualEnd: DateTime.utc(2026, 1, 4, 0, 20),
|
actualEnd: DateTime.utc(2026, 1, 4, 0, 20),
|
||||||
),
|
),
|
||||||
|
now: DateTime.utc(2026, 1, 4, 1),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(card.completionDateContextRequired, isTrue);
|
expect(card.completionDateContextRequired, isTrue);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
/// Tests past-task push presentation mapping.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:scheduler_core/scheduler_core.dart';
|
||||||
|
|
||||||
|
import 'package:focus_flow_flutter/models/today_screen_models.dart';
|
||||||
|
|
||||||
|
/// Runs past-task push presentation tests.
|
||||||
|
void main() {
|
||||||
|
final now = DateTime.utc(2026, 1, 3, 12);
|
||||||
|
|
||||||
|
test('planned flexible task earlier today shows Push state', () {
|
||||||
|
final updatedAt = DateTime.utc(2026, 1, 3, 8, 45);
|
||||||
|
final card = TimelineCardModel.fromItem(
|
||||||
|
_item(
|
||||||
|
start: DateTime.utc(2026, 1, 3, 9),
|
||||||
|
end: DateTime.utc(2026, 1, 3, 9, 30),
|
||||||
|
updatedAt: updatedAt,
|
||||||
|
),
|
||||||
|
now: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(card.showPastTaskPushButton, isTrue);
|
||||||
|
expect(card.showsQuickActions, isFalse);
|
||||||
|
expect(card.expectedUpdatedAt, updatedAt);
|
||||||
|
expect(card.pastTaskPushSemanticLabel, 'Push overdue task');
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'configured timezone offset changes wall-clock labels and positions',
|
||||||
|
() {
|
||||||
|
final card = TimelineCardModel.fromItem(
|
||||||
|
_item(
|
||||||
|
start: DateTime.utc(2026, 1, 3, 16),
|
||||||
|
end: DateTime.utc(2026, 1, 3, 16, 30),
|
||||||
|
),
|
||||||
|
now: DateTime.utc(2026, 1, 3, 17),
|
||||||
|
timeZoneOffset: const Duration(hours: -8),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(card.timeText, '8:00 AM - 8:30 AM');
|
||||||
|
expect(card.startMinutes, 8 * 60);
|
||||||
|
expect(card.endMinutes, 8 * 60 + 30);
|
||||||
|
expect(card.showPastTaskPushButton, isTrue);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('planned flexible task later today does not show Push state', () {
|
||||||
|
final card = TimelineCardModel.fromItem(
|
||||||
|
_item(
|
||||||
|
start: DateTime.utc(2026, 1, 3, 14),
|
||||||
|
end: DateTime.utc(2026, 1, 3, 14, 30),
|
||||||
|
),
|
||||||
|
now: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(card.showPastTaskPushButton, isFalse);
|
||||||
|
expect(card.showsQuickActions, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('planned flexible task on a previous date shows Push state', () {
|
||||||
|
final card = TimelineCardModel.fromItem(
|
||||||
|
_item(
|
||||||
|
start: DateTime.utc(2026, 1, 2, 14),
|
||||||
|
end: DateTime.utc(2026, 1, 2, 14, 30),
|
||||||
|
),
|
||||||
|
now: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(card.showPastTaskPushButton, isTrue);
|
||||||
|
expect(card.showsQuickActions, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('planned flexible task on a future date does not show Push state', () {
|
||||||
|
final card = TimelineCardModel.fromItem(
|
||||||
|
_item(
|
||||||
|
start: DateTime.utc(2026, 1, 4, 9),
|
||||||
|
end: DateTime.utc(2026, 1, 4, 9, 30),
|
||||||
|
),
|
||||||
|
now: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(card.showPastTaskPushButton, isFalse);
|
||||||
|
expect(card.showsQuickActions, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('completed flexible task in the past does not show Push state', () {
|
||||||
|
final card = TimelineCardModel.fromItem(
|
||||||
|
_item(
|
||||||
|
start: DateTime.utc(2026, 1, 3, 9),
|
||||||
|
end: DateTime.utc(2026, 1, 3, 9, 30),
|
||||||
|
status: TaskStatus.completed,
|
||||||
|
),
|
||||||
|
now: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(card.showPastTaskPushButton, isFalse);
|
||||||
|
expect(card.showsQuickActions, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unsupported task types do not show Push state', () {
|
||||||
|
for (final type in [
|
||||||
|
TaskType.critical,
|
||||||
|
TaskType.inflexible,
|
||||||
|
TaskType.locked,
|
||||||
|
TaskType.surprise,
|
||||||
|
TaskType.freeSlot,
|
||||||
|
]) {
|
||||||
|
final card = TimelineCardModel.fromItem(
|
||||||
|
_item(
|
||||||
|
start: DateTime.utc(2026, 1, 3, 9),
|
||||||
|
end: DateTime.utc(2026, 1, 3, 9, 30),
|
||||||
|
type: type,
|
||||||
|
),
|
||||||
|
now: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(card.showPastTaskPushButton, isFalse, reason: type.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('task without schedule placement does not show Push state', () {
|
||||||
|
final card = TimelineCardModel.fromItem(_item(), now: now);
|
||||||
|
|
||||||
|
expect(card.showPastTaskPushButton, isFalse);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a timeline item for push-state presentation tests.
|
||||||
|
TodayTimelineItem _item({
|
||||||
|
DateTime? start,
|
||||||
|
DateTime? end,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
TaskType type = TaskType.flexible,
|
||||||
|
TaskStatus status = TaskStatus.planned,
|
||||||
|
}) {
|
||||||
|
return TodayTimelineItem(
|
||||||
|
item: TimelineItem(
|
||||||
|
id: 'task-${type.name}-${status.name}',
|
||||||
|
displayTitle: 'Task',
|
||||||
|
taskType: type,
|
||||||
|
projectColorToken: 'project-home',
|
||||||
|
backgroundToken: TimelineBackgroundToken.flexible,
|
||||||
|
rewardIconToken: TimelineRewardIconToken.medium,
|
||||||
|
difficultyIconToken: TimelineDifficultyIconToken.medium,
|
||||||
|
showsExplicitTime: true,
|
||||||
|
quickActions: const [],
|
||||||
|
category: TimelineItemCategory.taskCard,
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
updatedAt: updatedAt,
|
||||||
|
durationMinutes: start == null || end == null
|
||||||
|
? null
|
||||||
|
: end.difference(start).inMinutes,
|
||||||
|
),
|
||||||
|
source: TodayTimelineItemSource.task,
|
||||||
|
taskStatus: status,
|
||||||
|
taskId: 'task-${type.name}-${status.name}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import 'package:scheduler_core/scheduler_core.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/app/persistent_scheduler_composition.dart';
|
import 'package:focus_flow_flutter/app/persistent_scheduler_composition.dart';
|
||||||
|
import 'package:focus_flow_flutter/app/runtime/focus_flow_runtime_config.dart';
|
||||||
import 'package:focus_flow_flutter/controllers/scheduler_command_controller.dart';
|
import 'package:focus_flow_flutter/controllers/scheduler_command_controller.dart';
|
||||||
import 'package:focus_flow_flutter/models/today_screen_models.dart';
|
import 'package:focus_flow_flutter/models/today_screen_models.dart';
|
||||||
|
|
||||||
|
|
@ -82,6 +83,43 @@ void main() {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
testWidgets('ui timezone config controls local startup date only', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final path = await _runAsync(tester, _tempDatabasePath);
|
||||||
|
final composition = await _runAsync(
|
||||||
|
tester,
|
||||||
|
() => PersistentSchedulerComposition.open(
|
||||||
|
sqlitePath: path,
|
||||||
|
clock: FixedClock(DateTime.utc(2026, 1, 3, 2)),
|
||||||
|
uiTimeZone: FocusFlowUiTimeZone.tryParse(
|
||||||
|
'pst',
|
||||||
|
referenceInstant: DateTime.utc(2026, 1, 3, 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
addTearDown(() => tester.runAsync(composition.dispose));
|
||||||
|
|
||||||
|
expect(composition.initialDate, CivilDate(2026, 1, 2));
|
||||||
|
expect(
|
||||||
|
composition.context('timezone-context').ownerTimeZone.timeZoneId,
|
||||||
|
'PST',
|
||||||
|
);
|
||||||
|
|
||||||
|
final storedSettings = await _runAsync(
|
||||||
|
tester,
|
||||||
|
() => composition.runtime.applicationStore.read<OwnerSettings?>(
|
||||||
|
action: (repositories) async {
|
||||||
|
return ApplicationResult.success(
|
||||||
|
await repositories.ownerSettings.findByOwnerId('owner-1'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(storedSettings.requireValue?.timeZoneId, 'UTC');
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'selected future-day schedule completion and uncomplete persist across reopen',
|
'selected future-day schedule completion and uncomplete persist across reopen',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
|
|
@ -128,7 +166,7 @@ void main() {
|
||||||
selectedDate: selectedDate,
|
selectedDate: selectedDate,
|
||||||
clock: FixedClock(_instant(8, 10)),
|
clock: FixedClock(_instant(8, 10)),
|
||||||
);
|
);
|
||||||
var scheduled = await _readTodayItem(
|
final scheduled = await _readTodayItem(
|
||||||
tester,
|
tester,
|
||||||
composition,
|
composition,
|
||||||
tomorrow,
|
tomorrow,
|
||||||
|
|
@ -169,6 +207,7 @@ void main() {
|
||||||
expect(completed.item.completedAt, isNotNull);
|
expect(completed.item.completedAt, isNotNull);
|
||||||
final completedData = TodayScreenData.fromTodayState(
|
final completedData = TodayScreenData.fromTodayState(
|
||||||
await _readToday(tester, composition, tomorrow),
|
await _readToday(tester, composition, tomorrow),
|
||||||
|
now: DateTime.utc(2026, 1, 4, 8, 20),
|
||||||
);
|
);
|
||||||
final completedCard = completedData.cards.singleWhere(
|
final completedCard = completedData.cards.singleWhere(
|
||||||
(card) => card.title == title,
|
(card) => card.title == title,
|
||||||
|
|
@ -208,6 +247,239 @@ void main() {
|
||||||
expect((await _readBacklog(tester, composition)).items, isEmpty);
|
expect((await _readBacklog(tester, composition)).items, isEmpty);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
testWidgets('past task push destinations persist across reopen', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final path = await _runAsync(tester, _tempDatabasePath);
|
||||||
|
const pushNextTitle = 'Push next persistence';
|
||||||
|
const pushTomorrowTitle = 'Push tomorrow persistence';
|
||||||
|
const pushBacklogTitle = 'Push backlog persistence';
|
||||||
|
var commandNow = _instant(8);
|
||||||
|
|
||||||
|
var composition = await _openComposition(
|
||||||
|
tester,
|
||||||
|
path: path,
|
||||||
|
selectedDate: _date,
|
||||||
|
clock: FixedClock(commandNow),
|
||||||
|
);
|
||||||
|
final commandController = _commandController(
|
||||||
|
composition,
|
||||||
|
selectedDate: _date,
|
||||||
|
operationIdPrefix: 'past-push',
|
||||||
|
now: () => commandNow,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _captureAndSchedule(
|
||||||
|
tester,
|
||||||
|
composition,
|
||||||
|
commandController,
|
||||||
|
pushNextTitle,
|
||||||
|
);
|
||||||
|
await _captureAndSchedule(
|
||||||
|
tester,
|
||||||
|
composition,
|
||||||
|
commandController,
|
||||||
|
pushTomorrowTitle,
|
||||||
|
);
|
||||||
|
await _captureAndSchedule(
|
||||||
|
tester,
|
||||||
|
composition,
|
||||||
|
commandController,
|
||||||
|
pushBacklogTitle,
|
||||||
|
);
|
||||||
|
|
||||||
|
commandNow = _instant(12);
|
||||||
|
final pastCards =
|
||||||
|
TodayScreenData.fromTodayState(
|
||||||
|
await _readToday(tester, composition, _date),
|
||||||
|
now: commandNow,
|
||||||
|
).cards.where(
|
||||||
|
(card) => {
|
||||||
|
pushNextTitle,
|
||||||
|
pushTomorrowTitle,
|
||||||
|
pushBacklogTitle,
|
||||||
|
}.contains(card.title),
|
||||||
|
);
|
||||||
|
expect(pastCards, hasLength(3));
|
||||||
|
expect(
|
||||||
|
pastCards.map((card) => card.showPastTaskPushButton),
|
||||||
|
everyElement(isTrue),
|
||||||
|
);
|
||||||
|
|
||||||
|
final pushNextItem = await _readTodayItem(
|
||||||
|
tester,
|
||||||
|
composition,
|
||||||
|
_date,
|
||||||
|
pushNextTitle,
|
||||||
|
);
|
||||||
|
await tester.runAsync(
|
||||||
|
() => commandController.pushTaskToNextAvailableSlot(
|
||||||
|
taskId: pushNextItem.id,
|
||||||
|
expectedUpdatedAt: pushNextItem.item.updatedAt,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(commandController.state, isA<SchedulerCommandSuccess>());
|
||||||
|
|
||||||
|
final pushTomorrowItem = await _readTodayItem(
|
||||||
|
tester,
|
||||||
|
composition,
|
||||||
|
_date,
|
||||||
|
pushTomorrowTitle,
|
||||||
|
);
|
||||||
|
await tester.runAsync(
|
||||||
|
() => commandController.pushTaskToTomorrow(
|
||||||
|
taskId: pushTomorrowItem.id,
|
||||||
|
expectedUpdatedAt: pushTomorrowItem.item.updatedAt,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(commandController.state, isA<SchedulerCommandSuccess>());
|
||||||
|
|
||||||
|
final pushBacklogItem = await _readTodayItem(
|
||||||
|
tester,
|
||||||
|
composition,
|
||||||
|
_date,
|
||||||
|
pushBacklogTitle,
|
||||||
|
);
|
||||||
|
await tester.runAsync(
|
||||||
|
() => commandController.pushTaskToBacklog(
|
||||||
|
taskId: pushBacklogItem.id,
|
||||||
|
expectedUpdatedAt: pushBacklogItem.item.updatedAt,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(commandController.state, isA<SchedulerCommandSuccess>());
|
||||||
|
|
||||||
|
final pushedNext = await _readTodayItem(
|
||||||
|
tester,
|
||||||
|
composition,
|
||||||
|
_date,
|
||||||
|
pushNextTitle,
|
||||||
|
);
|
||||||
|
expect(pushedNext.start, _instant(12));
|
||||||
|
expect(pushedNext.end, _instant(12, 30));
|
||||||
|
|
||||||
|
commandNow = _instant(12, 45);
|
||||||
|
await tester.runAsync(
|
||||||
|
() => commandController.completeTask(
|
||||||
|
taskId: pushedNext.id,
|
||||||
|
taskType: pushedNext.taskType,
|
||||||
|
expectedUpdatedAt: pushedNext.item.updatedAt,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(commandController.state, isA<SchedulerCommandSuccess>());
|
||||||
|
commandController.dispose();
|
||||||
|
await _closeComposition(tester, composition);
|
||||||
|
|
||||||
|
composition = await _openComposition(
|
||||||
|
tester,
|
||||||
|
path: path,
|
||||||
|
selectedDate: _date,
|
||||||
|
clock: FixedClock(_instant(13)),
|
||||||
|
);
|
||||||
|
addTearDown(() => tester.runAsync(composition.dispose));
|
||||||
|
|
||||||
|
final reopenedNext = await _readTodayItem(
|
||||||
|
tester,
|
||||||
|
composition,
|
||||||
|
_date,
|
||||||
|
pushNextTitle,
|
||||||
|
);
|
||||||
|
expect(reopenedNext.taskStatus, TaskStatus.completed);
|
||||||
|
expect(reopenedNext.start, _instant(12));
|
||||||
|
expect(reopenedNext.item.completedAt, _instant(12, 45));
|
||||||
|
final reopenedNextCard = TodayScreenData.fromTodayState(
|
||||||
|
await _readToday(tester, composition, _date),
|
||||||
|
now: _instant(13),
|
||||||
|
).cards.singleWhere((card) => card.title == pushNextTitle);
|
||||||
|
expect(reopenedNextCard.showPastTaskPushButton, isFalse);
|
||||||
|
|
||||||
|
final reopenedTomorrow = await _readTodayItem(
|
||||||
|
tester,
|
||||||
|
composition,
|
||||||
|
_date.addDays(1),
|
||||||
|
pushTomorrowTitle,
|
||||||
|
);
|
||||||
|
expect(reopenedTomorrow.taskStatus, TaskStatus.planned);
|
||||||
|
expect(reopenedTomorrow.start, DateTime.utc(2026, 1, 3));
|
||||||
|
expect(reopenedTomorrow.end, DateTime.utc(2026, 1, 3, 0, 30));
|
||||||
|
|
||||||
|
final reopenedBacklog = await _readBacklog(tester, composition);
|
||||||
|
final backlogTask = reopenedBacklog.items.single.task;
|
||||||
|
expect(backlogTask.title, pushBacklogTitle);
|
||||||
|
expect(backlogTask.scheduledStart, isNull);
|
||||||
|
expect(backlogTask.backlogEnteredAt, _instant(12));
|
||||||
|
expect(
|
||||||
|
backlogTask.backlogEnteredAtProvenance,
|
||||||
|
Task.backlogEnteredAtProvenanceMovedToBacklog,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await _readToday(
|
||||||
|
tester,
|
||||||
|
composition,
|
||||||
|
_date,
|
||||||
|
)).timelineItems.map((item) => item.item.displayTitle),
|
||||||
|
isNot(contains(pushBacklogTitle)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('push to tomorrow uses ui-local current date at UTC rollover', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final path = await _runAsync(tester, _tempDatabasePath);
|
||||||
|
final currentLocalDate = CivilDate(2026, 7, 4);
|
||||||
|
final tomorrow = currentLocalDate.addDays(1);
|
||||||
|
final dayAfterTomorrow = currentLocalDate.addDays(2);
|
||||||
|
final commandNow = DateTime.utc(2026, 7, 5, 2);
|
||||||
|
const title = 'Push tomorrow from evening PST';
|
||||||
|
|
||||||
|
final composition = await _openComposition(
|
||||||
|
tester,
|
||||||
|
path: path,
|
||||||
|
selectedDate: currentLocalDate,
|
||||||
|
clock: FixedClock(commandNow),
|
||||||
|
uiTimeZone: FocusFlowUiTimeZone.tryParse(
|
||||||
|
'pst',
|
||||||
|
referenceInstant: commandNow,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
addTearDown(() => tester.runAsync(composition.dispose));
|
||||||
|
final commandController = _commandController(
|
||||||
|
composition,
|
||||||
|
selectedDate: currentLocalDate,
|
||||||
|
operationIdPrefix: 'local-tomorrow-rollover',
|
||||||
|
now: () => commandNow,
|
||||||
|
);
|
||||||
|
addTearDown(commandController.dispose);
|
||||||
|
|
||||||
|
await _captureAndSchedule(tester, composition, commandController, title);
|
||||||
|
final scheduled = await _readTodayItem(
|
||||||
|
tester,
|
||||||
|
composition,
|
||||||
|
currentLocalDate,
|
||||||
|
title,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.runAsync(
|
||||||
|
() => commandController.pushTaskToTomorrow(
|
||||||
|
taskId: scheduled.id,
|
||||||
|
expectedUpdatedAt: scheduled.item.updatedAt,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(commandController.state, isA<SchedulerCommandSuccess>());
|
||||||
|
final pushed = await _readTodayItem(tester, composition, tomorrow, title);
|
||||||
|
expect(pushed.taskStatus, TaskStatus.planned);
|
||||||
|
expect(pushed.start, DateTime.utc(2026, 7, 5, 7));
|
||||||
|
expect(pushed.end, DateTime.utc(2026, 7, 5, 7, 30));
|
||||||
|
expect(
|
||||||
|
(await _readToday(
|
||||||
|
tester,
|
||||||
|
composition,
|
||||||
|
dayAfterTomorrow,
|
||||||
|
)).timelineItems.map((item) => item.item.displayTitle),
|
||||||
|
isNot(contains(title)),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fixed date used by persistent Flutter tests.
|
/// Fixed date used by persistent Flutter tests.
|
||||||
|
|
@ -233,6 +505,7 @@ Future<PersistentSchedulerComposition> _openComposition(
|
||||||
required String path,
|
required String path,
|
||||||
required CivilDate selectedDate,
|
required CivilDate selectedDate,
|
||||||
required Clock clock,
|
required Clock clock,
|
||||||
|
FocusFlowUiTimeZone? uiTimeZone,
|
||||||
}) {
|
}) {
|
||||||
return _runAsync(
|
return _runAsync(
|
||||||
tester,
|
tester,
|
||||||
|
|
@ -240,6 +513,7 @@ Future<PersistentSchedulerComposition> _openComposition(
|
||||||
sqlitePath: path,
|
sqlitePath: path,
|
||||||
selectedDate: selectedDate,
|
selectedDate: selectedDate,
|
||||||
clock: clock,
|
clock: clock,
|
||||||
|
uiTimeZone: uiTimeZone,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -283,10 +557,35 @@ SchedulerCommandController _commandController(
|
||||||
refreshReads: () async {},
|
refreshReads: () async {},
|
||||||
quickCaptureProjectId: composition.defaultProjectId,
|
quickCaptureProjectId: composition.defaultProjectId,
|
||||||
operationIdPrefix: operationIdPrefix,
|
operationIdPrefix: operationIdPrefix,
|
||||||
|
currentDateFor: composition.uiTimeZone.toLocalCivilDate,
|
||||||
now: now,
|
now: now,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Captures [title] into Backlog and schedules it on the selected test date.
|
||||||
|
Future<void> _captureAndSchedule(
|
||||||
|
WidgetTester tester,
|
||||||
|
PersistentSchedulerComposition composition,
|
||||||
|
SchedulerCommandController commandController,
|
||||||
|
String title,
|
||||||
|
) async {
|
||||||
|
await tester.runAsync(() => commandController.quickCaptureToBacklog(title));
|
||||||
|
expect(commandController.state, isA<SchedulerCommandSuccess>());
|
||||||
|
|
||||||
|
final captured = (await _readBacklog(
|
||||||
|
tester,
|
||||||
|
composition,
|
||||||
|
)).items.singleWhere((item) => item.task.title == title).task;
|
||||||
|
await tester.runAsync(
|
||||||
|
() => commandController.scheduleBacklogItem(
|
||||||
|
taskId: captured.id,
|
||||||
|
durationMinutes: 30,
|
||||||
|
expectedUpdatedAt: captured.updatedAt,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(commandController.state, isA<SchedulerCommandSuccess>());
|
||||||
|
}
|
||||||
|
|
||||||
/// Reads one Today timeline item by [title].
|
/// Reads one Today timeline item by [title].
|
||||||
Future<TodayTimelineItem> _readTodayItem(
|
Future<TodayTimelineItem> _readTodayItem(
|
||||||
WidgetTester tester,
|
WidgetTester tester,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
/// Tests Widget behavior in the FocusFlow Flutter app.
|
/// Tests Widget behavior in the FocusFlow Flutter app.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/gestures.dart';
|
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';
|
||||||
|
|
@ -14,6 +16,7 @@ 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/theme/focus_flow_theme.dart';
|
||||||
import 'package:focus_flow_flutter/widgets/required_banner.dart';
|
import 'package:focus_flow_flutter/widgets/required_banner.dart';
|
||||||
|
import 'package:focus_flow_flutter/widgets/task_selection_modal.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/reward_icon.dart';
|
||||||
import 'package:focus_flow_flutter/widgets/timeline/task_timeline_card.dart';
|
import 'package:focus_flow_flutter/widgets/timeline/task_timeline_card.dart';
|
||||||
|
|
@ -65,7 +68,10 @@ void main() {
|
||||||
'visual token adapter maps seeded items to expected visual kinds',
|
'visual token adapter maps seeded items to expected visual kinds',
|
||||||
() async {
|
() async {
|
||||||
final state = await _readSeededToday();
|
final state = await _readSeededToday();
|
||||||
final data = TodayScreenData.fromTodayState(state);
|
final data = TodayScreenData.fromTodayState(
|
||||||
|
state,
|
||||||
|
now: DateTime.utc(2025, 5, 20, 15, 20),
|
||||||
|
);
|
||||||
|
|
||||||
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, 'sort-mail'), TaskVisualKind.flexible);
|
||||||
|
|
@ -90,7 +96,7 @@ void main() {
|
||||||
expect(DifficultyBars.fillCountForLevel(DifficultyLevel.veryHard), 5);
|
expect(DifficultyBars.fillCountForLevel(DifficultyLevel.veryHard), 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('task modal opens closes and keeps action buttons inert', (
|
testWidgets('task modal opens closes and keeps compact actions inert', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await _pumpPlanApp(tester);
|
await _pumpPlanApp(tester);
|
||||||
|
|
@ -104,14 +110,18 @@ void main() {
|
||||||
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
|
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
|
||||||
expect(find.text('Pay bill'), findsNWidgets(2));
|
expect(find.text('Pay bill'), findsNWidgets(2));
|
||||||
expect(find.textContaining('Required'), findsWidgets);
|
expect(find.textContaining('Required'), findsWidgets);
|
||||||
expect(find.text('Reward level 2'), findsOneWidget);
|
expect(find.byTooltip('Reward level 2'), findsOneWidget);
|
||||||
expect(find.text('Medium effort'), findsOneWidget);
|
expect(find.byTooltip('Medium effort'), findsOneWidget);
|
||||||
expect(find.text('Done'), findsOneWidget);
|
expect(find.byTooltip('Mark complete'), findsNothing);
|
||||||
expect(find.text('Push'), findsOneWidget);
|
expect(find.text('Reward level 2'), findsNothing);
|
||||||
expect(find.text('Move to Backlog'), findsOneWidget);
|
expect(find.text('Medium effort'), findsNothing);
|
||||||
|
expect(find.text('Done'), findsNothing);
|
||||||
|
expect(find.text('Push'), findsWidgets);
|
||||||
|
expect(find.text('Move to Backlog'), findsNothing);
|
||||||
|
expect(find.text('Backlog'), findsWidgets);
|
||||||
expect(find.text('Break up'), findsOneWidget);
|
expect(find.text('Break up'), findsOneWidget);
|
||||||
|
|
||||||
await tester.tap(find.text('Done'));
|
await tester.tap(find.text('Break up'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
|
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
|
||||||
|
|
@ -132,6 +142,91 @@ void main() {
|
||||||
expect(find.text('Pay bill'), findsOneWidget);
|
expect(find.text('Pay bill'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('task modal Push exposes destination labels and callbacks', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
var nextCount = 0;
|
||||||
|
var tomorrowCount = 0;
|
||||||
|
var backlogCount = 0;
|
||||||
|
|
||||||
|
Future<void> openMenu() async {
|
||||||
|
await tester.tap(
|
||||||
|
find.byKey(const ValueKey('modal-push-modal-push-button')),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
await _pumpConstrainedWidget(
|
||||||
|
tester,
|
||||||
|
size: const Size(640, 380),
|
||||||
|
child: TaskSelectionModal(
|
||||||
|
card: _timelineCard(
|
||||||
|
id: 'modal-push',
|
||||||
|
title: 'Modal push task',
|
||||||
|
rewardIconToken: TimelineRewardIconToken.notSet,
|
||||||
|
difficultyIconToken: TimelineDifficultyIconToken.notSet,
|
||||||
|
),
|
||||||
|
onClose: () {},
|
||||||
|
onPushToNext: (_) async {
|
||||||
|
nextCount += 1;
|
||||||
|
},
|
||||||
|
onPushToTomorrow: (_) async {
|
||||||
|
tomorrowCount += 1;
|
||||||
|
},
|
||||||
|
onPushToBacklog: (_) async {
|
||||||
|
backlogCount += 1;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byTooltip('Not Set'), findsNWidgets(2));
|
||||||
|
expect(find.byTooltip('Push task'), findsNothing);
|
||||||
|
expect(find.text('Remove'), findsOneWidget);
|
||||||
|
|
||||||
|
await openMenu();
|
||||||
|
expect(find.text('Push to next'), findsOneWidget);
|
||||||
|
expect(find.text('Push to tomorrow'), findsOneWidget);
|
||||||
|
expect(find.text('Push to backlog'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Push to next'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(nextCount, 1);
|
||||||
|
|
||||||
|
await openMenu();
|
||||||
|
await tester.tap(find.text('Push to tomorrow'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tomorrowCount, 1);
|
||||||
|
|
||||||
|
await openMenu();
|
||||||
|
await tester.tap(find.text('Push to backlog'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(backlogCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('task modal Remove deletes task and closes modal', (
|
||||||
|
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('Water plants'), findsWidgets);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Remove'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
|
||||||
|
expect(find.text('Water plants'), findsNothing);
|
||||||
|
expect(
|
||||||
|
composition.store.currentTasks.any((task) => task.id == 'water-plants'),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('Today screen timeline range covers the full day', () {
|
test('Today screen timeline range covers the full day', () {
|
||||||
expect(TodayScreenData.defaultRange.startMinutes, 0);
|
expect(TodayScreenData.defaultRange.startMinutes, 0);
|
||||||
expect(TodayScreenData.defaultRange.endMinutes, 24 * 60);
|
expect(TodayScreenData.defaultRange.endMinutes, 24 * 60);
|
||||||
|
|
@ -193,7 +288,7 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'timeline defaults near the current time while keeping full day',
|
'timeline defaults with current time one quarter down the viewport',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await _pumpConstrainedWidget(
|
await _pumpConstrainedWidget(
|
||||||
tester,
|
tester,
|
||||||
|
|
@ -213,10 +308,48 @@ void main() {
|
||||||
final scrollable = tester.state<ScrollableState>(find.byType(Scrollable));
|
final scrollable = tester.state<ScrollableState>(find.byType(Scrollable));
|
||||||
final position = scrollable.position;
|
final position = scrollable.position;
|
||||||
expect(position.maxScrollExtent, greaterThan(3000));
|
expect(position.maxScrollExtent, greaterThan(3000));
|
||||||
expect(position.pixels, closeTo(1576, 1));
|
expect(position.pixels, closeTo(1676, 1));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
testWidgets('timeline scroll-to-now request reuses initial scroll math', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
var request = 0;
|
||||||
|
var currentTime = DateTime(2026, 6, 30, 12);
|
||||||
|
late StateSetter updateTimeline;
|
||||||
|
|
||||||
|
await _pumpConstrainedWidget(
|
||||||
|
tester,
|
||||||
|
size: const Size(680, 400),
|
||||||
|
child: StatefulBuilder(
|
||||||
|
builder: (context, setState) {
|
||||||
|
updateTimeline = setState;
|
||||||
|
return TimelineView(
|
||||||
|
cards: const [],
|
||||||
|
range: TodayScreenData.defaultRange,
|
||||||
|
now: () => currentTime,
|
||||||
|
scrollToNowRequest: request,
|
||||||
|
onCardSelected: (_) {},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final scrollable = tester.state<ScrollableState>(find.byType(Scrollable));
|
||||||
|
expect(scrollable.position.pixels, closeTo(1676, 1));
|
||||||
|
|
||||||
|
scrollable.position.jumpTo(0);
|
||||||
|
await tester.pump();
|
||||||
|
updateTimeline(() {
|
||||||
|
currentTime = DateTime(2026, 6, 30, 18);
|
||||||
|
request += 1;
|
||||||
|
});
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(scrollable.position.pixels, closeTo(2558, 1));
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('timeline hour labels stay on one line', (tester) async {
|
testWidgets('timeline hour labels stay on one line', (tester) async {
|
||||||
await _pumpConstrainedWidget(
|
await _pumpConstrainedWidget(
|
||||||
tester,
|
tester,
|
||||||
|
|
@ -357,7 +490,10 @@ void main() {
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
final state = await _readSeededToday();
|
final state = await _readSeededToday();
|
||||||
final data = TodayScreenData.fromTodayState(state);
|
final data = TodayScreenData.fromTodayState(
|
||||||
|
state,
|
||||||
|
now: DateTime.utc(2025, 5, 20, 15, 20),
|
||||||
|
);
|
||||||
|
|
||||||
await _pumpConstrainedWidget(
|
await _pumpConstrainedWidget(
|
||||||
tester,
|
tester,
|
||||||
|
|
@ -422,6 +558,7 @@ void main() {
|
||||||
var previousCount = 0;
|
var previousCount = 0;
|
||||||
var nextCount = 0;
|
var nextCount = 0;
|
||||||
var datePressedCount = 0;
|
var datePressedCount = 0;
|
||||||
|
var dateLabelPressedCount = 0;
|
||||||
|
|
||||||
await _pumpConstrainedWidget(
|
await _pumpConstrainedWidget(
|
||||||
tester,
|
tester,
|
||||||
|
|
@ -437,18 +574,24 @@ void main() {
|
||||||
onDatePressed: () {
|
onDatePressed: () {
|
||||||
datePressedCount += 1;
|
datePressedCount += 1;
|
||||||
},
|
},
|
||||||
|
onDateLabelPressed: () {
|
||||||
|
dateLabelPressedCount += 1;
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.tap(find.byTooltip('Previous day'));
|
await tester.tap(find.byTooltip('Previous day'));
|
||||||
await tester.tap(find.byTooltip('Next day'));
|
await tester.tap(find.byTooltip('Next day'));
|
||||||
|
await tester.tap(find.byTooltip('Choose date'));
|
||||||
await tester.tap(find.byKey(const ValueKey('top-bar-date-button')));
|
await tester.tap(find.byKey(const ValueKey('top-bar-date-button')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(previousCount, 1);
|
expect(previousCount, 1);
|
||||||
expect(nextCount, 1);
|
expect(nextCount, 1);
|
||||||
expect(datePressedCount, 1);
|
expect(datePressedCount, 1);
|
||||||
expect(find.byTooltip('Choose date'), findsWidgets);
|
expect(dateLabelPressedCount, 1);
|
||||||
|
expect(find.byTooltip('Choose date'), findsOneWidget);
|
||||||
|
expect(find.byTooltip('Scroll to current time'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('required banner scales inside a compact header width', (
|
testWidgets('required banner scales inside a compact header width', (
|
||||||
|
|
@ -507,7 +650,11 @@ void main() {
|
||||||
|
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
expect(
|
expect(
|
||||||
tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity)).opacity,
|
tester
|
||||||
|
.widget<AnimatedOpacity>(
|
||||||
|
find.byKey(const ValueKey('narrow-actions-quick-actions-opacity')),
|
||||||
|
)
|
||||||
|
.opacity,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -518,11 +665,15 @@ void main() {
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity)).opacity,
|
tester
|
||||||
|
.widget<AnimatedOpacity>(
|
||||||
|
find.byKey(const ValueKey('narrow-actions-quick-actions-opacity')),
|
||||||
|
)
|
||||||
|
.opacity,
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
final actionBackground = tester.widget<AnimatedContainer>(
|
final actionBackground = tester.widget<AnimatedContainer>(
|
||||||
find.byType(AnimatedContainer),
|
find.byKey(const ValueKey('narrow-actions-quick-actions-background')),
|
||||||
);
|
);
|
||||||
final decoration = actionBackground.decoration;
|
final decoration = actionBackground.decoration;
|
||||||
expect(decoration, isA<BoxDecoration>());
|
expect(decoration, isA<BoxDecoration>());
|
||||||
|
|
@ -540,6 +691,243 @@ void main() {
|
||||||
await gesture.removePointer();
|
await gesture.removePointer();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('past incomplete flexible card renders Push', (tester) async {
|
||||||
|
await _pumpTimelineCard(
|
||||||
|
tester,
|
||||||
|
card: _timelineCard(
|
||||||
|
id: 'past-flexible',
|
||||||
|
title: 'Past flexible task',
|
||||||
|
showPastTaskPushButton: true,
|
||||||
|
),
|
||||||
|
size: const Size(360, 88),
|
||||||
|
onPushToNext: (_) async {},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Push'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.byKey(const ValueKey('past-flexible-push-button')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('completed and future cards do not render Push', (tester) async {
|
||||||
|
await _pumpTimelineCard(
|
||||||
|
tester,
|
||||||
|
card: _timelineCard(
|
||||||
|
id: 'completed-flexible',
|
||||||
|
title: 'Completed flexible task',
|
||||||
|
isCompleted: true,
|
||||||
|
showsQuickActions: false,
|
||||||
|
),
|
||||||
|
size: const Size(360, 88),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Push'), findsNothing);
|
||||||
|
|
||||||
|
await _pumpTimelineCard(
|
||||||
|
tester,
|
||||||
|
card: _timelineCard(id: 'future-flexible', title: 'Future flexible task'),
|
||||||
|
size: const Size(360, 88),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Push'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('past incomplete flexible card suppresses hover quick actions', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _pumpTimelineCard(
|
||||||
|
tester,
|
||||||
|
card: _timelineCard(
|
||||||
|
id: 'past-no-hover',
|
||||||
|
title: 'Past task',
|
||||||
|
showPastTaskPushButton: true,
|
||||||
|
),
|
||||||
|
size: const Size(360, 88),
|
||||||
|
onPushToNext: (_) async {},
|
||||||
|
);
|
||||||
|
|
||||||
|
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||||
|
await gesture.addPointer(
|
||||||
|
location: tester.getCenter(find.byKey(const ValueKey('past-no-hover'))),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(AnimatedOpacity), findsNothing);
|
||||||
|
expect(find.byIcon(Icons.arrow_forward), findsNothing);
|
||||||
|
|
||||||
|
await gesture.removePointer();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Push menu exposes destination labels and callbacks', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
var nextCount = 0;
|
||||||
|
var tomorrowCount = 0;
|
||||||
|
var backlogCount = 0;
|
||||||
|
|
||||||
|
Future<void> openMenu() async {
|
||||||
|
await tester.tap(find.byKey(const ValueKey('push-menu-push-button')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
await _pumpTimelineCard(
|
||||||
|
tester,
|
||||||
|
card: _timelineCard(
|
||||||
|
id: 'push-menu',
|
||||||
|
title: 'Past task',
|
||||||
|
showPastTaskPushButton: true,
|
||||||
|
),
|
||||||
|
size: const Size(420, 88),
|
||||||
|
onPushToNext: (_) async {
|
||||||
|
nextCount += 1;
|
||||||
|
},
|
||||||
|
onPushToTomorrow: (_) async {
|
||||||
|
tomorrowCount += 1;
|
||||||
|
},
|
||||||
|
onPushToBacklog: (_) async {
|
||||||
|
backlogCount += 1;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await openMenu();
|
||||||
|
|
||||||
|
expect(find.text('Push to next'), findsOneWidget);
|
||||||
|
expect(find.text('Push to tomorrow'), findsOneWidget);
|
||||||
|
expect(find.text('Push to backlog'), findsOneWidget);
|
||||||
|
expect(find.textContaining('Push to '), findsNWidgets(3));
|
||||||
|
|
||||||
|
await tester.tap(find.text('Push to next'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(nextCount, 1);
|
||||||
|
|
||||||
|
await openMenu();
|
||||||
|
await tester.tap(find.text('Push to tomorrow'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tomorrowCount, 1);
|
||||||
|
|
||||||
|
await openMenu();
|
||||||
|
await tester.tap(find.text('Push to backlog'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(backlogCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('hover arrow exposes the same Push destination menu', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
var nextCount = 0;
|
||||||
|
var tomorrowCount = 0;
|
||||||
|
var backlogCount = 0;
|
||||||
|
|
||||||
|
Future<void> openMenu() async {
|
||||||
|
await tester.tap(
|
||||||
|
find.byKey(const ValueKey('quick-push-menu-quick-push-button')),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
await _pumpTimelineCard(
|
||||||
|
tester,
|
||||||
|
card: _timelineCard(id: 'quick-push-menu', title: 'Current task'),
|
||||||
|
size: const Size(420, 88),
|
||||||
|
onPushToNext: (_) async {
|
||||||
|
nextCount += 1;
|
||||||
|
},
|
||||||
|
onPushToTomorrow: (_) async {
|
||||||
|
tomorrowCount += 1;
|
||||||
|
},
|
||||||
|
onPushToBacklog: (_) async {
|
||||||
|
backlogCount += 1;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||||
|
await gesture.addPointer(
|
||||||
|
location: tester.getCenter(find.byKey(const ValueKey('quick-push-menu'))),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byIcon(Icons.check), findsNothing);
|
||||||
|
await openMenu();
|
||||||
|
expect(find.text('Push to next'), findsOneWidget);
|
||||||
|
expect(find.text('Push to tomorrow'), findsOneWidget);
|
||||||
|
expect(find.text('Push to backlog'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Push to next'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(nextCount, 1);
|
||||||
|
|
||||||
|
await openMenu();
|
||||||
|
await tester.tap(find.text('Push to tomorrow'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tomorrowCount, 1);
|
||||||
|
|
||||||
|
await openMenu();
|
||||||
|
await tester.tap(find.text('Push to backlog'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(backlogCount, 1);
|
||||||
|
|
||||||
|
await gesture.removePointer();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Push menu dismisses on outside click without callback', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
var count = 0;
|
||||||
|
await _pumpTimelineCard(
|
||||||
|
tester,
|
||||||
|
card: _timelineCard(
|
||||||
|
id: 'push-dismiss',
|
||||||
|
title: 'Past task',
|
||||||
|
showPastTaskPushButton: true,
|
||||||
|
),
|
||||||
|
size: const Size(420, 88),
|
||||||
|
onPushToNext: (_) async {
|
||||||
|
count += 1;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const ValueKey('push-dismiss-push-button')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tapAt(Offset.zero);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Push to next'), findsNothing);
|
||||||
|
expect(count, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Push selection is one shot while command is running', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final completer = Completer<void>();
|
||||||
|
var count = 0;
|
||||||
|
await _pumpTimelineCard(
|
||||||
|
tester,
|
||||||
|
card: _timelineCard(
|
||||||
|
id: 'push-running',
|
||||||
|
title: 'Past task',
|
||||||
|
showPastTaskPushButton: true,
|
||||||
|
),
|
||||||
|
size: const Size(420, 88),
|
||||||
|
onPushToNext: (_) async {
|
||||||
|
count += 1;
|
||||||
|
await completer.future;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const ValueKey('push-running-push-button')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Push to next'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.byKey(const ValueKey('push-running-push-button')));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(count, 1);
|
||||||
|
|
||||||
|
completer.complete();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('short task card keeps time inline with title', (tester) async {
|
testWidgets('short task card keeps time inline with title', (tester) async {
|
||||||
await _pumpTimelineCard(
|
await _pumpTimelineCard(
|
||||||
tester,
|
tester,
|
||||||
|
|
@ -593,6 +981,33 @@ void main() {
|
||||||
await _pumpPlanApp(tester, composition: composition);
|
await _pumpPlanApp(tester, composition: composition);
|
||||||
|
|
||||||
await _scrollIntoView(tester, const ValueKey('clean-coffee-maker'));
|
await _scrollIntoView(tester, const ValueKey('clean-coffee-maker'));
|
||||||
|
var statusDecoration =
|
||||||
|
tester
|
||||||
|
.widget<AnimatedContainer>(
|
||||||
|
find.byKey(const ValueKey('clean-coffee-maker-status')),
|
||||||
|
)
|
||||||
|
.decoration
|
||||||
|
as BoxDecoration;
|
||||||
|
expect(statusDecoration.boxShadow, isEmpty);
|
||||||
|
|
||||||
|
final hover = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||||
|
await hover.addPointer(
|
||||||
|
location: tester.getCenter(
|
||||||
|
find.byKey(const ValueKey('clean-coffee-maker-status')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
statusDecoration =
|
||||||
|
tester
|
||||||
|
.widget<AnimatedContainer>(
|
||||||
|
find.byKey(const ValueKey('clean-coffee-maker-status')),
|
||||||
|
)
|
||||||
|
.decoration
|
||||||
|
as BoxDecoration;
|
||||||
|
expect(statusDecoration.boxShadow, isNotEmpty);
|
||||||
|
await hover.removePointer();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
final beforeClick = DateTime.now().toUtc();
|
final beforeClick = DateTime.now().toUtc();
|
||||||
await tester.tap(find.byKey(const ValueKey('clean-coffee-maker-status')));
|
await tester.tap(find.byKey(const ValueKey('clean-coffee-maker-status')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
@ -610,6 +1025,26 @@ void main() {
|
||||||
expect(completedTask.completedAt!.isAfter(afterClick), isFalse);
|
expect(completedTask.completedAt!.isAfter(afterClick), isFalse);
|
||||||
expect(find.textContaining('Completed at:'), findsWidgets);
|
expect(find.textContaining('Completed at:'), findsWidgets);
|
||||||
|
|
||||||
|
final completedHover = await tester.createGesture(
|
||||||
|
kind: PointerDeviceKind.mouse,
|
||||||
|
);
|
||||||
|
await completedHover.addPointer(
|
||||||
|
location: tester.getCenter(
|
||||||
|
find.byKey(const ValueKey('clean-coffee-maker-status')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
statusDecoration =
|
||||||
|
tester
|
||||||
|
.widget<AnimatedContainer>(
|
||||||
|
find.byKey(const ValueKey('clean-coffee-maker-status')),
|
||||||
|
)
|
||||||
|
.decoration
|
||||||
|
as BoxDecoration;
|
||||||
|
expect(statusDecoration.boxShadow, isNotEmpty);
|
||||||
|
await completedHover.removePointer();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.byKey(const ValueKey('clean-coffee-maker-status')));
|
await tester.tap(find.byKey(const ValueKey('clean-coffee-maker-status')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
|
@ -638,6 +1073,32 @@ void main() {
|
||||||
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
|
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
|
||||||
expect(find.text('Flexible - 3:20 PM - 4:00 PM'), findsOneWidget);
|
expect(find.text('Flexible - 3:20 PM - 4:00 PM'), findsOneWidget);
|
||||||
expect(find.textContaining('Completed -'), findsNothing);
|
expect(find.textContaining('Completed -'), findsNothing);
|
||||||
|
var modalStatusDecoration =
|
||||||
|
tester
|
||||||
|
.widget<AnimatedContainer>(
|
||||||
|
find.byKey(const ValueKey('task-modal-status')),
|
||||||
|
)
|
||||||
|
.decoration
|
||||||
|
as BoxDecoration;
|
||||||
|
expect(modalStatusDecoration.boxShadow, isEmpty);
|
||||||
|
|
||||||
|
final hover = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||||
|
await hover.addPointer(
|
||||||
|
location: tester.getCenter(
|
||||||
|
find.byKey(const ValueKey('task-modal-status')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
modalStatusDecoration =
|
||||||
|
tester
|
||||||
|
.widget<AnimatedContainer>(
|
||||||
|
find.byKey(const ValueKey('task-modal-status')),
|
||||||
|
)
|
||||||
|
.decoration
|
||||||
|
as BoxDecoration;
|
||||||
|
expect(modalStatusDecoration.boxShadow, isNotEmpty);
|
||||||
|
await hover.removePointer();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.byKey(const ValueKey('task-modal-status')));
|
await tester.tap(find.byKey(const ValueKey('task-modal-status')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
@ -652,6 +1113,26 @@ void main() {
|
||||||
expect(find.text('Planned: 3:20 PM - 4:00 PM'), findsOneWidget);
|
expect(find.text('Planned: 3:20 PM - 4:00 PM'), findsOneWidget);
|
||||||
expect(find.byIcon(Icons.check), findsWidgets);
|
expect(find.byIcon(Icons.check), findsWidgets);
|
||||||
|
|
||||||
|
final completedHover = await tester.createGesture(
|
||||||
|
kind: PointerDeviceKind.mouse,
|
||||||
|
);
|
||||||
|
await completedHover.addPointer(
|
||||||
|
location: tester.getCenter(
|
||||||
|
find.byKey(const ValueKey('task-modal-status')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
modalStatusDecoration =
|
||||||
|
tester
|
||||||
|
.widget<AnimatedContainer>(
|
||||||
|
find.byKey(const ValueKey('task-modal-status')),
|
||||||
|
)
|
||||||
|
.decoration
|
||||||
|
as BoxDecoration;
|
||||||
|
expect(modalStatusDecoration.boxShadow, isNotEmpty);
|
||||||
|
await completedHover.removePointer();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.byKey(const ValueKey('task-modal-status')));
|
await tester.tap(find.byKey(const ValueKey('task-modal-status')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
|
@ -739,8 +1220,13 @@ Future<void> _pumpTimelineCard(
|
||||||
WidgetTester tester, {
|
WidgetTester tester, {
|
||||||
required TimelineCardModel card,
|
required TimelineCardModel card,
|
||||||
required Size size,
|
required Size size,
|
||||||
|
Future<void> Function(TimelineCardModel card)? onPushToNext,
|
||||||
|
Future<void> Function(TimelineCardModel card)? onPushToTomorrow,
|
||||||
|
Future<void> Function(TimelineCardModel card)? onPushToBacklog,
|
||||||
}) async {
|
}) async {
|
||||||
await tester.binding.setSurfaceSize(Size(size.width + 40, size.height + 40));
|
await tester.binding.setSurfaceSize(
|
||||||
|
Size(size.width + 120, size.height + 220),
|
||||||
|
);
|
||||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
|
|
@ -753,6 +1239,9 @@ Future<void> _pumpTimelineCard(
|
||||||
card: card,
|
card: card,
|
||||||
onTap: () {},
|
onTap: () {},
|
||||||
onStatusPressed: () {},
|
onStatusPressed: () {},
|
||||||
|
onPushToNext: onPushToNext,
|
||||||
|
onPushToTomorrow: onPushToTomorrow,
|
||||||
|
onPushToBacklog: onPushToBacklog,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -796,7 +1285,12 @@ TimelineCardModel _timelineCard({
|
||||||
TaskVisualKind visualKind = TaskVisualKind.flexible,
|
TaskVisualKind visualKind = TaskVisualKind.flexible,
|
||||||
TaskType? taskType,
|
TaskType? taskType,
|
||||||
bool showsQuickActions = true,
|
bool showsQuickActions = true,
|
||||||
|
bool showPastTaskPushButton = false,
|
||||||
|
bool isCompleted = false,
|
||||||
String? completedTimeText,
|
String? completedTimeText,
|
||||||
|
TimelineRewardIconToken rewardIconToken = TimelineRewardIconToken.medium,
|
||||||
|
TimelineDifficultyIconToken difficultyIconToken =
|
||||||
|
TimelineDifficultyIconToken.medium,
|
||||||
}) {
|
}) {
|
||||||
final resolvedTaskType =
|
final resolvedTaskType =
|
||||||
taskType ??
|
taskType ??
|
||||||
|
|
@ -817,12 +1311,13 @@ TimelineCardModel _timelineCard({
|
||||||
endMinutes: endMinutes,
|
endMinutes: endMinutes,
|
||||||
taskType: resolvedTaskType,
|
taskType: resolvedTaskType,
|
||||||
visualKind: visualKind,
|
visualKind: visualKind,
|
||||||
rewardIconToken: TimelineRewardIconToken.medium,
|
rewardIconToken: rewardIconToken,
|
||||||
difficultyIconToken: TimelineDifficultyIconToken.medium,
|
difficultyIconToken: difficultyIconToken,
|
||||||
completedTimeText: completedTimeText,
|
completedTimeText: completedTimeText,
|
||||||
isCompleted: false,
|
isCompleted: isCompleted,
|
||||||
isSelectable: true,
|
isSelectable: true,
|
||||||
showsQuickActions: showsQuickActions,
|
showsQuickActions: showPastTaskPushButton ? false : showsQuickActions,
|
||||||
|
showPastTaskPushButton: showPastTaskPushButton,
|
||||||
durationMinutes: endMinutes - startMinutes,
|
durationMinutes: endMinutes - startMinutes,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import 'dart:math';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:cryptography/cryptography.dart';
|
import 'package:cryptography/cryptography.dart';
|
||||||
|
import 'package:scheduler_core/scheduler_core.dart' show logger;
|
||||||
part 'encrypted_backup/errors/backup_decryption_exception.dart';
|
part 'encrypted_backup/errors/backup_decryption_exception.dart';
|
||||||
part 'encrypted_backup/codec/backup_encryption_codec.dart';
|
part 'encrypted_backup/codec/backup_encryption_codec.dart';
|
||||||
part 'encrypted_backup/errors/backup_exception.dart';
|
part 'encrypted_backup/errors/backup_exception.dart';
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,19 @@ Future<List<int>> _decryptBytes({
|
||||||
SecretBox(cipherText, nonce: nonce, mac: mac),
|
SecretBox(cipherText, nonce: nonce, mac: mac),
|
||||||
secretKey: key,
|
secretKey: key,
|
||||||
);
|
);
|
||||||
} on BackupDecryptionException {
|
} on BackupDecryptionException catch (error, stackTrace) {
|
||||||
|
logger.error(
|
||||||
|
() => 'Encrypted backup decryption failed with known backup error.',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
rethrow;
|
rethrow;
|
||||||
} on Object {
|
} on Object catch (error, stackTrace) {
|
||||||
|
logger.error(
|
||||||
|
() => 'Encrypted backup decryption failed.',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
throw const BackupDecryptionException();
|
throw const BackupDecryptionException();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,10 @@ Future<File> createEncryptedBackup({
|
||||||
DateTime? now,
|
DateTime? now,
|
||||||
}) async {
|
}) async {
|
||||||
final source = sqliteFile ?? defaultSchedulerSqliteFile();
|
final source = sqliteFile ?? defaultSchedulerSqliteFile();
|
||||||
|
logger.info(() => 'Encrypted backup started. sourcePath=${source.path}');
|
||||||
if (!await source.exists()) {
|
if (!await source.exists()) {
|
||||||
|
logger.warn(() =>
|
||||||
|
'Encrypted backup source file missing. sourcePath=${source.path}');
|
||||||
throw BackupException('SQLite file does not exist: ${source.path}');
|
throw BackupException('SQLite file does not exist: ${source.path}');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,7 +38,11 @@ Future<File> createEncryptedBackup({
|
||||||
passphrase: passphrase,
|
passphrase: passphrase,
|
||||||
plaintext: plaintext,
|
plaintext: plaintext,
|
||||||
);
|
);
|
||||||
return destination.writeAsBytes(encoded, flush: true);
|
final output = await destination.writeAsBytes(encoded, flush: true);
|
||||||
|
logger.info(() => 'Encrypted backup completed. sourcePath=${source.path} '
|
||||||
|
'destinationPath=${output.path} sourceBytes=${plaintext.length} '
|
||||||
|
'encodedBytes=${encoded.length}');
|
||||||
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restore an encrypted backup over the scheduler SQLite file.
|
/// Restore an encrypted backup over the scheduler SQLite file.
|
||||||
|
|
@ -47,7 +54,11 @@ Future<void> restoreEncryptedBackup(
|
||||||
String passphrase, {
|
String passphrase, {
|
||||||
File? targetFile,
|
File? targetFile,
|
||||||
}) async {
|
}) async {
|
||||||
|
logger.info(
|
||||||
|
() => 'Encrypted backup restore started. backupPath=${backup.path}');
|
||||||
if (!await backup.exists()) {
|
if (!await backup.exists()) {
|
||||||
|
logger.warn(() =>
|
||||||
|
'Encrypted backup restore source missing. backupPath=${backup.path}');
|
||||||
throw BackupException('Backup file does not exist: ${backup.path}');
|
throw BackupException('Backup file does not exist: ${backup.path}');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,4 +75,7 @@ Future<void> restoreEncryptedBackup(
|
||||||
await target.delete();
|
await target.delete();
|
||||||
}
|
}
|
||||||
await temporary.rename(target.path);
|
await temporary.rename(target.path);
|
||||||
|
logger.info(
|
||||||
|
() => 'Encrypted backup restore completed. backupPath=${backup.path} '
|
||||||
|
'targetPath=${target.path} restoredBytes=${plaintext.length}');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ Directory _homeDirectory() {
|
||||||
Platform.environment['USERPROFILE'] ??
|
Platform.environment['USERPROFILE'] ??
|
||||||
Platform.environment['HOMEDRIVE'];
|
Platform.environment['HOMEDRIVE'];
|
||||||
if (home == null || home.trim().isEmpty) {
|
if (home == null || home.trim().isEmpty) {
|
||||||
|
logger.warn(
|
||||||
|
() => 'Backup home directory could not be resolved from environment.');
|
||||||
throw const BackupException('Home directory could not be resolved.');
|
throw const BackupException('Home directory could not be resolved.');
|
||||||
}
|
}
|
||||||
return Directory(home);
|
return Directory(home);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ environment:
|
||||||
dependencies:
|
dependencies:
|
||||||
cryptography: ^2.7.0
|
cryptography: ^2.7.0
|
||||||
encrypt: ^5.0.3
|
encrypt: ^5.0.3
|
||||||
|
scheduler_core: any
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
lints: ^5.0.0
|
lints: ^5.0.0
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ export 'src/persistence/document_mapping.dart';
|
||||||
export 'src/persistence/document_migration.dart';
|
export 'src/persistence/document_migration.dart';
|
||||||
export 'src/scheduling/free_slots.dart';
|
export 'src/scheduling/free_slots.dart';
|
||||||
export 'src/domain/locked_time.dart';
|
export 'src/domain/locked_time.dart';
|
||||||
|
export 'src/logging/focus_flow_logger.dart';
|
||||||
export 'src/domain/occupancy_policy.dart';
|
export 'src/domain/occupancy_policy.dart';
|
||||||
export 'src/persistence/persistence_contract.dart';
|
export 'src/persistence/persistence_contract.dart';
|
||||||
export 'src/domain/project_statistics.dart';
|
export 'src/domain/project_statistics.dart';
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ import '../scheduling/scheduling_engine.dart';
|
||||||
import '../scheduling/task_actions.dart';
|
import '../scheduling/task_actions.dart';
|
||||||
import '../scheduling/task_lifecycle.dart';
|
import '../scheduling/task_lifecycle.dart';
|
||||||
import '../domain/time_contracts.dart';
|
import '../domain/time_contracts.dart';
|
||||||
|
import '../logging/focus_flow_logger.dart';
|
||||||
|
import '../persistence/repositories.dart';
|
||||||
part 'application_commands/codes/application_command_code.dart';
|
part 'application_commands/codes/application_command_code.dart';
|
||||||
part 'application_commands/messages/application_child_task_draft.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_read_hint.dart';
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,10 @@ enum ApplicationCommandCode {
|
||||||
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
|
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
|
||||||
moveFlexibleToBacklog,
|
moveFlexibleToBacklog,
|
||||||
|
|
||||||
|
/// Selects the `removeTask` option from this enum.
|
||||||
|
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
|
||||||
|
removeTask,
|
||||||
|
|
||||||
/// Selects the `completeFlexibleTask` option from this enum.
|
/// 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.
|
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
|
||||||
completeFlexibleTask,
|
completeFlexibleTask,
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,10 @@ class V1ApplicationCommandUseCases {
|
||||||
context: context,
|
context: context,
|
||||||
operationName: commandCode.name,
|
operationName: commandCode.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
|
logger.debug(() =>
|
||||||
|
'Application command executing. command=${commandCode.name} '
|
||||||
|
'operationId=${context.operationId} hasTitle=${title.trim().isNotEmpty} '
|
||||||
|
'projectId=$projectId taskType=${type.name} durationMinutes=$durationMinutes');
|
||||||
final result = quickCaptureService.capture(
|
final result = quickCaptureService.capture(
|
||||||
QuickCaptureRequest(
|
QuickCaptureRequest(
|
||||||
id: context.idGenerator.nextId(),
|
id: context.idGenerator.nextId(),
|
||||||
|
|
@ -136,6 +140,11 @@ class V1ApplicationCommandUseCases {
|
||||||
context: context,
|
context: context,
|
||||||
operationName: commandCode.name,
|
operationName: commandCode.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
|
logger.debug(() =>
|
||||||
|
'Application command executing. command=${commandCode.name} '
|
||||||
|
'operationId=${context.operationId} localDate=${localDate.toIsoString()} '
|
||||||
|
'hasTitle=${title.trim().isNotEmpty} projectId=$projectId '
|
||||||
|
'durationMinutes=$durationMinutes');
|
||||||
final state =
|
final state =
|
||||||
await _loadPlanningState(repositories, context, localDate);
|
await _loadPlanningState(repositories, context, localDate);
|
||||||
final taskId = context.idGenerator.nextId();
|
final taskId = context.idGenerator.nextId();
|
||||||
|
|
@ -199,9 +208,16 @@ class V1ApplicationCommandUseCases {
|
||||||
context: context,
|
context: context,
|
||||||
operationName: commandCode.name,
|
operationName: commandCode.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
|
logger.debug(() =>
|
||||||
|
'Application command executing. command=${commandCode.name} '
|
||||||
|
'operationId=${context.operationId} localDate=${localDate.toIsoString()} '
|
||||||
|
'taskId=$taskId durationMinutes=$durationMinutes');
|
||||||
var state = await _loadPlanningState(repositories, context, localDate);
|
var state = await _loadPlanningState(repositories, context, localDate);
|
||||||
var task = _taskById(state.tasks, taskId);
|
var task = _taskById(state.tasks, taskId);
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
|
logger.finer(() =>
|
||||||
|
'Planning-state task lookup missed; reading by id. '
|
||||||
|
'command=${commandCode.name} taskId=$taskId operationId=${context.operationId}');
|
||||||
task = await repositories.tasks.findById(taskId);
|
task = await repositories.tasks.findById(taskId);
|
||||||
if (task != null) {
|
if (task != null) {
|
||||||
state = state.withTask(task);
|
state = state.withTask(task);
|
||||||
|
|
@ -316,6 +332,9 @@ class V1ApplicationCommandUseCases {
|
||||||
context: context,
|
context: context,
|
||||||
operationName: commandCode.name,
|
operationName: commandCode.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
|
logger.debug(
|
||||||
|
() => 'Application command executing. command=${commandCode.name} '
|
||||||
|
'operationId=${context.operationId} taskId=$taskId');
|
||||||
final task = await repositories.tasks.findById(taskId);
|
final task = await repositories.tasks.findById(taskId);
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
||||||
|
|
@ -356,6 +375,54 @@ class V1ApplicationCommandUseCases {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Permanently remove a task from persistence.
|
||||||
|
Future<ApplicationResult<ApplicationCommandResult>> removeTask({
|
||||||
|
required ApplicationOperationContext context,
|
||||||
|
required String taskId,
|
||||||
|
DateTime? expectedUpdatedAt,
|
||||||
|
}) {
|
||||||
|
const commandCode = ApplicationCommandCode.removeTask;
|
||||||
|
return applicationStore.run<ApplicationCommandResult>(
|
||||||
|
context: context,
|
||||||
|
operationName: commandCode.name,
|
||||||
|
action: (repositories) async {
|
||||||
|
logger.debug(
|
||||||
|
() => 'Application command executing. command=${commandCode.name} '
|
||||||
|
'operationId=${context.operationId} taskId=$taskId');
|
||||||
|
final record = await repositories.tasks.findRecordById(taskId);
|
||||||
|
if (record == null) {
|
||||||
|
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
||||||
|
}
|
||||||
|
final staleFailure = _staleFailure(record.value, expectedUpdatedAt);
|
||||||
|
if (staleFailure != null) {
|
||||||
|
return ApplicationResult.failure(staleFailure);
|
||||||
|
}
|
||||||
|
final deleteResult = await repositories.tasks.deleteIfRevision(
|
||||||
|
taskId: taskId,
|
||||||
|
ownerId: context.ownerTimeZone.ownerId,
|
||||||
|
expectedRevision: record.revision,
|
||||||
|
);
|
||||||
|
final deleteFailure = deleteResult.failure;
|
||||||
|
if (deleteFailure != null) {
|
||||||
|
return ApplicationResult.failure(
|
||||||
|
_failureForRepositoryMutation(deleteFailure),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ApplicationResult.success(
|
||||||
|
ApplicationCommandResult(
|
||||||
|
commandCode: commandCode,
|
||||||
|
operationId: context.operationId,
|
||||||
|
changedTasks: const <Task>[],
|
||||||
|
readHint: ApplicationCommandReadHint(
|
||||||
|
affectedTaskIds: [taskId],
|
||||||
|
refreshToday: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Complete a flexible task.
|
/// Complete a flexible task.
|
||||||
Future<ApplicationResult<ApplicationCommandResult>> completeFlexibleTask({
|
Future<ApplicationResult<ApplicationCommandResult>> completeFlexibleTask({
|
||||||
required ApplicationOperationContext context,
|
required ApplicationOperationContext context,
|
||||||
|
|
@ -370,9 +437,15 @@ class V1ApplicationCommandUseCases {
|
||||||
context: context,
|
context: context,
|
||||||
operationName: commandCode.name,
|
operationName: commandCode.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
|
logger.debug(() =>
|
||||||
|
'Application command executing. command=${commandCode.name} '
|
||||||
|
'operationId=${context.operationId} localDate=${localDate.toIsoString()} taskId=$taskId');
|
||||||
var state = await _loadPlanningState(repositories, context, localDate);
|
var state = await _loadPlanningState(repositories, context, localDate);
|
||||||
var task = _taskById(state.tasks, taskId);
|
var task = _taskById(state.tasks, taskId);
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
|
logger.finer(() =>
|
||||||
|
'Planning-state task lookup missed; reading by id. '
|
||||||
|
'command=${commandCode.name} taskId=$taskId operationId=${context.operationId}');
|
||||||
task = await repositories.tasks.findById(taskId);
|
task = await repositories.tasks.findById(taskId);
|
||||||
if (task != null) {
|
if (task != null) {
|
||||||
state = state.withTask(task);
|
state = state.withTask(task);
|
||||||
|
|
@ -432,9 +505,15 @@ class V1ApplicationCommandUseCases {
|
||||||
context: context,
|
context: context,
|
||||||
operationName: commandCode.name,
|
operationName: commandCode.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
|
logger.debug(() =>
|
||||||
|
'Application command executing. command=${commandCode.name} '
|
||||||
|
'operationId=${context.operationId} localDate=${localDate.toIsoString()} taskId=$taskId');
|
||||||
var state = await _loadPlanningState(repositories, context, localDate);
|
var state = await _loadPlanningState(repositories, context, localDate);
|
||||||
var task = _taskById(state.tasks, taskId);
|
var task = _taskById(state.tasks, taskId);
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
|
logger.finer(() =>
|
||||||
|
'Planning-state task lookup missed; reading by id. '
|
||||||
|
'command=${commandCode.name} taskId=$taskId operationId=${context.operationId}');
|
||||||
task = await repositories.tasks.findById(taskId);
|
task = await repositories.tasks.findById(taskId);
|
||||||
if (task != null) {
|
if (task != null) {
|
||||||
state = state.withTask(task);
|
state = state.withTask(task);
|
||||||
|
|
@ -492,9 +571,16 @@ class V1ApplicationCommandUseCases {
|
||||||
context: context,
|
context: context,
|
||||||
operationName: commandCode.name,
|
operationName: commandCode.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
|
logger.debug(() =>
|
||||||
|
'Application command executing. command=${commandCode.name} '
|
||||||
|
'operationId=${context.operationId} localDate=${localDate.toIsoString()} taskId=$taskId '
|
||||||
|
'action=${action.name}');
|
||||||
var state = await _loadPlanningState(repositories, context, localDate);
|
var state = await _loadPlanningState(repositories, context, localDate);
|
||||||
var task = _taskById(state.tasks, taskId);
|
var task = _taskById(state.tasks, taskId);
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
|
logger.finer(() =>
|
||||||
|
'Planning-state task lookup missed; reading by id. '
|
||||||
|
'command=${commandCode.name} taskId=$taskId operationId=${context.operationId}');
|
||||||
task = await repositories.tasks.findById(taskId);
|
task = await repositories.tasks.findById(taskId);
|
||||||
if (task != null) {
|
if (task != null) {
|
||||||
state = state.withTask(task);
|
state = state.withTask(task);
|
||||||
|
|
@ -560,6 +646,11 @@ class V1ApplicationCommandUseCases {
|
||||||
context: context,
|
context: context,
|
||||||
operationName: commandCode.name,
|
operationName: commandCode.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
|
logger.debug(() =>
|
||||||
|
'Application command executing. command=${commandCode.name} '
|
||||||
|
'operationId=${context.operationId} localDate=${localDate.toIsoString()} '
|
||||||
|
'hasTitle=${title.trim().isNotEmpty} hasStartedAt=${startedAt != null} '
|
||||||
|
'timeUsedMinutes=$timeUsedMinutes projectId=$projectId');
|
||||||
final state =
|
final state =
|
||||||
await _loadPlanningState(repositories, context, localDate);
|
await _loadPlanningState(repositories, context, localDate);
|
||||||
final logResult = surpriseTaskLogService.log(
|
final logResult = surpriseTaskLogService.log(
|
||||||
|
|
@ -613,6 +704,11 @@ class V1ApplicationCommandUseCases {
|
||||||
context: context,
|
context: context,
|
||||||
operationName: commandCode.name,
|
operationName: commandCode.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
|
logger.debug(() =>
|
||||||
|
'Application command executing. command=${commandCode.name} '
|
||||||
|
'operationId=${context.operationId} localDate=${localDate.toIsoString()} '
|
||||||
|
'start=${start.toIso8601String()} end=${end.toIso8601String()} '
|
||||||
|
'projectId=$projectId hasTitle=${title.trim().isNotEmpty}');
|
||||||
final freeSlot = freeSlotService.create(
|
final freeSlot = freeSlotService.create(
|
||||||
id: context.idGenerator.nextId(),
|
id: context.idGenerator.nextId(),
|
||||||
title: title,
|
title: title,
|
||||||
|
|
@ -654,6 +750,11 @@ class V1ApplicationCommandUseCases {
|
||||||
context: context,
|
context: context,
|
||||||
operationName: commandCode.name,
|
operationName: commandCode.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
|
logger.debug(() =>
|
||||||
|
'Application command executing. command=${commandCode.name} '
|
||||||
|
'operationId=${context.operationId} localDate=${localDate.toIsoString()} taskId=$taskId '
|
||||||
|
'start=${start.toIso8601String()} end=${end.toIso8601String()} '
|
||||||
|
'hasTitle=${title != null} projectId=$projectId');
|
||||||
final task = await repositories.tasks.findById(taskId);
|
final task = await repositories.tasks.findById(taskId);
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
||||||
|
|
@ -698,6 +799,9 @@ class V1ApplicationCommandUseCases {
|
||||||
context: context,
|
context: context,
|
||||||
operationName: commandCode.name,
|
operationName: commandCode.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
|
logger.debug(() =>
|
||||||
|
'Application command executing. command=${commandCode.name} '
|
||||||
|
'operationId=${context.operationId} localDate=${localDate.toIsoString()} taskId=$taskId');
|
||||||
final task = await repositories.tasks.findById(taskId);
|
final task = await repositories.tasks.findById(taskId);
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
||||||
|
|
@ -746,6 +850,10 @@ class V1ApplicationCommandUseCases {
|
||||||
context: context,
|
context: context,
|
||||||
operationName: commandCode.name,
|
operationName: commandCode.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
|
logger.debug(
|
||||||
|
() => 'Application command executing. command=${commandCode.name} '
|
||||||
|
'operationId=${context.operationId} parentTaskId=$parentTaskId '
|
||||||
|
'childDraftCount=${children.length}');
|
||||||
final tasks = await repositories.tasks.findAll();
|
final tasks = await repositories.tasks.findAll();
|
||||||
final parent = _taskById(tasks, parentTaskId);
|
final parent = _taskById(tasks, parentTaskId);
|
||||||
if (parent == null) {
|
if (parent == null) {
|
||||||
|
|
@ -882,9 +990,16 @@ class V1ApplicationCommandUseCases {
|
||||||
context: context,
|
context: context,
|
||||||
operationName: commandCode.name,
|
operationName: commandCode.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
|
logger.debug(() =>
|
||||||
|
'Application command executing. command=${commandCode.name} '
|
||||||
|
'operationId=${context.operationId} localDate=${localDate.toIsoString()} taskId=$taskId '
|
||||||
|
'destination=${destination.name}');
|
||||||
var state = await _loadPlanningState(repositories, context, localDate);
|
var state = await _loadPlanningState(repositories, context, localDate);
|
||||||
var task = _taskById(state.tasks, taskId);
|
var task = _taskById(state.tasks, taskId);
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
|
logger.finer(() =>
|
||||||
|
'Planning-state task lookup missed; reading by id. '
|
||||||
|
'command=${commandCode.name} taskId=$taskId operationId=${context.operationId}');
|
||||||
task = await repositories.tasks.findById(taskId);
|
task = await repositories.tasks.findById(taskId);
|
||||||
if (task != null) {
|
if (task != null) {
|
||||||
state = state.withTask(task);
|
state = state.withTask(task);
|
||||||
|
|
@ -903,6 +1018,9 @@ class V1ApplicationCommandUseCases {
|
||||||
input: state.input,
|
input: state.input,
|
||||||
taskId: taskId,
|
taskId: taskId,
|
||||||
updatedAt: context.now,
|
updatedAt: context.now,
|
||||||
|
earliestStart: destination == PushDestination.nextAvailableSlot
|
||||||
|
? context.now
|
||||||
|
: null,
|
||||||
operationId: context.operationId,
|
operationId: context.operationId,
|
||||||
existingActivities: existingActivities,
|
existingActivities: existingActivities,
|
||||||
);
|
);
|
||||||
|
|
@ -947,6 +1065,9 @@ class V1ApplicationCommandUseCases {
|
||||||
context: context,
|
context: context,
|
||||||
operationName: commandCode.name,
|
operationName: commandCode.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
|
logger.debug(
|
||||||
|
() => 'Application command executing. command=${commandCode.name} '
|
||||||
|
'operationId=${context.operationId} taskId=$taskId');
|
||||||
final tasks = await repositories.tasks.findAll();
|
final tasks = await repositories.tasks.findAll();
|
||||||
final task = _taskById(tasks, taskId);
|
final task = _taskById(tasks, taskId);
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
|
|
@ -986,20 +1107,24 @@ class V1ApplicationCommandUseCases {
|
||||||
CivilDate localDate,
|
CivilDate localDate,
|
||||||
) async {
|
) async {
|
||||||
final ownerId = context.ownerTimeZone.ownerId;
|
final ownerId = context.ownerTimeZone.ownerId;
|
||||||
|
logger.debug(() => 'Loading planning state. ownerId=$ownerId '
|
||||||
|
'localDate=${localDate.toIsoString()} operationId=${context.operationId}');
|
||||||
final settings = await repositories.ownerSettings.findByOwnerId(ownerId) ??
|
final settings = await repositories.ownerSettings.findByOwnerId(ownerId) ??
|
||||||
OwnerSettings(
|
OwnerSettings(
|
||||||
ownerId: ownerId,
|
ownerId: ownerId,
|
||||||
timeZoneId: context.ownerTimeZone.timeZoneId,
|
timeZoneId: context.ownerTimeZone.timeZoneId,
|
||||||
);
|
);
|
||||||
|
final timeZoneId = context.ownerTimeZone.timeZoneId;
|
||||||
|
final effectiveSettings = settings.copyWith(timeZoneId: timeZoneId);
|
||||||
final dayStart = _resolve(
|
final dayStart = _resolve(
|
||||||
date: localDate,
|
date: localDate,
|
||||||
wallTime: settings.dayStart,
|
wallTime: effectiveSettings.dayStart,
|
||||||
timeZoneId: settings.timeZoneId,
|
timeZoneId: timeZoneId,
|
||||||
);
|
);
|
||||||
final dayEnd = _resolve(
|
final dayEnd = _resolve(
|
||||||
date: localDate,
|
date: localDate,
|
||||||
wallTime: settings.dayEnd,
|
wallTime: effectiveSettings.dayEnd,
|
||||||
timeZoneId: settings.timeZoneId,
|
timeZoneId: timeZoneId,
|
||||||
);
|
);
|
||||||
final window = SchedulingWindow(start: dayStart, end: dayEnd);
|
final window = SchedulingWindow(start: dayStart, end: dayEnd);
|
||||||
final tasks = (await repositories.tasks.findForLocalDay(
|
final tasks = (await repositories.tasks.findForLocalDay(
|
||||||
|
|
@ -1019,11 +1144,16 @@ class V1ApplicationCommandUseCases {
|
||||||
))
|
))
|
||||||
.items,
|
.items,
|
||||||
date: localDate,
|
date: localDate,
|
||||||
timeZoneId: settings.timeZoneId,
|
timeZoneId: timeZoneId,
|
||||||
timeZoneResolver: timeZoneResolver,
|
timeZoneResolver: timeZoneResolver,
|
||||||
resolutionOptions: resolutionOptions,
|
resolutionOptions: resolutionOptions,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
logger.debug(() =>
|
||||||
|
'Planning state loaded. ownerId=$ownerId localDate=${localDate.toIsoString()} '
|
||||||
|
'taskCount=${tasks.length} lockedIntervalCount=${lockedExpansion.schedulingIntervals.length} '
|
||||||
|
'windowStart=${window.start.toIso8601String()} windowEnd=${window.end.toIso8601String()}');
|
||||||
|
|
||||||
return _PlanningState(
|
return _PlanningState(
|
||||||
tasks: tasks,
|
tasks: tasks,
|
||||||
window: window,
|
window: window,
|
||||||
|
|
@ -1064,6 +1194,11 @@ class V1ApplicationCommandUseCases {
|
||||||
List<String> childTaskIds = const <String>[],
|
List<String> childTaskIds = const <String>[],
|
||||||
}) async {
|
}) async {
|
||||||
final changedTasks = _changedTasks(beforeTasks, afterTasks);
|
final changedTasks = _changedTasks(beforeTasks, afterTasks);
|
||||||
|
logger.debug(() =>
|
||||||
|
'Persisting application command result. command=${commandCode.name} '
|
||||||
|
'operationId=${context.operationId} changedTaskCount=${changedTasks.length} '
|
||||||
|
'activityCount=${activities.length} noticeCount=${schedulingResult?.notices.length ?? 0} '
|
||||||
|
'changeCount=${schedulingResult?.changes.length ?? 0}');
|
||||||
await repositories.tasks.saveAll(changedTasks);
|
await repositories.tasks.saveAll(changedTasks);
|
||||||
await repositories.taskActivities.saveAll(activities);
|
await repositories.taskActivities.saveAll(activities);
|
||||||
final projectStats = await _saveProjectStatistics(
|
final projectStats = await _saveProjectStatistics(
|
||||||
|
|
@ -1072,6 +1207,11 @@ class V1ApplicationCommandUseCases {
|
||||||
tasks: afterTasks,
|
tasks: afterTasks,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
logger.debug(() =>
|
||||||
|
'Application command persisted. command=${commandCode.name} '
|
||||||
|
'operationId=${context.operationId} changedTaskCount=${changedTasks.length} '
|
||||||
|
'projectStatisticsCount=${projectStats.length}');
|
||||||
|
|
||||||
return ApplicationResult.success(
|
return ApplicationResult.success(
|
||||||
ApplicationCommandResult(
|
ApplicationCommandResult(
|
||||||
commandCode: commandCode,
|
commandCode: commandCode,
|
||||||
|
|
@ -1101,6 +1241,8 @@ class V1ApplicationCommandUseCases {
|
||||||
.where((activity) => activity.code == TaskActivityCode.completed)
|
.where((activity) => activity.code == TaskActivityCode.completed)
|
||||||
.map((activity) => activity.projectId)
|
.map((activity) => activity.projectId)
|
||||||
.toSet();
|
.toSet();
|
||||||
|
logger.finer(() => 'Saving project statistics after command. '
|
||||||
|
'projectCount=${projectIds.length} activityCount=${activities.length}');
|
||||||
final updated = <ProjectStatistics>[];
|
final updated = <ProjectStatistics>[];
|
||||||
|
|
||||||
for (final projectId in projectIds) {
|
for (final projectId in projectIds) {
|
||||||
|
|
@ -1116,6 +1258,8 @@ class V1ApplicationCommandUseCases {
|
||||||
if (result.appliedActivityIds.isEmpty) {
|
if (result.appliedActivityIds.isEmpty) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
logger.finer(() => 'Project statistics updated. projectId=$projectId '
|
||||||
|
'appliedActivityCount=${result.appliedActivityIds.length}');
|
||||||
await repositories.projectStatistics.save(result.statistics);
|
await repositories.projectStatistics.save(result.statistics);
|
||||||
updated.add(result.statistics);
|
updated.add(result.statistics);
|
||||||
}
|
}
|
||||||
|
|
@ -1123,3 +1267,33 @@ class V1ApplicationCommandUseCases {
|
||||||
return List<ProjectStatistics>.unmodifiable(updated);
|
return List<ProjectStatistics>.unmodifiable(updated);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Top-level helper that performs the `_failureForRepositoryMutation` operation for this file.
|
||||||
|
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
|
||||||
|
ApplicationFailure _failureForRepositoryMutation(RepositoryFailure failure) {
|
||||||
|
return switch (failure.code) {
|
||||||
|
RepositoryFailureCode.notFound => ApplicationFailure(
|
||||||
|
code: ApplicationFailureCode.notFound,
|
||||||
|
entityId: failure.entityId,
|
||||||
|
detailCode: failure.code.name,
|
||||||
|
),
|
||||||
|
RepositoryFailureCode.staleRevision => ApplicationFailure(
|
||||||
|
code: ApplicationFailureCode.staleRevision,
|
||||||
|
entityId: failure.entityId,
|
||||||
|
detailCode: failure.code.name,
|
||||||
|
),
|
||||||
|
RepositoryFailureCode.invalidRevision => ApplicationFailure(
|
||||||
|
code: ApplicationFailureCode.validation,
|
||||||
|
entityId: failure.entityId,
|
||||||
|
detailCode: failure.code.name,
|
||||||
|
),
|
||||||
|
RepositoryFailureCode.ownerMismatch ||
|
||||||
|
RepositoryFailureCode.duplicateId ||
|
||||||
|
RepositoryFailureCode.duplicateOperation =>
|
||||||
|
ApplicationFailure(
|
||||||
|
code: ApplicationFailureCode.conflict,
|
||||||
|
entityId: failure.entityId,
|
||||||
|
detailCode: failure.code.name,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import '../persistence/repositories.dart';
|
||||||
import '../scheduling/scheduling_engine.dart';
|
import '../scheduling/scheduling_engine.dart';
|
||||||
import '../scheduling/task_lifecycle.dart';
|
import '../scheduling/task_lifecycle.dart';
|
||||||
import '../domain/time_contracts.dart';
|
import '../domain/time_contracts.dart';
|
||||||
|
import '../logging/focus_flow_logger.dart';
|
||||||
part 'application_layer/context/owner_time_zone_context.dart';
|
part 'application_layer/context/owner_time_zone_context.dart';
|
||||||
part 'application_layer/context/application_operation_context.dart';
|
part 'application_layer/context/application_operation_context.dart';
|
||||||
part 'application_layer/results/application_failure_code.dart';
|
part 'application_layer/results/application_failure_code.dart';
|
||||||
|
|
|
||||||
|
|
@ -195,7 +195,13 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
|
||||||
ApplicationUnitOfWorkRepositories repositories,
|
ApplicationUnitOfWorkRepositories repositories,
|
||||||
) action,
|
) action,
|
||||||
}) async {
|
}) async {
|
||||||
|
logger.debug(() => 'In-memory unit of work run started. '
|
||||||
|
'operationId=${context.operationId} operationName=$operationName '
|
||||||
|
'ownerId=${context.ownerTimeZone.ownerId}');
|
||||||
if (_state.operationsById.containsKey(context.operationId)) {
|
if (_state.operationsById.containsKey(context.operationId)) {
|
||||||
|
logger.warn(() =>
|
||||||
|
'Duplicate operation ignored by in-memory unit of work. '
|
||||||
|
'operationId=${context.operationId} operationName=$operationName');
|
||||||
return ApplicationResult.failure(
|
return ApplicationResult.failure(
|
||||||
ApplicationFailure(
|
ApplicationFailure(
|
||||||
code: ApplicationFailureCode.duplicateOperation,
|
code: ApplicationFailureCode.duplicateOperation,
|
||||||
|
|
@ -210,6 +216,9 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
|
||||||
try {
|
try {
|
||||||
final result = await action(repositories);
|
final result = await action(repositories);
|
||||||
if (result.isFailure) {
|
if (result.isFailure) {
|
||||||
|
logger.warn(() => 'In-memory unit of work action returned failure. '
|
||||||
|
'operationId=${context.operationId} operationName=$operationName '
|
||||||
|
'code=${result.failure!.code.name} detailCode=${result.failure!.detailCode}');
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -222,14 +231,29 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
|
||||||
|
|
||||||
if (_failNextCommit) {
|
if (_failNextCommit) {
|
||||||
_failNextCommit = false;
|
_failNextCommit = false;
|
||||||
|
logger.error(() => 'In-memory unit of work commit failed by test hook. '
|
||||||
|
'operationId=${context.operationId} operationName=$operationName');
|
||||||
throw const ApplicationPersistenceException();
|
throw const ApplicationPersistenceException();
|
||||||
}
|
}
|
||||||
|
|
||||||
_state = staged;
|
_state = staged;
|
||||||
|
logger.debug(() => 'In-memory unit of work run committed. '
|
||||||
|
'operationId=${context.operationId} operationName=$operationName '
|
||||||
|
'taskCount=${_state.tasksById.length} projectCount=${_state.projectsById.length} '
|
||||||
|
'lockedBlockCount=${_state.lockedBlocksById.length} '
|
||||||
|
'snapshotCount=${_state.schedulingSnapshotsById.length} '
|
||||||
|
'operationCount=${_state.operationsById.length}');
|
||||||
return result;
|
return result;
|
||||||
} on ApplicationFailureException catch (error) {
|
} on ApplicationFailureException catch (error) {
|
||||||
|
logger.warn(() => 'In-memory unit of work caught application failure. '
|
||||||
|
'operationId=${context.operationId} operationName=$operationName '
|
||||||
|
'code=${error.failure.code.name} detailCode=${error.failure.detailCode}');
|
||||||
return ApplicationResult.failure(error.failure);
|
return ApplicationResult.failure(error.failure);
|
||||||
} on DomainValidationException catch (error) {
|
} on DomainValidationException catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
() => 'In-memory unit of work caught domain validation failure. '
|
||||||
|
'operationId=${context.operationId} operationName=$operationName '
|
||||||
|
'code=${error.code.name}');
|
||||||
return ApplicationResult.failure(
|
return ApplicationResult.failure(
|
||||||
ApplicationFailure(
|
ApplicationFailure(
|
||||||
code: ApplicationFailureCode.validation,
|
code: ApplicationFailureCode.validation,
|
||||||
|
|
@ -237,19 +261,35 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} on ArgumentError catch (error) {
|
} on ArgumentError catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
() => 'In-memory unit of work caught argument validation failure. '
|
||||||
|
'operationId=${context.operationId} operationName=$operationName '
|
||||||
|
'argumentName=${error.name}');
|
||||||
return ApplicationResult.failure(
|
return ApplicationResult.failure(
|
||||||
ApplicationFailure(
|
ApplicationFailure(
|
||||||
code: ApplicationFailureCode.validation,
|
code: ApplicationFailureCode.validation,
|
||||||
detailCode: error.name,
|
detailCode: error.name,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} on ApplicationPersistenceException {
|
} on ApplicationPersistenceException catch (error, stackTrace) {
|
||||||
|
logger.error(
|
||||||
|
() => 'In-memory unit of work persistence failure. '
|
||||||
|
'operationId=${context.operationId} operationName=$operationName',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
return ApplicationResult.failure(
|
return ApplicationResult.failure(
|
||||||
const ApplicationFailure(
|
const ApplicationFailure(
|
||||||
code: ApplicationFailureCode.persistenceFailure,
|
code: ApplicationFailureCode.persistenceFailure,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (error, stackTrace) {
|
||||||
|
logger.error(
|
||||||
|
() => 'In-memory unit of work unexpected failure. '
|
||||||
|
'operationId=${context.operationId} operationName=$operationName',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
return ApplicationResult.failure(
|
return ApplicationResult.failure(
|
||||||
const ApplicationFailure(code: ApplicationFailureCode.unexpected),
|
const ApplicationFailure(code: ApplicationFailureCode.unexpected),
|
||||||
);
|
);
|
||||||
|
|
@ -264,14 +304,34 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
|
||||||
ApplicationUnitOfWorkRepositories repositories,
|
ApplicationUnitOfWorkRepositories repositories,
|
||||||
) action,
|
) action,
|
||||||
}) async {
|
}) async {
|
||||||
|
logger.debug(() => 'In-memory unit of work read started. '
|
||||||
|
'taskCount=${_state.tasksById.length} projectCount=${_state.projectsById.length} '
|
||||||
|
'lockedBlockCount=${_state.lockedBlocksById.length} '
|
||||||
|
'snapshotCount=${_state.schedulingSnapshotsById.length}');
|
||||||
final staged = _state.copy();
|
final staged = _state.copy();
|
||||||
final repositories = _InMemoryApplicationRepositories(staged);
|
final repositories = _InMemoryApplicationRepositories(staged);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await action(repositories);
|
final result = await action(repositories);
|
||||||
|
if (result.isFailure) {
|
||||||
|
logger.warn(() => 'In-memory unit of work read returned failure. '
|
||||||
|
'code=${result.failure!.code.name} detailCode=${result.failure!.detailCode}');
|
||||||
|
} else {
|
||||||
|
logger.debug(() => 'In-memory unit of work read finished. '
|
||||||
|
'taskCount=${staged.tasksById.length} projectCount=${staged.projectsById.length} '
|
||||||
|
'lockedBlockCount=${staged.lockedBlocksById.length} '
|
||||||
|
'snapshotCount=${staged.schedulingSnapshotsById.length}');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
} on ApplicationFailureException catch (error) {
|
} on ApplicationFailureException catch (error) {
|
||||||
|
logger.warn(() =>
|
||||||
|
'In-memory unit of work read caught application failure. '
|
||||||
|
'code=${error.failure.code.name} detailCode=${error.failure.detailCode}');
|
||||||
return ApplicationResult.failure(error.failure);
|
return ApplicationResult.failure(error.failure);
|
||||||
} on DomainValidationException catch (error) {
|
} on DomainValidationException catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
() => 'In-memory unit of work read caught domain validation failure. '
|
||||||
|
'code=${error.code.name}');
|
||||||
return ApplicationResult.failure(
|
return ApplicationResult.failure(
|
||||||
ApplicationFailure(
|
ApplicationFailure(
|
||||||
code: ApplicationFailureCode.validation,
|
code: ApplicationFailureCode.validation,
|
||||||
|
|
@ -279,19 +339,32 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} on ArgumentError catch (error) {
|
} on ArgumentError catch (error) {
|
||||||
|
logger.warn(() =>
|
||||||
|
'In-memory unit of work read caught argument validation failure. '
|
||||||
|
'argumentName=${error.name}');
|
||||||
return ApplicationResult.failure(
|
return ApplicationResult.failure(
|
||||||
ApplicationFailure(
|
ApplicationFailure(
|
||||||
code: ApplicationFailureCode.validation,
|
code: ApplicationFailureCode.validation,
|
||||||
detailCode: error.name,
|
detailCode: error.name,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} on ApplicationPersistenceException {
|
} on ApplicationPersistenceException catch (error, stackTrace) {
|
||||||
|
logger.error(
|
||||||
|
() => 'In-memory unit of work read persistence failure.',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
return ApplicationResult.failure(
|
return ApplicationResult.failure(
|
||||||
const ApplicationFailure(
|
const ApplicationFailure(
|
||||||
code: ApplicationFailureCode.persistenceFailure,
|
code: ApplicationFailureCode.persistenceFailure,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (error, stackTrace) {
|
||||||
|
logger.error(
|
||||||
|
() => 'In-memory unit of work read unexpected failure.',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
return ApplicationResult.failure(
|
return ApplicationResult.failure(
|
||||||
const ApplicationFailure(code: ApplicationFailureCode.unexpected),
|
const ApplicationFailure(code: ApplicationFailureCode.unexpected),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,38 @@ class _UnitOfWorkTaskRepository implements TaskRepository {
|
||||||
return RepositoryMutationResult.success(revision: nextRevision);
|
return RepositoryMutationResult.success(revision: nextRevision);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs the `deleteIfRevision` 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.
|
||||||
|
@override
|
||||||
|
Future<RepositoryMutationResult> deleteIfRevision({
|
||||||
|
required String taskId,
|
||||||
|
required String ownerId,
|
||||||
|
required int expectedRevision,
|
||||||
|
}) async {
|
||||||
|
if (!_tasksById.containsKey(taskId) || _ownerFor(taskId) != ownerId) {
|
||||||
|
return RepositoryMutationResult.failure(
|
||||||
|
RepositoryFailure(
|
||||||
|
code: RepositoryFailureCode.notFound, entityId: taskId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final actualRevision = _revisionFor(taskId);
|
||||||
|
if (actualRevision != expectedRevision) {
|
||||||
|
return RepositoryMutationResult.failure(
|
||||||
|
RepositoryFailure(
|
||||||
|
code: RepositoryFailureCode.staleRevision,
|
||||||
|
entityId: taskId,
|
||||||
|
expectedRevision: expectedRevision,
|
||||||
|
actualRevision: actualRevision,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final nextRevision = actualRevision + 1;
|
||||||
|
_tasksById.remove(taskId);
|
||||||
|
_ownersById.remove(taskId);
|
||||||
|
_revisionsById.remove(taskId);
|
||||||
|
return RepositoryMutationResult.success(revision: nextRevision);
|
||||||
|
}
|
||||||
|
|
||||||
/// Runs the `_ownerFor` helper used inside this library.
|
/// Runs the `_ownerFor` helper used inside this library.
|
||||||
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||||
String _ownerFor(String id) => _ownersById[id] ?? 'owner-1';
|
String _ownerFor(String id) => _ownersById[id] ?? 'owner-1';
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import '../domain/locked_time.dart';
|
||||||
import '../domain/models.dart';
|
import '../domain/models.dart';
|
||||||
import '../domain/project_statistics.dart';
|
import '../domain/project_statistics.dart';
|
||||||
import '../domain/time_contracts.dart';
|
import '../domain/time_contracts.dart';
|
||||||
|
import '../logging/focus_flow_logger.dart';
|
||||||
part 'application_management/codes/application_management_command_code.dart';
|
part 'application_management/codes/application_management_command_code.dart';
|
||||||
part 'application_management/codes/owner_settings_warning_code.dart';
|
part 'application_management/codes/owner_settings_warning_code.dart';
|
||||||
part 'application_management/requests/get_backlog_request.dart';
|
part 'application_management/requests/get_backlog_request.dart';
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ class V1ApplicationManagementUseCases {
|
||||||
return applicationStore.read(
|
return applicationStore.read(
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
final ownerId = request.context.ownerTimeZone.ownerId;
|
final ownerId = request.context.ownerTimeZone.ownerId;
|
||||||
|
logger.debug(() => 'Loading backlog query. ownerId=$ownerId '
|
||||||
|
'sortKey=${request.sortKey.name} hasFilter=${request.filter != null}');
|
||||||
final settings = await _ownerSettingsOrDefault(
|
final settings = await _ownerSettingsOrDefault(
|
||||||
repositories,
|
repositories,
|
||||||
request.context.ownerTimeZone,
|
request.context.ownerTimeZone,
|
||||||
|
|
@ -46,6 +48,9 @@ class V1ApplicationManagementUseCases {
|
||||||
stalenessSettings: settings.backlogStalenessSettings,
|
stalenessSettings: settings.backlogStalenessSettings,
|
||||||
);
|
);
|
||||||
final sorted = view.sorted(request.sortKey);
|
final sorted = view.sorted(request.sortKey);
|
||||||
|
logger.debug(() => 'Backlog query loaded. ownerId=$ownerId '
|
||||||
|
'candidateCount=${candidates.length} filteredCount=${filtered.length} '
|
||||||
|
'itemCount=${sorted.length}');
|
||||||
|
|
||||||
return ApplicationResult.success(
|
return ApplicationResult.success(
|
||||||
BacklogQueryResult(
|
BacklogQueryResult(
|
||||||
|
|
@ -70,6 +75,9 @@ class V1ApplicationManagementUseCases {
|
||||||
) {
|
) {
|
||||||
return applicationStore.read(
|
return applicationStore.read(
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
|
logger.debug(() =>
|
||||||
|
'Loading project defaults. ownerId=${request.context.ownerTimeZone.ownerId} '
|
||||||
|
'includeArchived=${request.includeArchived}');
|
||||||
final projects = (await repositories.projects.findByOwner(
|
final projects = (await repositories.projects.findByOwner(
|
||||||
ownerId: request.context.ownerTimeZone.ownerId,
|
ownerId: request.context.ownerTimeZone.ownerId,
|
||||||
includeArchived: request.includeArchived,
|
includeArchived: request.includeArchived,
|
||||||
|
|
@ -94,6 +102,10 @@ class V1ApplicationManagementUseCases {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.debug(() =>
|
||||||
|
'Project defaults loaded. ownerId=${request.context.ownerTimeZone.ownerId} '
|
||||||
|
'projectCount=${projects.length} resolutionCount=${resolutions.length}');
|
||||||
|
|
||||||
return ApplicationResult.success(
|
return ApplicationResult.success(
|
||||||
ProjectDefaultsQueryResult(
|
ProjectDefaultsQueryResult(
|
||||||
ownerId: request.context.ownerTimeZone.ownerId,
|
ownerId: request.context.ownerTimeZone.ownerId,
|
||||||
|
|
@ -115,8 +127,14 @@ class V1ApplicationManagementUseCases {
|
||||||
operationName: ApplicationManagementCommandCode.createProject.name,
|
operationName: ApplicationManagementCommandCode.createProject.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
final id = draft.id ?? context.idGenerator.nextId();
|
final id = draft.id ?? context.idGenerator.nextId();
|
||||||
|
logger.debug(() =>
|
||||||
|
'Management command executing. command=${ApplicationManagementCommandCode.createProject.name} '
|
||||||
|
'operationId=${context.operationId} projectId=$id hasName=${draft.name.trim().isNotEmpty} '
|
||||||
|
'defaultDurationMinutes=${draft.defaultDurationMinutes}');
|
||||||
final existing = await repositories.projects.findById(id);
|
final existing = await repositories.projects.findById(id);
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
|
logger.warn(() => 'Create project failed: project already exists. '
|
||||||
|
'operationId=${context.operationId} projectId=$id');
|
||||||
return _failure(
|
return _failure(
|
||||||
ApplicationFailureCode.conflict,
|
ApplicationFailureCode.conflict,
|
||||||
entityId: id,
|
entityId: id,
|
||||||
|
|
@ -134,6 +152,8 @@ class V1ApplicationManagementUseCases {
|
||||||
defaultDurationMinutes: draft.defaultDurationMinutes,
|
defaultDurationMinutes: draft.defaultDurationMinutes,
|
||||||
);
|
);
|
||||||
await repositories.projects.save(project);
|
await repositories.projects.save(project);
|
||||||
|
logger.debug(() =>
|
||||||
|
'Project created. operationId=${context.operationId} projectId=$id');
|
||||||
return ApplicationResult.success(project);
|
return ApplicationResult.success(project);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -150,6 +170,10 @@ class V1ApplicationManagementUseCases {
|
||||||
context: context,
|
context: context,
|
||||||
operationName: ApplicationManagementCommandCode.updateProject.name,
|
operationName: ApplicationManagementCommandCode.updateProject.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
|
logger.debug(() =>
|
||||||
|
'Management command executing. command=${ApplicationManagementCommandCode.updateProject.name} '
|
||||||
|
'operationId=${context.operationId} projectId=$projectId '
|
||||||
|
'hasName=${update.name != null} hasColorKey=${update.colorKey != null}');
|
||||||
final project = await _requireProject(repositories, projectId);
|
final project = await _requireProject(repositories, projectId);
|
||||||
final updated = project.copyWith(
|
final updated = project.copyWith(
|
||||||
name: update.name,
|
name: update.name,
|
||||||
|
|
@ -162,6 +186,8 @@ class V1ApplicationManagementUseCases {
|
||||||
clearDefaultDuration: update.clearDefaultDuration,
|
clearDefaultDuration: update.clearDefaultDuration,
|
||||||
);
|
);
|
||||||
await repositories.projects.save(updated);
|
await repositories.projects.save(updated);
|
||||||
|
logger.debug(() =>
|
||||||
|
'Project updated. operationId=${context.operationId} projectId=$projectId');
|
||||||
return ApplicationResult.success(updated);
|
return ApplicationResult.success(updated);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -177,9 +203,14 @@ class V1ApplicationManagementUseCases {
|
||||||
context: context,
|
context: context,
|
||||||
operationName: ApplicationManagementCommandCode.archiveProject.name,
|
operationName: ApplicationManagementCommandCode.archiveProject.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
|
logger.debug(() =>
|
||||||
|
'Management command executing. command=${ApplicationManagementCommandCode.archiveProject.name} '
|
||||||
|
'operationId=${context.operationId} projectId=$projectId');
|
||||||
final project = await _requireProject(repositories, projectId);
|
final project = await _requireProject(repositories, projectId);
|
||||||
final archived = project.copyWith(archivedAt: context.now);
|
final archived = project.copyWith(archivedAt: context.now);
|
||||||
await repositories.projects.save(archived);
|
await repositories.projects.save(archived);
|
||||||
|
logger.debug(() =>
|
||||||
|
'Project archived. operationId=${context.operationId} projectId=$projectId');
|
||||||
return ApplicationResult.success(archived);
|
return ApplicationResult.success(archived);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -196,8 +227,14 @@ class V1ApplicationManagementUseCases {
|
||||||
operationName: ApplicationManagementCommandCode.createLockedBlock.name,
|
operationName: ApplicationManagementCommandCode.createLockedBlock.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
final id = draft.id ?? context.idGenerator.nextId();
|
final id = draft.id ?? context.idGenerator.nextId();
|
||||||
|
logger.debug(() =>
|
||||||
|
'Management command executing. command=${ApplicationManagementCommandCode.createLockedBlock.name} '
|
||||||
|
'operationId=${context.operationId} lockedBlockId=$id date=${draft.date?.toIsoString()} '
|
||||||
|
'hasRecurrence=${draft.recurrence != null} hiddenByDefault=${draft.hiddenByDefault}');
|
||||||
final existing = await repositories.lockedBlocks.findBlockById(id);
|
final existing = await repositories.lockedBlocks.findBlockById(id);
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
|
logger.warn(() => 'Create locked block failed: block already exists. '
|
||||||
|
'operationId=${context.operationId} lockedBlockId=$id');
|
||||||
return _failure(
|
return _failure(
|
||||||
ApplicationFailureCode.conflict,
|
ApplicationFailureCode.conflict,
|
||||||
entityId: id,
|
entityId: id,
|
||||||
|
|
@ -217,6 +254,8 @@ class V1ApplicationManagementUseCases {
|
||||||
updatedAt: context.now,
|
updatedAt: context.now,
|
||||||
);
|
);
|
||||||
await repositories.lockedBlocks.saveBlock(block);
|
await repositories.lockedBlocks.saveBlock(block);
|
||||||
|
logger.debug(() =>
|
||||||
|
'Locked block created. operationId=${context.operationId} lockedBlockId=$id');
|
||||||
return ApplicationResult.success(block);
|
return ApplicationResult.success(block);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -233,6 +272,11 @@ class V1ApplicationManagementUseCases {
|
||||||
context: context,
|
context: context,
|
||||||
operationName: ApplicationManagementCommandCode.updateLockedBlock.name,
|
operationName: ApplicationManagementCommandCode.updateLockedBlock.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
|
logger.debug(() =>
|
||||||
|
'Management command executing. command=${ApplicationManagementCommandCode.updateLockedBlock.name} '
|
||||||
|
'operationId=${context.operationId} lockedBlockId=$lockedBlockId '
|
||||||
|
'hasName=${update.name != null} hasDate=${update.date != null} '
|
||||||
|
'hasRecurrence=${update.recurrence != null}');
|
||||||
final block = await _requireLockedBlock(repositories, lockedBlockId);
|
final block = await _requireLockedBlock(repositories, lockedBlockId);
|
||||||
final updated = block.copyWith(
|
final updated = block.copyWith(
|
||||||
name: update.name,
|
name: update.name,
|
||||||
|
|
@ -245,6 +289,8 @@ class V1ApplicationManagementUseCases {
|
||||||
updatedAt: context.now,
|
updatedAt: context.now,
|
||||||
);
|
);
|
||||||
await repositories.lockedBlocks.saveBlock(updated);
|
await repositories.lockedBlocks.saveBlock(updated);
|
||||||
|
logger.debug(() =>
|
||||||
|
'Locked block updated. operationId=${context.operationId} lockedBlockId=$lockedBlockId');
|
||||||
return ApplicationResult.success(updated);
|
return ApplicationResult.success(updated);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -260,12 +306,17 @@ class V1ApplicationManagementUseCases {
|
||||||
context: context,
|
context: context,
|
||||||
operationName: ApplicationManagementCommandCode.archiveLockedBlock.name,
|
operationName: ApplicationManagementCommandCode.archiveLockedBlock.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
|
logger.debug(() =>
|
||||||
|
'Management command executing. command=${ApplicationManagementCommandCode.archiveLockedBlock.name} '
|
||||||
|
'operationId=${context.operationId} lockedBlockId=$lockedBlockId');
|
||||||
final block = await _requireLockedBlock(repositories, lockedBlockId);
|
final block = await _requireLockedBlock(repositories, lockedBlockId);
|
||||||
final archived = block.copyWith(
|
final archived = block.copyWith(
|
||||||
updatedAt: context.now,
|
updatedAt: context.now,
|
||||||
archivedAt: context.now,
|
archivedAt: context.now,
|
||||||
);
|
);
|
||||||
await repositories.lockedBlocks.saveBlock(archived);
|
await repositories.lockedBlocks.saveBlock(archived);
|
||||||
|
logger.debug(() =>
|
||||||
|
'Locked block archived. operationId=${context.operationId} lockedBlockId=$lockedBlockId');
|
||||||
return ApplicationResult.success(archived);
|
return ApplicationResult.success(archived);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -287,6 +338,10 @@ class V1ApplicationManagementUseCases {
|
||||||
operationName:
|
operationName:
|
||||||
ApplicationManagementCommandCode.addLockedBlockOverride.name,
|
ApplicationManagementCommandCode.addLockedBlockOverride.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
|
logger.debug(() =>
|
||||||
|
'Management command executing. command=${ApplicationManagementCommandCode.addLockedBlockOverride.name} '
|
||||||
|
'operationId=${context.operationId} date=${date.toIsoString()} hiddenByDefault=$hiddenByDefault '
|
||||||
|
'hasProjectId=${projectId != null}');
|
||||||
final override = LockedBlockOverride.add(
|
final override = LockedBlockOverride.add(
|
||||||
id: context.idGenerator.nextId(),
|
id: context.idGenerator.nextId(),
|
||||||
date: date,
|
date: date,
|
||||||
|
|
@ -298,6 +353,8 @@ class V1ApplicationManagementUseCases {
|
||||||
createdAt: context.now,
|
createdAt: context.now,
|
||||||
);
|
);
|
||||||
await repositories.lockedBlocks.saveOverride(override);
|
await repositories.lockedBlocks.saveOverride(override);
|
||||||
|
logger.debug(() =>
|
||||||
|
'Locked block override added. operationId=${context.operationId} overrideId=${override.id}');
|
||||||
return ApplicationResult.success(override);
|
return ApplicationResult.success(override);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -315,6 +372,9 @@ class V1ApplicationManagementUseCases {
|
||||||
operationName:
|
operationName:
|
||||||
ApplicationManagementCommandCode.removeLockedBlockOccurrence.name,
|
ApplicationManagementCommandCode.removeLockedBlockOccurrence.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
|
logger.debug(() =>
|
||||||
|
'Management command executing. command=${ApplicationManagementCommandCode.removeLockedBlockOccurrence.name} '
|
||||||
|
'operationId=${context.operationId} lockedBlockId=$lockedBlockId date=${date.toIsoString()}');
|
||||||
await _requireLockedBlock(repositories, lockedBlockId);
|
await _requireLockedBlock(repositories, lockedBlockId);
|
||||||
final override = LockedBlockOverride.remove(
|
final override = LockedBlockOverride.remove(
|
||||||
id: context.idGenerator.nextId(),
|
id: context.idGenerator.nextId(),
|
||||||
|
|
@ -323,6 +383,9 @@ class V1ApplicationManagementUseCases {
|
||||||
createdAt: context.now,
|
createdAt: context.now,
|
||||||
);
|
);
|
||||||
await repositories.lockedBlocks.saveOverride(override);
|
await repositories.lockedBlocks.saveOverride(override);
|
||||||
|
logger.debug(() =>
|
||||||
|
'Locked block occurrence removed. operationId=${context.operationId} '
|
||||||
|
'lockedBlockId=$lockedBlockId overrideId=${override.id}');
|
||||||
return ApplicationResult.success(override);
|
return ApplicationResult.success(override);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -345,6 +408,10 @@ class V1ApplicationManagementUseCases {
|
||||||
operationName:
|
operationName:
|
||||||
ApplicationManagementCommandCode.replaceLockedBlockOccurrence.name,
|
ApplicationManagementCommandCode.replaceLockedBlockOccurrence.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
|
logger.debug(() =>
|
||||||
|
'Management command executing. command=${ApplicationManagementCommandCode.replaceLockedBlockOccurrence.name} '
|
||||||
|
'operationId=${context.operationId} lockedBlockId=$lockedBlockId date=${date.toIsoString()} '
|
||||||
|
'hasName=${name != null} hiddenByDefault=$hiddenByDefault');
|
||||||
await _requireLockedBlock(repositories, lockedBlockId);
|
await _requireLockedBlock(repositories, lockedBlockId);
|
||||||
final override = LockedBlockOverride.replace(
|
final override = LockedBlockOverride.replace(
|
||||||
id: context.idGenerator.nextId(),
|
id: context.idGenerator.nextId(),
|
||||||
|
|
@ -358,6 +425,9 @@ class V1ApplicationManagementUseCases {
|
||||||
createdAt: context.now,
|
createdAt: context.now,
|
||||||
);
|
);
|
||||||
await repositories.lockedBlocks.saveOverride(override);
|
await repositories.lockedBlocks.saveOverride(override);
|
||||||
|
logger.debug(() =>
|
||||||
|
'Locked block occurrence replaced. operationId=${context.operationId} '
|
||||||
|
'lockedBlockId=$lockedBlockId overrideId=${override.id}');
|
||||||
return ApplicationResult.success(override);
|
return ApplicationResult.success(override);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -373,6 +443,11 @@ class V1ApplicationManagementUseCases {
|
||||||
context: context,
|
context: context,
|
||||||
operationName: ApplicationManagementCommandCode.updateOwnerSettings.name,
|
operationName: ApplicationManagementCommandCode.updateOwnerSettings.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
|
logger.debug(() =>
|
||||||
|
'Management command executing. command=${ApplicationManagementCommandCode.updateOwnerSettings.name} '
|
||||||
|
'operationId=${context.operationId} ownerId=${context.ownerTimeZone.ownerId} '
|
||||||
|
'hasTimeZone=${update.timeZoneId != null} hasDayStart=${update.dayStart != null} '
|
||||||
|
'hasDayEnd=${update.dayEnd != null} hasStaleness=${update.backlogStalenessSettings != null}');
|
||||||
final current = await _ownerSettingsOrDefault(
|
final current = await _ownerSettingsOrDefault(
|
||||||
repositories,
|
repositories,
|
||||||
context.ownerTimeZone,
|
context.ownerTimeZone,
|
||||||
|
|
@ -382,6 +457,9 @@ class V1ApplicationManagementUseCases {
|
||||||
(stalenessSettings.greenMaxAge.isNegative ||
|
(stalenessSettings.greenMaxAge.isNegative ||
|
||||||
stalenessSettings.blueMaxAge.isNegative ||
|
stalenessSettings.blueMaxAge.isNegative ||
|
||||||
stalenessSettings.blueMaxAge < stalenessSettings.greenMaxAge)) {
|
stalenessSettings.blueMaxAge < stalenessSettings.greenMaxAge)) {
|
||||||
|
logger.warn(() =>
|
||||||
|
'Owner settings update failed: invalid staleness thresholds. '
|
||||||
|
'operationId=${context.operationId} ownerId=${context.ownerTimeZone.ownerId}');
|
||||||
return _failure(
|
return _failure(
|
||||||
ApplicationFailureCode.validation,
|
ApplicationFailureCode.validation,
|
||||||
detailCode: 'invalidBacklogStalenessThresholds',
|
detailCode: 'invalidBacklogStalenessThresholds',
|
||||||
|
|
@ -404,6 +482,9 @@ class V1ApplicationManagementUseCases {
|
||||||
OwnerSettingsWarningCode.planningWindowChanged,
|
OwnerSettingsWarningCode.planningWindowChanged,
|
||||||
];
|
];
|
||||||
await repositories.ownerSettings.save(updated);
|
await repositories.ownerSettings.save(updated);
|
||||||
|
logger.debug(() =>
|
||||||
|
'Owner settings updated. operationId=${context.operationId} '
|
||||||
|
'ownerId=${context.ownerTimeZone.ownerId} warningCount=${warnings.length}');
|
||||||
return ApplicationResult.success(
|
return ApplicationResult.success(
|
||||||
OwnerSettingsUpdateResult(settings: updated, warnings: warnings),
|
OwnerSettingsUpdateResult(settings: updated, warnings: warnings),
|
||||||
);
|
);
|
||||||
|
|
@ -422,8 +503,13 @@ class V1ApplicationManagementUseCases {
|
||||||
operationName: ApplicationManagementCommandCode.acknowledgeNotice.name,
|
operationName: ApplicationManagementCommandCode.acknowledgeNotice.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
final ownerId = context.ownerTimeZone.ownerId;
|
final ownerId = context.ownerTimeZone.ownerId;
|
||||||
|
logger.debug(() =>
|
||||||
|
'Management command executing. command=${ApplicationManagementCommandCode.acknowledgeNotice.name} '
|
||||||
|
'operationId=${context.operationId} ownerId=$ownerId noticeId=$noticeId');
|
||||||
final parsed = _parseNoticeId(noticeId);
|
final parsed = _parseNoticeId(noticeId);
|
||||||
if (parsed == null) {
|
if (parsed == null) {
|
||||||
|
logger.warn(() => 'Notice acknowledgement failed: invalid notice id. '
|
||||||
|
'operationId=${context.operationId} noticeId=$noticeId');
|
||||||
return _failure(
|
return _failure(
|
||||||
ApplicationFailureCode.validation,
|
ApplicationFailureCode.validation,
|
||||||
entityId: noticeId,
|
entityId: noticeId,
|
||||||
|
|
@ -433,6 +519,9 @@ class V1ApplicationManagementUseCases {
|
||||||
final snapshot =
|
final snapshot =
|
||||||
await repositories.schedulingSnapshots.findById(parsed.snapshotId);
|
await repositories.schedulingSnapshots.findById(parsed.snapshotId);
|
||||||
if (snapshot == null || parsed.noticeIndex >= snapshot.notices.length) {
|
if (snapshot == null || parsed.noticeIndex >= snapshot.notices.length) {
|
||||||
|
logger.warn(() => 'Notice acknowledgement failed: notice not found. '
|
||||||
|
'operationId=${context.operationId} noticeId=$noticeId '
|
||||||
|
'snapshotId=${parsed.snapshotId} noticeIndex=${parsed.noticeIndex}');
|
||||||
return _failure(
|
return _failure(
|
||||||
ApplicationFailureCode.notFound,
|
ApplicationFailureCode.notFound,
|
||||||
entityId: noticeId,
|
entityId: noticeId,
|
||||||
|
|
@ -444,6 +533,9 @@ class V1ApplicationManagementUseCases {
|
||||||
noticeId: noticeId,
|
noticeId: noticeId,
|
||||||
);
|
);
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
|
logger.finer(() =>
|
||||||
|
'Notice already acknowledged. operationId=${context.operationId} '
|
||||||
|
'noticeId=$noticeId ownerId=$ownerId');
|
||||||
return ApplicationResult.success(
|
return ApplicationResult.success(
|
||||||
NoticeAcknowledgementResult(
|
NoticeAcknowledgementResult(
|
||||||
record: existing,
|
record: existing,
|
||||||
|
|
@ -458,6 +550,9 @@ class V1ApplicationManagementUseCases {
|
||||||
acknowledgedAt: context.now,
|
acknowledgedAt: context.now,
|
||||||
);
|
);
|
||||||
await repositories.noticeAcknowledgements.save(record);
|
await repositories.noticeAcknowledgements.save(record);
|
||||||
|
logger.debug(
|
||||||
|
() => 'Notice acknowledged. operationId=${context.operationId} '
|
||||||
|
'noticeId=$noticeId ownerId=$ownerId');
|
||||||
return ApplicationResult.success(
|
return ApplicationResult.success(
|
||||||
NoticeAcknowledgementResult(
|
NoticeAcknowledgementResult(
|
||||||
record: record,
|
record: record,
|
||||||
|
|
@ -476,6 +571,7 @@ class V1ApplicationManagementUseCases {
|
||||||
) async {
|
) async {
|
||||||
final project = await repositories.projects.findById(projectId);
|
final project = await repositories.projects.findById(projectId);
|
||||||
if (project == null) {
|
if (project == null) {
|
||||||
|
logger.warn(() => 'Required project not found. projectId=$projectId');
|
||||||
throw ApplicationFailureException(
|
throw ApplicationFailureException(
|
||||||
ApplicationFailure(
|
ApplicationFailure(
|
||||||
code: ApplicationFailureCode.notFound,
|
code: ApplicationFailureCode.notFound,
|
||||||
|
|
@ -495,6 +591,8 @@ class V1ApplicationManagementUseCases {
|
||||||
) async {
|
) async {
|
||||||
final block = await repositories.lockedBlocks.findBlockById(lockedBlockId);
|
final block = await repositories.lockedBlocks.findBlockById(lockedBlockId);
|
||||||
if (block == null) {
|
if (block == null) {
|
||||||
|
logger.warn(() =>
|
||||||
|
'Required locked block not found. lockedBlockId=$lockedBlockId');
|
||||||
throw ApplicationFailureException(
|
throw ApplicationFailureException(
|
||||||
ApplicationFailure(
|
ApplicationFailure(
|
||||||
code: ApplicationFailureCode.notFound,
|
code: ApplicationFailureCode.notFound,
|
||||||
|
|
@ -513,12 +611,17 @@ Future<OwnerSettings> _ownerSettingsOrDefault(
|
||||||
ApplicationUnitOfWorkRepositories repositories,
|
ApplicationUnitOfWorkRepositories repositories,
|
||||||
OwnerTimeZoneContext ownerTimeZone,
|
OwnerTimeZoneContext ownerTimeZone,
|
||||||
) async {
|
) async {
|
||||||
return await repositories.ownerSettings
|
final settings =
|
||||||
.findByOwnerId(ownerTimeZone.ownerId) ??
|
await repositories.ownerSettings.findByOwnerId(ownerTimeZone.ownerId);
|
||||||
OwnerSettings(
|
if (settings != null) {
|
||||||
ownerId: ownerTimeZone.ownerId,
|
return settings;
|
||||||
timeZoneId: ownerTimeZone.timeZoneId,
|
}
|
||||||
);
|
logger.finer(() => 'Owner settings missing; using defaults. '
|
||||||
|
'ownerId=${ownerTimeZone.ownerId} timeZoneId=${ownerTimeZone.timeZoneId}');
|
||||||
|
return OwnerSettings(
|
||||||
|
ownerId: ownerTimeZone.ownerId,
|
||||||
|
timeZoneId: ownerTimeZone.timeZoneId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Top-level helper that performs the `_failure<T>` operation for this file.
|
/// Top-level helper that performs the `_failure<T>` operation for this file.
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import '../persistence/repositories.dart';
|
||||||
import '../scheduling/scheduling_engine.dart';
|
import '../scheduling/scheduling_engine.dart';
|
||||||
import '../scheduling/task_lifecycle.dart';
|
import '../scheduling/task_lifecycle.dart';
|
||||||
import '../domain/time_contracts.dart';
|
import '../domain/time_contracts.dart';
|
||||||
|
import '../logging/focus_flow_logger.dart';
|
||||||
part 'application_recovery/app_open_recovery_outcome.dart';
|
part 'application_recovery/app_open_recovery_outcome.dart';
|
||||||
part 'application_recovery/run_app_open_recovery_request.dart';
|
part 'application_recovery/run_app_open_recovery_request.dart';
|
||||||
part 'application_recovery/app_open_recovery_result.dart';
|
part 'application_recovery/app_open_recovery_result.dart';
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,16 @@ class V1AppOpenRecoveryUseCases {
|
||||||
ownerId: ownerId,
|
ownerId: ownerId,
|
||||||
sourceDate: sourceDate,
|
sourceDate: sourceDate,
|
||||||
);
|
);
|
||||||
|
logger.info(() => 'App-open recovery started. ownerId=$ownerId '
|
||||||
|
'operationId=${context.operationId} sourceDate=${sourceDate.toIsoString()} '
|
||||||
|
'targetDate=${targetDate.toIsoString()} snapshotId=$snapshotId');
|
||||||
final existingSnapshot =
|
final existingSnapshot =
|
||||||
await repositories.schedulingSnapshots.findById(snapshotId);
|
await repositories.schedulingSnapshots.findById(snapshotId);
|
||||||
if (existingSnapshot != null) {
|
if (existingSnapshot != null) {
|
||||||
|
logger.info(() =>
|
||||||
|
'App-open recovery already processed. ownerId=$ownerId '
|
||||||
|
'operationId=${context.operationId} snapshotId=$snapshotId '
|
||||||
|
'noticeCount=${existingSnapshot.notices.length} changeCount=${existingSnapshot.changes.length}');
|
||||||
return ApplicationResult.success(
|
return ApplicationResult.success(
|
||||||
AppOpenRecoveryResult(
|
AppOpenRecoveryResult(
|
||||||
outcome: AppOpenRecoveryOutcome.alreadyProcessed,
|
outcome: AppOpenRecoveryOutcome.alreadyProcessed,
|
||||||
|
|
@ -63,8 +70,11 @@ class V1AppOpenRecoveryUseCases {
|
||||||
repositories,
|
repositories,
|
||||||
context.ownerTimeZone,
|
context.ownerTimeZone,
|
||||||
);
|
);
|
||||||
final sourceWindow = _windowForDate(sourceDate, settings);
|
final effectiveSettings = settings.copyWith(
|
||||||
final targetWindow = _windowForDate(targetDate, settings);
|
timeZoneId: context.ownerTimeZone.timeZoneId,
|
||||||
|
);
|
||||||
|
final sourceWindow = _windowForDate(sourceDate, effectiveSettings);
|
||||||
|
final targetWindow = _windowForDate(targetDate, effectiveSettings);
|
||||||
final tasks = await _loadSourceAndTargetTasks(
|
final tasks = await _loadSourceAndTargetTasks(
|
||||||
repositories,
|
repositories,
|
||||||
sourceWindow: sourceWindow,
|
sourceWindow: sourceWindow,
|
||||||
|
|
@ -83,10 +93,14 @@ class V1AppOpenRecoveryUseCases {
|
||||||
blocks: blocks,
|
blocks: blocks,
|
||||||
overrides: overrides,
|
overrides: overrides,
|
||||||
date: targetDate,
|
date: targetDate,
|
||||||
timeZoneId: settings.timeZoneId,
|
timeZoneId: effectiveSettings.timeZoneId,
|
||||||
timeZoneResolver: timeZoneResolver,
|
timeZoneResolver: timeZoneResolver,
|
||||||
resolutionOptions: resolutionOptions,
|
resolutionOptions: resolutionOptions,
|
||||||
);
|
);
|
||||||
|
logger.debug(() => 'App-open recovery loaded source and target state. '
|
||||||
|
'ownerId=$ownerId taskCount=${tasks.length} blockCount=${blocks.length} '
|
||||||
|
'overrideCount=${overrides.length} '
|
||||||
|
'lockedIntervalCount=${lockedExpansion.schedulingIntervals.length}');
|
||||||
final input = SchedulingInput(
|
final input = SchedulingInput(
|
||||||
tasks: tasks,
|
tasks: tasks,
|
||||||
window: targetWindow,
|
window: targetWindow,
|
||||||
|
|
@ -101,6 +115,10 @@ class V1AppOpenRecoveryUseCases {
|
||||||
|
|
||||||
if (schedulingResult.outcomeCode == SchedulingOutcomeCode.overflow ||
|
if (schedulingResult.outcomeCode == SchedulingOutcomeCode.overflow ||
|
||||||
schedulingResult.outcomeCode == SchedulingOutcomeCode.noSlot) {
|
schedulingResult.outcomeCode == SchedulingOutcomeCode.noSlot) {
|
||||||
|
logger.warn(() => 'App-open recovery could not place rollover tasks. '
|
||||||
|
'ownerId=$ownerId operationId=${context.operationId} '
|
||||||
|
'snapshotId=$snapshotId outcome=${schedulingResult.outcomeCode.name} '
|
||||||
|
'noticeCount=${schedulingResult.notices.length}');
|
||||||
return ApplicationResult.failure(
|
return ApplicationResult.failure(
|
||||||
ApplicationFailure(
|
ApplicationFailure(
|
||||||
code: ApplicationFailureCode.noSlot,
|
code: ApplicationFailureCode.noSlot,
|
||||||
|
|
@ -135,9 +153,16 @@ class V1AppOpenRecoveryUseCases {
|
||||||
operationName: 'appOpenRecovery',
|
operationName: 'appOpenRecovery',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
logger.debug(() => 'Persisting app-open recovery. ownerId=$ownerId '
|
||||||
|
'operationId=${context.operationId} snapshotId=$snapshotId '
|
||||||
|
'changedTaskCount=${changedTasks.length} activityCount=${activities.length} '
|
||||||
|
'noticeCount=${snapshot.notices.length} changeCount=${schedulingResult.changes.length}');
|
||||||
await repositories.tasks.saveAll(changedTasks);
|
await repositories.tasks.saveAll(changedTasks);
|
||||||
await repositories.taskActivities.saveAll(activities);
|
await repositories.taskActivities.saveAll(activities);
|
||||||
await repositories.schedulingSnapshots.save(snapshot);
|
await repositories.schedulingSnapshots.save(snapshot);
|
||||||
|
logger.info(() => 'App-open recovery completed. ownerId=$ownerId '
|
||||||
|
'operationId=${context.operationId} outcome=${rolloverNotice == null ? AppOpenRecoveryOutcome.noUnfinishedFlexibleTasks.name : AppOpenRecoveryOutcome.rolledOver.name} '
|
||||||
|
'changedTaskCount=${changedTasks.length} activityCount=${activities.length}');
|
||||||
|
|
||||||
return ApplicationResult.success(
|
return ApplicationResult.success(
|
||||||
AppOpenRecoveryResult(
|
AppOpenRecoveryResult(
|
||||||
|
|
@ -198,12 +223,17 @@ Future<OwnerSettings> _ownerSettingsOrDefault(
|
||||||
ApplicationUnitOfWorkRepositories repositories,
|
ApplicationUnitOfWorkRepositories repositories,
|
||||||
OwnerTimeZoneContext ownerTimeZone,
|
OwnerTimeZoneContext ownerTimeZone,
|
||||||
) async {
|
) async {
|
||||||
return await repositories.ownerSettings
|
final settings =
|
||||||
.findByOwnerId(ownerTimeZone.ownerId) ??
|
await repositories.ownerSettings.findByOwnerId(ownerTimeZone.ownerId);
|
||||||
OwnerSettings(
|
if (settings != null) {
|
||||||
ownerId: ownerTimeZone.ownerId,
|
return settings;
|
||||||
timeZoneId: ownerTimeZone.timeZoneId,
|
}
|
||||||
);
|
logger.finer(() => 'Owner settings missing during recovery; using defaults. '
|
||||||
|
'ownerId=${ownerTimeZone.ownerId} timeZoneId=${ownerTimeZone.timeZoneId}');
|
||||||
|
return OwnerSettings(
|
||||||
|
ownerId: ownerTimeZone.ownerId,
|
||||||
|
timeZoneId: ownerTimeZone.timeZoneId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Top-level helper that performs the `_loadSourceAndTargetTasks` operation for this file.
|
/// Top-level helper that performs the `_loadSourceAndTargetTasks` operation for this file.
|
||||||
|
|
@ -213,6 +243,11 @@ Future<List<Task>> _loadSourceAndTargetTasks(
|
||||||
required SchedulingWindow sourceWindow,
|
required SchedulingWindow sourceWindow,
|
||||||
required SchedulingWindow targetWindow,
|
required SchedulingWindow targetWindow,
|
||||||
}) async {
|
}) async {
|
||||||
|
logger.debug(() => 'Loading recovery source and target tasks. '
|
||||||
|
'sourceStart=${sourceWindow.start.toIso8601String()} '
|
||||||
|
'sourceEnd=${sourceWindow.end.toIso8601String()} '
|
||||||
|
'targetStart=${targetWindow.start.toIso8601String()} '
|
||||||
|
'targetEnd=${targetWindow.end.toIso8601String()}');
|
||||||
final byId = <String, Task>{};
|
final byId = <String, Task>{};
|
||||||
for (final task in await repositories.tasks.findScheduledInWindow(
|
for (final task in await repositories.tasks.findScheduledInWindow(
|
||||||
sourceWindow,
|
sourceWindow,
|
||||||
|
|
@ -224,6 +259,8 @@ Future<List<Task>> _loadSourceAndTargetTasks(
|
||||||
)) {
|
)) {
|
||||||
byId[task.id] = task;
|
byId[task.id] = task;
|
||||||
}
|
}
|
||||||
|
logger.debug(() =>
|
||||||
|
'Recovery source and target tasks loaded. taskCount=${byId.length}');
|
||||||
return List<Task>.unmodifiable(byId.values);
|
return List<Task>.unmodifiable(byId.values);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -249,8 +286,12 @@ SchedulingNotice? _rolloverNotice({
|
||||||
.map((change) => change.taskId)
|
.map((change) => change.taskId)
|
||||||
.toSet();
|
.toSet();
|
||||||
if (rolledTaskIds.isEmpty) {
|
if (rolledTaskIds.isEmpty) {
|
||||||
|
logger.finer(
|
||||||
|
() => 'No rollover notice needed; no tasks moved from source day.');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
logger.debug(
|
||||||
|
() => 'Rollover notice created. rolledTaskCount=${rolledTaskIds.length}');
|
||||||
|
|
||||||
return SchedulingNotice(
|
return SchedulingNotice(
|
||||||
'${rolledTaskIds.length} unfinished flexible tasks were moved to today.',
|
'${rolledTaskIds.length} unfinished flexible tasks were moved to today.',
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ class Task {
|
||||||
this.actualStart,
|
this.actualStart,
|
||||||
this.actualEnd,
|
this.actualEnd,
|
||||||
this.completedAt,
|
this.completedAt,
|
||||||
|
this.backlogEnteredAt,
|
||||||
|
this.backlogEnteredAtProvenance,
|
||||||
String? parentTaskId,
|
String? parentTaskId,
|
||||||
Set<BacklogTag> backlogTags = const <BacklogTag>{},
|
Set<BacklogTag> backlogTags = const <BacklogTag>{},
|
||||||
this.reminderOverride,
|
this.reminderOverride,
|
||||||
|
|
@ -127,6 +129,7 @@ class Task {
|
||||||
Set<BacklogTag> backlogTags = const <BacklogTag>{},
|
Set<BacklogTag> backlogTags = const <BacklogTag>{},
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
}) {
|
}) {
|
||||||
|
final entersBacklog = status == TaskStatus.backlog;
|
||||||
return Task(
|
return Task(
|
||||||
id: id,
|
id: id,
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
|
|
@ -138,10 +141,19 @@ class Task {
|
||||||
difficulty: difficulty,
|
difficulty: difficulty,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
updatedAt: updatedAt ?? createdAt,
|
updatedAt: updatedAt ?? createdAt,
|
||||||
|
backlogEnteredAt: entersBacklog ? createdAt : null,
|
||||||
|
backlogEnteredAtProvenance:
|
||||||
|
entersBacklog ? backlogEnteredAtProvenanceQuickCapture : null,
|
||||||
backlogTags: backlogTags,
|
backlogTags: backlogTags,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Provenance value for tasks first created directly in Backlog.
|
||||||
|
static const backlogEnteredAtProvenanceQuickCapture = 'quickCapture';
|
||||||
|
|
||||||
|
/// Provenance value for scheduled tasks moved or pushed back to Backlog.
|
||||||
|
static const backlogEnteredAtProvenanceMovedToBacklog = 'movedToBacklog';
|
||||||
|
|
||||||
/// Stable identifier used by persistence, UI selection, and scheduler changes.
|
/// Stable identifier used by persistence, UI selection, and scheduler changes.
|
||||||
final String id;
|
final String id;
|
||||||
|
|
||||||
|
|
@ -186,6 +198,12 @@ class Task {
|
||||||
/// Explicit completion timestamp, distinct from the generic update timestamp.
|
/// Explicit completion timestamp, distinct from the generic update timestamp.
|
||||||
final DateTime? completedAt;
|
final DateTime? completedAt;
|
||||||
|
|
||||||
|
/// Freshness anchor for Backlog aging. Null falls back to [createdAt].
|
||||||
|
final DateTime? backlogEnteredAt;
|
||||||
|
|
||||||
|
/// Optional explanation for how [backlogEnteredAt] was set.
|
||||||
|
final String? backlogEnteredAtProvenance;
|
||||||
|
|
||||||
/// Parent task id when this task is a child/subtask. Null means top-level task.
|
/// Parent task id when this task is a child/subtask. Null means top-level task.
|
||||||
final String? parentTaskId;
|
final String? parentTaskId;
|
||||||
|
|
||||||
|
|
@ -219,6 +237,9 @@ class Task {
|
||||||
/// Backlog status means the task is stored for later and has no active slot.
|
/// Backlog status means the task is stored for later and has no active slot.
|
||||||
bool get isBacklog => status == TaskStatus.backlog;
|
bool get isBacklog => status == TaskStatus.backlog;
|
||||||
|
|
||||||
|
/// Timestamp used for Backlog freshness and age filters.
|
||||||
|
DateTime get backlogFreshnessAnchorAt => backlogEnteredAt ?? createdAt;
|
||||||
|
|
||||||
/// Return a copy with selected fields changed.
|
/// Return a copy with selected fields changed.
|
||||||
///
|
///
|
||||||
/// The core uses this instead of mutation. That matters because scheduling
|
/// The core uses this instead of mutation. That matters because scheduling
|
||||||
|
|
@ -247,6 +268,8 @@ class Task {
|
||||||
DateTime? actualStart,
|
DateTime? actualStart,
|
||||||
DateTime? actualEnd,
|
DateTime? actualEnd,
|
||||||
DateTime? completedAt,
|
DateTime? completedAt,
|
||||||
|
DateTime? backlogEnteredAt,
|
||||||
|
String? backlogEnteredAtProvenance,
|
||||||
String? parentTaskId,
|
String? parentTaskId,
|
||||||
Set<BacklogTag>? backlogTags,
|
Set<BacklogTag>? backlogTags,
|
||||||
ReminderProfile? reminderOverride,
|
ReminderProfile? reminderOverride,
|
||||||
|
|
@ -258,6 +281,7 @@ class Task {
|
||||||
bool clearSchedule = false,
|
bool clearSchedule = false,
|
||||||
bool clearActualInterval = false,
|
bool clearActualInterval = false,
|
||||||
bool clearCompletion = false,
|
bool clearCompletion = false,
|
||||||
|
bool clearBacklogEntry = false,
|
||||||
bool clearParentTask = false,
|
bool clearParentTask = false,
|
||||||
bool clearReminderOverride = false,
|
bool clearReminderOverride = false,
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -279,6 +303,12 @@ class Task {
|
||||||
clearActualInterval ? null : (actualStart ?? this.actualStart),
|
clearActualInterval ? null : (actualStart ?? this.actualStart),
|
||||||
actualEnd: clearActualInterval ? null : (actualEnd ?? this.actualEnd),
|
actualEnd: clearActualInterval ? null : (actualEnd ?? this.actualEnd),
|
||||||
completedAt: clearCompletion ? null : (completedAt ?? this.completedAt),
|
completedAt: clearCompletion ? null : (completedAt ?? this.completedAt),
|
||||||
|
backlogEnteredAt: clearBacklogEntry
|
||||||
|
? null
|
||||||
|
: (backlogEnteredAt ?? this.backlogEnteredAt),
|
||||||
|
backlogEnteredAtProvenance: clearBacklogEntry
|
||||||
|
? null
|
||||||
|
: (backlogEnteredAtProvenance ?? this.backlogEnteredAtProvenance),
|
||||||
parentTaskId:
|
parentTaskId:
|
||||||
clearParentTask ? null : (parentTaskId ?? this.parentTaskId),
|
clearParentTask ? null : (parentTaskId ?? this.parentTaskId),
|
||||||
backlogTags: backlogTags ?? this.backlogTags,
|
backlogTags: backlogTags ?? this.backlogTags,
|
||||||
|
|
|
||||||
390
packages/scheduler_core/lib/src/logging/focus_flow_logger.dart
Normal file
390
packages/scheduler_core/lib/src/logging/focus_flow_logger.dart
Normal file
|
|
@ -0,0 +1,390 @@
|
||||||
|
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
/// Provides shared FocusFlow logging primitives for scheduler code.
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// Lazily creates a log message after level gating passes.
|
||||||
|
typedef FocusFlowLogMessage = Object? Function();
|
||||||
|
|
||||||
|
/// Receives one enabled log entry.
|
||||||
|
typedef FocusFlowLogSink = void Function(FocusFlowLogEntry entry);
|
||||||
|
|
||||||
|
/// Shared process logger used by scheduler core code.
|
||||||
|
final FocusFlowLogger logger = FocusFlowLogger.disabled();
|
||||||
|
|
||||||
|
/// Supported FocusFlow logging levels, ordered from most to least verbose.
|
||||||
|
enum FocusFlowLogLevel {
|
||||||
|
/// Extremely granular diagnostics, data dumps, and caller details for every log.
|
||||||
|
finest,
|
||||||
|
|
||||||
|
/// Intra-method minor actions and small state changes.
|
||||||
|
finer,
|
||||||
|
|
||||||
|
/// Secondary details, possible issues, and rich warning/error context.
|
||||||
|
fine,
|
||||||
|
|
||||||
|
/// High-level debug flow and useful state values.
|
||||||
|
debug,
|
||||||
|
|
||||||
|
/// Very high-level operational checkpoints.
|
||||||
|
info,
|
||||||
|
|
||||||
|
/// Handled issues where the app recovered and kept going.
|
||||||
|
warn,
|
||||||
|
|
||||||
|
/// Invalid or unsafe states that need review.
|
||||||
|
error;
|
||||||
|
|
||||||
|
/// Parses [value] into a supported logging level.
|
||||||
|
static FocusFlowLogLevel? tryParse(String? value) {
|
||||||
|
final normalized = value?.trim().toLowerCase();
|
||||||
|
if (normalized == null || normalized.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (normalized == 'trace') {
|
||||||
|
return finest;
|
||||||
|
}
|
||||||
|
for (final level in FocusFlowLogLevel.values) {
|
||||||
|
if (level.name == normalized) {
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ordered severity used for threshold comparisons.
|
||||||
|
int get severity {
|
||||||
|
return switch (this) {
|
||||||
|
FocusFlowLogLevel.finest => 0,
|
||||||
|
FocusFlowLogLevel.finer => 1,
|
||||||
|
FocusFlowLogLevel.fine => 2,
|
||||||
|
FocusFlowLogLevel.debug => 3,
|
||||||
|
FocusFlowLogLevel.info => 4,
|
||||||
|
FocusFlowLogLevel.warn => 5,
|
||||||
|
FocusFlowLogLevel.error => 6,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this level should write when [minimumLevel] is configured.
|
||||||
|
bool writesAt(FocusFlowLogLevel minimumLevel) {
|
||||||
|
return severity >= minimumLevel.severity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Structured log entry produced after level gating has passed.
|
||||||
|
final class FocusFlowLogEntry {
|
||||||
|
/// Creates a structured log entry.
|
||||||
|
const FocusFlowLogEntry({
|
||||||
|
required this.timestamp,
|
||||||
|
required this.level,
|
||||||
|
required this.message,
|
||||||
|
this.caller,
|
||||||
|
this.error,
|
||||||
|
this.stackTrace,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// UTC timestamp for the log event.
|
||||||
|
final DateTime timestamp;
|
||||||
|
|
||||||
|
/// Severity level for the log event.
|
||||||
|
final FocusFlowLogLevel level;
|
||||||
|
|
||||||
|
/// Resolved log message.
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
/// Optional caller frame captured by [FocusFlowLogger].
|
||||||
|
final String? caller;
|
||||||
|
|
||||||
|
/// Optional error object attached to the entry.
|
||||||
|
final Object? error;
|
||||||
|
|
||||||
|
/// Optional stack trace attached to the entry.
|
||||||
|
final StackTrace? stackTrace;
|
||||||
|
|
||||||
|
/// Formats this entry as one append-only log line.
|
||||||
|
String toSingleLine() {
|
||||||
|
final buffer = StringBuffer()
|
||||||
|
..write(timestamp.toUtc().toIso8601String())
|
||||||
|
..write(' ')
|
||||||
|
..write(level.name.toUpperCase())
|
||||||
|
..write(' ');
|
||||||
|
final currentCaller = caller;
|
||||||
|
if (currentCaller != null) {
|
||||||
|
buffer
|
||||||
|
..write('caller=')
|
||||||
|
..write(_sanitizeForSingleLine(currentCaller))
|
||||||
|
..write(' ');
|
||||||
|
}
|
||||||
|
buffer.write(_sanitizeForSingleLine(message));
|
||||||
|
final currentError = error;
|
||||||
|
if (currentError != null) {
|
||||||
|
buffer
|
||||||
|
..write(' | error=')
|
||||||
|
..write(_sanitizeForSingleLine(currentError.toString()));
|
||||||
|
}
|
||||||
|
final currentStackTrace = stackTrace;
|
||||||
|
if (currentStackTrace != null) {
|
||||||
|
buffer
|
||||||
|
..write(' | stackTrace=')
|
||||||
|
..write(_sanitizeForSingleLine(currentStackTrace.toString()));
|
||||||
|
}
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logger that handles level gating, lazy messages, and caller capture.
|
||||||
|
final class FocusFlowLogger {
|
||||||
|
/// Creates a logger with optional runtime configuration.
|
||||||
|
FocusFlowLogger({
|
||||||
|
FocusFlowLogLevel? minimumLevel,
|
||||||
|
FocusFlowLogSink? sink,
|
||||||
|
DateTime Function()? now,
|
||||||
|
Iterable<String> callerFrameIgnores = const <String>[],
|
||||||
|
}) : _minimumLevel = minimumLevel,
|
||||||
|
_sink = sink,
|
||||||
|
_now = now ?? _utcNow,
|
||||||
|
_callerFrameIgnores = _normalizedCallerFrameIgnores(
|
||||||
|
callerFrameIgnores,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Creates a logger that drops every message.
|
||||||
|
factory FocusFlowLogger.disabled({DateTime Function()? now}) {
|
||||||
|
return FocusFlowLogger(now: now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimum level currently configured for this logger.
|
||||||
|
FocusFlowLogLevel? _minimumLevel;
|
||||||
|
|
||||||
|
/// Destination receiving enabled entries.
|
||||||
|
FocusFlowLogSink? _sink;
|
||||||
|
|
||||||
|
/// Clock used to timestamp log entries.
|
||||||
|
DateTime Function() _now;
|
||||||
|
|
||||||
|
/// Stack-frame markers ignored when capturing the caller.
|
||||||
|
List<String> _callerFrameIgnores;
|
||||||
|
|
||||||
|
/// Minimum level that will be written, or null when disabled.
|
||||||
|
FocusFlowLogLevel? get minimumLevel => _minimumLevel;
|
||||||
|
|
||||||
|
/// Whether this logger has enough configuration to write entries.
|
||||||
|
bool get isEnabled => _minimumLevel != null && _sink != null;
|
||||||
|
|
||||||
|
/// Replaces this logger's runtime configuration.
|
||||||
|
void configure({
|
||||||
|
required FocusFlowLogLevel minimumLevel,
|
||||||
|
required FocusFlowLogSink sink,
|
||||||
|
DateTime Function()? now,
|
||||||
|
Iterable<String> callerFrameIgnores = const <String>[],
|
||||||
|
}) {
|
||||||
|
_minimumLevel = minimumLevel;
|
||||||
|
_sink = sink;
|
||||||
|
_now = now ?? _utcNow;
|
||||||
|
_callerFrameIgnores = _normalizedCallerFrameIgnores(callerFrameIgnores);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disables this logger and drops subsequent messages.
|
||||||
|
void disable({DateTime Function()? now}) {
|
||||||
|
_minimumLevel = null;
|
||||||
|
_sink = null;
|
||||||
|
_now = now ?? _utcNow;
|
||||||
|
_callerFrameIgnores = _normalizedCallerFrameIgnores(const <String>[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether [level] would currently be written.
|
||||||
|
bool wouldWrite(FocusFlowLogLevel level) {
|
||||||
|
final configured = _minimumLevel;
|
||||||
|
return configured != null && _sink != null && level.writesAt(configured);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes [message] at `finest` when enabled.
|
||||||
|
void finest(
|
||||||
|
Object? message, {
|
||||||
|
Object? error,
|
||||||
|
StackTrace? stackTrace,
|
||||||
|
}) {
|
||||||
|
write(
|
||||||
|
FocusFlowLogLevel.finest,
|
||||||
|
message,
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes [message] at `finer` when enabled.
|
||||||
|
void finer(
|
||||||
|
Object? message, {
|
||||||
|
Object? error,
|
||||||
|
StackTrace? stackTrace,
|
||||||
|
}) {
|
||||||
|
write(
|
||||||
|
FocusFlowLogLevel.finer,
|
||||||
|
message,
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes [message] at `fine` when enabled.
|
||||||
|
void fine(
|
||||||
|
Object? message, {
|
||||||
|
Object? error,
|
||||||
|
StackTrace? stackTrace,
|
||||||
|
}) {
|
||||||
|
write(
|
||||||
|
FocusFlowLogLevel.fine,
|
||||||
|
message,
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes [message] at `debug` when enabled.
|
||||||
|
void debug(
|
||||||
|
Object? message, {
|
||||||
|
Object? error,
|
||||||
|
StackTrace? stackTrace,
|
||||||
|
}) {
|
||||||
|
write(
|
||||||
|
FocusFlowLogLevel.debug,
|
||||||
|
message,
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes [message] at `info` when enabled.
|
||||||
|
void info(
|
||||||
|
Object? message, {
|
||||||
|
Object? error,
|
||||||
|
StackTrace? stackTrace,
|
||||||
|
}) {
|
||||||
|
write(
|
||||||
|
FocusFlowLogLevel.info,
|
||||||
|
message,
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes [message] at `warn` when enabled.
|
||||||
|
void warn(
|
||||||
|
Object? message, {
|
||||||
|
Object? error,
|
||||||
|
StackTrace? stackTrace,
|
||||||
|
}) {
|
||||||
|
write(
|
||||||
|
FocusFlowLogLevel.warn,
|
||||||
|
message,
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes [message] at `error` when enabled.
|
||||||
|
void error(
|
||||||
|
Object? message, {
|
||||||
|
Object? error,
|
||||||
|
StackTrace? stackTrace,
|
||||||
|
}) {
|
||||||
|
write(
|
||||||
|
FocusFlowLogLevel.error,
|
||||||
|
message,
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes [message] at [level] when it passes the configured threshold.
|
||||||
|
void write(
|
||||||
|
FocusFlowLogLevel level,
|
||||||
|
Object? message, {
|
||||||
|
Object? error,
|
||||||
|
StackTrace? stackTrace,
|
||||||
|
}) {
|
||||||
|
final configured = _minimumLevel;
|
||||||
|
final currentSink = _sink;
|
||||||
|
if (configured == null ||
|
||||||
|
currentSink == null ||
|
||||||
|
!level.writesAt(configured)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final entry = FocusFlowLogEntry(
|
||||||
|
timestamp: _now().toUtc(),
|
||||||
|
level: level,
|
||||||
|
message: _resolveMessage(message),
|
||||||
|
caller: _shouldCaptureCaller(level: level, minimumLevel: configured)
|
||||||
|
? _callerFrame()
|
||||||
|
: null,
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
currentSink(entry);
|
||||||
|
} on Object {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Captures the first stack frame outside known logging internals.
|
||||||
|
String _callerFrame() {
|
||||||
|
for (final line in StackTrace.current.toString().split('\n')) {
|
||||||
|
final trimmed = line.trim();
|
||||||
|
final shouldIgnore = _callerFrameIgnores.any(trimmed.contains);
|
||||||
|
if (trimmed.isEmpty || shouldIgnore) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return _sanitizeForSingleLine(trimmed);
|
||||||
|
}
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether caller capture should run for [level].
|
||||||
|
bool _shouldCaptureCaller({
|
||||||
|
required FocusFlowLogLevel level,
|
||||||
|
required FocusFlowLogLevel minimumLevel,
|
||||||
|
}) {
|
||||||
|
if (minimumLevel == FocusFlowLogLevel.finest) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
final capturesWarningContext =
|
||||||
|
level == FocusFlowLogLevel.warn || level == FocusFlowLogLevel.error;
|
||||||
|
return capturesWarningContext &&
|
||||||
|
minimumLevel.severity <= FocusFlowLogLevel.fine.severity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves a plain or lazy [message] after level gating passes.
|
||||||
|
String _resolveMessage(Object? message) {
|
||||||
|
try {
|
||||||
|
final resolved = message is FocusFlowLogMessage ? message() : message;
|
||||||
|
return resolved?.toString().trim() ?? '';
|
||||||
|
} on Object catch (error) {
|
||||||
|
return 'Log message evaluation failed: $error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current UTC timestamp.
|
||||||
|
DateTime _utcNow() => DateTime.now().toUtc();
|
||||||
|
|
||||||
|
/// Builds the complete set of caller frame markers to skip.
|
||||||
|
List<String> _normalizedCallerFrameIgnores(
|
||||||
|
Iterable<String> callerFrameIgnores,
|
||||||
|
) {
|
||||||
|
final ignores = <String>{'focus_flow_logger.dart'};
|
||||||
|
for (final ignore in callerFrameIgnores) {
|
||||||
|
final trimmed = ignore.trim();
|
||||||
|
if (trimmed.isNotEmpty) {
|
||||||
|
ignores.add(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List<String>.unmodifiable(ignores);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps diagnostic text on one physical log line.
|
||||||
|
String _sanitizeForSingleLine(String value) {
|
||||||
|
return value.replaceAll('\n', r'\n');
|
||||||
|
}
|
||||||
|
|
@ -55,8 +55,10 @@ abstract final class TaskDocumentMapping {
|
||||||
TaskDocumentFields.stats: TaskStatisticsDocumentMapping.toDocument(
|
TaskDocumentFields.stats: TaskStatisticsDocumentMapping.toDocument(
|
||||||
task.stats,
|
task.stats,
|
||||||
),
|
),
|
||||||
TaskDocumentFields.backlogEnteredAt: _dateTimeToStored(backlogEnteredAt),
|
TaskDocumentFields.backlogEnteredAt:
|
||||||
TaskDocumentFields.backlogEnteredAtProvenance: backlogEnteredAtProvenance,
|
_dateTimeToStored(backlogEnteredAt ?? task.backlogEnteredAt),
|
||||||
|
TaskDocumentFields.backlogEnteredAtProvenance:
|
||||||
|
backlogEnteredAtProvenance ?? task.backlogEnteredAtProvenance,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,6 +108,14 @@ abstract final class TaskDocumentMapping {
|
||||||
document,
|
document,
|
||||||
TaskDocumentFields.completedAt,
|
TaskDocumentFields.completedAt,
|
||||||
),
|
),
|
||||||
|
backlogEnteredAt: _requiredNullableDateTime(
|
||||||
|
document,
|
||||||
|
TaskDocumentFields.backlogEnteredAt,
|
||||||
|
),
|
||||||
|
backlogEnteredAtProvenance: _requiredNullableString(
|
||||||
|
document,
|
||||||
|
TaskDocumentFields.backlogEnteredAtProvenance,
|
||||||
|
),
|
||||||
parentTaskId: _requiredNullableString(
|
parentTaskId: _requiredNullableString(
|
||||||
document,
|
document,
|
||||||
TaskDocumentFields.parentTaskId,
|
TaskDocumentFields.parentTaskId,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ library;
|
||||||
import 'document_mapping.dart';
|
import 'document_mapping.dart';
|
||||||
import 'persistence_contract.dart';
|
import 'persistence_contract.dart';
|
||||||
import '../domain/time_contracts.dart';
|
import '../domain/time_contracts.dart';
|
||||||
|
import '../logging/focus_flow_logger.dart';
|
||||||
part 'document_migration/codes/document_migration_entity.dart';
|
part 'document_migration/codes/document_migration_entity.dart';
|
||||||
part 'document_migration/codes/document_migration_status.dart';
|
part 'document_migration/codes/document_migration_status.dart';
|
||||||
part 'document_migration/codes/document_migration_failure_code.dart';
|
part 'document_migration/codes/document_migration_failure_code.dart';
|
||||||
|
|
|
||||||
|
|
@ -80,8 +80,14 @@ class V1DocumentMigrationService {
|
||||||
required void Function(Map<String, Object?> document) validateCurrent,
|
required void Function(Map<String, Object?> document) validateCurrent,
|
||||||
}) {
|
}) {
|
||||||
final documentId = _documentId(document);
|
final documentId = _documentId(document);
|
||||||
|
logger.debug(() => 'Document migration started. '
|
||||||
|
'entity=${entity.name} documentId=$documentId '
|
||||||
|
'ownerId=$ownerId migratedAt=${migratedAt.toIso8601String()}');
|
||||||
final schemaRead = _readSchemaVersion(document);
|
final schemaRead = _readSchemaVersion(document);
|
||||||
if (schemaRead.failure != null) {
|
if (schemaRead.failure != null) {
|
||||||
|
logger.warn(() => 'Document migration failed schema version read. '
|
||||||
|
'entity=${entity.name} documentId=$documentId '
|
||||||
|
'detailCode=${schemaRead.failure}');
|
||||||
return _failed(
|
return _failed(
|
||||||
entity: entity,
|
entity: entity,
|
||||||
documentId: documentId,
|
documentId: documentId,
|
||||||
|
|
@ -93,10 +99,15 @@ class V1DocumentMigrationService {
|
||||||
}
|
}
|
||||||
|
|
||||||
final fromVersion = schemaRead.version;
|
final fromVersion = schemaRead.version;
|
||||||
|
logger.finer(() => 'Document migration schema version resolved. '
|
||||||
|
'entity=${entity.name} documentId=$documentId fromSchemaVersion=$fromVersion');
|
||||||
if (fromVersion == v1SchemaVersion) {
|
if (fromVersion == v1SchemaVersion) {
|
||||||
try {
|
try {
|
||||||
final copy = _deepCopyDocument(document);
|
final copy = _deepCopyDocument(document);
|
||||||
validateCurrent(copy);
|
validateCurrent(copy);
|
||||||
|
logger.debug(() =>
|
||||||
|
'Document migration skipped; document already current. '
|
||||||
|
'entity=${entity.name} documentId=$documentId fromSchemaVersion=$fromVersion');
|
||||||
return DocumentMigrationResult(
|
return DocumentMigrationResult(
|
||||||
document: copy,
|
document: copy,
|
||||||
report: DocumentMigrationReport(
|
report: DocumentMigrationReport(
|
||||||
|
|
@ -107,6 +118,11 @@ class V1DocumentMigrationService {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} on DocumentMappingException catch (error) {
|
} on DocumentMappingException catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
() => 'Document migration failed current document validation. '
|
||||||
|
'entity=${entity.name} documentId=$documentId '
|
||||||
|
'fromSchemaVersion=$fromVersion mappingCode=${error.code.name} '
|
||||||
|
'fieldName=${error.fieldName} detailCode=${error.detailCode}');
|
||||||
return _failedFromMapping(
|
return _failedFromMapping(
|
||||||
entity: entity,
|
entity: entity,
|
||||||
documentId: documentId,
|
documentId: documentId,
|
||||||
|
|
@ -118,6 +134,9 @@ class V1DocumentMigrationService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fromVersion > v1SchemaVersion || fromVersion < 0) {
|
if (fromVersion > v1SchemaVersion || fromVersion < 0) {
|
||||||
|
logger.warn(() =>
|
||||||
|
'Document migration rejected unsupported schema version. '
|
||||||
|
'entity=${entity.name} documentId=$documentId fromSchemaVersion=$fromVersion');
|
||||||
return _failed(
|
return _failed(
|
||||||
entity: entity,
|
entity: entity,
|
||||||
documentId: documentId,
|
documentId: documentId,
|
||||||
|
|
@ -129,6 +148,9 @@ class V1DocumentMigrationService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fromVersion != 0) {
|
if (fromVersion != 0) {
|
||||||
|
logger.warn(() =>
|
||||||
|
'Document migration rejected unsupported schema version. '
|
||||||
|
'entity=${entity.name} documentId=$documentId fromSchemaVersion=$fromVersion');
|
||||||
return _failed(
|
return _failed(
|
||||||
entity: entity,
|
entity: entity,
|
||||||
documentId: documentId,
|
documentId: documentId,
|
||||||
|
|
@ -144,6 +166,10 @@ class V1DocumentMigrationService {
|
||||||
try {
|
try {
|
||||||
migrated = migrateV0(document, notes);
|
migrated = migrateV0(document, notes);
|
||||||
} on _LegacyDocumentException catch (error) {
|
} on _LegacyDocumentException catch (error) {
|
||||||
|
logger.warn(() => 'Document migration failed legacy document validation. '
|
||||||
|
'entity=${entity.name} documentId=$documentId '
|
||||||
|
'fromSchemaVersion=$fromVersion fieldName=${error.fieldName} '
|
||||||
|
'detailCode=${error.detailCode}');
|
||||||
return _failed(
|
return _failed(
|
||||||
entity: entity,
|
entity: entity,
|
||||||
documentId: documentId,
|
documentId: documentId,
|
||||||
|
|
@ -157,6 +183,11 @@ class V1DocumentMigrationService {
|
||||||
try {
|
try {
|
||||||
validateCurrent(migrated);
|
validateCurrent(migrated);
|
||||||
} on DocumentMappingException catch (error) {
|
} on DocumentMappingException catch (error) {
|
||||||
|
logger
|
||||||
|
.warn(() => 'Document migration failed migrated document validation. '
|
||||||
|
'entity=${entity.name} documentId=$documentId '
|
||||||
|
'fromSchemaVersion=$fromVersion mappingCode=${error.code.name} '
|
||||||
|
'fieldName=${error.fieldName} detailCode=${error.detailCode}');
|
||||||
return _failedFromMapping(
|
return _failedFromMapping(
|
||||||
entity: entity,
|
entity: entity,
|
||||||
documentId: documentId,
|
documentId: documentId,
|
||||||
|
|
@ -166,6 +197,9 @@ class V1DocumentMigrationService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.debug(() => 'Document migration finished. '
|
||||||
|
'entity=${entity.name} documentId=$documentId fromSchemaVersion=$fromVersion '
|
||||||
|
'toSchemaVersion=$v1SchemaVersion noteCount=${notes.length}');
|
||||||
return DocumentMigrationResult(
|
return DocumentMigrationResult(
|
||||||
document: migrated,
|
document: migrated,
|
||||||
report: DocumentMigrationReport(
|
report: DocumentMigrationReport(
|
||||||
|
|
|
||||||
|
|
@ -284,6 +284,58 @@ class InMemoryTaskRepository implements TaskRepository {
|
||||||
return RepositoryMutationResult.success(revision: nextRevision);
|
return RepositoryMutationResult.success(revision: nextRevision);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs the `deleteIfRevision` 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.
|
||||||
|
@override
|
||||||
|
Future<RepositoryMutationResult> deleteIfRevision({
|
||||||
|
required String taskId,
|
||||||
|
required String ownerId,
|
||||||
|
required int expectedRevision,
|
||||||
|
}) async {
|
||||||
|
if (expectedRevision <= 0) {
|
||||||
|
return RepositoryMutationResult.failure(
|
||||||
|
RepositoryFailure(
|
||||||
|
code: RepositoryFailureCode.invalidRevision,
|
||||||
|
entityId: taskId,
|
||||||
|
expectedRevision: expectedRevision,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final current = _tasksById[taskId];
|
||||||
|
if (current == null) {
|
||||||
|
return RepositoryMutationResult.failure(
|
||||||
|
RepositoryFailure(
|
||||||
|
code: RepositoryFailureCode.notFound,
|
||||||
|
entityId: taskId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_ownerFor(taskId) != ownerId) {
|
||||||
|
return RepositoryMutationResult.failure(
|
||||||
|
RepositoryFailure(
|
||||||
|
code: RepositoryFailureCode.ownerMismatch,
|
||||||
|
entityId: taskId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final actualRevision = _revisionFor(taskId);
|
||||||
|
if (actualRevision != expectedRevision) {
|
||||||
|
return RepositoryMutationResult.failure(
|
||||||
|
RepositoryFailure(
|
||||||
|
code: RepositoryFailureCode.staleRevision,
|
||||||
|
entityId: taskId,
|
||||||
|
expectedRevision: expectedRevision,
|
||||||
|
actualRevision: actualRevision,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final nextRevision = actualRevision + 1;
|
||||||
|
_tasksById.remove(taskId);
|
||||||
|
_ownersById.remove(taskId);
|
||||||
|
_revisionsById.remove(taskId);
|
||||||
|
return RepositoryMutationResult.success(revision: nextRevision);
|
||||||
|
}
|
||||||
|
|
||||||
/// Runs the `_ownerFor` helper used inside this library.
|
/// Runs the `_ownerFor` helper used inside this library.
|
||||||
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||||
String _ownerFor(String id) => _ownersById[id] ?? defaultOwnerId;
|
String _ownerFor(String id) => _ownersById[id] ?? defaultOwnerId;
|
||||||
|
|
|
||||||
|
|
@ -79,4 +79,11 @@ abstract interface class TaskRepository {
|
||||||
required String ownerId,
|
required String ownerId,
|
||||||
required int expectedRevision,
|
required int expectedRevision,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Delete [taskId] by expected repository revision.
|
||||||
|
Future<RepositoryMutationResult> deleteIfRevision({
|
||||||
|
required String taskId,
|
||||||
|
required String ownerId,
|
||||||
|
required int expectedRevision,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,14 +25,14 @@ class BacklogStalenessSettings {
|
||||||
|
|
||||||
/// Return the visual age marker for [task] relative to [now].
|
/// Return the visual age marker for [task] relative to [now].
|
||||||
///
|
///
|
||||||
/// This uses [Task.createdAt], not [Task.updatedAt], because the marker is
|
/// This uses [Task.backlogFreshnessAnchorAt], not [Task.updatedAt], because
|
||||||
/// meant to show how long the idea has existed in the system. A task edited
|
/// ordinary edits should not make an old backlog item look fresh. Commands
|
||||||
/// yesterday but created two months ago should still feel old in the backlog.
|
/// that deliberately re-enter Backlog reset the anchor.
|
||||||
BacklogStalenessMarker markerFor({
|
BacklogStalenessMarker markerFor({
|
||||||
required Task task,
|
required Task task,
|
||||||
required DateTime now,
|
required DateTime now,
|
||||||
}) {
|
}) {
|
||||||
final age = now.difference(task.createdAt);
|
final age = now.difference(task.backlogFreshnessAnchorAt);
|
||||||
|
|
||||||
if (age <= greenMaxAge) {
|
if (age <= greenMaxAge) {
|
||||||
return BacklogStalenessMarker.green;
|
return BacklogStalenessMarker.green;
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,11 @@ class BacklogView {
|
||||||
/// Clock value supplied by the caller so age/staleness behavior is testable.
|
/// Clock value supplied by the caller so age/staleness behavior is testable.
|
||||||
final DateTime now;
|
final DateTime now;
|
||||||
|
|
||||||
/// Age since [Task.createdAt] that qualifies for the `stale` filter.
|
/// Age since the task's Backlog freshness anchor that qualifies for the `stale` filter.
|
||||||
///
|
///
|
||||||
/// V1 does not yet store a separate "entered backlog at" timestamp. Until
|
/// Tasks persisted before the anchor existed fall back to [Task.createdAt].
|
||||||
/// persistence adds that field, both stale filtering and visual staleness use
|
/// This keeps older data readable while letting Push to Backlog make a task
|
||||||
/// task creation age so they do not disagree after a task edit.
|
/// fresh without rewriting original creation history.
|
||||||
final Duration staleAfter;
|
final Duration staleAfter;
|
||||||
|
|
||||||
/// Color-bucket threshold configuration for backlog aging indicators.
|
/// Color-bucket threshold configuration for backlog aging indicators.
|
||||||
|
|
@ -101,7 +101,8 @@ class BacklogView {
|
||||||
BacklogFilter.criticalMissed =>
|
BacklogFilter.criticalMissed =>
|
||||||
task.type == TaskType.critical && task.stats.missedCount > 0,
|
task.type == TaskType.critical && task.stats.missedCount > 0,
|
||||||
BacklogFilter.wishlist => task.backlogTags.contains(BacklogTag.wishlist),
|
BacklogFilter.wishlist => task.backlogTags.contains(BacklogTag.wishlist),
|
||||||
BacklogFilter.stale => now.difference(task.createdAt) >= staleAfter,
|
BacklogFilter.stale =>
|
||||||
|
now.difference(task.backlogFreshnessAnchorAt) >= staleAfter,
|
||||||
BacklogFilter.noRewardSet => task.reward == RewardLevel.notSet,
|
BacklogFilter.noRewardSet => task.reward == RewardLevel.notSet,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -116,7 +117,8 @@ class BacklogView {
|
||||||
_priorityRank(b.priority).compareTo(_priorityRank(a.priority)),
|
_priorityRank(b.priority).compareTo(_priorityRank(a.priority)),
|
||||||
BacklogSortKey.rewardVsEffort =>
|
BacklogSortKey.rewardVsEffort =>
|
||||||
_rewardVsEffortScore(b).compareTo(_rewardVsEffortScore(a)),
|
_rewardVsEffortScore(b).compareTo(_rewardVsEffortScore(a)),
|
||||||
BacklogSortKey.age => a.createdAt.compareTo(b.createdAt),
|
BacklogSortKey.age =>
|
||||||
|
a.backlogFreshnessAnchorAt.compareTo(b.backlogFreshnessAnchorAt),
|
||||||
BacklogSortKey.project => a.projectId.compareTo(b.projectId),
|
BacklogSortKey.project => a.projectId.compareTo(b.projectId),
|
||||||
BacklogSortKey.timesPushed => _timesPushed(b).compareTo(_timesPushed(a)),
|
BacklogSortKey.timesPushed => _timesPushed(b).compareTo(_timesPushed(a)),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ library;
|
||||||
import '../domain/models.dart';
|
import '../domain/models.dart';
|
||||||
import 'task_lifecycle.dart';
|
import 'task_lifecycle.dart';
|
||||||
import '../domain/time_contracts.dart';
|
import '../domain/time_contracts.dart';
|
||||||
|
import '../logging/focus_flow_logger.dart';
|
||||||
part 'child_tasks/models/child_task_entry.dart';
|
part 'child_tasks/models/child_task_entry.dart';
|
||||||
part 'child_tasks/requests/child_task_break_up_request.dart';
|
part 'child_tasks/requests/child_task_break_up_request.dart';
|
||||||
part 'child_tasks/results/child_task_break_up_result.dart';
|
part 'child_tasks/results/child_task_break_up_result.dart';
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,14 @@ class ChildTaskBreakUpService {
|
||||||
required List<Task> tasks,
|
required List<Task> tasks,
|
||||||
required ChildTaskBreakUpRequest request,
|
required ChildTaskBreakUpRequest request,
|
||||||
}) {
|
}) {
|
||||||
|
logger.debug(
|
||||||
|
() => 'Breaking up parent task. parentTaskId=${request.parentTaskId} '
|
||||||
|
'entryCount=${request.entries.length} taskCount=${tasks.length} '
|
||||||
|
'operationId=${request.operationId}');
|
||||||
final parent = _taskById(tasks, request.parentTaskId);
|
final parent = _taskById(tasks, request.parentTaskId);
|
||||||
if (parent == null) {
|
if (parent == null) {
|
||||||
|
logger.warn(() => 'Child task break-up failed: parent not found. '
|
||||||
|
'parentTaskId=${request.parentTaskId} operationId=${request.operationId}');
|
||||||
throw ArgumentError.value(
|
throw ArgumentError.value(
|
||||||
request.parentTaskId,
|
request.parentTaskId,
|
||||||
'parentTaskId',
|
'parentTaskId',
|
||||||
|
|
@ -26,6 +32,8 @@ class ChildTaskBreakUpService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (request.entries.isEmpty) {
|
if (request.entries.isEmpty) {
|
||||||
|
logger.warn(() => 'Child task break-up failed: no child entries. '
|
||||||
|
'parentTaskId=${request.parentTaskId} operationId=${request.operationId}');
|
||||||
throw ArgumentError.value(
|
throw ArgumentError.value(
|
||||||
request.entries,
|
request.entries,
|
||||||
'entries',
|
'entries',
|
||||||
|
|
@ -38,9 +46,14 @@ class ChildTaskBreakUpService {
|
||||||
for (final entry in request.entries) {
|
for (final entry in request.entries) {
|
||||||
final childId = entry.id.trim();
|
final childId = entry.id.trim();
|
||||||
if (childId.isEmpty) {
|
if (childId.isEmpty) {
|
||||||
|
logger.warn(() => 'Child task break-up failed: blank child id. '
|
||||||
|
'parentTaskId=${parent.id} operationId=${request.operationId}');
|
||||||
throw ArgumentError.value(entry.id, 'id', 'Child id is required.');
|
throw ArgumentError.value(entry.id, 'id', 'Child id is required.');
|
||||||
}
|
}
|
||||||
if (childId == parent.id) {
|
if (childId == parent.id) {
|
||||||
|
logger
|
||||||
|
.warn(() => 'Child task break-up failed: child id matched parent. '
|
||||||
|
'parentTaskId=${parent.id} operationId=${request.operationId}');
|
||||||
throw ArgumentError.value(
|
throw ArgumentError.value(
|
||||||
entry.id,
|
entry.id,
|
||||||
'id',
|
'id',
|
||||||
|
|
@ -48,6 +61,9 @@ class ChildTaskBreakUpService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!requestedIds.add(childId)) {
|
if (!requestedIds.add(childId)) {
|
||||||
|
logger.warn(() => 'Child task break-up failed: duplicate child id. '
|
||||||
|
'parentTaskId=${parent.id} childTaskId=$childId '
|
||||||
|
'operationId=${request.operationId}');
|
||||||
throw ArgumentError.value(
|
throw ArgumentError.value(
|
||||||
entry.id,
|
entry.id,
|
||||||
'id',
|
'id',
|
||||||
|
|
@ -55,6 +71,10 @@ class ChildTaskBreakUpService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (existingIds.contains(childId)) {
|
if (existingIds.contains(childId)) {
|
||||||
|
logger
|
||||||
|
.warn(() => 'Child task break-up failed: child id already exists. '
|
||||||
|
'parentTaskId=${parent.id} childTaskId=$childId '
|
||||||
|
'operationId=${request.operationId}');
|
||||||
throw ArgumentError.value(
|
throw ArgumentError.value(
|
||||||
entry.id,
|
entry.id,
|
||||||
'id',
|
'id',
|
||||||
|
|
@ -66,6 +86,9 @@ class ChildTaskBreakUpService {
|
||||||
final childTasks = request.entries
|
final childTasks = request.entries
|
||||||
.map((entry) => entry.toTask(parent: parent))
|
.map((entry) => entry.toTask(parent: parent))
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
|
logger.debug(() => 'Parent task break-up produced children. '
|
||||||
|
'parentTaskId=${parent.id} childCount=${childTasks.length} '
|
||||||
|
'operationId=${request.operationId}');
|
||||||
|
|
||||||
return ChildTaskBreakUpResult(
|
return ChildTaskBreakUpResult(
|
||||||
tasks: List<Task>.unmodifiable([...tasks, ...childTasks]),
|
tasks: List<Task>.unmodifiable([...tasks, ...childTasks]),
|
||||||
|
|
|
||||||
|
|
@ -32,19 +32,28 @@ class ChildTaskCompletionService {
|
||||||
Iterable<String> appliedActivityIds = const <String>[],
|
Iterable<String> appliedActivityIds = const <String>[],
|
||||||
}) {
|
}) {
|
||||||
final now = updatedAt ?? clock.now();
|
final now = updatedAt ?? clock.now();
|
||||||
|
logger.debug(() => 'Completing child task. childTaskId=$childTaskId '
|
||||||
|
'taskCount=${tasks.length} operationId=$operationId '
|
||||||
|
'updatedAt=${now.toIso8601String()}');
|
||||||
final child = _taskById(tasks, childTaskId);
|
final child = _taskById(tasks, childTaskId);
|
||||||
if (child == null) {
|
if (child == null) {
|
||||||
|
logger.warn(() => 'Child completion failed: task not found. '
|
||||||
|
'childTaskId=$childTaskId operationId=$operationId');
|
||||||
throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.');
|
throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
final parentId = child.parentTaskId;
|
final parentId = child.parentTaskId;
|
||||||
if (parentId == null) {
|
if (parentId == null) {
|
||||||
|
logger.warn(() => 'Child completion failed: task has no parent. '
|
||||||
|
'childTaskId=$childTaskId operationId=$operationId');
|
||||||
throw ArgumentError.value(
|
throw ArgumentError.value(
|
||||||
childTaskId, 'childTaskId', 'Task is not a child.');
|
childTaskId, 'childTaskId', 'Task is not a child.');
|
||||||
}
|
}
|
||||||
|
|
||||||
final parent = _taskById(tasks, parentId);
|
final parent = _taskById(tasks, parentId);
|
||||||
if (parent == null) {
|
if (parent == null) {
|
||||||
|
logger.warn(() => 'Child completion failed: parent not found. '
|
||||||
|
'childTaskId=$childTaskId parentTaskId=$parentId operationId=$operationId');
|
||||||
throw ArgumentError.value(parentId, 'parentTaskId', 'Parent not found.');
|
throw ArgumentError.value(parentId, 'parentTaskId', 'Parent not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,6 +78,9 @@ class ChildTaskCompletionService {
|
||||||
final outcomes = <TaskTransitionOutcomeCode>[childTransition.outcomeCode];
|
final outcomes = <TaskTransitionOutcomeCode>[childTransition.outcomeCode];
|
||||||
|
|
||||||
if (!shouldCompleteParent) {
|
if (!shouldCompleteParent) {
|
||||||
|
logger.debug(() => 'Child task completed without parent auto-complete. '
|
||||||
|
'childTaskId=${child.id} parentTaskId=${parent.id} '
|
||||||
|
'childApplied=${childTransition.applied} operationId=$opId');
|
||||||
return ChildTaskCompletionResult(
|
return ChildTaskCompletionResult(
|
||||||
tasks: List<Task>.unmodifiable(withChildCompleted),
|
tasks: List<Task>.unmodifiable(withChildCompleted),
|
||||||
changedTaskIds: childTransition.applied ? [child.id] : const <String>[],
|
changedTaskIds: childTransition.applied ? [child.id] : const <String>[],
|
||||||
|
|
@ -100,6 +112,10 @@ class ChildTaskCompletionService {
|
||||||
if (parentTransition.applied) parent.id,
|
if (parentTransition.applied) parent.id,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
logger.debug(() => 'Child task completion auto-completed parent. '
|
||||||
|
'childTaskId=${child.id} parentTaskId=${parent.id} '
|
||||||
|
'changedCount=${changedTaskIds.length} operationId=$opId');
|
||||||
|
|
||||||
return ChildTaskCompletionResult(
|
return ChildTaskCompletionResult(
|
||||||
tasks: List<Task>.unmodifiable(completedTasks),
|
tasks: List<Task>.unmodifiable(completedTasks),
|
||||||
changedTaskIds: List<String>.unmodifiable(changedTaskIds),
|
changedTaskIds: List<String>.unmodifiable(changedTaskIds),
|
||||||
|
|
@ -120,12 +136,19 @@ class ChildTaskCompletionService {
|
||||||
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
|
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
|
||||||
Iterable<String> appliedActivityIds = const <String>[],
|
Iterable<String> appliedActivityIds = const <String>[],
|
||||||
}) {
|
}) {
|
||||||
|
logger.debug(() => 'Completing parent from child. childTaskId=$childTaskId '
|
||||||
|
'taskCount=${tasks.length} operationId=$operationId');
|
||||||
final child = _taskById(tasks, childTaskId);
|
final child = _taskById(tasks, childTaskId);
|
||||||
if (child == null) {
|
if (child == null) {
|
||||||
|
logger.warn(() => 'Parent-from-child completion failed: task not found. '
|
||||||
|
'childTaskId=$childTaskId operationId=$operationId');
|
||||||
throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.');
|
throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.');
|
||||||
}
|
}
|
||||||
final parentId = child.parentTaskId;
|
final parentId = child.parentTaskId;
|
||||||
if (parentId == null) {
|
if (parentId == null) {
|
||||||
|
logger.warn(
|
||||||
|
() => 'Parent-from-child completion failed: task has no parent. '
|
||||||
|
'childTaskId=$childTaskId operationId=$operationId');
|
||||||
throw ArgumentError.value(
|
throw ArgumentError.value(
|
||||||
childTaskId,
|
childTaskId,
|
||||||
'childTaskId',
|
'childTaskId',
|
||||||
|
|
@ -155,8 +178,13 @@ class ChildTaskCompletionService {
|
||||||
Iterable<String> appliedActivityIds = const <String>[],
|
Iterable<String> appliedActivityIds = const <String>[],
|
||||||
}) {
|
}) {
|
||||||
final now = updatedAt ?? clock.now();
|
final now = updatedAt ?? clock.now();
|
||||||
|
logger.debug(() => 'Completing parent task. parentTaskId=$parentTaskId '
|
||||||
|
'taskCount=${tasks.length} initiatingChildTaskId=$initiatingChildTaskId '
|
||||||
|
'operationId=$operationId updatedAt=${now.toIso8601String()}');
|
||||||
final parent = _taskById(tasks, parentTaskId);
|
final parent = _taskById(tasks, parentTaskId);
|
||||||
if (parent == null) {
|
if (parent == null) {
|
||||||
|
logger.warn(() => 'Parent completion failed: parent not found. '
|
||||||
|
'parentTaskId=$parentTaskId operationId=$operationId');
|
||||||
throw ArgumentError.value(
|
throw ArgumentError.value(
|
||||||
parentTaskId, 'parentTaskId', 'Parent not found.');
|
parentTaskId, 'parentTaskId', 'Parent not found.');
|
||||||
}
|
}
|
||||||
|
|
@ -164,6 +192,10 @@ class ChildTaskCompletionService {
|
||||||
final directChildren = ChildTaskView(tasks: tasks).childrenOf(parent);
|
final directChildren = ChildTaskView(tasks: tasks).childrenOf(parent);
|
||||||
if (initiatingChildTaskId != null &&
|
if (initiatingChildTaskId != null &&
|
||||||
!directChildren.any((child) => child.id == initiatingChildTaskId)) {
|
!directChildren.any((child) => child.id == initiatingChildTaskId)) {
|
||||||
|
logger.warn(
|
||||||
|
() => 'Parent completion failed: initiating child is not direct. '
|
||||||
|
'parentTaskId=${parent.id} childTaskId=$initiatingChildTaskId '
|
||||||
|
'operationId=$operationId');
|
||||||
throw ArgumentError.value(
|
throw ArgumentError.value(
|
||||||
initiatingChildTaskId,
|
initiatingChildTaskId,
|
||||||
'initiatingChildTaskId',
|
'initiatingChildTaskId',
|
||||||
|
|
@ -234,6 +266,12 @@ class ChildTaskCompletionService {
|
||||||
updatedTasks.add(task);
|
updatedTasks.add(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
() => 'Parent task completion finished. parentTaskId=${parent.id} '
|
||||||
|
'directChildCount=${directChildren.length} '
|
||||||
|
'forceCompletedCount=${forceCompletedChildIds.length} '
|
||||||
|
'changedCount=${changedTaskIds.length} operationId=$opId');
|
||||||
|
|
||||||
return ChildTaskCompletionResult(
|
return ChildTaskCompletionResult(
|
||||||
tasks: List<Task>.unmodifiable(updatedTasks),
|
tasks: List<Task>.unmodifiable(updatedTasks),
|
||||||
changedTaskIds: List<String>.unmodifiable(changedTaskIds),
|
changedTaskIds: List<String>.unmodifiable(changedTaskIds),
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ library;
|
||||||
import '../domain/models.dart';
|
import '../domain/models.dart';
|
||||||
import '../domain/occupancy_policy.dart';
|
import '../domain/occupancy_policy.dart';
|
||||||
import 'scheduling_engine.dart';
|
import 'scheduling_engine.dart';
|
||||||
|
import '../logging/focus_flow_logger.dart';
|
||||||
part 'free_slots/required_commitment_schedule_status.dart';
|
part 'free_slots/required_commitment_schedule_status.dart';
|
||||||
part 'free_slots/protected_free_slot_conflict.dart';
|
part 'free_slots/protected_free_slot_conflict.dart';
|
||||||
part 'free_slots/required_commitment_schedule_result.dart';
|
part 'free_slots/required_commitment_schedule_result.dart';
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ class FreeSlotService {
|
||||||
String projectId = 'inbox',
|
String projectId = 'inbox',
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
}) {
|
}) {
|
||||||
|
logger.debug(() => 'Creating protected free slot. taskId=$id '
|
||||||
|
'start=${start.toIso8601String()} end=${end.toIso8601String()} '
|
||||||
|
'projectId=$projectId');
|
||||||
final interval = TimeInterval(start: start, end: end, label: id);
|
final interval = TimeInterval(start: start, end: end, label: id);
|
||||||
|
|
||||||
return Task(
|
return Task(
|
||||||
|
|
@ -45,7 +48,12 @@ class FreeSlotService {
|
||||||
String? title,
|
String? title,
|
||||||
String? projectId,
|
String? projectId,
|
||||||
}) {
|
}) {
|
||||||
|
logger.debug(() => 'Updating protected free slot. taskId=${freeSlot.id} '
|
||||||
|
'start=${start.toIso8601String()} end=${end.toIso8601String()} '
|
||||||
|
'projectId=$projectId hasTitle=${title != null}');
|
||||||
if (freeSlot.type != TaskType.freeSlot) {
|
if (freeSlot.type != TaskType.freeSlot) {
|
||||||
|
logger.error(() => 'Free slot update failed: invalid task type. '
|
||||||
|
'taskId=${freeSlot.id} taskType=${freeSlot.type.name}');
|
||||||
throw ArgumentError.value(
|
throw ArgumentError.value(
|
||||||
freeSlot.type,
|
freeSlot.type,
|
||||||
'freeSlot.type',
|
'freeSlot.type',
|
||||||
|
|
@ -78,8 +86,14 @@ class FreeSlotService {
|
||||||
required DateTime end,
|
required DateTime end,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
}) {
|
}) {
|
||||||
|
logger.debug(() => 'Scheduling required commitment. taskId=$taskId '
|
||||||
|
'start=${start.toIso8601String()} end=${end.toIso8601String()} '
|
||||||
|
'taskCount=${input.tasks.length}');
|
||||||
final task = _taskById(input.tasks, taskId);
|
final task = _taskById(input.tasks, taskId);
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
|
logger
|
||||||
|
.warn(() => 'Required commitment scheduling failed: task not found. '
|
||||||
|
'taskId=$taskId');
|
||||||
return _unchangedRequiredResult(
|
return _unchangedRequiredResult(
|
||||||
input: input,
|
input: input,
|
||||||
status: RequiredCommitmentScheduleStatus.taskNotFound,
|
status: RequiredCommitmentScheduleStatus.taskNotFound,
|
||||||
|
|
@ -94,6 +108,9 @@ class FreeSlotService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!task.isRequiredVisible) {
|
if (!task.isRequiredVisible) {
|
||||||
|
logger.warn(
|
||||||
|
() => 'Required commitment scheduling failed: invalid task type. '
|
||||||
|
'taskId=${task.id} taskType=${task.type.name}');
|
||||||
return _unchangedRequiredResult(
|
return _unchangedRequiredResult(
|
||||||
input: input,
|
input: input,
|
||||||
status: RequiredCommitmentScheduleStatus.invalidTaskType,
|
status: RequiredCommitmentScheduleStatus.invalidTaskType,
|
||||||
|
|
@ -171,6 +188,11 @@ class FreeSlotService {
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
logger.debug(() =>
|
||||||
|
'Required commitment scheduling finished. taskId=${task.id} '
|
||||||
|
'status=${conflicts.isEmpty ? RequiredCommitmentScheduleStatus.scheduled.name : RequiredCommitmentScheduleStatus.scheduledWithProtectedFreeSlotConflict.name} '
|
||||||
|
'conflictCount=${conflicts.length} changeCount=${changes.length}');
|
||||||
|
|
||||||
return RequiredCommitmentScheduleResult(
|
return RequiredCommitmentScheduleResult(
|
||||||
status: conflicts.isEmpty
|
status: conflicts.isEmpty
|
||||||
? RequiredCommitmentScheduleStatus.scheduled
|
? RequiredCommitmentScheduleStatus.scheduled
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ library;
|
||||||
import '../domain/models.dart';
|
import '../domain/models.dart';
|
||||||
import 'scheduling_engine.dart';
|
import 'scheduling_engine.dart';
|
||||||
import '../domain/time_contracts.dart';
|
import '../domain/time_contracts.dart';
|
||||||
|
import '../logging/focus_flow_logger.dart';
|
||||||
part 'quick_capture/quick_capture_status.dart';
|
part 'quick_capture/quick_capture_status.dart';
|
||||||
part 'quick_capture/quick_capture_request.dart';
|
part 'quick_capture/quick_capture_request.dart';
|
||||||
part 'quick_capture/quick_capture_result.dart';
|
part 'quick_capture/quick_capture_result.dart';
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,10 @@ class QuickCaptureService {
|
||||||
SchedulingInput? schedulingInput,
|
SchedulingInput? schedulingInput,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
}) {
|
}) {
|
||||||
|
logger.debug(() => 'Quick capture requested. taskId=${request.id} '
|
||||||
|
'projectId=${request.projectId} taskType=${request.type.name} '
|
||||||
|
'hasDuration=${request.durationMinutes != null} '
|
||||||
|
'addToNextAvailableSlot=${request.addToNextAvailableSlot}');
|
||||||
final capturedTaskWithoutDuration = Task.quickCapture(
|
final capturedTaskWithoutDuration = Task.quickCapture(
|
||||||
id: request.id,
|
id: request.id,
|
||||||
title: request.title,
|
title: request.title,
|
||||||
|
|
@ -55,6 +59,9 @@ class QuickCaptureService {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (request.durationMinutes != null && request.durationMinutes! <= 0) {
|
if (request.durationMinutes != null && request.durationMinutes! <= 0) {
|
||||||
|
logger.warn(() =>
|
||||||
|
'Quick capture validation failed: non-positive duration. '
|
||||||
|
'taskId=${request.id} durationMinutes=${request.durationMinutes}');
|
||||||
return QuickCaptureResult(
|
return QuickCaptureResult(
|
||||||
task: capturedTaskWithoutDuration,
|
task: capturedTaskWithoutDuration,
|
||||||
status: QuickCaptureStatus.validationError,
|
status: QuickCaptureStatus.validationError,
|
||||||
|
|
@ -67,6 +74,8 @@ class QuickCaptureService {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!request.addToNextAvailableSlot) {
|
if (!request.addToNextAvailableSlot) {
|
||||||
|
logger.debug(
|
||||||
|
() => 'Quick capture added to backlog. taskId=${capturedTask.id}');
|
||||||
return QuickCaptureResult(
|
return QuickCaptureResult(
|
||||||
task: capturedTask,
|
task: capturedTask,
|
||||||
status: QuickCaptureStatus.addedToBacklog,
|
status: QuickCaptureStatus.addedToBacklog,
|
||||||
|
|
@ -74,6 +83,9 @@ class QuickCaptureService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.durationMinutes == null || request.durationMinutes! <= 0) {
|
if (request.durationMinutes == null || request.durationMinutes! <= 0) {
|
||||||
|
logger.warn(
|
||||||
|
() => 'Quick capture scheduling validation failed: missing duration. '
|
||||||
|
'taskId=${capturedTask.id}');
|
||||||
return QuickCaptureResult(
|
return QuickCaptureResult(
|
||||||
task: capturedTask,
|
task: capturedTask,
|
||||||
status: QuickCaptureStatus.validationError,
|
status: QuickCaptureStatus.validationError,
|
||||||
|
|
@ -82,6 +94,9 @@ class QuickCaptureService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.type != TaskType.flexible) {
|
if (request.type != TaskType.flexible) {
|
||||||
|
logger.warn(() =>
|
||||||
|
'Quick capture scheduling validation failed: non-flexible task. '
|
||||||
|
'taskId=${capturedTask.id} taskType=${request.type.name}');
|
||||||
return QuickCaptureResult(
|
return QuickCaptureResult(
|
||||||
task: capturedTask,
|
task: capturedTask,
|
||||||
status: QuickCaptureStatus.validationError,
|
status: QuickCaptureStatus.validationError,
|
||||||
|
|
@ -90,6 +105,9 @@ class QuickCaptureService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (schedulingInput == null) {
|
if (schedulingInput == null) {
|
||||||
|
logger.warn(() =>
|
||||||
|
'Quick capture scheduling validation failed: missing scheduling input. '
|
||||||
|
'taskId=${capturedTask.id}');
|
||||||
return QuickCaptureResult(
|
return QuickCaptureResult(
|
||||||
task: capturedTask,
|
task: capturedTask,
|
||||||
status: QuickCaptureStatus.validationError,
|
status: QuickCaptureStatus.validationError,
|
||||||
|
|
@ -110,6 +128,9 @@ class QuickCaptureService {
|
||||||
final scheduledTask = _taskById(result.tasks, capturedTask.id);
|
final scheduledTask = _taskById(result.tasks, capturedTask.id);
|
||||||
|
|
||||||
if (scheduledTask == null || scheduledTask.isBacklog) {
|
if (scheduledTask == null || scheduledTask.isBacklog) {
|
||||||
|
logger.warn(() => 'Quick capture scheduling did not place task. '
|
||||||
|
'taskId=${capturedTask.id} outcome=${result.outcomeCode.name} '
|
||||||
|
'noticeCount=${result.notices.length}');
|
||||||
return QuickCaptureResult(
|
return QuickCaptureResult(
|
||||||
task: capturedTask,
|
task: capturedTask,
|
||||||
status: QuickCaptureStatus.validationError,
|
status: QuickCaptureStatus.validationError,
|
||||||
|
|
@ -118,6 +139,10 @@ class QuickCaptureService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.debug(() => 'Quick capture scheduled. taskId=${scheduledTask.id} '
|
||||||
|
'start=${scheduledTask.scheduledStart?.toIso8601String()} '
|
||||||
|
'end=${scheduledTask.scheduledEnd?.toIso8601String()} '
|
||||||
|
'changeCount=${result.changes.length} noticeCount=${result.notices.length}');
|
||||||
return QuickCaptureResult(
|
return QuickCaptureResult(
|
||||||
task: scheduledTask,
|
task: scheduledTask,
|
||||||
status: QuickCaptureStatus.scheduled,
|
status: QuickCaptureStatus.scheduled,
|
||||||
|
|
@ -142,10 +167,16 @@ class QuickCaptureService {
|
||||||
}) {
|
}) {
|
||||||
final generator = idGenerator;
|
final generator = idGenerator;
|
||||||
if (generator == null) {
|
if (generator == null) {
|
||||||
|
logger.error(
|
||||||
|
() => 'Quick capture rejected because id generator is missing.');
|
||||||
throw StateError('Quick capture needs an id generator.');
|
throw StateError('Quick capture needs an id generator.');
|
||||||
}
|
}
|
||||||
|
|
||||||
final now = createdAt ?? updatedAt ?? clock.now();
|
final now = createdAt ?? updatedAt ?? clock.now();
|
||||||
|
logger.debug(() => 'Quick capture new task requested. '
|
||||||
|
'hasTitle=${title.trim().isNotEmpty} projectId=$projectId '
|
||||||
|
'type=${type.name} addToNextAvailableSlot=$addToNextAvailableSlot '
|
||||||
|
'occurredAt=${now.toIso8601String()}');
|
||||||
|
|
||||||
return capture(
|
return capture(
|
||||||
QuickCaptureRequest(
|
QuickCaptureRequest(
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ library;
|
||||||
|
|
||||||
import '../domain/models.dart';
|
import '../domain/models.dart';
|
||||||
import '../domain/occupancy_policy.dart';
|
import '../domain/occupancy_policy.dart';
|
||||||
|
import '../logging/focus_flow_logger.dart';
|
||||||
part 'reminder_policy/directives/reminder_directive_action.dart';
|
part 'reminder_policy/directives/reminder_directive_action.dart';
|
||||||
part 'reminder_policy/directives/reminder_directive_reason.dart';
|
part 'reminder_policy/directives/reminder_directive_reason.dart';
|
||||||
part 'reminder_policy/profiles/effective_reminder_profile.dart';
|
part 'reminder_policy/profiles/effective_reminder_profile.dart';
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ class ReminderPolicyService {
|
||||||
}) {
|
}) {
|
||||||
final override = task.reminderOverride;
|
final override = task.reminderOverride;
|
||||||
if (override != null) {
|
if (override != null) {
|
||||||
|
logger.finer(() => 'Reminder profile resolved from task override. '
|
||||||
|
'taskId=${task.id} profile=${override.name}');
|
||||||
return EffectiveReminderProfile(
|
return EffectiveReminderProfile(
|
||||||
profile: override,
|
profile: override,
|
||||||
source: EffectiveReminderProfileSource.taskOverride,
|
source: EffectiveReminderProfileSource.taskOverride,
|
||||||
|
|
@ -32,12 +34,17 @@ class ReminderPolicyService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (project != null) {
|
if (project != null) {
|
||||||
|
logger.finer(() => 'Reminder profile resolved from project default. '
|
||||||
|
'taskId=${task.id} projectId=${project.id} '
|
||||||
|
'profile=${project.defaultReminderProfile.name}');
|
||||||
return EffectiveReminderProfile(
|
return EffectiveReminderProfile(
|
||||||
profile: project.defaultReminderProfile,
|
profile: project.defaultReminderProfile,
|
||||||
source: EffectiveReminderProfileSource.projectDefault,
|
source: EffectiveReminderProfileSource.projectDefault,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.finer(() => 'Reminder profile resolved from fallback. '
|
||||||
|
'taskId=${task.id} profile=${fallbackProfile.name}');
|
||||||
return EffectiveReminderProfile(
|
return EffectiveReminderProfile(
|
||||||
profile: fallbackProfile,
|
profile: fallbackProfile,
|
||||||
source: EffectiveReminderProfileSource.fallback,
|
source: EffectiveReminderProfileSource.fallback,
|
||||||
|
|
@ -51,11 +58,19 @@ class ReminderPolicyService {
|
||||||
ProjectProfile? project,
|
ProjectProfile? project,
|
||||||
Iterable<Task> contextTasks = const <Task>[],
|
Iterable<Task> contextTasks = const <Task>[],
|
||||||
}) {
|
}) {
|
||||||
|
logger.debug(() => 'Reminder directive evaluation started. '
|
||||||
|
'taskId=${task.id} type=${task.type.name} status=${task.status.name} '
|
||||||
|
'now=${now.toIso8601String()}');
|
||||||
final effective = resolveEffectiveProfile(task: task, project: project);
|
final effective = resolveEffectiveProfile(task: task, project: project);
|
||||||
final activeFreeSlot = _activeProtectedFreeSlot(
|
final activeFreeSlot = _activeProtectedFreeSlot(
|
||||||
now: now,
|
now: now,
|
||||||
contextTasks: contextTasks,
|
contextTasks: contextTasks,
|
||||||
);
|
);
|
||||||
|
if (activeFreeSlot != null) {
|
||||||
|
logger.finer(() => 'Reminder directive found active protected free slot. '
|
||||||
|
'taskId=${task.id} freeSlotTaskId=${activeFreeSlot.taskId} '
|
||||||
|
'freeSlotEnd=${activeFreeSlot.interval?.end.toIso8601String()}');
|
||||||
|
}
|
||||||
|
|
||||||
return _directiveForTask(
|
return _directiveForTask(
|
||||||
task: task,
|
task: task,
|
||||||
|
|
@ -81,6 +96,12 @@ class ReminderPolicyService {
|
||||||
required ReminderDirectiveReason reason,
|
required ReminderDirectiveReason reason,
|
||||||
DateTime? nextEligibleAt,
|
DateTime? nextEligibleAt,
|
||||||
}) {
|
}) {
|
||||||
|
logger.debug(() => 'Reminder directive evaluation finished. '
|
||||||
|
'taskId=${task.id} action=${action.name} reason=${reason.name} '
|
||||||
|
'profile=${effective.profile.name} source=${effective.source.name} '
|
||||||
|
'hasSchedule=${start != null && end != null} '
|
||||||
|
'nextEligibleAt=${nextEligibleAt?.toIso8601String()} '
|
||||||
|
'protectedFreeSlotTaskId=${activeFreeSlot?.taskId}');
|
||||||
return ReminderDirective(
|
return ReminderDirective(
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
action: action,
|
action: action,
|
||||||
|
|
@ -165,6 +186,8 @@ class ReminderPolicyService {
|
||||||
|
|
||||||
if (task.type == TaskType.critical &&
|
if (task.type == TaskType.critical &&
|
||||||
effective.profile == ReminderProfile.strict) {
|
effective.profile == ReminderProfile.strict) {
|
||||||
|
logger.finer(() => 'Reminder directive requires acknowledgement. '
|
||||||
|
'taskId=${task.id} profile=${effective.profile.name}');
|
||||||
return build(
|
return build(
|
||||||
action: ReminderDirectiveAction.requireAcknowledgement,
|
action: ReminderDirectiveAction.requireAcknowledgement,
|
||||||
reason: ReminderDirectiveReason.requireRequiredAcknowledgement,
|
reason: ReminderDirectiveReason.requireRequiredAcknowledgement,
|
||||||
|
|
@ -191,6 +214,9 @@ class ReminderPolicyService {
|
||||||
}) {
|
}) {
|
||||||
if (task.type == TaskType.critical &&
|
if (task.type == TaskType.critical &&
|
||||||
effective.profile == ReminderProfile.strict) {
|
effective.profile == ReminderProfile.strict) {
|
||||||
|
logger.finer(() =>
|
||||||
|
'Reminder directive requires acknowledgement during protected rest. '
|
||||||
|
'taskId=${task.id} profile=${effective.profile.name}');
|
||||||
return build(
|
return build(
|
||||||
action: ReminderDirectiveAction.requireAcknowledgement,
|
action: ReminderDirectiveAction.requireAcknowledgement,
|
||||||
reason: ReminderDirectiveReason.requireRequiredAcknowledgement,
|
reason: ReminderDirectiveReason.requireRequiredAcknowledgement,
|
||||||
|
|
@ -199,6 +225,10 @@ class ReminderPolicyService {
|
||||||
|
|
||||||
if (task.type == TaskType.inflexible &&
|
if (task.type == TaskType.inflexible &&
|
||||||
effective.profile == ReminderProfile.gentle) {
|
effective.profile == ReminderProfile.gentle) {
|
||||||
|
logger.finer(() =>
|
||||||
|
'Reminder directive deferred required task during protected rest. '
|
||||||
|
'taskId=${task.id} profile=${effective.profile.name} '
|
||||||
|
'nextEligibleAt=${freeSlotEnd?.toIso8601String()}');
|
||||||
return build(
|
return build(
|
||||||
action: ReminderDirectiveAction.defer,
|
action: ReminderDirectiveAction.defer,
|
||||||
reason: ReminderDirectiveReason.deferRequiredDuringProtectedRest,
|
reason: ReminderDirectiveReason.deferRequiredDuringProtectedRest,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import '../domain/models.dart';
|
||||||
import '../domain/occupancy_policy.dart';
|
import '../domain/occupancy_policy.dart';
|
||||||
import 'task_lifecycle.dart';
|
import 'task_lifecycle.dart';
|
||||||
import '../domain/time_contracts.dart';
|
import '../domain/time_contracts.dart';
|
||||||
|
import '../logging/focus_flow_logger.dart';
|
||||||
part 'scheduling_engine/codes/notices/scheduling_notice_type.dart';
|
part 'scheduling_engine/codes/notices/scheduling_notice_type.dart';
|
||||||
part 'scheduling_engine/codes/operations/scheduling_operation_code.dart';
|
part 'scheduling_engine/codes/operations/scheduling_operation_code.dart';
|
||||||
part 'scheduling_engine/codes/operations/scheduling_outcome_code.dart';
|
part 'scheduling_engine/codes/operations/scheduling_outcome_code.dart';
|
||||||
|
|
|
||||||
|
|
@ -45,10 +45,16 @@ class SchedulingEngine {
|
||||||
required String taskId,
|
required String taskId,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
}) {
|
}) {
|
||||||
|
logger.debug(() => 'Scheduling backlog task into next available slot. '
|
||||||
|
'taskId=$taskId taskCount=${input.tasks.length} '
|
||||||
|
'windowStart=${input.window.start.toIso8601String()} '
|
||||||
|
'windowEnd=${input.window.end.toIso8601String()}');
|
||||||
// Step 1: resolve and validate the task. The engine does not create tasks;
|
// Step 1: resolve and validate the task. The engine does not create tasks;
|
||||||
// quick capture or persistence code is responsible for adding it to the list.
|
// quick capture or persistence code is responsible for adding it to the list.
|
||||||
final task = _taskById(input.tasks, taskId);
|
final task = _taskById(input.tasks, taskId);
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
|
logger.warn(
|
||||||
|
() => 'Backlog scheduling failed: task not found. taskId=$taskId');
|
||||||
return _unchangedResult(
|
return _unchangedResult(
|
||||||
input,
|
input,
|
||||||
SchedulingNotice(
|
SchedulingNotice(
|
||||||
|
|
@ -64,6 +70,8 @@ class SchedulingEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!task.isFlexible || !task.isBacklog) {
|
if (!task.isFlexible || !task.isBacklog) {
|
||||||
|
logger.warn(() => 'Backlog scheduling failed: invalid task state. '
|
||||||
|
'taskId=${task.id} taskType=${task.type.name} taskStatus=${task.status.name}');
|
||||||
return _unchangedResult(
|
return _unchangedResult(
|
||||||
input,
|
input,
|
||||||
SchedulingNotice(
|
SchedulingNotice(
|
||||||
|
|
@ -80,6 +88,8 @@ class SchedulingEngine {
|
||||||
|
|
||||||
final taskDuration = _durationFromMinutes(task.durationMinutes);
|
final taskDuration = _durationFromMinutes(task.durationMinutes);
|
||||||
if (taskDuration == null) {
|
if (taskDuration == null) {
|
||||||
|
logger.warn(() => 'Backlog scheduling failed: missing positive duration. '
|
||||||
|
'taskId=${task.id} durationMinutes=${task.durationMinutes}');
|
||||||
return _unchangedResult(
|
return _unchangedResult(
|
||||||
input,
|
input,
|
||||||
SchedulingNotice(
|
SchedulingNotice(
|
||||||
|
|
@ -103,6 +113,8 @@ class SchedulingEngine {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (placement == null) {
|
if (placement == null) {
|
||||||
|
logger.warn(() => 'Backlog scheduling failed: no available slot. '
|
||||||
|
'taskId=${task.id} blockedIntervalCount=${input.blockedIntervals.length}');
|
||||||
return _unchangedResult(
|
return _unchangedResult(
|
||||||
input,
|
input,
|
||||||
SchedulingNotice(
|
SchedulingNotice(
|
||||||
|
|
@ -137,12 +149,18 @@ class SchedulingEngine {
|
||||||
required SchedulingInput input,
|
required SchedulingInput input,
|
||||||
required String taskId,
|
required String taskId,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
|
DateTime? earliestStart,
|
||||||
bool countAsManualPush = true,
|
bool countAsManualPush = true,
|
||||||
}) {
|
}) {
|
||||||
|
logger.debug(() => 'Pushing flexible task to next available slot. '
|
||||||
|
'taskId=$taskId taskCount=${input.tasks.length} '
|
||||||
|
'earliestStart=${earliestStart?.toIso8601String()} '
|
||||||
|
'countAsManualPush=$countAsManualPush');
|
||||||
// Resolve the selected task by id so UI code only needs to pass a stable
|
// Resolve the selected task by id so UI code only needs to pass a stable
|
||||||
// identifier, not object references.
|
// identifier, not object references.
|
||||||
final task = _taskById(input.tasks, taskId);
|
final task = _taskById(input.tasks, taskId);
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
|
logger.warn(() => 'Flexible push failed: task not found. taskId=$taskId');
|
||||||
return _unchangedResult(
|
return _unchangedResult(
|
||||||
input,
|
input,
|
||||||
SchedulingNotice(
|
SchedulingNotice(
|
||||||
|
|
@ -158,6 +176,8 @@ class SchedulingEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!task.isFlexible || task.status != TaskStatus.planned) {
|
if (!task.isFlexible || task.status != TaskStatus.planned) {
|
||||||
|
logger.warn(() => 'Flexible push failed: invalid task state. '
|
||||||
|
'taskId=${task.id} taskType=${task.type.name} taskStatus=${task.status.name}');
|
||||||
return _unchangedResult(
|
return _unchangedResult(
|
||||||
input,
|
input,
|
||||||
SchedulingNotice(
|
SchedulingNotice(
|
||||||
|
|
@ -173,7 +193,11 @@ class SchedulingEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
final currentInterval = _scheduledIntervalFor(task);
|
final currentInterval = _scheduledIntervalFor(task);
|
||||||
if (currentInterval == null || !input.window.contains(currentInterval)) {
|
if (currentInterval == null ||
|
||||||
|
(earliestStart == null && !input.window.contains(currentInterval))) {
|
||||||
|
logger.warn(() =>
|
||||||
|
'Flexible push failed: missing or out-of-window scheduled slot. '
|
||||||
|
'taskId=${task.id} hasCurrentInterval=${currentInterval != null}');
|
||||||
return _unchangedResult(
|
return _unchangedResult(
|
||||||
input,
|
input,
|
||||||
SchedulingNotice(
|
SchedulingNotice(
|
||||||
|
|
@ -189,6 +213,9 @@ class SchedulingEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentInterval.duration.inMicroseconds <= 0) {
|
if (currentInterval.duration.inMicroseconds <= 0) {
|
||||||
|
logger.warn(() =>
|
||||||
|
'Flexible push failed: non-positive scheduled duration. '
|
||||||
|
'taskId=${task.id} durationMicroseconds=${currentInterval.duration.inMicroseconds}');
|
||||||
return _unchangedResult(
|
return _unchangedResult(
|
||||||
input,
|
input,
|
||||||
SchedulingNotice(
|
SchedulingNotice(
|
||||||
|
|
@ -207,9 +234,12 @@ class SchedulingEngine {
|
||||||
input: input,
|
input: input,
|
||||||
task: task,
|
task: task,
|
||||||
currentInterval: currentInterval,
|
currentInterval: currentInterval,
|
||||||
|
earliestStart: earliestStart,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (placement == null) {
|
if (placement == null) {
|
||||||
|
logger.warn(() =>
|
||||||
|
'Flexible push failed: no available slot today. taskId=${task.id}');
|
||||||
return _unchangedResult(
|
return _unchangedResult(
|
||||||
input,
|
input,
|
||||||
SchedulingNotice(
|
SchedulingNotice(
|
||||||
|
|
@ -250,8 +280,13 @@ class SchedulingEngine {
|
||||||
required String taskId,
|
required String taskId,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
}) {
|
}) {
|
||||||
|
logger.debug(() => 'Pushing flexible task to tomorrow top of queue. '
|
||||||
|
'taskId=$taskId taskCount=${input.tasks.length} '
|
||||||
|
'windowStart=${input.window.start.toIso8601String()} '
|
||||||
|
'windowEnd=${input.window.end.toIso8601String()}');
|
||||||
final task = _taskById(input.tasks, taskId);
|
final task = _taskById(input.tasks, taskId);
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
|
logger.warn(() => 'Tomorrow push failed: task not found. taskId=$taskId');
|
||||||
return _unchangedResult(
|
return _unchangedResult(
|
||||||
input,
|
input,
|
||||||
SchedulingNotice(
|
SchedulingNotice(
|
||||||
|
|
@ -267,6 +302,8 @@ class SchedulingEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!task.isFlexible || task.status != TaskStatus.planned) {
|
if (!task.isFlexible || task.status != TaskStatus.planned) {
|
||||||
|
logger.warn(() => 'Tomorrow push failed: invalid task state. '
|
||||||
|
'taskId=${task.id} taskType=${task.type.name} taskStatus=${task.status.name}');
|
||||||
return _unchangedResult(
|
return _unchangedResult(
|
||||||
input,
|
input,
|
||||||
SchedulingNotice(
|
SchedulingNotice(
|
||||||
|
|
@ -284,6 +321,8 @@ class SchedulingEngine {
|
||||||
final taskDuration = _scheduledIntervalFor(task)?.duration ??
|
final taskDuration = _scheduledIntervalFor(task)?.duration ??
|
||||||
_durationFromMinutes(task.durationMinutes);
|
_durationFromMinutes(task.durationMinutes);
|
||||||
if (taskDuration == null || taskDuration.inMicroseconds <= 0) {
|
if (taskDuration == null || taskDuration.inMicroseconds <= 0) {
|
||||||
|
logger.warn(() => 'Tomorrow push failed: missing positive duration. '
|
||||||
|
'taskId=${task.id} durationMinutes=${task.durationMinutes}');
|
||||||
return _unchangedResult(
|
return _unchangedResult(
|
||||||
input,
|
input,
|
||||||
SchedulingNotice(
|
SchedulingNotice(
|
||||||
|
|
@ -305,6 +344,8 @@ class SchedulingEngine {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (placement == null) {
|
if (placement == null) {
|
||||||
|
logger.warn(
|
||||||
|
() => 'Tomorrow push failed: no available slot. taskId=${task.id}');
|
||||||
return _unchangedResult(
|
return _unchangedResult(
|
||||||
input,
|
input,
|
||||||
SchedulingNotice(
|
SchedulingNotice(
|
||||||
|
|
@ -349,6 +390,10 @@ class SchedulingEngine {
|
||||||
SchedulingWindow? sourceWindow,
|
SchedulingWindow? sourceWindow,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
}) {
|
}) {
|
||||||
|
logger.debug(() => 'Rolling over unfinished flexible tasks. '
|
||||||
|
'taskCount=${input.tasks.length} targetWindowStart=${input.window.start.toIso8601String()} '
|
||||||
|
'targetWindowEnd=${input.window.end.toIso8601String()} '
|
||||||
|
'hasSourceWindow=${sourceWindow != null}');
|
||||||
// Build the explicit queue of tasks to roll before asking the planner to
|
// Build the explicit queue of tasks to roll before asking the planner to
|
||||||
// place anything. This keeps selection separate from placement.
|
// place anything. This keeps selection separate from placement.
|
||||||
final rolledItems = <_PlacementItem>[];
|
final rolledItems = <_PlacementItem>[];
|
||||||
|
|
@ -385,6 +430,8 @@ class SchedulingEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rolledItems.isEmpty) {
|
if (rolledItems.isEmpty) {
|
||||||
|
logger
|
||||||
|
.debug(() => 'Rollover finished with no unfinished flexible tasks.');
|
||||||
return SchedulingResult(
|
return SchedulingResult(
|
||||||
tasks: input.tasks,
|
tasks: input.tasks,
|
||||||
operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks,
|
operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks,
|
||||||
|
|
@ -405,6 +452,9 @@ class SchedulingEngine {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (placement == null) {
|
if (placement == null) {
|
||||||
|
logger.warn(
|
||||||
|
() => 'Rollover failed: unfinished flexible tasks could not fit. '
|
||||||
|
'rolledCount=${rolledItems.length}');
|
||||||
return _unchangedResult(
|
return _unchangedResult(
|
||||||
input,
|
input,
|
||||||
SchedulingNotice(
|
SchedulingNotice(
|
||||||
|
|
@ -431,6 +481,8 @@ class SchedulingEngine {
|
||||||
/// reports any overlap with blocked intervals. It deliberately returns the
|
/// reports any overlap with blocked intervals. It deliberately returns the
|
||||||
/// original task list unchanged.
|
/// original task list unchanged.
|
||||||
SchedulingResult analyzeSchedule(SchedulingInput input) {
|
SchedulingResult analyzeSchedule(SchedulingInput input) {
|
||||||
|
logger.debug(() => 'Analyzing schedule. taskCount=${input.tasks.length} '
|
||||||
|
'conflictOccupancyCount=${input.conflictReportingOccupancies.length}');
|
||||||
final overlaps = <SchedulingOverlap>[];
|
final overlaps = <SchedulingOverlap>[];
|
||||||
final notices = <SchedulingNotice>[];
|
final notices = <SchedulingNotice>[];
|
||||||
final conflictOccupancies = input.conflictReportingOccupancies;
|
final conflictOccupancies = input.conflictReportingOccupancies;
|
||||||
|
|
@ -474,6 +526,9 @@ class SchedulingEngine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
() => 'Schedule analysis finished. overlapCount=${overlaps.length} '
|
||||||
|
'noticeCount=${notices.length}');
|
||||||
return SchedulingResult(
|
return SchedulingResult(
|
||||||
tasks: input.tasks,
|
tasks: input.tasks,
|
||||||
operationCode: SchedulingOperationCode.analyzeSchedule,
|
operationCode: SchedulingOperationCode.analyzeSchedule,
|
||||||
|
|
@ -492,6 +547,8 @@ class SchedulingEngine {
|
||||||
/// reports can distinguish this from a task that was never scheduled.
|
/// reports can distinguish this from a task that was never scheduled.
|
||||||
Task moveToBacklog(Task task, {DateTime? updatedAt}) {
|
Task moveToBacklog(Task task, {DateTime? updatedAt}) {
|
||||||
final now = updatedAt ?? clock.now();
|
final now = updatedAt ?? clock.now();
|
||||||
|
logger.debug(() => 'Legacy move-to-backlog requested. taskId=${task.id} '
|
||||||
|
'taskStatus=${task.status.name} occurredAt=${now.toIso8601String()}');
|
||||||
final result = _transitionService.apply(
|
final result = _transitionService.apply(
|
||||||
task: task,
|
task: task,
|
||||||
transitionCode: TaskTransitionCode.moveToBacklog,
|
transitionCode: TaskTransitionCode.moveToBacklog,
|
||||||
|
|
@ -500,6 +557,8 @@ class SchedulingEngine {
|
||||||
'legacy-move-to-backlog:${task.id}:${now.toIso8601String()}:activity',
|
'legacy-move-to-backlog:${task.id}:${now.toIso8601String()}:activity',
|
||||||
occurredAt: now,
|
occurredAt: now,
|
||||||
);
|
);
|
||||||
|
logger.debug(() => 'Legacy move-to-backlog finished. taskId=${task.id} '
|
||||||
|
'outcome=${result.outcomeCode.name} nextStatus=${result.task.status.name}');
|
||||||
return result.applied ? result.task : task;
|
return result.applied ? result.task : task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -509,6 +568,9 @@ class SchedulingEngine {
|
||||||
/// actual scheduled slot should change.
|
/// actual scheduled slot should change.
|
||||||
Task markManuallyPushed(Task task, {DateTime? updatedAt}) {
|
Task markManuallyPushed(Task task, {DateTime? updatedAt}) {
|
||||||
final now = updatedAt ?? clock.now();
|
final now = updatedAt ?? clock.now();
|
||||||
|
logger.debug(
|
||||||
|
() => 'Legacy manual push statistic requested. taskId=${task.id} '
|
||||||
|
'occurredAt=${now.toIso8601String()}');
|
||||||
final result = _transitionService.apply(
|
final result = _transitionService.apply(
|
||||||
task: task,
|
task: task,
|
||||||
transitionCode: TaskTransitionCode.manualPush,
|
transitionCode: TaskTransitionCode.manualPush,
|
||||||
|
|
@ -517,6 +579,9 @@ class SchedulingEngine {
|
||||||
'legacy-manual-push:${task.id}:${now.toIso8601String()}:activity',
|
'legacy-manual-push:${task.id}:${now.toIso8601String()}:activity',
|
||||||
occurredAt: now,
|
occurredAt: now,
|
||||||
);
|
);
|
||||||
|
logger
|
||||||
|
.debug(() => 'Legacy manual push statistic finished. taskId=${task.id} '
|
||||||
|
'outcome=${result.outcomeCode.name}');
|
||||||
return result.applied ? result.task : task;
|
return result.applied ? result.task : task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -527,6 +592,9 @@ class SchedulingEngine {
|
||||||
/// or time block that cannot simply be rescheduled automatically.
|
/// or time block that cannot simply be rescheduled automatically.
|
||||||
Task markMissed(Task task, {DateTime? updatedAt}) {
|
Task markMissed(Task task, {DateTime? updatedAt}) {
|
||||||
final now = updatedAt ?? clock.now();
|
final now = updatedAt ?? clock.now();
|
||||||
|
logger.debug(() => 'Legacy mark-missed requested. taskId=${task.id} '
|
||||||
|
'taskType=${task.type.name} taskStatus=${task.status.name} '
|
||||||
|
'occurredAt=${now.toIso8601String()}');
|
||||||
final result = _transitionService.apply(
|
final result = _transitionService.apply(
|
||||||
task: task,
|
task: task,
|
||||||
transitionCode: TaskTransitionCode.miss,
|
transitionCode: TaskTransitionCode.miss,
|
||||||
|
|
@ -534,6 +602,8 @@ class SchedulingEngine {
|
||||||
activityId: 'legacy-miss:${task.id}:${now.toIso8601String()}:activity',
|
activityId: 'legacy-miss:${task.id}:${now.toIso8601String()}:activity',
|
||||||
occurredAt: now,
|
occurredAt: now,
|
||||||
);
|
);
|
||||||
|
logger.debug(() => 'Legacy mark-missed finished. taskId=${task.id} '
|
||||||
|
'outcome=${result.outcomeCode.name} nextStatus=${result.task.status.name}');
|
||||||
return result.applied ? result.task : task;
|
return result.applied ? result.task : task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -550,6 +620,10 @@ class SchedulingEngine {
|
||||||
required Duration duration,
|
required Duration duration,
|
||||||
required List<TimeInterval> blocked,
|
required List<TimeInterval> blocked,
|
||||||
}) {
|
}) {
|
||||||
|
logger.debug(() => 'Finding first open interval. '
|
||||||
|
'windowStart=${windowStart.toIso8601String()} '
|
||||||
|
'windowEnd=${windowEnd.toIso8601String()} '
|
||||||
|
'durationMinutes=${duration.inMinutes} blockedCount=${blocked.length}');
|
||||||
final sortedBlocked = [...blocked]
|
final sortedBlocked = [...blocked]
|
||||||
..sort((a, b) => a.start.compareTo(b.start));
|
..sort((a, b) => a.start.compareTo(b.start));
|
||||||
var cursor = windowStart;
|
var cursor = windowStart;
|
||||||
|
|
@ -738,7 +812,12 @@ _BacklogInsertionPlan? _planFlexiblePush({
|
||||||
required SchedulingInput input,
|
required SchedulingInput input,
|
||||||
required Task task,
|
required Task task,
|
||||||
required TimeInterval currentInterval,
|
required TimeInterval currentInterval,
|
||||||
|
DateTime? earliestStart,
|
||||||
}) {
|
}) {
|
||||||
|
final pushPoint = _laterOf(
|
||||||
|
currentInterval.end,
|
||||||
|
earliestStart ?? input.window.start,
|
||||||
|
);
|
||||||
final fixedBlocks = <TimeInterval>[
|
final fixedBlocks = <TimeInterval>[
|
||||||
...input.blockedIntervals,
|
...input.blockedIntervals,
|
||||||
];
|
];
|
||||||
|
|
@ -746,7 +825,7 @@ _BacklogInsertionPlan? _planFlexiblePush({
|
||||||
_PlacementItem(
|
_PlacementItem(
|
||||||
task: task,
|
task: task,
|
||||||
duration: currentInterval.duration,
|
duration: currentInterval.duration,
|
||||||
earliestStart: currentInterval.end,
|
earliestStart: pushPoint,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -766,7 +845,7 @@ _BacklogInsertionPlan? _planFlexiblePush({
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
final startsBeforePushPoint = interval.start.isBefore(currentInterval.end);
|
final startsBeforePushPoint = interval.start.isBefore(pushPoint);
|
||||||
final startsAfterWindow = interval.start.isAfter(input.window.end) ||
|
final startsAfterWindow = interval.start.isAfter(input.window.end) ||
|
||||||
interval.start.isAtSameMomentAs(input.window.end);
|
interval.start.isAtSameMomentAs(input.window.end);
|
||||||
|
|
||||||
|
|
@ -786,7 +865,7 @@ _BacklogInsertionPlan? _planFlexiblePush({
|
||||||
|
|
||||||
fixedBlocks.sort((a, b) => a.start.compareTo(b.start));
|
fixedBlocks.sort((a, b) => a.start.compareTo(b.start));
|
||||||
|
|
||||||
var cursor = currentInterval.end;
|
var cursor = pushPoint;
|
||||||
final placements = <String, TimeInterval>{};
|
final placements = <String, TimeInterval>{};
|
||||||
|
|
||||||
for (final item in queue) {
|
for (final item in queue) {
|
||||||
|
|
@ -944,6 +1023,7 @@ SchedulingResult _applyPlacement({
|
||||||
scheduledStart: interval.start,
|
scheduledStart: interval.start,
|
||||||
scheduledEnd: interval.end,
|
scheduledEnd: interval.end,
|
||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt,
|
||||||
|
clearBacklogEntry: isInsertedTask,
|
||||||
);
|
);
|
||||||
final updatedTask = _applySchedulingActivity(
|
final updatedTask = _applySchedulingActivity(
|
||||||
originalTask: task,
|
originalTask: task,
|
||||||
|
|
@ -990,6 +1070,9 @@ SchedulingResult _applyPlacement({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.debug(() => 'Backlog insertion placement applied. '
|
||||||
|
'insertedTaskId=${insertedTask.id} changeCount=${changes.length} '
|
||||||
|
'noticeCount=${notices.length}');
|
||||||
return SchedulingResult(
|
return SchedulingResult(
|
||||||
tasks: List<Task>.unmodifiable(updatedTasks),
|
tasks: List<Task>.unmodifiable(updatedTasks),
|
||||||
operationCode:
|
operationCode:
|
||||||
|
|
@ -1039,6 +1122,7 @@ SchedulingResult _applyPushPlacement({
|
||||||
scheduledStart: interval.start,
|
scheduledStart: interval.start,
|
||||||
scheduledEnd: interval.end,
|
scheduledEnd: interval.end,
|
||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt,
|
||||||
|
clearBacklogEntry: true,
|
||||||
);
|
);
|
||||||
final updatedTask = _applySchedulingActivity(
|
final updatedTask = _applySchedulingActivity(
|
||||||
originalTask: task,
|
originalTask: task,
|
||||||
|
|
@ -1082,6 +1166,9 @@ SchedulingResult _applyPushPlacement({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.debug(() => 'Flexible push placement applied. '
|
||||||
|
'operationCode=${operationCode.name} pushedTaskId=${pushedTask.id} '
|
||||||
|
'changeCount=${changes.length} noticeCount=${notices.length}');
|
||||||
return SchedulingResult(
|
return SchedulingResult(
|
||||||
tasks: List<Task>.unmodifiable(updatedTasks),
|
tasks: List<Task>.unmodifiable(updatedTasks),
|
||||||
operationCode: operationCode,
|
operationCode: operationCode,
|
||||||
|
|
@ -1183,6 +1270,8 @@ SchedulingResult _applyRolloverPlacement({
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
logger.debug(() => 'Rollover placement applied. rolledCount=$rolledCount '
|
||||||
|
'changeCount=${changes.length} noticeCount=${notices.length}');
|
||||||
return SchedulingResult(
|
return SchedulingResult(
|
||||||
tasks: List<Task>.unmodifiable(updatedTasks),
|
tasks: List<Task>.unmodifiable(updatedTasks),
|
||||||
operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks,
|
operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ library;
|
||||||
|
|
||||||
import '../domain/models.dart';
|
import '../domain/models.dart';
|
||||||
import '../domain/occupancy_policy.dart';
|
import '../domain/occupancy_policy.dart';
|
||||||
|
import '../logging/focus_flow_logger.dart';
|
||||||
import 'scheduling_engine.dart';
|
import 'scheduling_engine.dart';
|
||||||
import 'task_lifecycle.dart';
|
import 'task_lifecycle.dart';
|
||||||
import '../domain/time_contracts.dart';
|
import '../domain/time_contracts.dart';
|
||||||
|
|
|
||||||
|
|
@ -43,15 +43,35 @@ class FlexibleTaskActionService {
|
||||||
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
|
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
|
||||||
}) {
|
}) {
|
||||||
if (!task.isFlexible) {
|
if (!task.isFlexible) {
|
||||||
|
logger.error(() => 'Flexible action rejected for non-flexible task. '
|
||||||
|
'action=${action.name} taskId=${task.id} '
|
||||||
|
'taskType=${task.type.name} taskStatus=${task.status.name}');
|
||||||
throw ArgumentError.value(
|
throw ArgumentError.value(
|
||||||
task.type, 'task.type', 'Task must be flexible.');
|
task.type, 'task.type', 'Task must be flexible.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.debug(() => 'Applying flexible quick action. '
|
||||||
|
'action=${action.name} taskId=${task.id} '
|
||||||
|
'taskStatus=${task.status.name} hasExplicitUpdatedAt=${updatedAt != null} '
|
||||||
|
'hasOperationId=${operationId != null}');
|
||||||
|
logger.finest(() => 'Flexible quick action input dump. '
|
||||||
|
'action=${action.name} task=$task '
|
||||||
|
'updatedAt=${updatedAt?.toIso8601String()} operationId=$operationId '
|
||||||
|
'actualStart=${actualStart?.toIso8601String()} '
|
||||||
|
'actualEnd=${actualEnd?.toIso8601String()} '
|
||||||
|
'lockedIntervals=$lockedIntervals '
|
||||||
|
'existingActivityCount=${existingActivities.length}');
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case FlexibleTaskQuickAction.done:
|
case FlexibleTaskQuickAction.done:
|
||||||
final now = updatedAt ?? clock.now();
|
final now = updatedAt ?? clock.now();
|
||||||
final opId =
|
final opId =
|
||||||
operationId ?? _defaultOperationId(action.name, task.id, now);
|
operationId ?? _defaultOperationId(action.name, task.id, now);
|
||||||
|
logger.finer(() => 'Completing flexible task. taskId=${task.id} '
|
||||||
|
'operationId=$opId occurredAt=${now.toIso8601String()} '
|
||||||
|
'hasActualStart=${actualStart != null} '
|
||||||
|
'hasActualEnd=${actualEnd != null} '
|
||||||
|
'lockedIntervalCount=${lockedIntervals.length}');
|
||||||
final transition = transitionService.apply(
|
final transition = transitionService.apply(
|
||||||
task: task,
|
task: task,
|
||||||
transitionCode: TaskTransitionCode.complete,
|
transitionCode: TaskTransitionCode.complete,
|
||||||
|
|
@ -67,6 +87,17 @@ class FlexibleTaskActionService {
|
||||||
lockedIntervals: lockedIntervals,
|
lockedIntervals: lockedIntervals,
|
||||||
existingActivities: existingActivities,
|
existingActivities: existingActivities,
|
||||||
);
|
);
|
||||||
|
logger.debug(() => 'Flexible task completion transition finished. '
|
||||||
|
'taskId=${task.id} outcome=${transition.outcomeCode.name} '
|
||||||
|
'nextStatus=${transition.task.status.name} '
|
||||||
|
'activityCount=${transition.activities.length}');
|
||||||
|
if (transition.outcomeCode != TaskTransitionOutcomeCode.applied) {
|
||||||
|
logger
|
||||||
|
.fine(() => 'Possible issue: flexible completion did not apply. '
|
||||||
|
'taskId=${task.id} outcome=${transition.outcomeCode.name} '
|
||||||
|
'previousStatus=${task.status.name} '
|
||||||
|
'nextStatus=${transition.task.status.name}');
|
||||||
|
}
|
||||||
return FlexibleTaskActionResult(
|
return FlexibleTaskActionResult(
|
||||||
action: action,
|
action: action,
|
||||||
task: transition.task,
|
task: transition.task,
|
||||||
|
|
@ -74,6 +105,10 @@ class FlexibleTaskActionService {
|
||||||
transitionOutcomeCode: transition.outcomeCode,
|
transitionOutcomeCode: transition.outcomeCode,
|
||||||
);
|
);
|
||||||
case FlexibleTaskQuickAction.push:
|
case FlexibleTaskQuickAction.push:
|
||||||
|
logger.debug(() => 'Flexible push destination menu requested. '
|
||||||
|
'taskId=${task.id} taskStatus=${task.status.name}');
|
||||||
|
logger.finer(() => 'Flexible push destinations prepared. '
|
||||||
|
'taskId=${task.id} destinationCount=3');
|
||||||
return FlexibleTaskActionResult(
|
return FlexibleTaskActionResult(
|
||||||
action: action,
|
action: action,
|
||||||
task: task,
|
task: task,
|
||||||
|
|
@ -87,6 +122,10 @@ class FlexibleTaskActionService {
|
||||||
final now = updatedAt ?? clock.now();
|
final now = updatedAt ?? clock.now();
|
||||||
final opId =
|
final opId =
|
||||||
operationId ?? _defaultOperationId(action.name, task.id, now);
|
operationId ?? _defaultOperationId(action.name, task.id, now);
|
||||||
|
logger.debug(() => 'Moving flexible task to backlog from quick action. '
|
||||||
|
'taskId=${task.id} operationId=$opId '
|
||||||
|
'occurredAt=${now.toIso8601String()} '
|
||||||
|
'previousStatus=${task.status.name}');
|
||||||
final transition = transitionService.apply(
|
final transition = transitionService.apply(
|
||||||
task: task,
|
task: task,
|
||||||
transitionCode: TaskTransitionCode.moveToBacklog,
|
transitionCode: TaskTransitionCode.moveToBacklog,
|
||||||
|
|
@ -99,6 +138,17 @@ class FlexibleTaskActionService {
|
||||||
occurredAt: now,
|
occurredAt: now,
|
||||||
existingActivities: existingActivities,
|
existingActivities: existingActivities,
|
||||||
);
|
);
|
||||||
|
logger.debug(() => 'Flexible task backlog transition finished. '
|
||||||
|
'taskId=${task.id} outcome=${transition.outcomeCode.name} '
|
||||||
|
'nextStatus=${transition.task.status.name} '
|
||||||
|
'activityCount=${transition.activities.length}');
|
||||||
|
if (transition.outcomeCode != TaskTransitionOutcomeCode.applied) {
|
||||||
|
logger.fine(() =>
|
||||||
|
'Possible issue: move-to-backlog quick action did not apply. '
|
||||||
|
'taskId=${task.id} outcome=${transition.outcomeCode.name} '
|
||||||
|
'previousStatus=${task.status.name} '
|
||||||
|
'nextStatus=${transition.task.status.name}');
|
||||||
|
}
|
||||||
return FlexibleTaskActionResult(
|
return FlexibleTaskActionResult(
|
||||||
action: action,
|
action: action,
|
||||||
task: transition.task,
|
task: transition.task,
|
||||||
|
|
@ -106,6 +156,8 @@ class FlexibleTaskActionService {
|
||||||
transitionOutcomeCode: transition.outcomeCode,
|
transitionOutcomeCode: transition.outcomeCode,
|
||||||
);
|
);
|
||||||
case FlexibleTaskQuickAction.breakUp:
|
case FlexibleTaskQuickAction.breakUp:
|
||||||
|
logger.debug(() => 'Flexible break-up flow requested. '
|
||||||
|
'taskId=${task.id} taskStatus=${task.status.name}');
|
||||||
return FlexibleTaskActionResult(
|
return FlexibleTaskActionResult(
|
||||||
action: action,
|
action: action,
|
||||||
task: task,
|
task: task,
|
||||||
|
|
@ -123,17 +175,28 @@ class FlexibleTaskActionService {
|
||||||
required SchedulingInput input,
|
required SchedulingInput input,
|
||||||
required String taskId,
|
required String taskId,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
|
DateTime? earliestStart,
|
||||||
String? operationId,
|
String? operationId,
|
||||||
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
|
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
|
||||||
}) {
|
}) {
|
||||||
final now = updatedAt ?? clock.now();
|
final now = updatedAt ?? clock.now();
|
||||||
final opId = operationId ??
|
final opId = operationId ??
|
||||||
_defaultOperationId('push-${destination.name}', taskId, now);
|
_defaultOperationId('push-${destination.name}', taskId, now);
|
||||||
|
logger.debug(() => 'Applying flexible push destination. '
|
||||||
|
'destination=${destination.name} taskId=$taskId operationId=$opId '
|
||||||
|
'occurredAt=${now.toIso8601String()} taskCount=${input.tasks.length} '
|
||||||
|
'windowStart=${input.window.start.toIso8601String()} '
|
||||||
|
'windowEnd=${input.window.end.toIso8601String()}');
|
||||||
|
logger.finest(() => 'Flexible push destination input dump. '
|
||||||
|
'destination=${destination.name} taskId=$taskId input=$input '
|
||||||
|
'existingActivityCount=${existingActivities.length}');
|
||||||
if (_hasDuplicateActivity(
|
if (_hasDuplicateActivity(
|
||||||
existingActivities: existingActivities,
|
existingActivities: existingActivities,
|
||||||
operationId: opId,
|
operationId: opId,
|
||||||
taskId: taskId,
|
taskId: taskId,
|
||||||
)) {
|
)) {
|
||||||
|
logger.warn(() => 'Duplicate flexible push operation ignored. '
|
||||||
|
'destination=${destination.name} taskId=$taskId operationId=$opId');
|
||||||
return PushDestinationResult(
|
return PushDestinationResult(
|
||||||
destination: destination,
|
destination: destination,
|
||||||
schedulingResult: SchedulingResult(
|
schedulingResult: SchedulingResult(
|
||||||
|
|
@ -147,12 +210,15 @@ class FlexibleTaskActionService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.finer(() => 'Dispatching flexible push destination to scheduler. '
|
||||||
|
'destination=${destination.name} taskId=$taskId');
|
||||||
final result = switch (destination) {
|
final result = switch (destination) {
|
||||||
PushDestination.nextAvailableSlot =>
|
PushDestination.nextAvailableSlot =>
|
||||||
schedulingEngine.pushFlexibleTaskToNextAvailableSlot(
|
schedulingEngine.pushFlexibleTaskToNextAvailableSlot(
|
||||||
input: input,
|
input: input,
|
||||||
taskId: taskId,
|
taskId: taskId,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
earliestStart: earliestStart,
|
||||||
),
|
),
|
||||||
PushDestination.tomorrowTopOfQueue =>
|
PushDestination.tomorrowTopOfQueue =>
|
||||||
schedulingEngine.pushFlexibleTaskToTomorrowTopOfQueue(
|
schedulingEngine.pushFlexibleTaskToTomorrowTopOfQueue(
|
||||||
|
|
@ -166,19 +232,38 @@ class FlexibleTaskActionService {
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
logger.debug(() => 'Flexible push destination scheduling finished. '
|
||||||
|
'destination=${destination.name} taskId=$taskId '
|
||||||
|
'outcome=${result.outcomeCode.name} changeCount=${result.changes.length} '
|
||||||
|
'noticeCount=${result.notices.length}');
|
||||||
|
if (result.outcomeCode != SchedulingOutcomeCode.success) {
|
||||||
|
logger.warn(() => 'Handled flexible push issue. '
|
||||||
|
'destination=${destination.name} taskId=$taskId '
|
||||||
|
'outcome=${result.outcomeCode.name} notices=${result.notices}');
|
||||||
|
}
|
||||||
|
logger.fine(() => 'Flexible push result details. '
|
||||||
|
'destination=${destination.name} taskId=$taskId '
|
||||||
|
'changes=${result.changes} notices=${result.notices}');
|
||||||
|
logger.finest(() => 'Flexible push destination result dump. '
|
||||||
|
'destination=${destination.name} taskId=$taskId result=$result');
|
||||||
|
|
||||||
|
final activities = _activitiesForPushDestination(
|
||||||
|
destination: destination,
|
||||||
|
input: input,
|
||||||
|
result: result,
|
||||||
|
taskId: taskId,
|
||||||
|
operationId: opId,
|
||||||
|
occurredAt: now,
|
||||||
|
existingActivities: existingActivities,
|
||||||
|
);
|
||||||
|
logger.debug(() => 'Flexible push destination activities prepared. '
|
||||||
|
'destination=${destination.name} taskId=$taskId '
|
||||||
|
'activityCount=${activities.length}');
|
||||||
|
|
||||||
return PushDestinationResult(
|
return PushDestinationResult(
|
||||||
destination: destination,
|
destination: destination,
|
||||||
schedulingResult: result,
|
schedulingResult: result,
|
||||||
activities: _activitiesForPushDestination(
|
activities: activities,
|
||||||
destination: destination,
|
|
||||||
input: input,
|
|
||||||
result: result,
|
|
||||||
taskId: taskId,
|
|
||||||
operationId: opId,
|
|
||||||
occurredAt: now,
|
|
||||||
existingActivities: existingActivities,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -191,8 +276,15 @@ class FlexibleTaskActionService {
|
||||||
required String taskId,
|
required String taskId,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
}) {
|
}) {
|
||||||
|
logger.debug(() => 'Moving flexible task to backlog from push destination. '
|
||||||
|
'taskId=$taskId taskCount=${input.tasks.length} '
|
||||||
|
'hasExplicitUpdatedAt=${updatedAt != null}');
|
||||||
|
logger.finest(() => 'Move-to-backlog input dump. '
|
||||||
|
'taskId=$taskId tasks=${input.tasks}');
|
||||||
final task = _taskById(input.tasks, taskId);
|
final task = _taskById(input.tasks, taskId);
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
|
logger.warn(() => 'Handled move-to-backlog issue: task not found. '
|
||||||
|
'taskId=$taskId');
|
||||||
return SchedulingResult(
|
return SchedulingResult(
|
||||||
tasks: input.tasks,
|
tasks: input.tasks,
|
||||||
operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog,
|
operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog,
|
||||||
|
|
@ -209,6 +301,9 @@ class FlexibleTaskActionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!task.isFlexible || task.status != TaskStatus.planned) {
|
if (!task.isFlexible || task.status != TaskStatus.planned) {
|
||||||
|
logger.warn(() => 'Handled move-to-backlog issue: invalid task state. '
|
||||||
|
'taskId=${task.id} taskType=${task.type.name} '
|
||||||
|
'taskStatus=${task.status.name}');
|
||||||
return SchedulingResult(
|
return SchedulingResult(
|
||||||
tasks: input.tasks,
|
tasks: input.tasks,
|
||||||
operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog,
|
operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog,
|
||||||
|
|
@ -228,6 +323,10 @@ class FlexibleTaskActionService {
|
||||||
task,
|
task,
|
||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt,
|
||||||
);
|
);
|
||||||
|
logger.debug(() => 'Flexible task moved to backlog. '
|
||||||
|
'taskId=${task.id} previousStart=${task.scheduledStart?.toIso8601String()} '
|
||||||
|
'previousEnd=${task.scheduledEnd?.toIso8601String()} '
|
||||||
|
'updatedAt=${updatedAt?.toIso8601String()}');
|
||||||
|
|
||||||
return SchedulingResult(
|
return SchedulingResult(
|
||||||
tasks: List<Task>.unmodifiable(
|
tasks: List<Task>.unmodifiable(
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,12 @@ class RequiredTaskActionService {
|
||||||
List<TimeInterval> lockedIntervals = const <TimeInterval>[],
|
List<TimeInterval> lockedIntervals = const <TimeInterval>[],
|
||||||
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
|
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
|
||||||
}) {
|
}) {
|
||||||
|
logger.debug(() => 'Applying required task action. taskId=${task.id} '
|
||||||
|
'action=${action.name} taskType=${task.type.name} status=${task.status.name} '
|
||||||
|
'operationId=$operationId');
|
||||||
if (!task.isRequiredVisible) {
|
if (!task.isRequiredVisible) {
|
||||||
|
logger.error(() => 'Required task action failed: invalid task type. '
|
||||||
|
'taskId=${task.id} taskType=${task.type.name} action=${action.name}');
|
||||||
throw ArgumentError.value(
|
throw ArgumentError.value(
|
||||||
task.type,
|
task.type,
|
||||||
'task.type',
|
'task.type',
|
||||||
|
|
@ -61,6 +66,10 @@ class RequiredTaskActionService {
|
||||||
RequiredTaskAction.noLongerRelevant => TaskActivityCode.noLongerRelevant,
|
RequiredTaskAction.noLongerRelevant => TaskActivityCode.noLongerRelevant,
|
||||||
};
|
};
|
||||||
final opId = operationId ?? _defaultOperationId(action.name, task.id, now);
|
final opId = operationId ?? _defaultOperationId(action.name, task.id, now);
|
||||||
|
logger.finer(
|
||||||
|
() => 'Required task action resolved transition. taskId=${task.id} '
|
||||||
|
'action=${action.name} transition=${transitionCode.name} '
|
||||||
|
'operationId=$opId');
|
||||||
final transition = transitionService.apply(
|
final transition = transitionService.apply(
|
||||||
task: task,
|
task: task,
|
||||||
transitionCode: transitionCode,
|
transitionCode: transitionCode,
|
||||||
|
|
@ -73,6 +82,11 @@ class RequiredTaskActionService {
|
||||||
existingActivities: existingActivities,
|
existingActivities: existingActivities,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
logger.debug(() => 'Required task action finished. taskId=${task.id} '
|
||||||
|
'action=${action.name} outcome=${transition.outcomeCode.name} '
|
||||||
|
'activityCount=${transition.activities.length} '
|
||||||
|
'removedFromActivePlan=${transition.task.status == TaskStatus.backlog || transition.task.status == TaskStatus.cancelled || transition.task.status == TaskStatus.noLongerRelevant}');
|
||||||
|
|
||||||
return RequiredTaskActionResult(
|
return RequiredTaskActionResult(
|
||||||
action: action,
|
action: action,
|
||||||
task: transition.task,
|
task: transition.task,
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,16 @@ class SurpriseTaskLogService {
|
||||||
bool revealHiddenLockedDetails = false,
|
bool revealHiddenLockedDetails = false,
|
||||||
}) {
|
}) {
|
||||||
final now = updatedAt ?? clock.now();
|
final now = updatedAt ?? clock.now();
|
||||||
|
logger.debug(() => 'Logging surprise task. taskId=${request.id} '
|
||||||
|
'taskCount=${input.tasks.length} hasStartedAt=${request.startedAt != null} '
|
||||||
|
'hasDuration=${request.timeUsedMinutes != null} '
|
||||||
|
'revealHiddenLockedDetails=$revealHiddenLockedDetails');
|
||||||
final existingTask = _taskById(input.tasks, request.id);
|
final existingTask = _taskById(input.tasks, request.id);
|
||||||
if (existingTask != null) {
|
if (existingTask != null) {
|
||||||
if (existingTask.type != TaskType.surprise) {
|
if (existingTask.type != TaskType.surprise) {
|
||||||
|
logger.error(() =>
|
||||||
|
'Surprise task log failed: id already used by another task type. '
|
||||||
|
'taskId=${request.id} taskType=${existingTask.type.name}');
|
||||||
throw ArgumentError.value(
|
throw ArgumentError.value(
|
||||||
request.id,
|
request.id,
|
||||||
'request.id',
|
'request.id',
|
||||||
|
|
@ -49,6 +56,8 @@ class SurpriseTaskLogService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
() => 'Duplicate surprise task log ignored. taskId=${request.id}');
|
||||||
return SurpriseTaskLogResult(
|
return SurpriseTaskLogResult(
|
||||||
surpriseTask: existingTask,
|
surpriseTask: existingTask,
|
||||||
schedulingResult: SchedulingResult(
|
schedulingResult: SchedulingResult(
|
||||||
|
|
@ -82,6 +91,8 @@ class SurpriseTaskLogService {
|
||||||
var currentTasks = <Task>[surpriseTask, ...input.tasks];
|
var currentTasks = <Task>[surpriseTask, ...input.tasks];
|
||||||
|
|
||||||
if (surpriseInterval == null) {
|
if (surpriseInterval == null) {
|
||||||
|
logger.debug(() => 'Surprise task logged without occupying interval. '
|
||||||
|
'taskId=${surpriseTask.id} activityCount=1');
|
||||||
return SurpriseTaskLogResult(
|
return SurpriseTaskLogResult(
|
||||||
surpriseTask: surpriseTask,
|
surpriseTask: surpriseTask,
|
||||||
schedulingResult: SchedulingResult(
|
schedulingResult: SchedulingResult(
|
||||||
|
|
@ -113,6 +124,12 @@ class SurpriseTaskLogService {
|
||||||
final movementNotices = <SchedulingNotice>[];
|
final movementNotices = <SchedulingNotice>[];
|
||||||
var usedNormalPushRules = false;
|
var usedNormalPushRules = false;
|
||||||
|
|
||||||
|
logger.debug(() =>
|
||||||
|
'Surprise task repair scanning overlaps. taskId=${surpriseTask.id} '
|
||||||
|
'requiredOverlapCount=${requiredOverlaps.length} '
|
||||||
|
'lockedOverlapCount=${lockedOverlaps.length} '
|
||||||
|
'flexibleOverlapCount=${flexibleTaskIds.length}');
|
||||||
|
|
||||||
for (final taskId in flexibleTaskIds) {
|
for (final taskId in flexibleTaskIds) {
|
||||||
final currentTask = _taskById(currentTasks, taskId);
|
final currentTask = _taskById(currentTasks, taskId);
|
||||||
final currentInterval =
|
final currentInterval =
|
||||||
|
|
@ -120,9 +137,14 @@ class SurpriseTaskLogService {
|
||||||
if (currentTask == null ||
|
if (currentTask == null ||
|
||||||
currentInterval == null ||
|
currentInterval == null ||
|
||||||
!currentInterval.overlaps(surpriseInterval)) {
|
!currentInterval.overlaps(surpriseInterval)) {
|
||||||
|
logger.finer(() => 'Surprise task repair skipped flexible candidate. '
|
||||||
|
'surpriseTaskId=${surpriseTask.id} taskId=$taskId '
|
||||||
|
'hasCurrentTask=${currentTask != null} hasInterval=${currentInterval != null}');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.finer(() => 'Surprise task repair pushing flexible overlap. '
|
||||||
|
'surpriseTaskId=${surpriseTask.id} taskId=$taskId');
|
||||||
final pushResult = schedulingEngine.pushFlexibleTaskToNextAvailableSlot(
|
final pushResult = schedulingEngine.pushFlexibleTaskToNextAvailableSlot(
|
||||||
input: SchedulingInput(
|
input: SchedulingInput(
|
||||||
tasks: currentTasks,
|
tasks: currentTasks,
|
||||||
|
|
@ -157,6 +179,11 @@ class SurpriseTaskLogService {
|
||||||
)
|
)
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
|
|
||||||
|
logger.debug(() => 'Surprise task log finished. taskId=${surpriseTask.id} '
|
||||||
|
'outcome=${requiredOverlaps.isEmpty ? SchedulingOutcomeCode.success.name : SchedulingOutcomeCode.conflict.name} '
|
||||||
|
'changeCount=${changes.length} noticeCount=${movementNotices.length + overlapNotices.length + 1} '
|
||||||
|
'requiredOverlapCount=${requiredOverlaps.length} lockedOverlapCount=${lockedOverlaps.length}');
|
||||||
|
|
||||||
return SurpriseTaskLogResult(
|
return SurpriseTaskLogResult(
|
||||||
surpriseTask: _taskById(currentTasks, surpriseTask.id) ?? surpriseTask,
|
surpriseTask: _taskById(currentTasks, surpriseTask.id) ?? surpriseTask,
|
||||||
schedulingResult: SchedulingResult(
|
schedulingResult: SchedulingResult(
|
||||||
|
|
@ -196,10 +223,15 @@ class SurpriseTaskLogService {
|
||||||
}) {
|
}) {
|
||||||
final generator = idGenerator;
|
final generator = idGenerator;
|
||||||
if (generator == null) {
|
if (generator == null) {
|
||||||
|
logger.error(() => 'Surprise task logNew failed: missing id generator.');
|
||||||
throw StateError('Surprise task logging needs an id generator.');
|
throw StateError('Surprise task logging needs an id generator.');
|
||||||
}
|
}
|
||||||
|
|
||||||
final now = createdAt ?? updatedAt ?? clock.now();
|
final now = createdAt ?? updatedAt ?? clock.now();
|
||||||
|
logger.debug(() =>
|
||||||
|
'Logging new surprise task. hasTitle=${title.trim().isNotEmpty} '
|
||||||
|
'hasStartedAt=${startedAt != null} hasDuration=${timeUsedMinutes != null} '
|
||||||
|
'projectId=$projectId');
|
||||||
|
|
||||||
return log(
|
return log(
|
||||||
request: SurpriseTaskLogRequest(
|
request: SurpriseTaskLogRequest(
|
||||||
|
|
@ -227,6 +259,8 @@ class SurpriseTaskLogService {
|
||||||
}) {
|
}) {
|
||||||
final trimmedTitle = request.title.trim();
|
final trimmedTitle = request.title.trim();
|
||||||
if (trimmedTitle.isEmpty) {
|
if (trimmedTitle.isEmpty) {
|
||||||
|
logger.warn(() => 'Surprise task creation failed: blank title. '
|
||||||
|
'taskId=${request.id}');
|
||||||
throw ArgumentError.value(request.title, 'title', 'Title is required.');
|
throw ArgumentError.value(request.title, 'title', 'Title is required.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ library;
|
||||||
import '../domain/models.dart';
|
import '../domain/models.dart';
|
||||||
import '../domain/task_statistics.dart';
|
import '../domain/task_statistics.dart';
|
||||||
import '../domain/time_contracts.dart';
|
import '../domain/time_contracts.dart';
|
||||||
|
import '../logging/focus_flow_logger.dart';
|
||||||
part 'task_lifecycle/codes/task_transition_code.dart';
|
part 'task_lifecycle/codes/task_transition_code.dart';
|
||||||
part 'task_lifecycle/codes/task_activity_code.dart';
|
part 'task_lifecycle/codes/task_activity_code.dart';
|
||||||
part 'task_lifecycle/codes/task_transition_outcome_code.dart';
|
part 'task_lifecycle/codes/task_transition_outcome_code.dart';
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue