forked from eva/focus-flow
merge: task pushing controls
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.
|
||||
- Date Selection 01, the completed day navigation and date picker plan for the
|
||||
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
|
||||
runtime plans, planned/blocked UI handoff plans, old archive indexes, and review
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
# 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.
|
||||
|
||||
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
|
||||
|
||||
**Status:** Planned.
|
||||
**Status:** Complete.
|
||||
**Goal:** Confirm the exact pushable task scope and lock destination semantics
|
||||
before adding visible controls.
|
||||
|
||||
|
|
@ -15,6 +15,21 @@ before adding visible controls.
|
|||
After this block, the implementation should know which cards can show `Push`,
|
||||
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
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
# 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
|
||||
Push button.
|
||||
|
||||
|
|
@ -15,6 +15,17 @@ Push button.
|
|||
After this block, `TimelineCardModel` or its successor exposes a clear push-state
|
||||
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
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
# 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.
|
||||
|
||||
---
|
||||
|
|
@ -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
|
||||
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
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
# 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
|
||||
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
|
||||
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
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
# Task Pushing 01 Block 05 — Command Wiring and Persistence
|
||||
|
||||
**Status:** Planned.
|
||||
**Status:** Complete.
|
||||
**Goal:** Connect push menu destinations to application commands and prove their
|
||||
results persist.
|
||||
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
# Task Pushing 01 Block 06 — Destination Scheduling Rules
|
||||
|
||||
**Status:** Planned.
|
||||
**Status:** Complete.
|
||||
**Goal:** Verify or adjust backend push scheduling so destination behavior exactly
|
||||
matches the requested product rules.
|
||||
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
# Task Pushing 01 Block 07 — Backlog Freshness Reset
|
||||
|
||||
**Status:** Planned.
|
||||
**Status:** Complete.
|
||||
**Goal:** Make Push to backlog reset the backlog freshness timer without losing
|
||||
original task creation history.
|
||||
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
# 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.
|
||||
|
||||
---
|
||||
|
|
@ -113,3 +113,35 @@ flutter test
|
|||
- Push results persist through SQLite.
|
||||
- Push to Backlog resets freshness without rewriting creation history.
|
||||
- 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
|
||||
|
||||
**Status:** Planned.
|
||||
**Status:** Complete.
|
||||
|
||||
Run these files in order. All chunks are intended for `XHIGH` execution unless a
|
||||
chunk explicitly says otherwise.
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
# Task Pushing 01 Summary — Past Task Push Controls
|
||||
|
||||
**Status:** Planned.
|
||||
**Status:** Complete.
|
||||
**Scope level:** XHIGH implementation plan.
|
||||
**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
|
||||
|
|
@ -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.
|
||||
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
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- .dart_tool/**
|
||||
- archive/**
|
||||
- builds/**
|
||||
- coverage/**
|
||||
- doc/api/**
|
||||
- logging_handoff_with_logging/**
|
||||
language:
|
||||
strict-casts: 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`.
|
||||
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
|
||||
|
||||
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(
|
||||
selectedDates: selectedDates,
|
||||
dateLabelFor: dateLabelFor,
|
||||
now: () => readAt,
|
||||
read: (date) {
|
||||
return todayQuery.execute(
|
||||
GetTodayStateRequest(
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
logger.debug(() => 'FocusFlow home initializing controllers.');
|
||||
selectedDateController = widget.composition.createSelectedDateController();
|
||||
controller = widget.composition.createTodayScreenController(
|
||||
selectedDates: selectedDateController,
|
||||
|
|
@ -31,6 +32,7 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
refreshReads: controller.load,
|
||||
selectedDate: () => selectedDateController.date,
|
||||
);
|
||||
logger.finer(() => 'FocusFlow home triggering initial Today 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.
|
||||
@override
|
||||
void dispose() {
|
||||
logger.debug(() => 'FocusFlow home disposing controllers.');
|
||||
commandController.dispose();
|
||||
controller.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.
|
||||
Future<void> _toggleCardCompletion(TimelineCardModel card) async {
|
||||
if (!card.canToggleCompletion) {
|
||||
logger.finer(
|
||||
() => 'Timeline completion toggle ignored. cardId=${card.id}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
logger.debug(
|
||||
() =>
|
||||
'Timeline completion toggle requested. '
|
||||
'cardId=${card.id} taskType=${card.taskType.name} isCompleted=${card.isCompleted}',
|
||||
);
|
||||
if (card.isCompleted) {
|
||||
await commandController.uncompleteTask(taskId: card.id);
|
||||
} else {
|
||||
|
|
@ -63,16 +74,58 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
|
||||
/// Adds a generic filler task from the timeline context menu.
|
||||
Future<void> _addGenericFlexibleTask() async {
|
||||
logger.debug(() => 'Timeline add generic flexible task requested.');
|
||||
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.
|
||||
void _selectPreviousDate() {
|
||||
logger.finer(() => 'Previous date action requested.');
|
||||
selectedDateController.selectPreviousDay();
|
||||
}
|
||||
|
||||
/// Moves the visible planning date forward by one day.
|
||||
void _selectNextDate() {
|
||||
logger.finer(() => 'Next date action requested.');
|
||||
selectedDateController.selectNextDay();
|
||||
}
|
||||
|
||||
|
|
@ -86,9 +139,14 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
builder: _buildDatePickerTheme,
|
||||
);
|
||||
if (picked == null) {
|
||||
logger.finer(() => 'Date picker dismissed without selection.');
|
||||
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.
|
||||
|
|
@ -114,6 +172,10 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
selectedCard: controller.selectedCard,
|
||||
onSelectCard: controller.selectCard,
|
||||
onCompleteCard: _toggleCardCompletion,
|
||||
onPushToNext: _pushCardToNext,
|
||||
onPushToTomorrow: _pushCardToTomorrow,
|
||||
onPushToBacklog: _pushCardToBacklog,
|
||||
onRemoveCard: _removeCard,
|
||||
onAddTask: _addGenericFlexibleTask,
|
||||
onClearSelection: controller.clearSelection,
|
||||
onPreviousDate: _selectPreviousDate,
|
||||
|
|
@ -127,6 +189,10 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
selectedCard: controller.selectedCard,
|
||||
onSelectCard: controller.selectCard,
|
||||
onCompleteCard: _toggleCardCompletion,
|
||||
onPushToNext: _pushCardToNext,
|
||||
onPushToTomorrow: _pushCardToTomorrow,
|
||||
onPushToBacklog: _pushCardToBacklog,
|
||||
onRemoveCard: _removeCard,
|
||||
onAddTask: _addGenericFlexibleTask,
|
||||
onClearSelection: controller.clearSelection,
|
||||
onPreviousDate: _selectPreviousDate,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ class _TodayScreenScaffold extends StatefulWidget {
|
|||
required this.selectedCard,
|
||||
required this.onSelectCard,
|
||||
required this.onCompleteCard,
|
||||
required this.onPushToNext,
|
||||
required this.onPushToTomorrow,
|
||||
required this.onPushToBacklog,
|
||||
required this.onRemoveCard,
|
||||
required this.onAddTask,
|
||||
required this.onClearSelection,
|
||||
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.
|
||||
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.
|
||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
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.
|
||||
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.
|
||||
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||
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.
|
||||
/// 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
|
||||
|
|
@ -41,6 +54,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
|
|||
}
|
||||
_timelineScrollTargetCardId = null;
|
||||
_timelineScrollRequest = 0;
|
||||
_timelineScrollToNowRequest = 0;
|
||||
}
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
|
|
@ -64,6 +78,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
|
|||
onPreviousDate: widget.onPreviousDate,
|
||||
onNextDate: widget.onNextDate,
|
||||
onDatePressed: widget.onChooseDate,
|
||||
onDateLabelPressed: _scrollTimelineToCurrentTime,
|
||||
),
|
||||
if (widget.data.requiredBanner == null)
|
||||
const SizedBox(height: 16)
|
||||
|
|
@ -81,9 +96,15 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
|
|||
range: widget.data.timelineRange,
|
||||
scrollTargetCardId: _timelineScrollTargetCardId,
|
||||
scrollRequest: _timelineScrollRequest,
|
||||
scrollToNowRequest: _timelineScrollToNowRequest,
|
||||
onCardSelected: widget.onSelectCard,
|
||||
onAddTask: widget.onAddTask,
|
||||
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
|
||||
? () => widget.onCompleteCard(widget.selectedCard!)
|
||||
: null,
|
||||
onPushToNext: widget.onPushToNext,
|
||||
onPushToTomorrow: widget.onPushToTomorrow,
|
||||
onPushToBacklog: widget.onPushToBacklog,
|
||||
onRemove: widget.onRemoveCard,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -6,12 +6,17 @@ library;
|
|||
|
||||
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 '../controllers/scheduler_command_controller.dart';
|
||||
import '../controllers/selected_date_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 'scheduler_app_composition.dart';
|
||||
|
||||
|
|
@ -25,6 +30,10 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
|||
required this.commandUseCases,
|
||||
required this.date,
|
||||
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.
|
||||
|
|
@ -32,36 +41,65 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
|||
String? sqlitePath,
|
||||
Clock? clock,
|
||||
CivilDate? selectedDate,
|
||||
FocusFlowFileLogger? logger,
|
||||
FocusFlowUiTimeZone? uiTimeZone,
|
||||
}) 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(
|
||||
path: sqlitePath ?? sqlitePathFromEnvironment(),
|
||||
path: resolvedSqlitePath,
|
||||
);
|
||||
final bootstrap = await runtime.bootstrap();
|
||||
if (bootstrap.isFailure) {
|
||||
await runtime.close();
|
||||
scheduler_core.logger.error(
|
||||
() =>
|
||||
'SQLite bootstrap failed. failureCode=${bootstrap.failure!.code.name}',
|
||||
error: bootstrap.failure!.code.name,
|
||||
);
|
||||
throw StateError(
|
||||
'SQLite bootstrap failed: ${bootstrap.failure!.code.name}',
|
||||
);
|
||||
}
|
||||
scheduler_core.logger.debug(() => 'SQLite bootstrap completed.');
|
||||
final resolvedClock = clock ?? const SystemClock();
|
||||
final date = selectedDate ?? CivilDate.fromDateTime(resolvedClock.now());
|
||||
final readAt = resolvedClock.now().toUtc();
|
||||
final openedAt = resolvedClock.now();
|
||||
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._(
|
||||
runtime: runtime,
|
||||
todayQuery: GetTodayStateQuery(
|
||||
applicationStore: runtime.applicationStore,
|
||||
timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(),
|
||||
timeZoneResolver: resolver,
|
||||
),
|
||||
managementUseCases: V1ApplicationManagementUseCases(
|
||||
applicationStore: runtime.applicationStore,
|
||||
),
|
||||
commandUseCases: V1ApplicationCommandUseCases(
|
||||
applicationStore: runtime.applicationStore,
|
||||
timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(),
|
||||
timeZoneResolver: resolver,
|
||||
),
|
||||
date: date,
|
||||
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.
|
||||
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.
|
||||
Future<void>? _disposeFuture;
|
||||
|
||||
|
|
@ -122,6 +172,10 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
|||
/// Creates the controller that owns the visible planning date.
|
||||
@override
|
||||
SelectedDateController createSelectedDateController() {
|
||||
scheduler_core.logger.finer(
|
||||
() =>
|
||||
'Creating selected date controller. date=${initialDate.toIsoString()}',
|
||||
);
|
||||
return SelectedDateController(initialDate: initialDate);
|
||||
}
|
||||
|
||||
|
|
@ -130,9 +184,14 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
|||
TodayScreenController createTodayScreenController({
|
||||
required SelectedDateController selectedDates,
|
||||
}) {
|
||||
scheduler_core.logger.finer(
|
||||
() => 'Creating today screen controller. date=${date.toIsoString()}',
|
||||
);
|
||||
return TodayScreenController(
|
||||
selectedDates: selectedDates,
|
||||
dateLabelFor: dateLabelFor,
|
||||
now: _utcNow,
|
||||
timeZoneOffset: uiTimeZone.utcOffset,
|
||||
read: (date) {
|
||||
return todayQuery.execute(
|
||||
GetTodayStateRequest(
|
||||
|
|
@ -151,6 +210,10 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
|||
required ReadRefresh refreshReads,
|
||||
required SelectedDateReader selectedDate,
|
||||
}) {
|
||||
scheduler_core.logger.finer(
|
||||
() =>
|
||||
'Creating scheduler command controller. operationIdPrefix=ui-command-${readAt.microsecondsSinceEpoch}',
|
||||
);
|
||||
return SchedulerCommandController(
|
||||
commands: commandUseCases,
|
||||
contextFor: context,
|
||||
|
|
@ -158,6 +221,8 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
|||
refreshReads: refreshReads,
|
||||
quickCaptureProjectId: defaultProjectId,
|
||||
operationIdPrefix: 'ui-command-${readAt.microsecondsSinceEpoch}',
|
||||
currentDateFor: uiTimeZone.toLocalCivilDate,
|
||||
now: _utcNow,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -167,9 +232,9 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
|||
operationId: operationId,
|
||||
ownerTimeZone: OwnerTimeZoneContext(
|
||||
ownerId: SqliteApplicationRuntime.defaultV1OwnerId,
|
||||
timeZoneId: SqliteApplicationRuntime.defaultV1TimeZoneId,
|
||||
timeZoneId: uiTimeZone.code,
|
||||
),
|
||||
clock: FixedClock(now ?? readAt),
|
||||
clock: FixedClock((now ?? _utcNow()).toUtc()),
|
||||
idGenerator: SequentialIdGenerator(prefix: operationId),
|
||||
);
|
||||
}
|
||||
|
|
@ -177,6 +242,18 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
|||
/// Closes the persistent runtime.
|
||||
@override
|
||||
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.
|
||||
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.refreshReads,
|
||||
required this.quickCaptureProjectId,
|
||||
String operationIdPrefix = 'ui-command',
|
||||
this.operationIdPrefix = 'ui-command',
|
||||
SchedulerCurrentDateResolver? currentDateFor,
|
||||
DateTime Function()? now,
|
||||
}) : _operationIdPrefix = operationIdPrefix,
|
||||
}) : currentDateFor = currentDateFor ?? _utcCivilDateFor,
|
||||
now = now ?? _utcNow;
|
||||
|
||||
/// Scheduler write use cases invoked by this controller.
|
||||
|
|
@ -32,13 +33,15 @@ class SchedulerCommandController extends ChangeNotifier {
|
|||
/// Project id used by quick-capture commands.
|
||||
final String quickCaptureProjectId;
|
||||
|
||||
/// Private state stored as `_operationIdPrefix` for this implementation.
|
||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
final String _operationIdPrefix;
|
||||
/// Prefix used to generate unique operation ids for scheduler commands.
|
||||
final String operationIdPrefix;
|
||||
|
||||
/// Clock used by direct UI commands such as marking a task complete.
|
||||
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.
|
||||
static const _genericFlexibleTaskTitle = 'New flexible task';
|
||||
|
||||
|
|
@ -58,6 +61,11 @@ class SchedulerCommandController extends ChangeNotifier {
|
|||
|
||||
/// Captures [title] as a backlog task.
|
||||
Future<void> quickCaptureToBacklog(String title) async {
|
||||
logger.debug(
|
||||
() =>
|
||||
'UI command requested: quick capture to backlog. '
|
||||
'projectId=$quickCaptureProjectId hasTitle=${title.trim().isNotEmpty}',
|
||||
);
|
||||
await _run(
|
||||
label: 'Capturing task',
|
||||
successMessage: 'Task captured',
|
||||
|
|
@ -75,6 +83,11 @@ class SchedulerCommandController extends ChangeNotifier {
|
|||
|
||||
/// Adds a generic flexible task to the next available Today slot.
|
||||
Future<void> addGenericFlexibleTask() async {
|
||||
logger.debug(
|
||||
() =>
|
||||
'UI command requested: add generic flexible task. '
|
||||
'durationMinutes=$_genericFlexibleTaskDurationMinutes projectId=$quickCaptureProjectId',
|
||||
);
|
||||
await _run(
|
||||
label: 'Adding task',
|
||||
successMessage: 'Task added',
|
||||
|
|
@ -98,6 +111,12 @@ class SchedulerCommandController extends ChangeNotifier {
|
|||
required int durationMinutes,
|
||||
required DateTime expectedUpdatedAt,
|
||||
}) async {
|
||||
logger.debug(
|
||||
() =>
|
||||
'UI command requested: schedule backlog item. '
|
||||
'taskId=$taskId durationMinutes=$durationMinutes '
|
||||
'expectedUpdatedAt=${expectedUpdatedAt.toIso8601String()}',
|
||||
);
|
||||
await _run(
|
||||
label: 'Scheduling task',
|
||||
successMessage: 'Task scheduled',
|
||||
|
|
@ -134,6 +153,13 @@ class SchedulerCommandController extends ChangeNotifier {
|
|||
DateTime? expectedUpdatedAt,
|
||||
}) async {
|
||||
final completedAt = now();
|
||||
logger.debug(
|
||||
() =>
|
||||
'UI command requested: complete task. '
|
||||
'taskId=$taskId taskType=${taskType.name} '
|
||||
'hasExpectedUpdatedAt=${expectedUpdatedAt != null} '
|
||||
'occurredAt=${completedAt.toIso8601String()}',
|
||||
);
|
||||
await _run(
|
||||
label: 'Completing task',
|
||||
successMessage: 'Task completed',
|
||||
|
|
@ -177,6 +203,12 @@ class SchedulerCommandController extends ChangeNotifier {
|
|||
DateTime? expectedUpdatedAt,
|
||||
}) async {
|
||||
final occurredAt = now();
|
||||
logger.debug(
|
||||
() =>
|
||||
'UI command requested: uncomplete task. '
|
||||
'taskId=$taskId hasExpectedUpdatedAt=${expectedUpdatedAt != null} '
|
||||
'occurredAt=${occurredAt.toIso8601String()}',
|
||||
);
|
||||
await _run(
|
||||
label: 'Reopening task',
|
||||
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.
|
||||
/// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order.
|
||||
Future<void> _run({
|
||||
|
|
@ -204,19 +342,44 @@ class SchedulerCommandController extends ChangeNotifier {
|
|||
action,
|
||||
}) async {
|
||||
final operationId = _nextOperationId();
|
||||
logger.debug(
|
||||
() => 'Scheduler command started. operationId=$operationId label=$label',
|
||||
);
|
||||
_setState(SchedulerCommandRunning(label));
|
||||
try {
|
||||
final result = await action(operationId);
|
||||
final failure = result.failure;
|
||||
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));
|
||||
return;
|
||||
}
|
||||
logger.debug(
|
||||
() =>
|
||||
'Scheduler command succeeded. operationId=$operationId '
|
||||
'refreshAfterSuccess=$refreshAfterSuccess',
|
||||
);
|
||||
if (refreshAfterSuccess) {
|
||||
logger.finer(
|
||||
() =>
|
||||
'Refreshing reads after scheduler command. '
|
||||
'operationId=$operationId',
|
||||
);
|
||||
await refreshReads();
|
||||
}
|
||||
_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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
String _nextOperationId() {
|
||||
_sequence += 1;
|
||||
return '$_operationIdPrefix-$_sequence';
|
||||
return '$operationIdPrefix-$_sequence';
|
||||
}
|
||||
|
||||
/// Runs the `_setState` helper used inside this library.
|
||||
|
|
@ -238,4 +401,11 @@ class SchedulerCommandController extends ChangeNotifier {
|
|||
/// 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();
|
||||
|
||||
/// 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].
|
||||
@override
|
||||
Future<void> load() async {
|
||||
logger.debug(() => 'Scheduler read started. valueType=$T');
|
||||
_setState(SchedulerReadLoading<T>());
|
||||
try {
|
||||
final result = await read();
|
||||
final failure = result.failure;
|
||||
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));
|
||||
return;
|
||||
}
|
||||
final value = result.requireValue;
|
||||
_setState(
|
||||
isEmpty(value)
|
||||
? SchedulerReadEmpty<T>(value)
|
||||
: SchedulerReadData<T>(value),
|
||||
final empty = isEmpty(value);
|
||||
logger.debug(
|
||||
() => 'Scheduler read finished. valueType=$T isEmpty=$empty',
|
||||
);
|
||||
_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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,8 +25,13 @@ class SelectedDateController extends ChangeNotifier {
|
|||
/// Selects [date] as the visible planning day.
|
||||
bool selectDate(CivilDate date) {
|
||||
if (date == _date) {
|
||||
logger.finer(() => 'Selected date unchanged. date=${date.toIsoString()}');
|
||||
return false;
|
||||
}
|
||||
logger.debug(
|
||||
() =>
|
||||
'Selected date changed. previous=${_date.toIsoString()} next=${date.toIsoString()}',
|
||||
);
|
||||
_date = date;
|
||||
notifyListeners();
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -71,7 +71,10 @@ class TodayScreenController extends ChangeNotifier {
|
|||
required this.read,
|
||||
required this.selectedDates,
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -84,6 +87,12 @@ class TodayScreenController extends ChangeNotifier {
|
|||
/// Formats dates before a backend read has returned screen data.
|
||||
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.
|
||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
TodayScreenState _state;
|
||||
|
|
@ -113,15 +122,30 @@ class TodayScreenController extends ChangeNotifier {
|
|||
Future<void> load() async {
|
||||
final date = selectedDates.date;
|
||||
final sequence = _nextLoadSequence();
|
||||
logger.debug(
|
||||
() =>
|
||||
'Today screen load started. date=${date.toIsoString()} sequence=$sequence',
|
||||
);
|
||||
_state = TodayScreenLoading(dateLabelFor(date));
|
||||
notifyListeners();
|
||||
try {
|
||||
final result = await read(date);
|
||||
if (_isStale(sequence)) {
|
||||
logger.finer(
|
||||
() =>
|
||||
'Ignoring stale Today screen load result. '
|
||||
'date=${date.toIsoString()} sequence=$sequence currentSequence=$_loadSequence',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final failure = result.failure;
|
||||
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(
|
||||
message: failure.detailCode ?? failure.code.name,
|
||||
data: _emptyDataFor(date),
|
||||
|
|
@ -130,16 +154,36 @@ class TodayScreenController extends ChangeNotifier {
|
|||
return;
|
||||
}
|
||||
|
||||
final data = TodayScreenData.fromTodayState(result.requireValue);
|
||||
final data = TodayScreenData.fromTodayState(
|
||||
result.requireValue,
|
||||
now: now(),
|
||||
timeZoneOffset: timeZoneOffset,
|
||||
);
|
||||
_syncSelectedCard(data);
|
||||
_state = data.cards.isEmpty
|
||||
? TodayScreenEmpty(data)
|
||||
: TodayScreenReady(data);
|
||||
logger.debug(
|
||||
() =>
|
||||
'Today screen load finished. date=${date.toIsoString()} '
|
||||
'sequence=$sequence cardCount=${data.cards.length}',
|
||||
);
|
||||
notifyListeners();
|
||||
} on Object catch (error) {
|
||||
} on Object catch (error, stackTrace) {
|
||||
if (_isStale(sequence)) {
|
||||
logger.finer(
|
||||
() =>
|
||||
'Ignoring stale Today screen load exception. '
|
||||
'date=${date.toIsoString()} sequence=$sequence currentSequence=$_loadSequence',
|
||||
);
|
||||
return;
|
||||
}
|
||||
logger.error(
|
||||
() =>
|
||||
'Today screen load threw unexpectedly. date=${date.toIsoString()} sequence=$sequence',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
_state = TodayScreenFailure(
|
||||
message: error.toString(),
|
||||
data: _emptyDataFor(date),
|
||||
|
|
@ -151,8 +195,12 @@ class TodayScreenController extends ChangeNotifier {
|
|||
/// Selects [card] when the card allows selection.
|
||||
void selectCard(TimelineCardModel card) {
|
||||
if (!card.isSelectable) {
|
||||
logger.finer(
|
||||
() => 'Today screen card selection ignored. cardId=${card.id}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
logger.finer(() => 'Today screen card selected. cardId=${card.id}');
|
||||
_selectedCard = card;
|
||||
notifyListeners();
|
||||
}
|
||||
|
|
@ -162,6 +210,9 @@ class TodayScreenController extends ChangeNotifier {
|
|||
if (_selectedCard == null) {
|
||||
return;
|
||||
}
|
||||
logger.finer(
|
||||
() => 'Today screen card selection cleared. cardId=${_selectedCard!.id}',
|
||||
);
|
||||
_selectedCard = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
|
@ -177,6 +228,10 @@ class TodayScreenController extends ChangeNotifier {
|
|||
/// 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.
|
||||
void _handleSelectedDateChanged() {
|
||||
logger.debug(
|
||||
() =>
|
||||
'Today screen selected date changed. date=${selectedDates.date.toIsoString()}',
|
||||
);
|
||||
_selectedCard = null;
|
||||
unawaited(load());
|
||||
}
|
||||
|
|
@ -197,7 +252,10 @@ class TodayScreenController extends ChangeNotifier {
|
|||
/// 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.
|
||||
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.
|
||||
|
|
@ -213,6 +271,14 @@ class TodayScreenController extends ChangeNotifier {
|
|||
return;
|
||||
}
|
||||
}
|
||||
logger.finer(
|
||||
() =>
|
||||
'Today screen selected card no longer present. cardId=${selected.id}',
|
||||
);
|
||||
_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;
|
||||
|
||||
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/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/focus_flow_app.dart';
|
||||
|
|
@ -16,10 +21,26 @@ export 'app/persistent_scheduler_composition.dart';
|
|||
/// Starts the FocusFlow app with the persistent SQLite scheduler composition.
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
var logger = FocusFlowFileLogger.disabled();
|
||||
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));
|
||||
} 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()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,11 @@ String _dateLabel(CivilDate date) {
|
|||
|
||||
/// 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.
|
||||
String _shortDateTimeLabel(DateTime value) {
|
||||
String _shortDateTimeLabel(
|
||||
DateTime value, {
|
||||
Duration timeZoneOffset = Duration.zero,
|
||||
}) {
|
||||
final localValue = _displayDateTime(value, timeZoneOffset);
|
||||
const months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
|
|
@ -40,36 +44,52 @@ String _shortDateTimeLabel(DateTime value) {
|
|||
'Nov',
|
||||
'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.
|
||||
/// 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) {
|
||||
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.
|
||||
/// 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) {
|
||||
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.
|
||||
/// 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) {
|
||||
return '';
|
||||
}
|
||||
final period = value.hour >= 12 ? 'PM' : 'AM';
|
||||
final rawHour = value.hour % 12;
|
||||
final localValue = _displayDateTime(value, timeZoneOffset);
|
||||
final period = localValue.hour >= 12 ? 'PM' : 'AM';
|
||||
final rawHour = localValue.hour % 12;
|
||||
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';
|
||||
}
|
||||
|
||||
/// 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.isSelectable,
|
||||
required this.showsQuickActions,
|
||||
this.showPastTaskPushButton = false,
|
||||
this.completionDateContextRequired = false,
|
||||
this.completedTimeText,
|
||||
this.expectedUpdatedAt,
|
||||
this.durationMinutes,
|
||||
});
|
||||
|
||||
/// 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 end = item.end;
|
||||
final visualKind = _visualKindFor(item);
|
||||
final isCompleted = item.taskStatus == TaskStatus.completed;
|
||||
final showPastTaskPushButton = _showPastTaskPushButton(item, now: now);
|
||||
final title = item.item.displayTitle;
|
||||
final duration = item.item.durationMinutes;
|
||||
final timeText = start == null || end == null
|
||||
? duration == null
|
||||
? ''
|
||||
: '$duration min'
|
||||
: '${_formatTime(start)} - ${_formatTime(end)}';
|
||||
: '${_formatTime(start, timeZoneOffset: timeZoneOffset)} - '
|
||||
'${_formatTime(end, timeZoneOffset: timeZoneOffset)}';
|
||||
final completionDateContextRequired =
|
||||
isCompleted && _completionDateContextRequired(item);
|
||||
isCompleted &&
|
||||
_completionDateContextRequired(item, timeZoneOffset: timeZoneOffset);
|
||||
return TimelineCardModel(
|
||||
id: item.id,
|
||||
title: title,
|
||||
|
|
@ -50,10 +59,14 @@ class TimelineCardModel {
|
|||
visualKind,
|
||||
timeText,
|
||||
completionDateContextRequired: completionDateContextRequired,
|
||||
timeZoneOffset: timeZoneOffset,
|
||||
),
|
||||
timeText: timeText,
|
||||
startMinutes: _minutesSinceMidnight(start),
|
||||
endMinutes: _minutesSinceMidnight(end),
|
||||
startMinutes: _minutesSinceMidnight(
|
||||
start,
|
||||
timeZoneOffset: timeZoneOffset,
|
||||
),
|
||||
endMinutes: _minutesSinceMidnight(end, timeZoneOffset: timeZoneOffset),
|
||||
taskType: item.taskType,
|
||||
visualKind: visualKind,
|
||||
rewardIconToken: item.item.rewardIconToken,
|
||||
|
|
@ -64,11 +77,17 @@ class TimelineCardModel {
|
|||
? _completionDisplayText(
|
||||
item,
|
||||
includeDate: completionDateContextRequired,
|
||||
timeZoneOffset: timeZoneOffset,
|
||||
)
|
||||
: null,
|
||||
expectedUpdatedAt: item.item.updatedAt,
|
||||
isCompleted: isCompleted,
|
||||
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.
|
||||
final String? completedTimeText;
|
||||
|
||||
/// Expected update timestamp for stale-write protection.
|
||||
final DateTime? expectedUpdatedAt;
|
||||
|
||||
/// Whether completion text includes date context because completion crossed dates.
|
||||
final bool completionDateContextRequired;
|
||||
|
||||
|
|
@ -123,6 +145,12 @@ class TimelineCardModel {
|
|||
/// Whether the card should show compact quick-action controls.
|
||||
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.
|
||||
bool get canToggleCompletion {
|
||||
return switch (taskType) {
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ String _subtitleFor(
|
|||
TaskVisualKind visualKind,
|
||||
String timeText, {
|
||||
required bool completionDateContextRequired,
|
||||
required Duration timeZoneOffset,
|
||||
}) {
|
||||
if (visualKind == TaskVisualKind.freeSlot) {
|
||||
return 'Intentional rest';
|
||||
|
|
@ -53,6 +54,7 @@ String _subtitleFor(
|
|||
final completedText = _completionDisplayText(
|
||||
item,
|
||||
includeDate: completionDateContextRequired,
|
||||
timeZoneOffset: timeZoneOffset,
|
||||
);
|
||||
if (completedText == null || completedText.isEmpty) {
|
||||
return 'Completed';
|
||||
|
|
@ -70,15 +72,19 @@ String _subtitleFor(
|
|||
String? _completionDisplayText(
|
||||
TodayTimelineItem item, {
|
||||
required bool includeDate,
|
||||
required Duration timeZoneOffset,
|
||||
}) {
|
||||
final completionInstant = _completionDisplayInstant(item);
|
||||
if (completionInstant == null) {
|
||||
return null;
|
||||
}
|
||||
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.
|
||||
|
|
@ -89,15 +95,46 @@ DateTime? _completionDisplayInstant(TodayTimelineItem item) {
|
|||
|
||||
/// 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.
|
||||
bool _completionDateContextRequired(TodayTimelineItem item) {
|
||||
final scheduledDate = _civilDateForDateTime(item.item.start ?? item.start);
|
||||
bool _completionDateContextRequired(
|
||||
TodayTimelineItem item, {
|
||||
required Duration timeZoneOffset,
|
||||
}) {
|
||||
final scheduledDate = _civilDateForDateTime(
|
||||
item.item.start ?? item.start,
|
||||
timeZoneOffset: timeZoneOffset,
|
||||
);
|
||||
if (scheduledDate == null) {
|
||||
return false;
|
||||
}
|
||||
final completedAtDate = _civilDateForDateTime(item.item.completedAt);
|
||||
final actualStartDate = _civilDateForDateTime(item.item.actualStart);
|
||||
final actualEndDate = _civilDateForDateTime(item.item.actualEnd);
|
||||
final completedAtDate = _civilDateForDateTime(
|
||||
item.item.completedAt,
|
||||
timeZoneOffset: timeZoneOffset,
|
||||
);
|
||||
final actualStartDate = _civilDateForDateTime(
|
||||
item.item.actualStart,
|
||||
timeZoneOffset: timeZoneOffset,
|
||||
);
|
||||
final actualEndDate = _civilDateForDateTime(
|
||||
item.item.actualEnd,
|
||||
timeZoneOffset: timeZoneOffset,
|
||||
);
|
||||
return completedAtDate != null && completedAtDate != scheduledDate ||
|
||||
actualStartDate != null && actualStartDate != 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.timelineRange,
|
||||
required this.cards,
|
||||
this.timeZoneOffset = Duration.zero,
|
||||
this.requiredBanner,
|
||||
});
|
||||
|
||||
/// 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(
|
||||
dateLabel: dateLabel,
|
||||
timelineRange: defaultRange,
|
||||
cards: const [],
|
||||
timeZoneOffset: timeZoneOffset,
|
||||
);
|
||||
}
|
||||
|
||||
/// 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 = [
|
||||
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;
|
||||
return TodayScreenData(
|
||||
|
|
@ -36,9 +50,13 @@ class TodayScreenData {
|
|||
: RequiredBannerModel(
|
||||
timelineCardId: nextRequired.id,
|
||||
title: nextRequired.item.displayTitle,
|
||||
timeText: _formatTime(nextRequired.start),
|
||||
timeText: _formatTime(
|
||||
nextRequired.start,
|
||||
timeZoneOffset: timeZoneOffset,
|
||||
),
|
||||
),
|
||||
cards: List<TimelineCardModel>.unmodifiable(cards),
|
||||
timeZoneOffset: timeZoneOffset,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -57,6 +75,9 @@ class TodayScreenData {
|
|||
/// Visible timeline range.
|
||||
final TimelineRangeModel timelineRange;
|
||||
|
||||
/// UI-local offset used for wall-clock timeline labels and positions.
|
||||
final Duration timeZoneOffset;
|
||||
|
||||
/// Timeline cards to render.
|
||||
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';
|
||||
|
||||
/// 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.
|
||||
/// 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.
|
||||
/// 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.
|
||||
/// 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.
|
||||
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.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return OutlinedButton.icon(
|
||||
onPressed: () {},
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: FocusFlowTokens.textPrimary,
|
||||
side: const BorderSide(color: FocusFlowTokens.subtleBorder),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
FocusFlowTokens.smallButtonRadius,
|
||||
return _ModalActionSurface(
|
||||
icon: widget.icon,
|
||||
label: widget.label,
|
||||
isHovered: _isHovered,
|
||||
onHoverChanged: (value) {
|
||||
setState(() {
|
||||
_isHovered = value;
|
||||
});
|
||||
},
|
||||
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(
|
||||
fontSize: FocusFlowTokens.modalButtonSize,
|
||||
fontWeight: FontWeight.w700,
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the push destination option list for this button.
|
||||
List<_ModalPushDestinationOption> _modalPushDestinationOptions() {
|
||||
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';
|
||||
|
||||
/// Private implementation type for `_InfoTile` in this library.
|
||||
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
|
||||
class _InfoTile extends StatelessWidget {
|
||||
/// Creates a `_InfoTile` instance with the values required by its invariants.
|
||||
/// Private implementation type for `_HeaderMetricIcons` in this library.
|
||||
/// It keeps task effort and reward metadata near the modal close action.
|
||||
class _HeaderMetricIcons extends StatelessWidget {
|
||||
/// 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.
|
||||
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.
|
||||
final Widget child;
|
||||
|
||||
/// Stores the `label` value for this object.
|
||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
final String label;
|
||||
final TimelineCardModel card;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 58,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(FocusFlowTokens.smallButtonRadius),
|
||||
border: Border.all(color: FocusFlowTokens.subtleBorder),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
child,
|
||||
const SizedBox(width: 18),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: FocusFlowTokens.textPrimary,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_HeaderMetricIcon(
|
||||
tooltip: card.effortLabel == 'Effort not set'
|
||||
? 'Not Set'
|
||||
: card.effortLabel,
|
||||
child: DifficultyBars(
|
||||
difficulty: card.difficultyIconToken,
|
||||
color: FocusFlowTokens.restPurple,
|
||||
size: const Size(28, 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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';
|
||||
|
||||
/// 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 {
|
||||
/// 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.
|
||||
|
|
@ -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.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ring = Container(
|
||||
key: const ValueKey('task-modal-status'),
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: card.isCompleted ? color : Colors.transparent,
|
||||
border: Border.all(color: color, width: 4),
|
||||
),
|
||||
child: card.isCompleted
|
||||
? const Icon(
|
||||
Icons.check,
|
||||
color: FocusFlowTokens.appBackground,
|
||||
size: 26,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
if (onPressed == null) {
|
||||
return ring;
|
||||
}
|
||||
return Tooltip(
|
||||
message: card.isCompleted ? 'Mark not done' : 'Mark complete',
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: card.isCompleted
|
||||
? 'Mark ${card.title} not done'
|
||||
: 'Mark ${card.title} complete',
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onPressed,
|
||||
child: ring,
|
||||
),
|
||||
),
|
||||
),
|
||||
return StatusCircleButton(
|
||||
circleKey: const ValueKey('task-modal-status'),
|
||||
color: color,
|
||||
checkColor: FocusFlowTokens.appBackground,
|
||||
isCompleted: card.isCompleted,
|
||||
size: 44,
|
||||
borderWidth: 4,
|
||||
iconSize: 26,
|
||||
hoverBlurRadius: 12,
|
||||
semanticLabel: card.isCompleted
|
||||
? 'Mark ${card.title} not done'
|
||||
: 'Mark ${card.title} complete',
|
||||
onPressed: onPressed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,13 @@
|
|||
/// Renders the Task Selection Modal widget for the FocusFlow Flutter UI.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/today_screen_models.dart';
|
||||
import '../theme/focus_flow_tokens.dart';
|
||||
import 'status_circle_button.dart';
|
||||
import 'timeline/difficulty_bars.dart';
|
||||
import 'timeline/reward_icon.dart';
|
||||
|
||||
|
|
@ -24,6 +27,10 @@ class TaskSelectionModal extends StatelessWidget {
|
|||
required this.card,
|
||||
required this.onClose,
|
||||
this.onStatusPressed,
|
||||
this.onPushToNext,
|
||||
this.onPushToTomorrow,
|
||||
this.onPushToBacklog,
|
||||
this.onRemove,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -36,6 +43,18 @@ class TaskSelectionModal extends StatelessWidget {
|
|||
/// Callback invoked when the status circle should toggle completion.
|
||||
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.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
|
|
@ -81,6 +100,9 @@ class TaskSelectionModal extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
_HeaderMetricIcons(card: card),
|
||||
const SizedBox(width: 14),
|
||||
IconButton(
|
||||
key: const ValueKey('close-task-modal'),
|
||||
onPressed: onClose,
|
||||
|
|
@ -92,43 +114,35 @@ class TaskSelectionModal extends StatelessWidget {
|
|||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 26),
|
||||
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),
|
||||
const SizedBox(height: 24),
|
||||
GridView.count(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 26,
|
||||
mainAxisSpacing: 16,
|
||||
childAspectRatio: 4.1,
|
||||
crossAxisSpacing: 18,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: 5.3,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: const [
|
||||
_ActionButton(icon: Icons.check, label: 'Done'),
|
||||
_ActionButton(icon: Icons.arrow_forward, label: 'Push'),
|
||||
children: [
|
||||
_PushActionButton(
|
||||
card: card,
|
||||
onPushToNext: onPushToNext,
|
||||
onPushToTomorrow: onPushToTomorrow,
|
||||
onPushToBacklog: onPushToBacklog,
|
||||
),
|
||||
_ActionButton(
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -114,7 +114,19 @@ class _CardMetrics {
|
|||
|
||||
/// Returns the derived `actionsWidth` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
double get actionsWidth => actionButtonSize * 4 + actionGap;
|
||||
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.
|
||||
/// 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 =>
|
||||
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.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
double get compactTrailingReserve => indicatorWidth + math.max(6, 8 * scale);
|
||||
|
||||
/// Returns the derived `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.
|
||||
/// 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);
|
||||
|
|
|
|||
|
|
@ -88,7 +88,11 @@ class _CompactCardText extends StatelessWidget {
|
|||
style: metaStyle,
|
||||
),
|
||||
),
|
||||
SizedBox(width: metrics.compactTrailingReserve),
|
||||
SizedBox(
|
||||
width: card.showPastTaskPushButton
|
||||
? metrics.compactPushTrailingReserve
|
||||
: metrics.compactTrailingReserve,
|
||||
),
|
||||
],
|
||||
);
|
||||
if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) {
|
||||
|
|
|
|||
|
|
@ -48,7 +48,11 @@ class _ExpandedCardContent extends StatelessWidget {
|
|||
],
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(right: metrics.expandedTrailingReserve),
|
||||
padding: EdgeInsets.only(
|
||||
right: card.showPastTaskPushButton
|
||||
? metrics.expandedPushTrailingReserve
|
||||
: metrics.expandedTrailingReserve,
|
||||
),
|
||||
child: _CardText(
|
||||
card: card,
|
||||
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.
|
||||
const _NoOpIcon({
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.color,
|
||||
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.
|
||||
final IconData icon;
|
||||
|
||||
/// Tooltip message for this inactive icon affordance.
|
||||
final String tooltip;
|
||||
|
||||
/// 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;
|
||||
|
|
@ -31,7 +35,7 @@ class _NoOpIcon extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: 'No-op action',
|
||||
message: tooltip,
|
||||
child: GestureDetector(
|
||||
onTap: () {},
|
||||
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.
|
||||
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
||||
const _QuickActions({
|
||||
required this.card,
|
||||
required this.color,
|
||||
required this.backgroundColor,
|
||||
required this.metrics,
|
||||
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.
|
||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
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.
|
||||
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.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
|
|
@ -41,6 +63,7 @@ class _QuickActions extends StatelessWidget {
|
|||
ignoring: !visible,
|
||||
child: ClipRect(
|
||||
child: AnimatedContainer(
|
||||
key: ValueKey('${card.id}-quick-actions-background'),
|
||||
width: visible ? metrics.actionsWidth : 0,
|
||||
height: metrics.actionButtonSize,
|
||||
decoration: BoxDecoration(color: backgroundColor),
|
||||
|
|
@ -51,29 +74,31 @@ class _QuickActions extends StatelessWidget {
|
|||
maxWidth: metrics.actionsWidth,
|
||||
alignment: Alignment.centerRight,
|
||||
child: AnimatedOpacity(
|
||||
key: ValueKey('${card.id}-quick-actions-opacity'),
|
||||
opacity: visible ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 90),
|
||||
curve: Curves.easeOut,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_NoOpIcon(
|
||||
icon: Icons.check,
|
||||
color: color,
|
||||
metrics: metrics,
|
||||
),
|
||||
_NoOpIcon(
|
||||
icon: Icons.arrow_forward,
|
||||
_QuickPushIcon(
|
||||
card: card,
|
||||
color: color,
|
||||
metrics: metrics,
|
||||
isRunning: pushRunning,
|
||||
onPushToNext: onPushToNext,
|
||||
onPushToTomorrow: onPushToTomorrow,
|
||||
onPushToBacklog: onPushToBacklog,
|
||||
),
|
||||
_NoOpIcon(
|
||||
icon: Icons.calendar_today,
|
||||
tooltip: 'Schedule',
|
||||
color: color,
|
||||
metrics: metrics,
|
||||
),
|
||||
_NoOpIcon(
|
||||
icon: Icons.wb_sunny_outlined,
|
||||
tooltip: 'Protect time',
|
||||
color: color,
|
||||
metrics: metrics,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
part of '../task_timeline_card.dart';
|
||||
|
||||
/// Private implementation type for `_StatusRing` in this library.
|
||||
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
|
||||
/// It adapts timeline card sizing and labels to the shared status-circle button.
|
||||
class _StatusRing extends StatelessWidget {
|
||||
/// Creates a `_StatusRing` instance with the values required by its invariants.
|
||||
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
||||
|
|
@ -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.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final border = Border.all(color: color, width: metrics.statusBorderWidth);
|
||||
final ring = Container(
|
||||
key: ValueKey('${card.id}-status'),
|
||||
width: metrics.statusRingSize,
|
||||
height: metrics.statusRingSize,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: card.isCompleted ? color : Colors.transparent,
|
||||
border: border,
|
||||
),
|
||||
child: card.isCompleted
|
||||
? Icon(
|
||||
Icons.check,
|
||||
color: FocusFlowTokens.appBackground,
|
||||
size: metrics.statusIconSize,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
if (onPressed == null) {
|
||||
return ring;
|
||||
}
|
||||
return Tooltip(
|
||||
message: card.isCompleted ? 'Mark not done' : 'Mark complete',
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: card.isCompleted
|
||||
? 'Mark ${card.title} not done'
|
||||
: 'Mark ${card.title} complete',
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onPressed,
|
||||
child: ring,
|
||||
),
|
||||
),
|
||||
),
|
||||
return StatusCircleButton(
|
||||
circleKey: ValueKey('${card.id}-status'),
|
||||
color: color,
|
||||
checkColor: FocusFlowTokens.appBackground,
|
||||
isCompleted: card.isCompleted,
|
||||
size: metrics.statusRingSize,
|
||||
borderWidth: metrics.statusBorderWidth,
|
||||
iconSize: metrics.statusIconSize,
|
||||
semanticLabel: card.isCompleted
|
||||
? 'Mark ${card.title} not done'
|
||||
: 'Mark ${card.title} complete',
|
||||
onPressed: onPressed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
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.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
|
|
@ -48,6 +52,16 @@ class _TaskTimelineCardState extends State<TaskTimelineCard> {
|
|||
colors: colors,
|
||||
metrics: metrics,
|
||||
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.metrics,
|
||||
required this.actionsVisible,
|
||||
required this.pushRunning,
|
||||
this.onPushToNext,
|
||||
this.onPushToTomorrow,
|
||||
this.onPushToBacklog,
|
||||
});
|
||||
|
||||
/// 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.
|
||||
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.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
|
|
@ -40,11 +57,29 @@ class _TrailingControls extends StatelessWidget {
|
|||
children: [
|
||||
if (card.showsQuickActions)
|
||||
_QuickActions(
|
||||
card: card,
|
||||
color: colors.accent,
|
||||
backgroundColor: colors.background.withValues(alpha: 1),
|
||||
metrics: metrics,
|
||||
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),
|
||||
SizedBox(width: metrics.indicatorGap),
|
||||
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.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart' show logger;
|
||||
|
||||
import '../../models/today_screen_models.dart';
|
||||
import '../../theme/focus_flow_tokens.dart';
|
||||
import '../status_circle_button.dart';
|
||||
import 'difficulty_bars.dart';
|
||||
import 'reward_icon.dart';
|
||||
|
||||
|
|
@ -31,6 +34,9 @@ class TaskTimelineCard extends StatefulWidget {
|
|||
required this.card,
|
||||
required this.onTap,
|
||||
this.onStatusPressed,
|
||||
this.onPushToNext,
|
||||
this.onPushToTomorrow,
|
||||
this.onPushToBacklog,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -43,6 +49,15 @@ class TaskTimelineCard extends StatefulWidget {
|
|||
/// Callback invoked when the left status control is clicked.
|
||||
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.
|
||||
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -26,9 +26,13 @@ class TimelineView extends StatefulWidget {
|
|||
required this.onCardSelected,
|
||||
this.onAddTask,
|
||||
this.onCardCompleted,
|
||||
this.onPushToNext,
|
||||
this.onPushToTomorrow,
|
||||
this.onPushToBacklog,
|
||||
this.now,
|
||||
this.scrollTargetCardId,
|
||||
this.scrollRequest = 0,
|
||||
this.scrollToNowRequest = 0,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -47,6 +51,15 @@ class TimelineView extends StatefulWidget {
|
|||
/// Callback invoked when a task card status control requests completion.
|
||||
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.
|
||||
final DateTime Function()? now;
|
||||
|
||||
|
|
@ -56,6 +69,9 @@ class TimelineView extends StatefulWidget {
|
|||
/// Monotonic request number that allows repeated jumps to the same card.
|
||||
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.
|
||||
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
|
||||
@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.
|
||||
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.
|
||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
late TimelineGeometry _geometry;
|
||||
|
|
@ -86,6 +90,7 @@ class _TimelineViewState extends State<TimelineView> {
|
|||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
_scheduleInitialScroll(_geometry, constraints.maxHeight);
|
||||
_scheduleCurrentTimeScroll(_geometry, constraints.maxHeight);
|
||||
_scheduleTargetScroll(_geometry);
|
||||
return ScrollConfiguration(
|
||||
behavior: const _TimelineScrollBehavior(),
|
||||
|
|
@ -140,6 +145,9 @@ class _TimelineViewState extends State<TimelineView> {
|
|||
: () => widget.onCardCompleted!(
|
||||
placement.card,
|
||||
),
|
||||
onPushToNext: widget.onPushToNext,
|
||||
onPushToTomorrow: widget.onPushToTomorrow,
|
||||
onPushToBacklog: widget.onPushToBacklog,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -234,21 +242,31 @@ class _TimelineViewState extends State<TimelineView> {
|
|||
return;
|
||||
}
|
||||
final now = widget.now?.call() ?? DateTime.now();
|
||||
final currentMinute = now.hour * 60 + now.minute;
|
||||
final clampedMinute = currentMinute
|
||||
.clamp(geometry.startMinutes, geometry.endMinutes)
|
||||
.toInt();
|
||||
final centeredOffset =
|
||||
geometry.yForMinutesSinceMidnight(clampedMinute) +
|
||||
_topInset -
|
||||
viewportHeight / 2;
|
||||
final maxOffset = _scrollController.position.maxScrollExtent;
|
||||
final offset = centeredOffset.clamp(0, maxOffset).toDouble();
|
||||
_scrollController.jumpTo(offset);
|
||||
_jumpToCurrentTime(geometry, viewportHeight, now);
|
||||
_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.
|
||||
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||
void _scheduleTargetScroll(TimelineGeometry geometry) {
|
||||
|
|
@ -281,4 +299,40 @@ class _TimelineViewState extends State<TimelineView> {
|
|||
_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.onNextDate,
|
||||
this.onDatePressed,
|
||||
this.onDateLabelPressed,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -36,6 +37,9 @@ class TopBar extends StatelessWidget {
|
|||
/// Callback invoked when the choose-date control is activated.
|
||||
final VoidCallback? onDatePressed;
|
||||
|
||||
/// Callback invoked when the displayed date label is activated.
|
||||
final VoidCallback? onDateLabelPressed;
|
||||
|
||||
/// 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
|
||||
|
|
@ -78,13 +82,13 @@ class TopBar extends StatelessWidget {
|
|||
width: metrics.dateWidth,
|
||||
height: metrics.controlHeight,
|
||||
child: Tooltip(
|
||||
message: 'Choose date',
|
||||
message: 'Scroll to current time',
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: 'Choose date, $dateLabel',
|
||||
label: 'Scroll to current time, $dateLabel',
|
||||
child: TextButton(
|
||||
key: const ValueKey('top-bar-date-button'),
|
||||
onPressed: onDatePressed,
|
||||
onPressed: onDateLabelPressed,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: FocusFlowTokens.textPrimary,
|
||||
padding: EdgeInsets.symmetric(
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ void main() {
|
|||
testWidgets('date picker jumps directly to the chosen date', (tester) async {
|
||||
await _pumpPlanApp(tester);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('top-bar-date-button')));
|
||||
await tester.tap(find.byTooltip('Choose date'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(DatePickerDialog), findsOneWidget);
|
||||
|
|
@ -49,6 +49,18 @@ void main() {
|
|||
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 {
|
||||
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' &&
|
||||
relativePath != 'test/persistent_composition_test.dart' &&
|
||||
!_isPersistentComposition(relativePath))
|
||||
relativePath != 'test/app/runtime_config_test.dart' &&
|
||||
!_isApprovedRuntimeBoundary(relativePath))
|
||||
const _ForbiddenImport("dart:io", 'dart:io'),
|
||||
];
|
||||
}
|
||||
|
|
@ -87,6 +88,12 @@ bool _isPersistentComposition(String relativePath) {
|
|||
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.
|
||||
/// 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 {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ void main() {
|
|||
scheduledEnd: DateTime.utc(2026, 1, 3, 9, 30),
|
||||
completedAt: DateTime.utc(2026, 1, 3, 9, 25),
|
||||
),
|
||||
now: DateTime.utc(2026, 1, 3, 10),
|
||||
);
|
||||
|
||||
expect(card.completionDateContextRequired, isFalse);
|
||||
|
|
@ -32,6 +33,7 @@ void main() {
|
|||
scheduledEnd: DateTime.utc(2026, 1, 3, 9, 30),
|
||||
completedAt: DateTime.utc(2026, 1, 4, 8, 5),
|
||||
),
|
||||
now: DateTime.utc(2026, 1, 4, 9),
|
||||
);
|
||||
|
||||
expect(card.completionDateContextRequired, isTrue);
|
||||
|
|
@ -48,6 +50,7 @@ void main() {
|
|||
actualStart: DateTime.utc(2026, 1, 4, 0, 10),
|
||||
actualEnd: DateTime.utc(2026, 1, 4, 0, 20),
|
||||
),
|
||||
now: DateTime.utc(2026, 1, 4, 1),
|
||||
);
|
||||
|
||||
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/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/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(
|
||||
'selected future-day schedule completion and uncomplete persist across reopen',
|
||||
(tester) async {
|
||||
|
|
@ -128,7 +166,7 @@ void main() {
|
|||
selectedDate: selectedDate,
|
||||
clock: FixedClock(_instant(8, 10)),
|
||||
);
|
||||
var scheduled = await _readTodayItem(
|
||||
final scheduled = await _readTodayItem(
|
||||
tester,
|
||||
composition,
|
||||
tomorrow,
|
||||
|
|
@ -169,6 +207,7 @@ void main() {
|
|||
expect(completed.item.completedAt, isNotNull);
|
||||
final completedData = TodayScreenData.fromTodayState(
|
||||
await _readToday(tester, composition, tomorrow),
|
||||
now: DateTime.utc(2026, 1, 4, 8, 20),
|
||||
);
|
||||
final completedCard = completedData.cards.singleWhere(
|
||||
(card) => card.title == title,
|
||||
|
|
@ -208,6 +247,239 @@ void main() {
|
|||
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.
|
||||
|
|
@ -233,6 +505,7 @@ Future<PersistentSchedulerComposition> _openComposition(
|
|||
required String path,
|
||||
required CivilDate selectedDate,
|
||||
required Clock clock,
|
||||
FocusFlowUiTimeZone? uiTimeZone,
|
||||
}) {
|
||||
return _runAsync(
|
||||
tester,
|
||||
|
|
@ -240,6 +513,7 @@ Future<PersistentSchedulerComposition> _openComposition(
|
|||
sqlitePath: path,
|
||||
selectedDate: selectedDate,
|
||||
clock: clock,
|
||||
uiTimeZone: uiTimeZone,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -283,10 +557,35 @@ SchedulerCommandController _commandController(
|
|||
refreshReads: () async {},
|
||||
quickCaptureProjectId: composition.defaultProjectId,
|
||||
operationIdPrefix: operationIdPrefix,
|
||||
currentDateFor: composition.uiTimeZone.toLocalCivilDate,
|
||||
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].
|
||||
Future<TodayTimelineItem> _readTodayItem(
|
||||
WidgetTester tester,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
/// Tests Widget behavior in the FocusFlow Flutter app.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.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/theme/focus_flow_theme.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/reward_icon.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',
|
||||
() async {
|
||||
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, 'sort-mail'), TaskVisualKind.flexible);
|
||||
|
|
@ -90,7 +96,7 @@ void main() {
|
|||
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,
|
||||
) async {
|
||||
await _pumpPlanApp(tester);
|
||||
|
|
@ -104,14 +110,18 @@ void main() {
|
|||
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
|
||||
expect(find.text('Pay bill'), findsNWidgets(2));
|
||||
expect(find.textContaining('Required'), findsWidgets);
|
||||
expect(find.text('Reward level 2'), findsOneWidget);
|
||||
expect(find.text('Medium effort'), findsOneWidget);
|
||||
expect(find.text('Done'), findsOneWidget);
|
||||
expect(find.text('Push'), findsOneWidget);
|
||||
expect(find.text('Move to Backlog'), findsOneWidget);
|
||||
expect(find.byTooltip('Reward level 2'), findsOneWidget);
|
||||
expect(find.byTooltip('Medium effort'), findsOneWidget);
|
||||
expect(find.byTooltip('Mark complete'), findsNothing);
|
||||
expect(find.text('Reward level 2'), findsNothing);
|
||||
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);
|
||||
|
||||
await tester.tap(find.text('Done'));
|
||||
await tester.tap(find.text('Break up'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
|
||||
|
|
@ -132,6 +142,91 @@ void main() {
|
|||
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', () {
|
||||
expect(TodayScreenData.defaultRange.startMinutes, 0);
|
||||
expect(TodayScreenData.defaultRange.endMinutes, 24 * 60);
|
||||
|
|
@ -193,7 +288,7 @@ void main() {
|
|||
});
|
||||
|
||||
testWidgets(
|
||||
'timeline defaults near the current time while keeping full day',
|
||||
'timeline defaults with current time one quarter down the viewport',
|
||||
(tester) async {
|
||||
await _pumpConstrainedWidget(
|
||||
tester,
|
||||
|
|
@ -213,10 +308,48 @@ void main() {
|
|||
final scrollable = tester.state<ScrollableState>(find.byType(Scrollable));
|
||||
final position = scrollable.position;
|
||||
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 {
|
||||
await _pumpConstrainedWidget(
|
||||
tester,
|
||||
|
|
@ -357,7 +490,10 @@ void main() {
|
|||
tester,
|
||||
) async {
|
||||
final state = await _readSeededToday();
|
||||
final data = TodayScreenData.fromTodayState(state);
|
||||
final data = TodayScreenData.fromTodayState(
|
||||
state,
|
||||
now: DateTime.utc(2025, 5, 20, 15, 20),
|
||||
);
|
||||
|
||||
await _pumpConstrainedWidget(
|
||||
tester,
|
||||
|
|
@ -422,6 +558,7 @@ void main() {
|
|||
var previousCount = 0;
|
||||
var nextCount = 0;
|
||||
var datePressedCount = 0;
|
||||
var dateLabelPressedCount = 0;
|
||||
|
||||
await _pumpConstrainedWidget(
|
||||
tester,
|
||||
|
|
@ -437,18 +574,24 @@ void main() {
|
|||
onDatePressed: () {
|
||||
datePressedCount += 1;
|
||||
},
|
||||
onDateLabelPressed: () {
|
||||
dateLabelPressedCount += 1;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byTooltip('Previous 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.pumpAndSettle();
|
||||
|
||||
expect(previousCount, 1);
|
||||
expect(nextCount, 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', (
|
||||
|
|
@ -507,7 +650,11 @@ void main() {
|
|||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(
|
||||
tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity)).opacity,
|
||||
tester
|
||||
.widget<AnimatedOpacity>(
|
||||
find.byKey(const ValueKey('narrow-actions-quick-actions-opacity')),
|
||||
)
|
||||
.opacity,
|
||||
0,
|
||||
);
|
||||
|
||||
|
|
@ -518,11 +665,15 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity)).opacity,
|
||||
tester
|
||||
.widget<AnimatedOpacity>(
|
||||
find.byKey(const ValueKey('narrow-actions-quick-actions-opacity')),
|
||||
)
|
||||
.opacity,
|
||||
1,
|
||||
);
|
||||
final actionBackground = tester.widget<AnimatedContainer>(
|
||||
find.byType(AnimatedContainer),
|
||||
find.byKey(const ValueKey('narrow-actions-quick-actions-background')),
|
||||
);
|
||||
final decoration = actionBackground.decoration;
|
||||
expect(decoration, isA<BoxDecoration>());
|
||||
|
|
@ -540,6 +691,243 @@ void main() {
|
|||
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 {
|
||||
await _pumpTimelineCard(
|
||||
tester,
|
||||
|
|
@ -593,6 +981,33 @@ void main() {
|
|||
await _pumpPlanApp(tester, composition: composition);
|
||||
|
||||
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();
|
||||
await tester.tap(find.byKey(const ValueKey('clean-coffee-maker-status')));
|
||||
await tester.pumpAndSettle();
|
||||
|
|
@ -610,6 +1025,26 @@ void main() {
|
|||
expect(completedTask.completedAt!.isAfter(afterClick), isFalse);
|
||||
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.pumpAndSettle();
|
||||
|
||||
|
|
@ -638,6 +1073,32 @@ void main() {
|
|||
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
|
||||
expect(find.text('Flexible - 3:20 PM - 4:00 PM'), findsOneWidget);
|
||||
expect(find.textContaining('Completed -'), findsNothing);
|
||||
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.pumpAndSettle();
|
||||
|
|
@ -652,6 +1113,26 @@ void main() {
|
|||
expect(find.text('Planned: 3:20 PM - 4:00 PM'), findsOneWidget);
|
||||
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.pumpAndSettle();
|
||||
|
||||
|
|
@ -739,8 +1220,13 @@ Future<void> _pumpTimelineCard(
|
|||
WidgetTester tester, {
|
||||
required TimelineCardModel card,
|
||||
required Size size,
|
||||
Future<void> Function(TimelineCardModel card)? onPushToNext,
|
||||
Future<void> Function(TimelineCardModel card)? onPushToTomorrow,
|
||||
Future<void> Function(TimelineCardModel card)? onPushToBacklog,
|
||||
}) 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));
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
|
|
@ -753,6 +1239,9 @@ Future<void> _pumpTimelineCard(
|
|||
card: card,
|
||||
onTap: () {},
|
||||
onStatusPressed: () {},
|
||||
onPushToNext: onPushToNext,
|
||||
onPushToTomorrow: onPushToTomorrow,
|
||||
onPushToBacklog: onPushToBacklog,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -796,7 +1285,12 @@ TimelineCardModel _timelineCard({
|
|||
TaskVisualKind visualKind = TaskVisualKind.flexible,
|
||||
TaskType? taskType,
|
||||
bool showsQuickActions = true,
|
||||
bool showPastTaskPushButton = false,
|
||||
bool isCompleted = false,
|
||||
String? completedTimeText,
|
||||
TimelineRewardIconToken rewardIconToken = TimelineRewardIconToken.medium,
|
||||
TimelineDifficultyIconToken difficultyIconToken =
|
||||
TimelineDifficultyIconToken.medium,
|
||||
}) {
|
||||
final resolvedTaskType =
|
||||
taskType ??
|
||||
|
|
@ -817,12 +1311,13 @@ TimelineCardModel _timelineCard({
|
|||
endMinutes: endMinutes,
|
||||
taskType: resolvedTaskType,
|
||||
visualKind: visualKind,
|
||||
rewardIconToken: TimelineRewardIconToken.medium,
|
||||
difficultyIconToken: TimelineDifficultyIconToken.medium,
|
||||
rewardIconToken: rewardIconToken,
|
||||
difficultyIconToken: difficultyIconToken,
|
||||
completedTimeText: completedTimeText,
|
||||
isCompleted: false,
|
||||
isCompleted: isCompleted,
|
||||
isSelectable: true,
|
||||
showsQuickActions: showsQuickActions,
|
||||
showsQuickActions: showPastTaskPushButton ? false : showsQuickActions,
|
||||
showPastTaskPushButton: showPastTaskPushButton,
|
||||
durationMinutes: endMinutes - startMinutes,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import 'dart:math';
|
|||
import 'dart:typed_data';
|
||||
|
||||
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/codec/backup_encryption_codec.dart';
|
||||
part 'encrypted_backup/errors/backup_exception.dart';
|
||||
|
|
|
|||
|
|
@ -56,9 +56,19 @@ Future<List<int>> _decryptBytes({
|
|||
SecretBox(cipherText, nonce: nonce, mac: mac),
|
||||
secretKey: key,
|
||||
);
|
||||
} on BackupDecryptionException {
|
||||
} on BackupDecryptionException catch (error, stackTrace) {
|
||||
logger.error(
|
||||
() => 'Encrypted backup decryption failed with known backup error.',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
rethrow;
|
||||
} on Object {
|
||||
} on Object catch (error, stackTrace) {
|
||||
logger.error(
|
||||
() => 'Encrypted backup decryption failed.',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
throw const BackupDecryptionException();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,10 @@ Future<File> createEncryptedBackup({
|
|||
DateTime? now,
|
||||
}) async {
|
||||
final source = sqliteFile ?? defaultSchedulerSqliteFile();
|
||||
logger.info(() => 'Encrypted backup started. sourcePath=${source.path}');
|
||||
if (!await source.exists()) {
|
||||
logger.warn(() =>
|
||||
'Encrypted backup source file missing. sourcePath=${source.path}');
|
||||
throw BackupException('SQLite file does not exist: ${source.path}');
|
||||
}
|
||||
|
||||
|
|
@ -35,7 +38,11 @@ Future<File> createEncryptedBackup({
|
|||
passphrase: passphrase,
|
||||
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.
|
||||
|
|
@ -47,7 +54,11 @@ Future<void> restoreEncryptedBackup(
|
|||
String passphrase, {
|
||||
File? targetFile,
|
||||
}) async {
|
||||
logger.info(
|
||||
() => 'Encrypted backup restore started. backupPath=${backup.path}');
|
||||
if (!await backup.exists()) {
|
||||
logger.warn(() =>
|
||||
'Encrypted backup restore source missing. backupPath=${backup.path}');
|
||||
throw BackupException('Backup file does not exist: ${backup.path}');
|
||||
}
|
||||
|
||||
|
|
@ -64,4 +75,7 @@ Future<void> restoreEncryptedBackup(
|
|||
await target.delete();
|
||||
}
|
||||
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['HOMEDRIVE'];
|
||||
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.');
|
||||
}
|
||||
return Directory(home);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ environment:
|
|||
dependencies:
|
||||
cryptography: ^2.7.0
|
||||
encrypt: ^5.0.3
|
||||
scheduler_core: any
|
||||
|
||||
dev_dependencies:
|
||||
lints: ^5.0.0
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export 'src/persistence/document_mapping.dart';
|
|||
export 'src/persistence/document_migration.dart';
|
||||
export 'src/scheduling/free_slots.dart';
|
||||
export 'src/domain/locked_time.dart';
|
||||
export 'src/logging/focus_flow_logger.dart';
|
||||
export 'src/domain/occupancy_policy.dart';
|
||||
export 'src/persistence/persistence_contract.dart';
|
||||
export 'src/domain/project_statistics.dart';
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import '../scheduling/scheduling_engine.dart';
|
|||
import '../scheduling/task_actions.dart';
|
||||
import '../scheduling/task_lifecycle.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/messages/application_child_task_draft.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.
|
||||
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.
|
||||
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
|
||||
completeFlexibleTask,
|
||||
|
|
|
|||
|
|
@ -80,6 +80,10 @@ class V1ApplicationCommandUseCases {
|
|||
context: context,
|
||||
operationName: commandCode.name,
|
||||
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(
|
||||
QuickCaptureRequest(
|
||||
id: context.idGenerator.nextId(),
|
||||
|
|
@ -136,6 +140,11 @@ class V1ApplicationCommandUseCases {
|
|||
context: context,
|
||||
operationName: commandCode.name,
|
||||
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 =
|
||||
await _loadPlanningState(repositories, context, localDate);
|
||||
final taskId = context.idGenerator.nextId();
|
||||
|
|
@ -199,9 +208,16 @@ class V1ApplicationCommandUseCases {
|
|||
context: context,
|
||||
operationName: commandCode.name,
|
||||
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 task = _taskById(state.tasks, taskId);
|
||||
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);
|
||||
if (task != null) {
|
||||
state = state.withTask(task);
|
||||
|
|
@ -316,6 +332,9 @@ class V1ApplicationCommandUseCases {
|
|||
context: context,
|
||||
operationName: commandCode.name,
|
||||
action: (repositories) async {
|
||||
logger.debug(
|
||||
() => 'Application command executing. command=${commandCode.name} '
|
||||
'operationId=${context.operationId} taskId=$taskId');
|
||||
final task = await repositories.tasks.findById(taskId);
|
||||
if (task == null) {
|
||||
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.
|
||||
Future<ApplicationResult<ApplicationCommandResult>> completeFlexibleTask({
|
||||
required ApplicationOperationContext context,
|
||||
|
|
@ -370,9 +437,15 @@ class V1ApplicationCommandUseCases {
|
|||
context: context,
|
||||
operationName: commandCode.name,
|
||||
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 task = _taskById(state.tasks, taskId);
|
||||
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);
|
||||
if (task != null) {
|
||||
state = state.withTask(task);
|
||||
|
|
@ -432,9 +505,15 @@ class V1ApplicationCommandUseCases {
|
|||
context: context,
|
||||
operationName: commandCode.name,
|
||||
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 task = _taskById(state.tasks, taskId);
|
||||
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);
|
||||
if (task != null) {
|
||||
state = state.withTask(task);
|
||||
|
|
@ -492,9 +571,16 @@ class V1ApplicationCommandUseCases {
|
|||
context: context,
|
||||
operationName: commandCode.name,
|
||||
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 task = _taskById(state.tasks, taskId);
|
||||
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);
|
||||
if (task != null) {
|
||||
state = state.withTask(task);
|
||||
|
|
@ -560,6 +646,11 @@ class V1ApplicationCommandUseCases {
|
|||
context: context,
|
||||
operationName: commandCode.name,
|
||||
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 =
|
||||
await _loadPlanningState(repositories, context, localDate);
|
||||
final logResult = surpriseTaskLogService.log(
|
||||
|
|
@ -613,6 +704,11 @@ class V1ApplicationCommandUseCases {
|
|||
context: context,
|
||||
operationName: commandCode.name,
|
||||
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(
|
||||
id: context.idGenerator.nextId(),
|
||||
title: title,
|
||||
|
|
@ -654,6 +750,11 @@ class V1ApplicationCommandUseCases {
|
|||
context: context,
|
||||
operationName: commandCode.name,
|
||||
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);
|
||||
if (task == null) {
|
||||
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
||||
|
|
@ -698,6 +799,9 @@ class V1ApplicationCommandUseCases {
|
|||
context: context,
|
||||
operationName: commandCode.name,
|
||||
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);
|
||||
if (task == null) {
|
||||
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
||||
|
|
@ -746,6 +850,10 @@ class V1ApplicationCommandUseCases {
|
|||
context: context,
|
||||
operationName: commandCode.name,
|
||||
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 parent = _taskById(tasks, parentTaskId);
|
||||
if (parent == null) {
|
||||
|
|
@ -882,9 +990,16 @@ class V1ApplicationCommandUseCases {
|
|||
context: context,
|
||||
operationName: commandCode.name,
|
||||
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 task = _taskById(state.tasks, taskId);
|
||||
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);
|
||||
if (task != null) {
|
||||
state = state.withTask(task);
|
||||
|
|
@ -903,6 +1018,9 @@ class V1ApplicationCommandUseCases {
|
|||
input: state.input,
|
||||
taskId: taskId,
|
||||
updatedAt: context.now,
|
||||
earliestStart: destination == PushDestination.nextAvailableSlot
|
||||
? context.now
|
||||
: null,
|
||||
operationId: context.operationId,
|
||||
existingActivities: existingActivities,
|
||||
);
|
||||
|
|
@ -947,6 +1065,9 @@ class V1ApplicationCommandUseCases {
|
|||
context: context,
|
||||
operationName: commandCode.name,
|
||||
action: (repositories) async {
|
||||
logger.debug(
|
||||
() => 'Application command executing. command=${commandCode.name} '
|
||||
'operationId=${context.operationId} taskId=$taskId');
|
||||
final tasks = await repositories.tasks.findAll();
|
||||
final task = _taskById(tasks, taskId);
|
||||
if (task == null) {
|
||||
|
|
@ -986,20 +1107,24 @@ class V1ApplicationCommandUseCases {
|
|||
CivilDate localDate,
|
||||
) async {
|
||||
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) ??
|
||||
OwnerSettings(
|
||||
ownerId: ownerId,
|
||||
timeZoneId: context.ownerTimeZone.timeZoneId,
|
||||
);
|
||||
final timeZoneId = context.ownerTimeZone.timeZoneId;
|
||||
final effectiveSettings = settings.copyWith(timeZoneId: timeZoneId);
|
||||
final dayStart = _resolve(
|
||||
date: localDate,
|
||||
wallTime: settings.dayStart,
|
||||
timeZoneId: settings.timeZoneId,
|
||||
wallTime: effectiveSettings.dayStart,
|
||||
timeZoneId: timeZoneId,
|
||||
);
|
||||
final dayEnd = _resolve(
|
||||
date: localDate,
|
||||
wallTime: settings.dayEnd,
|
||||
timeZoneId: settings.timeZoneId,
|
||||
wallTime: effectiveSettings.dayEnd,
|
||||
timeZoneId: timeZoneId,
|
||||
);
|
||||
final window = SchedulingWindow(start: dayStart, end: dayEnd);
|
||||
final tasks = (await repositories.tasks.findForLocalDay(
|
||||
|
|
@ -1019,11 +1144,16 @@ class V1ApplicationCommandUseCases {
|
|||
))
|
||||
.items,
|
||||
date: localDate,
|
||||
timeZoneId: settings.timeZoneId,
|
||||
timeZoneId: timeZoneId,
|
||||
timeZoneResolver: timeZoneResolver,
|
||||
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(
|
||||
tasks: tasks,
|
||||
window: window,
|
||||
|
|
@ -1064,6 +1194,11 @@ class V1ApplicationCommandUseCases {
|
|||
List<String> childTaskIds = const <String>[],
|
||||
}) async {
|
||||
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.taskActivities.saveAll(activities);
|
||||
final projectStats = await _saveProjectStatistics(
|
||||
|
|
@ -1072,6 +1207,11 @@ class V1ApplicationCommandUseCases {
|
|||
tasks: afterTasks,
|
||||
);
|
||||
|
||||
logger.debug(() =>
|
||||
'Application command persisted. command=${commandCode.name} '
|
||||
'operationId=${context.operationId} changedTaskCount=${changedTasks.length} '
|
||||
'projectStatisticsCount=${projectStats.length}');
|
||||
|
||||
return ApplicationResult.success(
|
||||
ApplicationCommandResult(
|
||||
commandCode: commandCode,
|
||||
|
|
@ -1101,6 +1241,8 @@ class V1ApplicationCommandUseCases {
|
|||
.where((activity) => activity.code == TaskActivityCode.completed)
|
||||
.map((activity) => activity.projectId)
|
||||
.toSet();
|
||||
logger.finer(() => 'Saving project statistics after command. '
|
||||
'projectCount=${projectIds.length} activityCount=${activities.length}');
|
||||
final updated = <ProjectStatistics>[];
|
||||
|
||||
for (final projectId in projectIds) {
|
||||
|
|
@ -1116,6 +1258,8 @@ class V1ApplicationCommandUseCases {
|
|||
if (result.appliedActivityIds.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
logger.finer(() => 'Project statistics updated. projectId=$projectId '
|
||||
'appliedActivityCount=${result.appliedActivityIds.length}');
|
||||
await repositories.projectStatistics.save(result.statistics);
|
||||
updated.add(result.statistics);
|
||||
}
|
||||
|
|
@ -1123,3 +1267,33 @@ class V1ApplicationCommandUseCases {
|
|||
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/task_lifecycle.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/application_operation_context.dart';
|
||||
part 'application_layer/results/application_failure_code.dart';
|
||||
|
|
|
|||
|
|
@ -195,7 +195,13 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
|
|||
ApplicationUnitOfWorkRepositories repositories,
|
||||
) action,
|
||||
}) 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)) {
|
||||
logger.warn(() =>
|
||||
'Duplicate operation ignored by in-memory unit of work. '
|
||||
'operationId=${context.operationId} operationName=$operationName');
|
||||
return ApplicationResult.failure(
|
||||
ApplicationFailure(
|
||||
code: ApplicationFailureCode.duplicateOperation,
|
||||
|
|
@ -210,6 +216,9 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
|
|||
try {
|
||||
final result = await action(repositories);
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -222,14 +231,29 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
|
|||
|
||||
if (_failNextCommit) {
|
||||
_failNextCommit = false;
|
||||
logger.error(() => 'In-memory unit of work commit failed by test hook. '
|
||||
'operationId=${context.operationId} operationName=$operationName');
|
||||
throw const ApplicationPersistenceException();
|
||||
}
|
||||
|
||||
_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;
|
||||
} 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);
|
||||
} 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(
|
||||
ApplicationFailure(
|
||||
code: ApplicationFailureCode.validation,
|
||||
|
|
@ -237,19 +261,35 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
|
|||
),
|
||||
);
|
||||
} 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(
|
||||
ApplicationFailure(
|
||||
code: ApplicationFailureCode.validation,
|
||||
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(
|
||||
const ApplicationFailure(
|
||||
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(
|
||||
const ApplicationFailure(code: ApplicationFailureCode.unexpected),
|
||||
);
|
||||
|
|
@ -264,14 +304,34 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
|
|||
ApplicationUnitOfWorkRepositories repositories,
|
||||
) action,
|
||||
}) 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 repositories = _InMemoryApplicationRepositories(staged);
|
||||
|
||||
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) {
|
||||
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);
|
||||
} on DomainValidationException catch (error) {
|
||||
logger.warn(
|
||||
() => 'In-memory unit of work read caught domain validation failure. '
|
||||
'code=${error.code.name}');
|
||||
return ApplicationResult.failure(
|
||||
ApplicationFailure(
|
||||
code: ApplicationFailureCode.validation,
|
||||
|
|
@ -279,19 +339,32 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
|
|||
),
|
||||
);
|
||||
} on ArgumentError catch (error) {
|
||||
logger.warn(() =>
|
||||
'In-memory unit of work read caught argument validation failure. '
|
||||
'argumentName=${error.name}');
|
||||
return ApplicationResult.failure(
|
||||
ApplicationFailure(
|
||||
code: ApplicationFailureCode.validation,
|
||||
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(
|
||||
const ApplicationFailure(
|
||||
code: ApplicationFailureCode.persistenceFailure,
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
} catch (error, stackTrace) {
|
||||
logger.error(
|
||||
() => 'In-memory unit of work read unexpected failure.',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return ApplicationResult.failure(
|
||||
const ApplicationFailure(code: ApplicationFailureCode.unexpected),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -251,6 +251,38 @@ class _UnitOfWorkTaskRepository implements TaskRepository {
|
|||
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.
|
||||
/// 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';
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import '../domain/locked_time.dart';
|
|||
import '../domain/models.dart';
|
||||
import '../domain/project_statistics.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/owner_settings_warning_code.dart';
|
||||
part 'application_management/requests/get_backlog_request.dart';
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ class V1ApplicationManagementUseCases {
|
|||
return applicationStore.read(
|
||||
action: (repositories) async {
|
||||
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(
|
||||
repositories,
|
||||
request.context.ownerTimeZone,
|
||||
|
|
@ -46,6 +48,9 @@ class V1ApplicationManagementUseCases {
|
|||
stalenessSettings: settings.backlogStalenessSettings,
|
||||
);
|
||||
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(
|
||||
BacklogQueryResult(
|
||||
|
|
@ -70,6 +75,9 @@ class V1ApplicationManagementUseCases {
|
|||
) {
|
||||
return applicationStore.read(
|
||||
action: (repositories) async {
|
||||
logger.debug(() =>
|
||||
'Loading project defaults. ownerId=${request.context.ownerTimeZone.ownerId} '
|
||||
'includeArchived=${request.includeArchived}');
|
||||
final projects = (await repositories.projects.findByOwner(
|
||||
ownerId: request.context.ownerTimeZone.ownerId,
|
||||
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(
|
||||
ProjectDefaultsQueryResult(
|
||||
ownerId: request.context.ownerTimeZone.ownerId,
|
||||
|
|
@ -115,8 +127,14 @@ class V1ApplicationManagementUseCases {
|
|||
operationName: ApplicationManagementCommandCode.createProject.name,
|
||||
action: (repositories) async {
|
||||
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);
|
||||
if (existing != null) {
|
||||
logger.warn(() => 'Create project failed: project already exists. '
|
||||
'operationId=${context.operationId} projectId=$id');
|
||||
return _failure(
|
||||
ApplicationFailureCode.conflict,
|
||||
entityId: id,
|
||||
|
|
@ -134,6 +152,8 @@ class V1ApplicationManagementUseCases {
|
|||
defaultDurationMinutes: draft.defaultDurationMinutes,
|
||||
);
|
||||
await repositories.projects.save(project);
|
||||
logger.debug(() =>
|
||||
'Project created. operationId=${context.operationId} projectId=$id');
|
||||
return ApplicationResult.success(project);
|
||||
},
|
||||
);
|
||||
|
|
@ -150,6 +170,10 @@ class V1ApplicationManagementUseCases {
|
|||
context: context,
|
||||
operationName: ApplicationManagementCommandCode.updateProject.name,
|
||||
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 updated = project.copyWith(
|
||||
name: update.name,
|
||||
|
|
@ -162,6 +186,8 @@ class V1ApplicationManagementUseCases {
|
|||
clearDefaultDuration: update.clearDefaultDuration,
|
||||
);
|
||||
await repositories.projects.save(updated);
|
||||
logger.debug(() =>
|
||||
'Project updated. operationId=${context.operationId} projectId=$projectId');
|
||||
return ApplicationResult.success(updated);
|
||||
},
|
||||
);
|
||||
|
|
@ -177,9 +203,14 @@ class V1ApplicationManagementUseCases {
|
|||
context: context,
|
||||
operationName: ApplicationManagementCommandCode.archiveProject.name,
|
||||
action: (repositories) async {
|
||||
logger.debug(() =>
|
||||
'Management command executing. command=${ApplicationManagementCommandCode.archiveProject.name} '
|
||||
'operationId=${context.operationId} projectId=$projectId');
|
||||
final project = await _requireProject(repositories, projectId);
|
||||
final archived = project.copyWith(archivedAt: context.now);
|
||||
await repositories.projects.save(archived);
|
||||
logger.debug(() =>
|
||||
'Project archived. operationId=${context.operationId} projectId=$projectId');
|
||||
return ApplicationResult.success(archived);
|
||||
},
|
||||
);
|
||||
|
|
@ -196,8 +227,14 @@ class V1ApplicationManagementUseCases {
|
|||
operationName: ApplicationManagementCommandCode.createLockedBlock.name,
|
||||
action: (repositories) async {
|
||||
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);
|
||||
if (existing != null) {
|
||||
logger.warn(() => 'Create locked block failed: block already exists. '
|
||||
'operationId=${context.operationId} lockedBlockId=$id');
|
||||
return _failure(
|
||||
ApplicationFailureCode.conflict,
|
||||
entityId: id,
|
||||
|
|
@ -217,6 +254,8 @@ class V1ApplicationManagementUseCases {
|
|||
updatedAt: context.now,
|
||||
);
|
||||
await repositories.lockedBlocks.saveBlock(block);
|
||||
logger.debug(() =>
|
||||
'Locked block created. operationId=${context.operationId} lockedBlockId=$id');
|
||||
return ApplicationResult.success(block);
|
||||
},
|
||||
);
|
||||
|
|
@ -233,6 +272,11 @@ class V1ApplicationManagementUseCases {
|
|||
context: context,
|
||||
operationName: ApplicationManagementCommandCode.updateLockedBlock.name,
|
||||
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 updated = block.copyWith(
|
||||
name: update.name,
|
||||
|
|
@ -245,6 +289,8 @@ class V1ApplicationManagementUseCases {
|
|||
updatedAt: context.now,
|
||||
);
|
||||
await repositories.lockedBlocks.saveBlock(updated);
|
||||
logger.debug(() =>
|
||||
'Locked block updated. operationId=${context.operationId} lockedBlockId=$lockedBlockId');
|
||||
return ApplicationResult.success(updated);
|
||||
},
|
||||
);
|
||||
|
|
@ -260,12 +306,17 @@ class V1ApplicationManagementUseCases {
|
|||
context: context,
|
||||
operationName: ApplicationManagementCommandCode.archiveLockedBlock.name,
|
||||
action: (repositories) async {
|
||||
logger.debug(() =>
|
||||
'Management command executing. command=${ApplicationManagementCommandCode.archiveLockedBlock.name} '
|
||||
'operationId=${context.operationId} lockedBlockId=$lockedBlockId');
|
||||
final block = await _requireLockedBlock(repositories, lockedBlockId);
|
||||
final archived = block.copyWith(
|
||||
updatedAt: context.now,
|
||||
archivedAt: context.now,
|
||||
);
|
||||
await repositories.lockedBlocks.saveBlock(archived);
|
||||
logger.debug(() =>
|
||||
'Locked block archived. operationId=${context.operationId} lockedBlockId=$lockedBlockId');
|
||||
return ApplicationResult.success(archived);
|
||||
},
|
||||
);
|
||||
|
|
@ -287,6 +338,10 @@ class V1ApplicationManagementUseCases {
|
|||
operationName:
|
||||
ApplicationManagementCommandCode.addLockedBlockOverride.name,
|
||||
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(
|
||||
id: context.idGenerator.nextId(),
|
||||
date: date,
|
||||
|
|
@ -298,6 +353,8 @@ class V1ApplicationManagementUseCases {
|
|||
createdAt: context.now,
|
||||
);
|
||||
await repositories.lockedBlocks.saveOverride(override);
|
||||
logger.debug(() =>
|
||||
'Locked block override added. operationId=${context.operationId} overrideId=${override.id}');
|
||||
return ApplicationResult.success(override);
|
||||
},
|
||||
);
|
||||
|
|
@ -315,6 +372,9 @@ class V1ApplicationManagementUseCases {
|
|||
operationName:
|
||||
ApplicationManagementCommandCode.removeLockedBlockOccurrence.name,
|
||||
action: (repositories) async {
|
||||
logger.debug(() =>
|
||||
'Management command executing. command=${ApplicationManagementCommandCode.removeLockedBlockOccurrence.name} '
|
||||
'operationId=${context.operationId} lockedBlockId=$lockedBlockId date=${date.toIsoString()}');
|
||||
await _requireLockedBlock(repositories, lockedBlockId);
|
||||
final override = LockedBlockOverride.remove(
|
||||
id: context.idGenerator.nextId(),
|
||||
|
|
@ -323,6 +383,9 @@ class V1ApplicationManagementUseCases {
|
|||
createdAt: context.now,
|
||||
);
|
||||
await repositories.lockedBlocks.saveOverride(override);
|
||||
logger.debug(() =>
|
||||
'Locked block occurrence removed. operationId=${context.operationId} '
|
||||
'lockedBlockId=$lockedBlockId overrideId=${override.id}');
|
||||
return ApplicationResult.success(override);
|
||||
},
|
||||
);
|
||||
|
|
@ -345,6 +408,10 @@ class V1ApplicationManagementUseCases {
|
|||
operationName:
|
||||
ApplicationManagementCommandCode.replaceLockedBlockOccurrence.name,
|
||||
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);
|
||||
final override = LockedBlockOverride.replace(
|
||||
id: context.idGenerator.nextId(),
|
||||
|
|
@ -358,6 +425,9 @@ class V1ApplicationManagementUseCases {
|
|||
createdAt: context.now,
|
||||
);
|
||||
await repositories.lockedBlocks.saveOverride(override);
|
||||
logger.debug(() =>
|
||||
'Locked block occurrence replaced. operationId=${context.operationId} '
|
||||
'lockedBlockId=$lockedBlockId overrideId=${override.id}');
|
||||
return ApplicationResult.success(override);
|
||||
},
|
||||
);
|
||||
|
|
@ -373,6 +443,11 @@ class V1ApplicationManagementUseCases {
|
|||
context: context,
|
||||
operationName: ApplicationManagementCommandCode.updateOwnerSettings.name,
|
||||
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(
|
||||
repositories,
|
||||
context.ownerTimeZone,
|
||||
|
|
@ -382,6 +457,9 @@ class V1ApplicationManagementUseCases {
|
|||
(stalenessSettings.greenMaxAge.isNegative ||
|
||||
stalenessSettings.blueMaxAge.isNegative ||
|
||||
stalenessSettings.blueMaxAge < stalenessSettings.greenMaxAge)) {
|
||||
logger.warn(() =>
|
||||
'Owner settings update failed: invalid staleness thresholds. '
|
||||
'operationId=${context.operationId} ownerId=${context.ownerTimeZone.ownerId}');
|
||||
return _failure(
|
||||
ApplicationFailureCode.validation,
|
||||
detailCode: 'invalidBacklogStalenessThresholds',
|
||||
|
|
@ -404,6 +482,9 @@ class V1ApplicationManagementUseCases {
|
|||
OwnerSettingsWarningCode.planningWindowChanged,
|
||||
];
|
||||
await repositories.ownerSettings.save(updated);
|
||||
logger.debug(() =>
|
||||
'Owner settings updated. operationId=${context.operationId} '
|
||||
'ownerId=${context.ownerTimeZone.ownerId} warningCount=${warnings.length}');
|
||||
return ApplicationResult.success(
|
||||
OwnerSettingsUpdateResult(settings: updated, warnings: warnings),
|
||||
);
|
||||
|
|
@ -422,8 +503,13 @@ class V1ApplicationManagementUseCases {
|
|||
operationName: ApplicationManagementCommandCode.acknowledgeNotice.name,
|
||||
action: (repositories) async {
|
||||
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);
|
||||
if (parsed == null) {
|
||||
logger.warn(() => 'Notice acknowledgement failed: invalid notice id. '
|
||||
'operationId=${context.operationId} noticeId=$noticeId');
|
||||
return _failure(
|
||||
ApplicationFailureCode.validation,
|
||||
entityId: noticeId,
|
||||
|
|
@ -433,6 +519,9 @@ class V1ApplicationManagementUseCases {
|
|||
final snapshot =
|
||||
await repositories.schedulingSnapshots.findById(parsed.snapshotId);
|
||||
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(
|
||||
ApplicationFailureCode.notFound,
|
||||
entityId: noticeId,
|
||||
|
|
@ -444,6 +533,9 @@ class V1ApplicationManagementUseCases {
|
|||
noticeId: noticeId,
|
||||
);
|
||||
if (existing != null) {
|
||||
logger.finer(() =>
|
||||
'Notice already acknowledged. operationId=${context.operationId} '
|
||||
'noticeId=$noticeId ownerId=$ownerId');
|
||||
return ApplicationResult.success(
|
||||
NoticeAcknowledgementResult(
|
||||
record: existing,
|
||||
|
|
@ -458,6 +550,9 @@ class V1ApplicationManagementUseCases {
|
|||
acknowledgedAt: context.now,
|
||||
);
|
||||
await repositories.noticeAcknowledgements.save(record);
|
||||
logger.debug(
|
||||
() => 'Notice acknowledged. operationId=${context.operationId} '
|
||||
'noticeId=$noticeId ownerId=$ownerId');
|
||||
return ApplicationResult.success(
|
||||
NoticeAcknowledgementResult(
|
||||
record: record,
|
||||
|
|
@ -476,6 +571,7 @@ class V1ApplicationManagementUseCases {
|
|||
) async {
|
||||
final project = await repositories.projects.findById(projectId);
|
||||
if (project == null) {
|
||||
logger.warn(() => 'Required project not found. projectId=$projectId');
|
||||
throw ApplicationFailureException(
|
||||
ApplicationFailure(
|
||||
code: ApplicationFailureCode.notFound,
|
||||
|
|
@ -495,6 +591,8 @@ class V1ApplicationManagementUseCases {
|
|||
) async {
|
||||
final block = await repositories.lockedBlocks.findBlockById(lockedBlockId);
|
||||
if (block == null) {
|
||||
logger.warn(() =>
|
||||
'Required locked block not found. lockedBlockId=$lockedBlockId');
|
||||
throw ApplicationFailureException(
|
||||
ApplicationFailure(
|
||||
code: ApplicationFailureCode.notFound,
|
||||
|
|
@ -513,12 +611,17 @@ Future<OwnerSettings> _ownerSettingsOrDefault(
|
|||
ApplicationUnitOfWorkRepositories repositories,
|
||||
OwnerTimeZoneContext ownerTimeZone,
|
||||
) async {
|
||||
return await repositories.ownerSettings
|
||||
.findByOwnerId(ownerTimeZone.ownerId) ??
|
||||
OwnerSettings(
|
||||
ownerId: ownerTimeZone.ownerId,
|
||||
timeZoneId: ownerTimeZone.timeZoneId,
|
||||
);
|
||||
final settings =
|
||||
await repositories.ownerSettings.findByOwnerId(ownerTimeZone.ownerId);
|
||||
if (settings != null) {
|
||||
return settings;
|
||||
}
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import '../persistence/repositories.dart';
|
|||
import '../scheduling/scheduling_engine.dart';
|
||||
import '../scheduling/task_lifecycle.dart';
|
||||
import '../domain/time_contracts.dart';
|
||||
import '../logging/focus_flow_logger.dart';
|
||||
part 'application_recovery/app_open_recovery_outcome.dart';
|
||||
part 'application_recovery/run_app_open_recovery_request.dart';
|
||||
part 'application_recovery/app_open_recovery_result.dart';
|
||||
|
|
|
|||
|
|
@ -42,9 +42,16 @@ class V1AppOpenRecoveryUseCases {
|
|||
ownerId: ownerId,
|
||||
sourceDate: sourceDate,
|
||||
);
|
||||
logger.info(() => 'App-open recovery started. ownerId=$ownerId '
|
||||
'operationId=${context.operationId} sourceDate=${sourceDate.toIsoString()} '
|
||||
'targetDate=${targetDate.toIsoString()} snapshotId=$snapshotId');
|
||||
final existingSnapshot =
|
||||
await repositories.schedulingSnapshots.findById(snapshotId);
|
||||
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(
|
||||
AppOpenRecoveryResult(
|
||||
outcome: AppOpenRecoveryOutcome.alreadyProcessed,
|
||||
|
|
@ -63,8 +70,11 @@ class V1AppOpenRecoveryUseCases {
|
|||
repositories,
|
||||
context.ownerTimeZone,
|
||||
);
|
||||
final sourceWindow = _windowForDate(sourceDate, settings);
|
||||
final targetWindow = _windowForDate(targetDate, settings);
|
||||
final effectiveSettings = settings.copyWith(
|
||||
timeZoneId: context.ownerTimeZone.timeZoneId,
|
||||
);
|
||||
final sourceWindow = _windowForDate(sourceDate, effectiveSettings);
|
||||
final targetWindow = _windowForDate(targetDate, effectiveSettings);
|
||||
final tasks = await _loadSourceAndTargetTasks(
|
||||
repositories,
|
||||
sourceWindow: sourceWindow,
|
||||
|
|
@ -83,10 +93,14 @@ class V1AppOpenRecoveryUseCases {
|
|||
blocks: blocks,
|
||||
overrides: overrides,
|
||||
date: targetDate,
|
||||
timeZoneId: settings.timeZoneId,
|
||||
timeZoneId: effectiveSettings.timeZoneId,
|
||||
timeZoneResolver: timeZoneResolver,
|
||||
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(
|
||||
tasks: tasks,
|
||||
window: targetWindow,
|
||||
|
|
@ -101,6 +115,10 @@ class V1AppOpenRecoveryUseCases {
|
|||
|
||||
if (schedulingResult.outcomeCode == SchedulingOutcomeCode.overflow ||
|
||||
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(
|
||||
ApplicationFailure(
|
||||
code: ApplicationFailureCode.noSlot,
|
||||
|
|
@ -135,9 +153,16 @@ class V1AppOpenRecoveryUseCases {
|
|||
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.taskActivities.saveAll(activities);
|
||||
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(
|
||||
AppOpenRecoveryResult(
|
||||
|
|
@ -198,12 +223,17 @@ Future<OwnerSettings> _ownerSettingsOrDefault(
|
|||
ApplicationUnitOfWorkRepositories repositories,
|
||||
OwnerTimeZoneContext ownerTimeZone,
|
||||
) async {
|
||||
return await repositories.ownerSettings
|
||||
.findByOwnerId(ownerTimeZone.ownerId) ??
|
||||
OwnerSettings(
|
||||
ownerId: ownerTimeZone.ownerId,
|
||||
timeZoneId: ownerTimeZone.timeZoneId,
|
||||
);
|
||||
final settings =
|
||||
await repositories.ownerSettings.findByOwnerId(ownerTimeZone.ownerId);
|
||||
if (settings != null) {
|
||||
return settings;
|
||||
}
|
||||
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.
|
||||
|
|
@ -213,6 +243,11 @@ Future<List<Task>> _loadSourceAndTargetTasks(
|
|||
required SchedulingWindow sourceWindow,
|
||||
required SchedulingWindow targetWindow,
|
||||
}) 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>{};
|
||||
for (final task in await repositories.tasks.findScheduledInWindow(
|
||||
sourceWindow,
|
||||
|
|
@ -224,6 +259,8 @@ Future<List<Task>> _loadSourceAndTargetTasks(
|
|||
)) {
|
||||
byId[task.id] = task;
|
||||
}
|
||||
logger.debug(() =>
|
||||
'Recovery source and target tasks loaded. taskCount=${byId.length}');
|
||||
return List<Task>.unmodifiable(byId.values);
|
||||
}
|
||||
|
||||
|
|
@ -249,8 +286,12 @@ SchedulingNotice? _rolloverNotice({
|
|||
.map((change) => change.taskId)
|
||||
.toSet();
|
||||
if (rolledTaskIds.isEmpty) {
|
||||
logger.finer(
|
||||
() => 'No rollover notice needed; no tasks moved from source day.');
|
||||
return null;
|
||||
}
|
||||
logger.debug(
|
||||
() => 'Rollover notice created. rolledTaskCount=${rolledTaskIds.length}');
|
||||
|
||||
return SchedulingNotice(
|
||||
'${rolledTaskIds.length} unfinished flexible tasks were moved to today.',
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ class Task {
|
|||
this.actualStart,
|
||||
this.actualEnd,
|
||||
this.completedAt,
|
||||
this.backlogEnteredAt,
|
||||
this.backlogEnteredAtProvenance,
|
||||
String? parentTaskId,
|
||||
Set<BacklogTag> backlogTags = const <BacklogTag>{},
|
||||
this.reminderOverride,
|
||||
|
|
@ -127,6 +129,7 @@ class Task {
|
|||
Set<BacklogTag> backlogTags = const <BacklogTag>{},
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
final entersBacklog = status == TaskStatus.backlog;
|
||||
return Task(
|
||||
id: id,
|
||||
title: title.trim(),
|
||||
|
|
@ -138,10 +141,19 @@ class Task {
|
|||
difficulty: difficulty,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt ?? createdAt,
|
||||
backlogEnteredAt: entersBacklog ? createdAt : null,
|
||||
backlogEnteredAtProvenance:
|
||||
entersBacklog ? backlogEnteredAtProvenanceQuickCapture : null,
|
||||
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.
|
||||
final String id;
|
||||
|
||||
|
|
@ -186,6 +198,12 @@ class Task {
|
|||
/// Explicit completion timestamp, distinct from the generic update timestamp.
|
||||
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.
|
||||
final String? parentTaskId;
|
||||
|
||||
|
|
@ -219,6 +237,9 @@ class Task {
|
|||
/// Backlog status means the task is stored for later and has no active slot.
|
||||
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.
|
||||
///
|
||||
/// The core uses this instead of mutation. That matters because scheduling
|
||||
|
|
@ -247,6 +268,8 @@ class Task {
|
|||
DateTime? actualStart,
|
||||
DateTime? actualEnd,
|
||||
DateTime? completedAt,
|
||||
DateTime? backlogEnteredAt,
|
||||
String? backlogEnteredAtProvenance,
|
||||
String? parentTaskId,
|
||||
Set<BacklogTag>? backlogTags,
|
||||
ReminderProfile? reminderOverride,
|
||||
|
|
@ -258,6 +281,7 @@ class Task {
|
|||
bool clearSchedule = false,
|
||||
bool clearActualInterval = false,
|
||||
bool clearCompletion = false,
|
||||
bool clearBacklogEntry = false,
|
||||
bool clearParentTask = false,
|
||||
bool clearReminderOverride = false,
|
||||
}) {
|
||||
|
|
@ -279,6 +303,12 @@ class Task {
|
|||
clearActualInterval ? null : (actualStart ?? this.actualStart),
|
||||
actualEnd: clearActualInterval ? null : (actualEnd ?? this.actualEnd),
|
||||
completedAt: clearCompletion ? null : (completedAt ?? this.completedAt),
|
||||
backlogEnteredAt: clearBacklogEntry
|
||||
? null
|
||||
: (backlogEnteredAt ?? this.backlogEnteredAt),
|
||||
backlogEnteredAtProvenance: clearBacklogEntry
|
||||
? null
|
||||
: (backlogEnteredAtProvenance ?? this.backlogEnteredAtProvenance),
|
||||
parentTaskId:
|
||||
clearParentTask ? null : (parentTaskId ?? this.parentTaskId),
|
||||
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(
|
||||
task.stats,
|
||||
),
|
||||
TaskDocumentFields.backlogEnteredAt: _dateTimeToStored(backlogEnteredAt),
|
||||
TaskDocumentFields.backlogEnteredAtProvenance: backlogEnteredAtProvenance,
|
||||
TaskDocumentFields.backlogEnteredAt:
|
||||
_dateTimeToStored(backlogEnteredAt ?? task.backlogEnteredAt),
|
||||
TaskDocumentFields.backlogEnteredAtProvenance:
|
||||
backlogEnteredAtProvenance ?? task.backlogEnteredAtProvenance,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -106,6 +108,14 @@ abstract final class TaskDocumentMapping {
|
|||
document,
|
||||
TaskDocumentFields.completedAt,
|
||||
),
|
||||
backlogEnteredAt: _requiredNullableDateTime(
|
||||
document,
|
||||
TaskDocumentFields.backlogEnteredAt,
|
||||
),
|
||||
backlogEnteredAtProvenance: _requiredNullableString(
|
||||
document,
|
||||
TaskDocumentFields.backlogEnteredAtProvenance,
|
||||
),
|
||||
parentTaskId: _requiredNullableString(
|
||||
document,
|
||||
TaskDocumentFields.parentTaskId,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ library;
|
|||
import 'document_mapping.dart';
|
||||
import 'persistence_contract.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_status.dart';
|
||||
part 'document_migration/codes/document_migration_failure_code.dart';
|
||||
|
|
|
|||
|
|
@ -80,8 +80,14 @@ class V1DocumentMigrationService {
|
|||
required void Function(Map<String, Object?> document) validateCurrent,
|
||||
}) {
|
||||
final documentId = _documentId(document);
|
||||
logger.debug(() => 'Document migration started. '
|
||||
'entity=${entity.name} documentId=$documentId '
|
||||
'ownerId=$ownerId migratedAt=${migratedAt.toIso8601String()}');
|
||||
final schemaRead = _readSchemaVersion(document);
|
||||
if (schemaRead.failure != null) {
|
||||
logger.warn(() => 'Document migration failed schema version read. '
|
||||
'entity=${entity.name} documentId=$documentId '
|
||||
'detailCode=${schemaRead.failure}');
|
||||
return _failed(
|
||||
entity: entity,
|
||||
documentId: documentId,
|
||||
|
|
@ -93,10 +99,15 @@ class V1DocumentMigrationService {
|
|||
}
|
||||
|
||||
final fromVersion = schemaRead.version;
|
||||
logger.finer(() => 'Document migration schema version resolved. '
|
||||
'entity=${entity.name} documentId=$documentId fromSchemaVersion=$fromVersion');
|
||||
if (fromVersion == v1SchemaVersion) {
|
||||
try {
|
||||
final copy = _deepCopyDocument(document);
|
||||
validateCurrent(copy);
|
||||
logger.debug(() =>
|
||||
'Document migration skipped; document already current. '
|
||||
'entity=${entity.name} documentId=$documentId fromSchemaVersion=$fromVersion');
|
||||
return DocumentMigrationResult(
|
||||
document: copy,
|
||||
report: DocumentMigrationReport(
|
||||
|
|
@ -107,6 +118,11 @@ class V1DocumentMigrationService {
|
|||
),
|
||||
);
|
||||
} 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(
|
||||
entity: entity,
|
||||
documentId: documentId,
|
||||
|
|
@ -118,6 +134,9 @@ class V1DocumentMigrationService {
|
|||
}
|
||||
|
||||
if (fromVersion > v1SchemaVersion || fromVersion < 0) {
|
||||
logger.warn(() =>
|
||||
'Document migration rejected unsupported schema version. '
|
||||
'entity=${entity.name} documentId=$documentId fromSchemaVersion=$fromVersion');
|
||||
return _failed(
|
||||
entity: entity,
|
||||
documentId: documentId,
|
||||
|
|
@ -129,6 +148,9 @@ class V1DocumentMigrationService {
|
|||
}
|
||||
|
||||
if (fromVersion != 0) {
|
||||
logger.warn(() =>
|
||||
'Document migration rejected unsupported schema version. '
|
||||
'entity=${entity.name} documentId=$documentId fromSchemaVersion=$fromVersion');
|
||||
return _failed(
|
||||
entity: entity,
|
||||
documentId: documentId,
|
||||
|
|
@ -144,6 +166,10 @@ class V1DocumentMigrationService {
|
|||
try {
|
||||
migrated = migrateV0(document, notes);
|
||||
} 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(
|
||||
entity: entity,
|
||||
documentId: documentId,
|
||||
|
|
@ -157,6 +183,11 @@ class V1DocumentMigrationService {
|
|||
try {
|
||||
validateCurrent(migrated);
|
||||
} 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(
|
||||
entity: entity,
|
||||
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(
|
||||
document: migrated,
|
||||
report: DocumentMigrationReport(
|
||||
|
|
|
|||
|
|
@ -284,6 +284,58 @@ class InMemoryTaskRepository implements TaskRepository {
|
|||
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.
|
||||
/// 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;
|
||||
|
|
|
|||
|
|
@ -79,4 +79,11 @@ abstract interface class TaskRepository {
|
|||
required String ownerId,
|
||||
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].
|
||||
///
|
||||
/// This uses [Task.createdAt], not [Task.updatedAt], because the marker is
|
||||
/// meant to show how long the idea has existed in the system. A task edited
|
||||
/// yesterday but created two months ago should still feel old in the backlog.
|
||||
/// This uses [Task.backlogFreshnessAnchorAt], not [Task.updatedAt], because
|
||||
/// ordinary edits should not make an old backlog item look fresh. Commands
|
||||
/// that deliberately re-enter Backlog reset the anchor.
|
||||
BacklogStalenessMarker markerFor({
|
||||
required Task task,
|
||||
required DateTime now,
|
||||
}) {
|
||||
final age = now.difference(task.createdAt);
|
||||
final age = now.difference(task.backlogFreshnessAnchorAt);
|
||||
|
||||
if (age <= greenMaxAge) {
|
||||
return BacklogStalenessMarker.green;
|
||||
|
|
|
|||
|
|
@ -26,11 +26,11 @@ class BacklogView {
|
|||
/// Clock value supplied by the caller so age/staleness behavior is testable.
|
||||
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
|
||||
/// persistence adds that field, both stale filtering and visual staleness use
|
||||
/// task creation age so they do not disagree after a task edit.
|
||||
/// Tasks persisted before the anchor existed fall back to [Task.createdAt].
|
||||
/// This keeps older data readable while letting Push to Backlog make a task
|
||||
/// fresh without rewriting original creation history.
|
||||
final Duration staleAfter;
|
||||
|
||||
/// Color-bucket threshold configuration for backlog aging indicators.
|
||||
|
|
@ -101,7 +101,8 @@ class BacklogView {
|
|||
BacklogFilter.criticalMissed =>
|
||||
task.type == TaskType.critical && task.stats.missedCount > 0,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -116,7 +117,8 @@ class BacklogView {
|
|||
_priorityRank(b.priority).compareTo(_priorityRank(a.priority)),
|
||||
BacklogSortKey.rewardVsEffort =>
|
||||
_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.timesPushed => _timesPushed(b).compareTo(_timesPushed(a)),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ library;
|
|||
import '../domain/models.dart';
|
||||
import 'task_lifecycle.dart';
|
||||
import '../domain/time_contracts.dart';
|
||||
import '../logging/focus_flow_logger.dart';
|
||||
part 'child_tasks/models/child_task_entry.dart';
|
||||
part 'child_tasks/requests/child_task_break_up_request.dart';
|
||||
part 'child_tasks/results/child_task_break_up_result.dart';
|
||||
|
|
|
|||
|
|
@ -17,8 +17,14 @@ class ChildTaskBreakUpService {
|
|||
required List<Task> tasks,
|
||||
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);
|
||||
if (parent == null) {
|
||||
logger.warn(() => 'Child task break-up failed: parent not found. '
|
||||
'parentTaskId=${request.parentTaskId} operationId=${request.operationId}');
|
||||
throw ArgumentError.value(
|
||||
request.parentTaskId,
|
||||
'parentTaskId',
|
||||
|
|
@ -26,6 +32,8 @@ class ChildTaskBreakUpService {
|
|||
);
|
||||
}
|
||||
if (request.entries.isEmpty) {
|
||||
logger.warn(() => 'Child task break-up failed: no child entries. '
|
||||
'parentTaskId=${request.parentTaskId} operationId=${request.operationId}');
|
||||
throw ArgumentError.value(
|
||||
request.entries,
|
||||
'entries',
|
||||
|
|
@ -38,9 +46,14 @@ class ChildTaskBreakUpService {
|
|||
for (final entry in request.entries) {
|
||||
final childId = entry.id.trim();
|
||||
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.');
|
||||
}
|
||||
if (childId == parent.id) {
|
||||
logger
|
||||
.warn(() => 'Child task break-up failed: child id matched parent. '
|
||||
'parentTaskId=${parent.id} operationId=${request.operationId}');
|
||||
throw ArgumentError.value(
|
||||
entry.id,
|
||||
'id',
|
||||
|
|
@ -48,6 +61,9 @@ class ChildTaskBreakUpService {
|
|||
);
|
||||
}
|
||||
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(
|
||||
entry.id,
|
||||
'id',
|
||||
|
|
@ -55,6 +71,10 @@ class ChildTaskBreakUpService {
|
|||
);
|
||||
}
|
||||
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(
|
||||
entry.id,
|
||||
'id',
|
||||
|
|
@ -66,6 +86,9 @@ class ChildTaskBreakUpService {
|
|||
final childTasks = request.entries
|
||||
.map((entry) => entry.toTask(parent: parent))
|
||||
.toList(growable: false);
|
||||
logger.debug(() => 'Parent task break-up produced children. '
|
||||
'parentTaskId=${parent.id} childCount=${childTasks.length} '
|
||||
'operationId=${request.operationId}');
|
||||
|
||||
return ChildTaskBreakUpResult(
|
||||
tasks: List<Task>.unmodifiable([...tasks, ...childTasks]),
|
||||
|
|
|
|||
|
|
@ -32,19 +32,28 @@ class ChildTaskCompletionService {
|
|||
Iterable<String> appliedActivityIds = const <String>[],
|
||||
}) {
|
||||
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);
|
||||
if (child == null) {
|
||||
logger.warn(() => 'Child completion failed: task not found. '
|
||||
'childTaskId=$childTaskId operationId=$operationId');
|
||||
throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.');
|
||||
}
|
||||
|
||||
final parentId = child.parentTaskId;
|
||||
if (parentId == null) {
|
||||
logger.warn(() => 'Child completion failed: task has no parent. '
|
||||
'childTaskId=$childTaskId operationId=$operationId');
|
||||
throw ArgumentError.value(
|
||||
childTaskId, 'childTaskId', 'Task is not a child.');
|
||||
}
|
||||
|
||||
final parent = _taskById(tasks, parentId);
|
||||
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.');
|
||||
}
|
||||
|
||||
|
|
@ -69,6 +78,9 @@ class ChildTaskCompletionService {
|
|||
final outcomes = <TaskTransitionOutcomeCode>[childTransition.outcomeCode];
|
||||
|
||||
if (!shouldCompleteParent) {
|
||||
logger.debug(() => 'Child task completed without parent auto-complete. '
|
||||
'childTaskId=${child.id} parentTaskId=${parent.id} '
|
||||
'childApplied=${childTransition.applied} operationId=$opId');
|
||||
return ChildTaskCompletionResult(
|
||||
tasks: List<Task>.unmodifiable(withChildCompleted),
|
||||
changedTaskIds: childTransition.applied ? [child.id] : const <String>[],
|
||||
|
|
@ -100,6 +112,10 @@ class ChildTaskCompletionService {
|
|||
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(
|
||||
tasks: List<Task>.unmodifiable(completedTasks),
|
||||
changedTaskIds: List<String>.unmodifiable(changedTaskIds),
|
||||
|
|
@ -120,12 +136,19 @@ class ChildTaskCompletionService {
|
|||
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
|
||||
Iterable<String> appliedActivityIds = const <String>[],
|
||||
}) {
|
||||
logger.debug(() => 'Completing parent from child. childTaskId=$childTaskId '
|
||||
'taskCount=${tasks.length} operationId=$operationId');
|
||||
final child = _taskById(tasks, childTaskId);
|
||||
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.');
|
||||
}
|
||||
final parentId = child.parentTaskId;
|
||||
if (parentId == null) {
|
||||
logger.warn(
|
||||
() => 'Parent-from-child completion failed: task has no parent. '
|
||||
'childTaskId=$childTaskId operationId=$operationId');
|
||||
throw ArgumentError.value(
|
||||
childTaskId,
|
||||
'childTaskId',
|
||||
|
|
@ -155,8 +178,13 @@ class ChildTaskCompletionService {
|
|||
Iterable<String> appliedActivityIds = const <String>[],
|
||||
}) {
|
||||
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);
|
||||
if (parent == null) {
|
||||
logger.warn(() => 'Parent completion failed: parent not found. '
|
||||
'parentTaskId=$parentTaskId operationId=$operationId');
|
||||
throw ArgumentError.value(
|
||||
parentTaskId, 'parentTaskId', 'Parent not found.');
|
||||
}
|
||||
|
|
@ -164,6 +192,10 @@ class ChildTaskCompletionService {
|
|||
final directChildren = ChildTaskView(tasks: tasks).childrenOf(parent);
|
||||
if (initiatingChildTaskId != null &&
|
||||
!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(
|
||||
initiatingChildTaskId,
|
||||
'initiatingChildTaskId',
|
||||
|
|
@ -234,6 +266,12 @@ class ChildTaskCompletionService {
|
|||
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(
|
||||
tasks: List<Task>.unmodifiable(updatedTasks),
|
||||
changedTaskIds: List<String>.unmodifiable(changedTaskIds),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ library;
|
|||
import '../domain/models.dart';
|
||||
import '../domain/occupancy_policy.dart';
|
||||
import 'scheduling_engine.dart';
|
||||
import '../logging/focus_flow_logger.dart';
|
||||
part 'free_slots/required_commitment_schedule_status.dart';
|
||||
part 'free_slots/protected_free_slot_conflict.dart';
|
||||
part 'free_slots/required_commitment_schedule_result.dart';
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ class FreeSlotService {
|
|||
String projectId = 'inbox',
|
||||
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);
|
||||
|
||||
return Task(
|
||||
|
|
@ -45,7 +48,12 @@ class FreeSlotService {
|
|||
String? title,
|
||||
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) {
|
||||
logger.error(() => 'Free slot update failed: invalid task type. '
|
||||
'taskId=${freeSlot.id} taskType=${freeSlot.type.name}');
|
||||
throw ArgumentError.value(
|
||||
freeSlot.type,
|
||||
'freeSlot.type',
|
||||
|
|
@ -78,8 +86,14 @@ class FreeSlotService {
|
|||
required DateTime end,
|
||||
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);
|
||||
if (task == null) {
|
||||
logger
|
||||
.warn(() => 'Required commitment scheduling failed: task not found. '
|
||||
'taskId=$taskId');
|
||||
return _unchangedRequiredResult(
|
||||
input: input,
|
||||
status: RequiredCommitmentScheduleStatus.taskNotFound,
|
||||
|
|
@ -94,6 +108,9 @@ class FreeSlotService {
|
|||
}
|
||||
|
||||
if (!task.isRequiredVisible) {
|
||||
logger.warn(
|
||||
() => 'Required commitment scheduling failed: invalid task type. '
|
||||
'taskId=${task.id} taskType=${task.type.name}');
|
||||
return _unchangedRequiredResult(
|
||||
input: input,
|
||||
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(
|
||||
status: conflicts.isEmpty
|
||||
? RequiredCommitmentScheduleStatus.scheduled
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ library;
|
|||
import '../domain/models.dart';
|
||||
import 'scheduling_engine.dart';
|
||||
import '../domain/time_contracts.dart';
|
||||
import '../logging/focus_flow_logger.dart';
|
||||
part 'quick_capture/quick_capture_status.dart';
|
||||
part 'quick_capture/quick_capture_request.dart';
|
||||
part 'quick_capture/quick_capture_result.dart';
|
||||
|
|
|
|||
|
|
@ -41,6 +41,10 @@ class QuickCaptureService {
|
|||
SchedulingInput? schedulingInput,
|
||||
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(
|
||||
id: request.id,
|
||||
title: request.title,
|
||||
|
|
@ -55,6 +59,9 @@ class QuickCaptureService {
|
|||
);
|
||||
|
||||
if (request.durationMinutes != null && request.durationMinutes! <= 0) {
|
||||
logger.warn(() =>
|
||||
'Quick capture validation failed: non-positive duration. '
|
||||
'taskId=${request.id} durationMinutes=${request.durationMinutes}');
|
||||
return QuickCaptureResult(
|
||||
task: capturedTaskWithoutDuration,
|
||||
status: QuickCaptureStatus.validationError,
|
||||
|
|
@ -67,6 +74,8 @@ class QuickCaptureService {
|
|||
);
|
||||
|
||||
if (!request.addToNextAvailableSlot) {
|
||||
logger.debug(
|
||||
() => 'Quick capture added to backlog. taskId=${capturedTask.id}');
|
||||
return QuickCaptureResult(
|
||||
task: capturedTask,
|
||||
status: QuickCaptureStatus.addedToBacklog,
|
||||
|
|
@ -74,6 +83,9 @@ class QuickCaptureService {
|
|||
}
|
||||
|
||||
if (request.durationMinutes == null || request.durationMinutes! <= 0) {
|
||||
logger.warn(
|
||||
() => 'Quick capture scheduling validation failed: missing duration. '
|
||||
'taskId=${capturedTask.id}');
|
||||
return QuickCaptureResult(
|
||||
task: capturedTask,
|
||||
status: QuickCaptureStatus.validationError,
|
||||
|
|
@ -82,6 +94,9 @@ class QuickCaptureService {
|
|||
}
|
||||
|
||||
if (request.type != TaskType.flexible) {
|
||||
logger.warn(() =>
|
||||
'Quick capture scheduling validation failed: non-flexible task. '
|
||||
'taskId=${capturedTask.id} taskType=${request.type.name}');
|
||||
return QuickCaptureResult(
|
||||
task: capturedTask,
|
||||
status: QuickCaptureStatus.validationError,
|
||||
|
|
@ -90,6 +105,9 @@ class QuickCaptureService {
|
|||
}
|
||||
|
||||
if (schedulingInput == null) {
|
||||
logger.warn(() =>
|
||||
'Quick capture scheduling validation failed: missing scheduling input. '
|
||||
'taskId=${capturedTask.id}');
|
||||
return QuickCaptureResult(
|
||||
task: capturedTask,
|
||||
status: QuickCaptureStatus.validationError,
|
||||
|
|
@ -110,6 +128,9 @@ class QuickCaptureService {
|
|||
final scheduledTask = _taskById(result.tasks, capturedTask.id);
|
||||
|
||||
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(
|
||||
task: capturedTask,
|
||||
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(
|
||||
task: scheduledTask,
|
||||
status: QuickCaptureStatus.scheduled,
|
||||
|
|
@ -142,10 +167,16 @@ class QuickCaptureService {
|
|||
}) {
|
||||
final generator = idGenerator;
|
||||
if (generator == null) {
|
||||
logger.error(
|
||||
() => 'Quick capture rejected because id generator is missing.');
|
||||
throw StateError('Quick capture needs an id generator.');
|
||||
}
|
||||
|
||||
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(
|
||||
QuickCaptureRequest(
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ library;
|
|||
|
||||
import '../domain/models.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_reason.dart';
|
||||
part 'reminder_policy/profiles/effective_reminder_profile.dart';
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ class ReminderPolicyService {
|
|||
}) {
|
||||
final override = task.reminderOverride;
|
||||
if (override != null) {
|
||||
logger.finer(() => 'Reminder profile resolved from task override. '
|
||||
'taskId=${task.id} profile=${override.name}');
|
||||
return EffectiveReminderProfile(
|
||||
profile: override,
|
||||
source: EffectiveReminderProfileSource.taskOverride,
|
||||
|
|
@ -32,12 +34,17 @@ class ReminderPolicyService {
|
|||
}
|
||||
|
||||
if (project != null) {
|
||||
logger.finer(() => 'Reminder profile resolved from project default. '
|
||||
'taskId=${task.id} projectId=${project.id} '
|
||||
'profile=${project.defaultReminderProfile.name}');
|
||||
return EffectiveReminderProfile(
|
||||
profile: project.defaultReminderProfile,
|
||||
source: EffectiveReminderProfileSource.projectDefault,
|
||||
);
|
||||
}
|
||||
|
||||
logger.finer(() => 'Reminder profile resolved from fallback. '
|
||||
'taskId=${task.id} profile=${fallbackProfile.name}');
|
||||
return EffectiveReminderProfile(
|
||||
profile: fallbackProfile,
|
||||
source: EffectiveReminderProfileSource.fallback,
|
||||
|
|
@ -51,11 +58,19 @@ class ReminderPolicyService {
|
|||
ProjectProfile? project,
|
||||
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 activeFreeSlot = _activeProtectedFreeSlot(
|
||||
now: now,
|
||||
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(
|
||||
task: task,
|
||||
|
|
@ -81,6 +96,12 @@ class ReminderPolicyService {
|
|||
required ReminderDirectiveReason reason,
|
||||
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(
|
||||
taskId: task.id,
|
||||
action: action,
|
||||
|
|
@ -165,6 +186,8 @@ class ReminderPolicyService {
|
|||
|
||||
if (task.type == TaskType.critical &&
|
||||
effective.profile == ReminderProfile.strict) {
|
||||
logger.finer(() => 'Reminder directive requires acknowledgement. '
|
||||
'taskId=${task.id} profile=${effective.profile.name}');
|
||||
return build(
|
||||
action: ReminderDirectiveAction.requireAcknowledgement,
|
||||
reason: ReminderDirectiveReason.requireRequiredAcknowledgement,
|
||||
|
|
@ -191,6 +214,9 @@ class ReminderPolicyService {
|
|||
}) {
|
||||
if (task.type == TaskType.critical &&
|
||||
effective.profile == ReminderProfile.strict) {
|
||||
logger.finer(() =>
|
||||
'Reminder directive requires acknowledgement during protected rest. '
|
||||
'taskId=${task.id} profile=${effective.profile.name}');
|
||||
return build(
|
||||
action: ReminderDirectiveAction.requireAcknowledgement,
|
||||
reason: ReminderDirectiveReason.requireRequiredAcknowledgement,
|
||||
|
|
@ -199,6 +225,10 @@ class ReminderPolicyService {
|
|||
|
||||
if (task.type == TaskType.inflexible &&
|
||||
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(
|
||||
action: ReminderDirectiveAction.defer,
|
||||
reason: ReminderDirectiveReason.deferRequiredDuringProtectedRest,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import '../domain/models.dart';
|
|||
import '../domain/occupancy_policy.dart';
|
||||
import 'task_lifecycle.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/operations/scheduling_operation_code.dart';
|
||||
part 'scheduling_engine/codes/operations/scheduling_outcome_code.dart';
|
||||
|
|
|
|||
|
|
@ -45,10 +45,16 @@ class SchedulingEngine {
|
|||
required String taskId,
|
||||
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;
|
||||
// quick capture or persistence code is responsible for adding it to the list.
|
||||
final task = _taskById(input.tasks, taskId);
|
||||
if (task == null) {
|
||||
logger.warn(
|
||||
() => 'Backlog scheduling failed: task not found. taskId=$taskId');
|
||||
return _unchangedResult(
|
||||
input,
|
||||
SchedulingNotice(
|
||||
|
|
@ -64,6 +70,8 @@ class SchedulingEngine {
|
|||
}
|
||||
|
||||
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(
|
||||
input,
|
||||
SchedulingNotice(
|
||||
|
|
@ -80,6 +88,8 @@ class SchedulingEngine {
|
|||
|
||||
final taskDuration = _durationFromMinutes(task.durationMinutes);
|
||||
if (taskDuration == null) {
|
||||
logger.warn(() => 'Backlog scheduling failed: missing positive duration. '
|
||||
'taskId=${task.id} durationMinutes=${task.durationMinutes}');
|
||||
return _unchangedResult(
|
||||
input,
|
||||
SchedulingNotice(
|
||||
|
|
@ -103,6 +113,8 @@ class SchedulingEngine {
|
|||
);
|
||||
|
||||
if (placement == null) {
|
||||
logger.warn(() => 'Backlog scheduling failed: no available slot. '
|
||||
'taskId=${task.id} blockedIntervalCount=${input.blockedIntervals.length}');
|
||||
return _unchangedResult(
|
||||
input,
|
||||
SchedulingNotice(
|
||||
|
|
@ -137,12 +149,18 @@ class SchedulingEngine {
|
|||
required SchedulingInput input,
|
||||
required String taskId,
|
||||
DateTime? updatedAt,
|
||||
DateTime? earliestStart,
|
||||
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
|
||||
// identifier, not object references.
|
||||
final task = _taskById(input.tasks, taskId);
|
||||
if (task == null) {
|
||||
logger.warn(() => 'Flexible push failed: task not found. taskId=$taskId');
|
||||
return _unchangedResult(
|
||||
input,
|
||||
SchedulingNotice(
|
||||
|
|
@ -158,6 +176,8 @@ class SchedulingEngine {
|
|||
}
|
||||
|
||||
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(
|
||||
input,
|
||||
SchedulingNotice(
|
||||
|
|
@ -173,7 +193,11 @@ class SchedulingEngine {
|
|||
}
|
||||
|
||||
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(
|
||||
input,
|
||||
SchedulingNotice(
|
||||
|
|
@ -189,6 +213,9 @@ class SchedulingEngine {
|
|||
}
|
||||
|
||||
if (currentInterval.duration.inMicroseconds <= 0) {
|
||||
logger.warn(() =>
|
||||
'Flexible push failed: non-positive scheduled duration. '
|
||||
'taskId=${task.id} durationMicroseconds=${currentInterval.duration.inMicroseconds}');
|
||||
return _unchangedResult(
|
||||
input,
|
||||
SchedulingNotice(
|
||||
|
|
@ -207,9 +234,12 @@ class SchedulingEngine {
|
|||
input: input,
|
||||
task: task,
|
||||
currentInterval: currentInterval,
|
||||
earliestStart: earliestStart,
|
||||
);
|
||||
|
||||
if (placement == null) {
|
||||
logger.warn(() =>
|
||||
'Flexible push failed: no available slot today. taskId=${task.id}');
|
||||
return _unchangedResult(
|
||||
input,
|
||||
SchedulingNotice(
|
||||
|
|
@ -250,8 +280,13 @@ class SchedulingEngine {
|
|||
required String taskId,
|
||||
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);
|
||||
if (task == null) {
|
||||
logger.warn(() => 'Tomorrow push failed: task not found. taskId=$taskId');
|
||||
return _unchangedResult(
|
||||
input,
|
||||
SchedulingNotice(
|
||||
|
|
@ -267,6 +302,8 @@ class SchedulingEngine {
|
|||
}
|
||||
|
||||
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(
|
||||
input,
|
||||
SchedulingNotice(
|
||||
|
|
@ -284,6 +321,8 @@ class SchedulingEngine {
|
|||
final taskDuration = _scheduledIntervalFor(task)?.duration ??
|
||||
_durationFromMinutes(task.durationMinutes);
|
||||
if (taskDuration == null || taskDuration.inMicroseconds <= 0) {
|
||||
logger.warn(() => 'Tomorrow push failed: missing positive duration. '
|
||||
'taskId=${task.id} durationMinutes=${task.durationMinutes}');
|
||||
return _unchangedResult(
|
||||
input,
|
||||
SchedulingNotice(
|
||||
|
|
@ -305,6 +344,8 @@ class SchedulingEngine {
|
|||
);
|
||||
|
||||
if (placement == null) {
|
||||
logger.warn(
|
||||
() => 'Tomorrow push failed: no available slot. taskId=${task.id}');
|
||||
return _unchangedResult(
|
||||
input,
|
||||
SchedulingNotice(
|
||||
|
|
@ -349,6 +390,10 @@ class SchedulingEngine {
|
|||
SchedulingWindow? sourceWindow,
|
||||
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
|
||||
// place anything. This keeps selection separate from placement.
|
||||
final rolledItems = <_PlacementItem>[];
|
||||
|
|
@ -385,6 +430,8 @@ class SchedulingEngine {
|
|||
}
|
||||
|
||||
if (rolledItems.isEmpty) {
|
||||
logger
|
||||
.debug(() => 'Rollover finished with no unfinished flexible tasks.');
|
||||
return SchedulingResult(
|
||||
tasks: input.tasks,
|
||||
operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks,
|
||||
|
|
@ -405,6 +452,9 @@ class SchedulingEngine {
|
|||
);
|
||||
|
||||
if (placement == null) {
|
||||
logger.warn(
|
||||
() => 'Rollover failed: unfinished flexible tasks could not fit. '
|
||||
'rolledCount=${rolledItems.length}');
|
||||
return _unchangedResult(
|
||||
input,
|
||||
SchedulingNotice(
|
||||
|
|
@ -431,6 +481,8 @@ class SchedulingEngine {
|
|||
/// reports any overlap with blocked intervals. It deliberately returns the
|
||||
/// original task list unchanged.
|
||||
SchedulingResult analyzeSchedule(SchedulingInput input) {
|
||||
logger.debug(() => 'Analyzing schedule. taskCount=${input.tasks.length} '
|
||||
'conflictOccupancyCount=${input.conflictReportingOccupancies.length}');
|
||||
final overlaps = <SchedulingOverlap>[];
|
||||
final notices = <SchedulingNotice>[];
|
||||
final conflictOccupancies = input.conflictReportingOccupancies;
|
||||
|
|
@ -474,6 +526,9 @@ class SchedulingEngine {
|
|||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
() => 'Schedule analysis finished. overlapCount=${overlaps.length} '
|
||||
'noticeCount=${notices.length}');
|
||||
return SchedulingResult(
|
||||
tasks: input.tasks,
|
||||
operationCode: SchedulingOperationCode.analyzeSchedule,
|
||||
|
|
@ -492,6 +547,8 @@ class SchedulingEngine {
|
|||
/// reports can distinguish this from a task that was never scheduled.
|
||||
Task moveToBacklog(Task task, {DateTime? updatedAt}) {
|
||||
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(
|
||||
task: task,
|
||||
transitionCode: TaskTransitionCode.moveToBacklog,
|
||||
|
|
@ -500,6 +557,8 @@ class SchedulingEngine {
|
|||
'legacy-move-to-backlog:${task.id}:${now.toIso8601String()}:activity',
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -509,6 +568,9 @@ class SchedulingEngine {
|
|||
/// actual scheduled slot should change.
|
||||
Task markManuallyPushed(Task task, {DateTime? updatedAt}) {
|
||||
final now = updatedAt ?? clock.now();
|
||||
logger.debug(
|
||||
() => 'Legacy manual push statistic requested. taskId=${task.id} '
|
||||
'occurredAt=${now.toIso8601String()}');
|
||||
final result = _transitionService.apply(
|
||||
task: task,
|
||||
transitionCode: TaskTransitionCode.manualPush,
|
||||
|
|
@ -517,6 +579,9 @@ class SchedulingEngine {
|
|||
'legacy-manual-push:${task.id}:${now.toIso8601String()}:activity',
|
||||
occurredAt: now,
|
||||
);
|
||||
logger
|
||||
.debug(() => 'Legacy manual push statistic finished. taskId=${task.id} '
|
||||
'outcome=${result.outcomeCode.name}');
|
||||
return result.applied ? result.task : task;
|
||||
}
|
||||
|
||||
|
|
@ -527,6 +592,9 @@ class SchedulingEngine {
|
|||
/// or time block that cannot simply be rescheduled automatically.
|
||||
Task markMissed(Task task, {DateTime? updatedAt}) {
|
||||
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(
|
||||
task: task,
|
||||
transitionCode: TaskTransitionCode.miss,
|
||||
|
|
@ -534,6 +602,8 @@ class SchedulingEngine {
|
|||
activityId: 'legacy-miss:${task.id}:${now.toIso8601String()}:activity',
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -550,6 +620,10 @@ class SchedulingEngine {
|
|||
required Duration duration,
|
||||
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]
|
||||
..sort((a, b) => a.start.compareTo(b.start));
|
||||
var cursor = windowStart;
|
||||
|
|
@ -738,7 +812,12 @@ _BacklogInsertionPlan? _planFlexiblePush({
|
|||
required SchedulingInput input,
|
||||
required Task task,
|
||||
required TimeInterval currentInterval,
|
||||
DateTime? earliestStart,
|
||||
}) {
|
||||
final pushPoint = _laterOf(
|
||||
currentInterval.end,
|
||||
earliestStart ?? input.window.start,
|
||||
);
|
||||
final fixedBlocks = <TimeInterval>[
|
||||
...input.blockedIntervals,
|
||||
];
|
||||
|
|
@ -746,7 +825,7 @@ _BacklogInsertionPlan? _planFlexiblePush({
|
|||
_PlacementItem(
|
||||
task: task,
|
||||
duration: currentInterval.duration,
|
||||
earliestStart: currentInterval.end,
|
||||
earliestStart: pushPoint,
|
||||
),
|
||||
];
|
||||
|
||||
|
|
@ -766,7 +845,7 @@ _BacklogInsertionPlan? _planFlexiblePush({
|
|||
continue;
|
||||
}
|
||||
|
||||
final startsBeforePushPoint = interval.start.isBefore(currentInterval.end);
|
||||
final startsBeforePushPoint = interval.start.isBefore(pushPoint);
|
||||
final startsAfterWindow = interval.start.isAfter(input.window.end) ||
|
||||
interval.start.isAtSameMomentAs(input.window.end);
|
||||
|
||||
|
|
@ -786,7 +865,7 @@ _BacklogInsertionPlan? _planFlexiblePush({
|
|||
|
||||
fixedBlocks.sort((a, b) => a.start.compareTo(b.start));
|
||||
|
||||
var cursor = currentInterval.end;
|
||||
var cursor = pushPoint;
|
||||
final placements = <String, TimeInterval>{};
|
||||
|
||||
for (final item in queue) {
|
||||
|
|
@ -944,6 +1023,7 @@ SchedulingResult _applyPlacement({
|
|||
scheduledStart: interval.start,
|
||||
scheduledEnd: interval.end,
|
||||
updatedAt: updatedAt,
|
||||
clearBacklogEntry: isInsertedTask,
|
||||
);
|
||||
final updatedTask = _applySchedulingActivity(
|
||||
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(
|
||||
tasks: List<Task>.unmodifiable(updatedTasks),
|
||||
operationCode:
|
||||
|
|
@ -1039,6 +1122,7 @@ SchedulingResult _applyPushPlacement({
|
|||
scheduledStart: interval.start,
|
||||
scheduledEnd: interval.end,
|
||||
updatedAt: updatedAt,
|
||||
clearBacklogEntry: true,
|
||||
);
|
||||
final updatedTask = _applySchedulingActivity(
|
||||
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(
|
||||
tasks: List<Task>.unmodifiable(updatedTasks),
|
||||
operationCode: operationCode,
|
||||
|
|
@ -1183,6 +1270,8 @@ SchedulingResult _applyRolloverPlacement({
|
|||
),
|
||||
);
|
||||
|
||||
logger.debug(() => 'Rollover placement applied. rolledCount=$rolledCount '
|
||||
'changeCount=${changes.length} noticeCount=${notices.length}');
|
||||
return SchedulingResult(
|
||||
tasks: List<Task>.unmodifiable(updatedTasks),
|
||||
operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ library;
|
|||
|
||||
import '../domain/models.dart';
|
||||
import '../domain/occupancy_policy.dart';
|
||||
import '../logging/focus_flow_logger.dart';
|
||||
import 'scheduling_engine.dart';
|
||||
import 'task_lifecycle.dart';
|
||||
import '../domain/time_contracts.dart';
|
||||
|
|
|
|||
|
|
@ -43,15 +43,35 @@ class FlexibleTaskActionService {
|
|||
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
|
||||
}) {
|
||||
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(
|
||||
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) {
|
||||
case FlexibleTaskQuickAction.done:
|
||||
final now = updatedAt ?? clock.now();
|
||||
final opId =
|
||||
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(
|
||||
task: task,
|
||||
transitionCode: TaskTransitionCode.complete,
|
||||
|
|
@ -67,6 +87,17 @@ class FlexibleTaskActionService {
|
|||
lockedIntervals: lockedIntervals,
|
||||
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(
|
||||
action: action,
|
||||
task: transition.task,
|
||||
|
|
@ -74,6 +105,10 @@ class FlexibleTaskActionService {
|
|||
transitionOutcomeCode: transition.outcomeCode,
|
||||
);
|
||||
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(
|
||||
action: action,
|
||||
task: task,
|
||||
|
|
@ -87,6 +122,10 @@ class FlexibleTaskActionService {
|
|||
final now = updatedAt ?? clock.now();
|
||||
final opId =
|
||||
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(
|
||||
task: task,
|
||||
transitionCode: TaskTransitionCode.moveToBacklog,
|
||||
|
|
@ -99,6 +138,17 @@ class FlexibleTaskActionService {
|
|||
occurredAt: now,
|
||||
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(
|
||||
action: action,
|
||||
task: transition.task,
|
||||
|
|
@ -106,6 +156,8 @@ class FlexibleTaskActionService {
|
|||
transitionOutcomeCode: transition.outcomeCode,
|
||||
);
|
||||
case FlexibleTaskQuickAction.breakUp:
|
||||
logger.debug(() => 'Flexible break-up flow requested. '
|
||||
'taskId=${task.id} taskStatus=${task.status.name}');
|
||||
return FlexibleTaskActionResult(
|
||||
action: action,
|
||||
task: task,
|
||||
|
|
@ -123,17 +175,28 @@ class FlexibleTaskActionService {
|
|||
required SchedulingInput input,
|
||||
required String taskId,
|
||||
DateTime? updatedAt,
|
||||
DateTime? earliestStart,
|
||||
String? operationId,
|
||||
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
|
||||
}) {
|
||||
final now = updatedAt ?? clock.now();
|
||||
final opId = operationId ??
|
||||
_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(
|
||||
existingActivities: existingActivities,
|
||||
operationId: opId,
|
||||
taskId: taskId,
|
||||
)) {
|
||||
logger.warn(() => 'Duplicate flexible push operation ignored. '
|
||||
'destination=${destination.name} taskId=$taskId operationId=$opId');
|
||||
return PushDestinationResult(
|
||||
destination: destination,
|
||||
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) {
|
||||
PushDestination.nextAvailableSlot =>
|
||||
schedulingEngine.pushFlexibleTaskToNextAvailableSlot(
|
||||
input: input,
|
||||
taskId: taskId,
|
||||
updatedAt: now,
|
||||
earliestStart: earliestStart,
|
||||
),
|
||||
PushDestination.tomorrowTopOfQueue =>
|
||||
schedulingEngine.pushFlexibleTaskToTomorrowTopOfQueue(
|
||||
|
|
@ -166,19 +232,38 @@ class FlexibleTaskActionService {
|
|||
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(
|
||||
destination: destination,
|
||||
schedulingResult: result,
|
||||
activities: _activitiesForPushDestination(
|
||||
destination: destination,
|
||||
input: input,
|
||||
result: result,
|
||||
taskId: taskId,
|
||||
operationId: opId,
|
||||
occurredAt: now,
|
||||
existingActivities: existingActivities,
|
||||
),
|
||||
activities: activities,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -191,8 +276,15 @@ class FlexibleTaskActionService {
|
|||
required String taskId,
|
||||
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);
|
||||
if (task == null) {
|
||||
logger.warn(() => 'Handled move-to-backlog issue: task not found. '
|
||||
'taskId=$taskId');
|
||||
return SchedulingResult(
|
||||
tasks: input.tasks,
|
||||
operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog,
|
||||
|
|
@ -209,6 +301,9 @@ class FlexibleTaskActionService {
|
|||
}
|
||||
|
||||
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(
|
||||
tasks: input.tasks,
|
||||
operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog,
|
||||
|
|
@ -228,6 +323,10 @@ class FlexibleTaskActionService {
|
|||
task,
|
||||
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(
|
||||
tasks: List<Task>.unmodifiable(
|
||||
|
|
|
|||
|
|
@ -37,7 +37,12 @@ class RequiredTaskActionService {
|
|||
List<TimeInterval> lockedIntervals = const <TimeInterval>[],
|
||||
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) {
|
||||
logger.error(() => 'Required task action failed: invalid task type. '
|
||||
'taskId=${task.id} taskType=${task.type.name} action=${action.name}');
|
||||
throw ArgumentError.value(
|
||||
task.type,
|
||||
'task.type',
|
||||
|
|
@ -61,6 +66,10 @@ class RequiredTaskActionService {
|
|||
RequiredTaskAction.noLongerRelevant => TaskActivityCode.noLongerRelevant,
|
||||
};
|
||||
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(
|
||||
task: task,
|
||||
transitionCode: transitionCode,
|
||||
|
|
@ -73,6 +82,11 @@ class RequiredTaskActionService {
|
|||
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(
|
||||
action: action,
|
||||
task: transition.task,
|
||||
|
|
|
|||
|
|
@ -39,9 +39,16 @@ class SurpriseTaskLogService {
|
|||
bool revealHiddenLockedDetails = false,
|
||||
}) {
|
||||
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);
|
||||
if (existingTask != null) {
|
||||
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(
|
||||
request.id,
|
||||
'request.id',
|
||||
|
|
@ -49,6 +56,8 @@ class SurpriseTaskLogService {
|
|||
);
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
() => 'Duplicate surprise task log ignored. taskId=${request.id}');
|
||||
return SurpriseTaskLogResult(
|
||||
surpriseTask: existingTask,
|
||||
schedulingResult: SchedulingResult(
|
||||
|
|
@ -82,6 +91,8 @@ class SurpriseTaskLogService {
|
|||
var currentTasks = <Task>[surpriseTask, ...input.tasks];
|
||||
|
||||
if (surpriseInterval == null) {
|
||||
logger.debug(() => 'Surprise task logged without occupying interval. '
|
||||
'taskId=${surpriseTask.id} activityCount=1');
|
||||
return SurpriseTaskLogResult(
|
||||
surpriseTask: surpriseTask,
|
||||
schedulingResult: SchedulingResult(
|
||||
|
|
@ -113,6 +124,12 @@ class SurpriseTaskLogService {
|
|||
final movementNotices = <SchedulingNotice>[];
|
||||
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) {
|
||||
final currentTask = _taskById(currentTasks, taskId);
|
||||
final currentInterval =
|
||||
|
|
@ -120,9 +137,14 @@ class SurpriseTaskLogService {
|
|||
if (currentTask == null ||
|
||||
currentInterval == null ||
|
||||
!currentInterval.overlaps(surpriseInterval)) {
|
||||
logger.finer(() => 'Surprise task repair skipped flexible candidate. '
|
||||
'surpriseTaskId=${surpriseTask.id} taskId=$taskId '
|
||||
'hasCurrentTask=${currentTask != null} hasInterval=${currentInterval != null}');
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.finer(() => 'Surprise task repair pushing flexible overlap. '
|
||||
'surpriseTaskId=${surpriseTask.id} taskId=$taskId');
|
||||
final pushResult = schedulingEngine.pushFlexibleTaskToNextAvailableSlot(
|
||||
input: SchedulingInput(
|
||||
tasks: currentTasks,
|
||||
|
|
@ -157,6 +179,11 @@ class SurpriseTaskLogService {
|
|||
)
|
||||
.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(
|
||||
surpriseTask: _taskById(currentTasks, surpriseTask.id) ?? surpriseTask,
|
||||
schedulingResult: SchedulingResult(
|
||||
|
|
@ -196,10 +223,15 @@ class SurpriseTaskLogService {
|
|||
}) {
|
||||
final generator = idGenerator;
|
||||
if (generator == null) {
|
||||
logger.error(() => 'Surprise task logNew failed: missing id generator.');
|
||||
throw StateError('Surprise task logging needs an id generator.');
|
||||
}
|
||||
|
||||
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(
|
||||
request: SurpriseTaskLogRequest(
|
||||
|
|
@ -227,6 +259,8 @@ class SurpriseTaskLogService {
|
|||
}) {
|
||||
final trimmedTitle = request.title.trim();
|
||||
if (trimmedTitle.isEmpty) {
|
||||
logger.warn(() => 'Surprise task creation failed: blank title. '
|
||||
'taskId=${request.id}');
|
||||
throw ArgumentError.value(request.title, 'title', 'Title is required.');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ library;
|
|||
import '../domain/models.dart';
|
||||
import '../domain/task_statistics.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_activity_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