Adds a getOwnerSettings read query to scheduler_core (previously only an internal helper existed) and an updateOwnerSettings command on the Flutter command controller, then builds a shared Settings dialog surfacing timezone, day window, compact mode, and backlog staleness thresholds. Both existing no-op entry points (sidebar nav item, top bar button) now open the same dialog and persist edits through the existing OwnerSettings/BacklogStalenessSettings backend. Per the design spec (circuitforge-plans/focus-flow/superpowers/specs/ 2026-07-07-settings-screen-design.md), the compact-mode toggle persists but is disclosed in-UI as not yet affecting Today rendering, since that wiring is separate scope (#11). Closes: #16
294 lines
9.6 KiB
Dart
294 lines
9.6 KiB
Dart
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
/// Composes the FocusFlow app around persistent SQLite scheduler state.
|
|
library;
|
|
|
|
import 'dart:io';
|
|
|
|
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/scheduler_read_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';
|
|
|
|
/// Persistent SQLite-backed scheduler composition for normal app runtime.
|
|
final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
|
/// Creates a persistent composition from already-open collaborators.
|
|
PersistentSchedulerComposition._({
|
|
required this.runtime,
|
|
required this.todayQuery,
|
|
required this.managementUseCases,
|
|
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.
|
|
static Future<PersistentSchedulerComposition> open({
|
|
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: 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 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: resolver,
|
|
),
|
|
managementUseCases: V1ApplicationManagementUseCases(
|
|
applicationStore: runtime.applicationStore,
|
|
),
|
|
commandUseCases: V1ApplicationCommandUseCases(
|
|
applicationStore: runtime.applicationStore,
|
|
timeZoneResolver: resolver,
|
|
),
|
|
date: date,
|
|
readAt: readAt,
|
|
logger: resolvedLogger,
|
|
uiTimeZone: resolvedTimeZone,
|
|
clock: resolvedClock,
|
|
timeZoneResolver: resolver,
|
|
);
|
|
}
|
|
|
|
/// Resolves the SQLite path from Dart defines or the local dev default.
|
|
static String sqlitePathFromEnvironment() {
|
|
const configured = String.fromEnvironment('SCHEDULER_SQLITE_PATH');
|
|
final trimmed = configured.trim();
|
|
if (trimmed.isNotEmpty) {
|
|
return trimmed;
|
|
}
|
|
final home =
|
|
Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
|
|
if (home == null || home.trim().isEmpty) {
|
|
return 'scheduler.sqlite';
|
|
}
|
|
return '$home/ADHD_Scheduler/scheduler.sqlite';
|
|
}
|
|
|
|
/// SQLite runtime owning the database handle.
|
|
final SqliteApplicationRuntime runtime;
|
|
|
|
/// Query object used to read the Today state.
|
|
final GetTodayStateQuery todayQuery;
|
|
|
|
/// Management use cases used by backlog-facing UI surfaces.
|
|
final V1ApplicationManagementUseCases managementUseCases;
|
|
|
|
/// Command use cases used by interactive UI controls.
|
|
final V1ApplicationCommandUseCases commandUseCases;
|
|
|
|
/// Local date represented by the app.
|
|
final CivilDate date;
|
|
|
|
/// 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;
|
|
|
|
/// Default project id used by quick capture.
|
|
@override
|
|
String get defaultProjectId => SqliteApplicationRuntime.defaultV1ProjectId;
|
|
|
|
/// Human-readable label for [date].
|
|
@override
|
|
String get dateLabel => dateLabelFor(date);
|
|
|
|
/// Initial selected owner-local planning date.
|
|
@override
|
|
CivilDate get initialDate => date;
|
|
|
|
/// Formats [date] for compact UI date labels.
|
|
@override
|
|
String dateLabelFor(CivilDate date) {
|
|
return formatSchedulerDateLabel(date);
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
|
|
/// Creates the controller used by the compact Today screen mockup.
|
|
@override
|
|
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(
|
|
context: context('ui-plan-1-read-today'),
|
|
date: date,
|
|
includeNextFlexibleItem: true,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Creates a read controller for the backlog screen.
|
|
@override
|
|
UiReadController<BacklogQueryResult> createBacklogController() {
|
|
scheduler_core.logger.finer(() => 'Creating backlog read controller.');
|
|
return ApplicationReadController<BacklogQueryResult>(
|
|
read: () {
|
|
return managementUseCases.getBacklog(
|
|
GetBacklogRequest(
|
|
context: context('ui-read-backlog'),
|
|
sortKey: BacklogSortKey.age,
|
|
),
|
|
);
|
|
},
|
|
isEmpty: (state) => state.items.isEmpty,
|
|
);
|
|
}
|
|
|
|
/// Creates a read controller for the settings screen.
|
|
@override
|
|
UiReadController<OwnerSettings> createOwnerSettingsController() {
|
|
scheduler_core.logger.finer(
|
|
() => 'Creating owner settings read controller.',
|
|
);
|
|
return ApplicationReadController<OwnerSettings>(
|
|
read: () {
|
|
return managementUseCases.getOwnerSettings(
|
|
GetOwnerSettingsRequest(context: context('ui-read-owner-settings')),
|
|
);
|
|
},
|
|
isEmpty: (_) => false,
|
|
);
|
|
}
|
|
|
|
/// Creates the command controller used by interactive scheduler controls.
|
|
@override
|
|
SchedulerCommandController createCommandController({
|
|
required ReadRefresh refreshReads,
|
|
required SelectedDateReader selectedDate,
|
|
}) {
|
|
scheduler_core.logger.finer(
|
|
() =>
|
|
'Creating scheduler command controller. operationIdPrefix=ui-command-${readAt.microsecondsSinceEpoch}',
|
|
);
|
|
return SchedulerCommandController(
|
|
commands: commandUseCases,
|
|
management: managementUseCases,
|
|
contextFor: context,
|
|
selectedDate: selectedDate,
|
|
refreshReads: refreshReads,
|
|
quickCaptureProjectId: defaultProjectId,
|
|
operationIdPrefix: 'ui-command-${readAt.microsecondsSinceEpoch}',
|
|
currentDateFor: uiTimeZone.toLocalCivilDate,
|
|
now: _utcNow,
|
|
);
|
|
}
|
|
|
|
/// Creates an application operation context for [operationId].
|
|
ApplicationOperationContext context(String operationId, {DateTime? now}) {
|
|
return ApplicationOperationContext.start(
|
|
operationId: operationId,
|
|
ownerTimeZone: OwnerTimeZoneContext(
|
|
ownerId: SqliteApplicationRuntime.defaultV1OwnerId,
|
|
timeZoneId: uiTimeZone.code,
|
|
),
|
|
clock: FixedClock((now ?? _utcNow()).toUtc()),
|
|
idGenerator: SequentialIdGenerator(prefix: operationId),
|
|
);
|
|
}
|
|
|
|
/// Closes the persistent runtime.
|
|
@override
|
|
Future<void> dispose() {
|
|
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();
|
|
}
|
|
}
|