feat(app): appearance, language and version in Settings, and Settings from Home
Settings gets a card for how this device shows the app: its appearance (as the device, light or dark) and its language (the device's, or one the app speaks, each named in its own language), side by side from medium windows on, as two outlined dropdowns. Both stay on the device rather than following the person: they are kept in <app support>/device_settings.json (written to a temporary file and renamed into place), read before the app shows, so its first frame already looks as chosen, and a file that can't be read falls back to what the device does. DeviceSettingsBloc sits above SessionGate's keyed subtree, since the settings outlive every person, and both the plain app and the person's app take their theme mode and locale from it. At the bottom of Settings, the release and its build, read once at start together with the build the server is asked about. Home has a Settings cog beside the streak, with the sync trouble dot as on Profile. The library's create button reads "Create studyset", and Delete is red in every menu that offers it: the studyset's card, screen and rail, and the composer's draft. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
63d4e642d6
commit
55a0a7fdf0
30 changed files with 943 additions and 112 deletions
|
|
@ -83,6 +83,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
||||||
- **Manual dependency injection.** One `Dependencies` object (`lib/src/core/dependencies/`) is the composition root, built at startup (`Dependencies.create()`) and exposed through the `DependenciesScope` InheritedWidget. It hands features repositories only. No service locator, no DI framework, no globals (the scaffold's global `client` moved into it). Why: every dependency is a typed, greppable field, and nothing is resolved by magic.
|
- **Manual dependency injection.** One `Dependencies` object (`lib/src/core/dependencies/`) is the composition root, built at startup (`Dependencies.create()`) and exposed through the `DependenciesScope` InheritedWidget. It hands features repositories only. No service locator, no DI framework, no globals (the scaffold's global `client` moved into it). Why: every dependency is a typed, greppable field, and nothing is resolved by magic.
|
||||||
- **One graph per context.** The dependency graph belongs to one instance and one signed-in person; when either changes, a `KeyedSubtree` disposes the whole subtree and rebuilds it. Why: nothing from one person's session (blocs, streams, caches) can leak into another's.
|
- **One graph per context.** The dependency graph belongs to one instance and one signed-in person; when either changes, a `KeyedSubtree` disposes the whole subtree and rebuilds it. Why: nothing from one person's session (blocs, streams, caches) can leak into another's.
|
||||||
- **App-wide vs. per person.** `Dependencies` holds what outlives a person (the client, auth). Everything of the signed-in person (their local database, its sync, their repositories) is `PersonDependencies`, opened by `PersonScope` inside `SessionGate`'s keyed subtree (`Dependencies.openPerson`) and disposed with it; widgets reach it through `PersonScope.of(context)`.
|
- **App-wide vs. per person.** `Dependencies` holds what outlives a person (the client, auth). Everything of the signed-in person (their local database, its sync, their repositories) is `PersonDependencies`, opened by `PersonScope` inside `SessionGate`'s keyed subtree (`Dependencies.openPerson`) and disposed with it; widgets reach it through `PersonScope.of(context)`.
|
||||||
|
- **The device's own settings** (appearance, language; `core/device_settings/`) belong to the device, not to a person: kept in `<app support>/device_settings.json`, read in `Dependencies.create()` so the first frame already has them, and provided by `DeviceSettingsScope` above `SessionGate`'s keyed subtree, which both its plain app and `PersonApp` read. What should follow the person to their other devices goes into their synced `PersonSettings` instead.
|
||||||
- **Every person has an app of their own** (`PersonApp`, `lib/src/app/`), which `SessionGate` builds inside the keyed subtree: a `MaterialApp.router` with its own `GoRouter`, and `PersonScope` around the router's navigator (`MaterialApp.builder`). So every screen and dialog is inside the person's scope and reads their repositories from `PersonScope.of(context)` wherever it is built, and a change of person disposes the router with every screen on it and starts again at home. Until someone is signed in, `SessionGate` shows a plain `MaterialApp`: the spinner, or the failure with a way to try again.
|
- **Every person has an app of their own** (`PersonApp`, `lib/src/app/`), which `SessionGate` builds inside the keyed subtree: a `MaterialApp.router` with its own `GoRouter`, and `PersonScope` around the router's navigator (`MaterialApp.builder`). So every screen and dialog is inside the person's scope and reads their repositories from `PersonScope.of(context)` wherever it is built, and a change of person disposes the router with every screen on it and starts again at home. Until someone is signed in, `SessionGate` shows a plain `MaterialApp`: the spinner, or the failure with a way to try again.
|
||||||
- **Platform plugins sit behind small interfaces in `Dependencies`**, with fakes in tests: picking files (`FilePicker`, `file_selector`), playing sounds (`SoundPlayer`, `audioplayers`), playing YouTube videos (`YoutubeVideos`, `youtube_player_iframe` where a web view exists, else the browser), reading PDFs (`PdfEngine`, pdfrx on PDFium, which `flutter test` can't load: widget tests use `FakePdfEngine`, whose PDF bytes say their pages and chapters), reading Anki packages on another isolate (`AnkiPackageReader`; a widget test's fake clock can't wait for one).
|
- **Platform plugins sit behind small interfaces in `Dependencies`**, with fakes in tests: picking files (`FilePicker`, `file_selector`), playing sounds (`SoundPlayer`, `audioplayers`), playing YouTube videos (`YoutubeVideos`, `youtube_player_iframe` where a web view exists, else the browser), reading PDFs (`PdfEngine`, pdfrx on PDFium, which `flutter test` can't load: widget tests use `FakePdfEngine`, whose PDF bytes say their pages and chapters), reading Anki packages on another isolate (`AnkiPackageReader`; a widget test's fake clock can't wait for one).
|
||||||
- **Material's `Card` widget clashes with the domain's `Card`**: files that need both import `material_ui` with `hide Card`.
|
- **Material's `Card` widget clashes with the domain's `Card`**: files that need both import `material_ui` with `hide Card`.
|
||||||
|
|
@ -147,7 +148,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
||||||
- **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables.
|
- **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables.
|
||||||
- **The client's local migration** (in `halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device.
|
- **The client's local migration** (in `halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device.
|
||||||
- **Older apps** can't be made to update once published. Before the first sync of each launch the app asks whether the server still works with its build (`minimumAppBuild` in `instance.info()`, from `lib/src/instance/app_compatibility.dart` in the server; the build is the number after `+` in the app's version), and the sync engine refuses an app whose synced tables differ from the server's. Both end in `AppServerMismatchException` (`core/sync/`), and the app says so: a banner on Home and the Sync card in Settings. Raise `minimumAppBuild` in the commit that breaks older apps (an endpoint they call changes or goes), to the build of the first app that works again; a change to the synced tables needs no raise. Every release raises the build number.
|
- **Older apps** can't be made to update once published. Before the first sync of each launch the app asks whether the server still works with its build (`minimumAppBuild` in `instance.info()`, from `lib/src/instance/app_compatibility.dart` in the server; the build is the number after `+` in the app's version), and the sync engine refuses an app whose synced tables differ from the server's. Both end in `AppServerMismatchException` (`core/sync/`), and the app says so: a banner on Home and the Sync card in Settings. Raise `minimumAppBuild` in the commit that breaks older apps (an endpoint they call changes or goes), to the build of the first app that works again; a change to the synced tables needs no raise. Every release raises the build number.
|
||||||
- **Where sync stands** is `SyncService.status` (syncing, the last sync that went through, the last failure; since the app started), which `SyncStatusBloc` (`core/sync/`, provided by `PersonApp`) hands to the trouble dot on the Profile tab and the Settings cog, the Sync card in Settings and the banner on Home. Only the last outcome counts: one sync that goes through clears a failure.
|
- **Where sync stands** is `SyncService.status` (syncing, the last sync that went through, the last failure; since the app started), which `SyncStatusBloc` (`core/sync/`, provided by `PersonApp`) hands to the trouble dot on the Profile tab and the Settings cogs (on Home and Profile), the Sync card in Settings and the banner on Home. Only the last outcome counts: one sync that goes through clears a failure.
|
||||||
- **Serverpod's version moves together with `serverpod_offline_sync`**, which pins Serverpod exactly: the root `pubspec.yaml` overrides the four core packages to the project's version. Upgrade Serverpod only together with those overrides and the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`), after reading the sync package's changelog.
|
- **Serverpod's version moves together with `serverpod_offline_sync`**, which pins Serverpod exactly: the root `pubspec.yaml` overrides the four core packages to the project's version. Upgrade Serverpod only together with those overrides and the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`), after reading the sync package's changelog.
|
||||||
- **The source-code link**: both licences require offering the source of what an instance runs to the people who use it. `instance.info().sourceCodeUrl` names it (the operator's `SOURCE_URL`, else this project's repository) and the app's About dialog shows it next to the licences and every package's notice. Keep it working when changing the dialog or the endpoint.
|
- **The source-code link**: both licences require offering the source of what an instance runs to the people who use it. `instance.info().sourceCodeUrl` names it (the operator's `SOURCE_URL`, else this project's repository) and the app's About dialog shows it next to the licences and every package's notice. Keep it working when changing the dialog or the endpoint.
|
||||||
- **Deleting a person** goes through `deletePerson` (`lib/src/auth/account.dart`), in one transaction and in ADR 0001 rule 6's order: their sessions end first (a device with a valid session would re-upload everything), then their space goes with all its content through the foreign keys, then the person with their identities. `AccountEndpoint.delete` deletes the caller; the app also calls it when an anonymous account is given up (signed out of, or switched away from to GitHub), since nobody could ever sign into it again.
|
- **Deleting a person** goes through `deletePerson` (`lib/src/auth/account.dart`), in one transaction and in ADR 0001 rule 6's order: their sessions end first (a device with a valid session would re-upload everything), then their space goes with all its content through the foreign keys, then the person with their identities. `AccountEndpoint.delete` deletes the caller; the app also calls it when an anonymous account is given up (signed out of, or switched away from to GitHub), since nobody could ever sign into it again.
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
"homeTitle": "Home",
|
"homeTitle": "Home",
|
||||||
|
|
||||||
"libraryTitle": "Library",
|
"libraryTitle": "Library",
|
||||||
"libraryNewStudyset": "New studyset",
|
"libraryCreateStudyset": "Create studyset",
|
||||||
"libraryEmpty": "No studysets yet",
|
"libraryEmpty": "No studysets yet",
|
||||||
"libraryUnreadable": "Couldn't read your studysets.",
|
"libraryUnreadable": "Couldn't read your studysets.",
|
||||||
"libraryArchivedSection": "Archived",
|
"libraryArchivedSection": "Archived",
|
||||||
|
|
@ -267,6 +267,14 @@
|
||||||
"settingsDeleteTitle": "Delete your account?",
|
"settingsDeleteTitle": "Delete your account?",
|
||||||
"settingsDeleteMessage": "Everything you have, your studysets, cards and reviews, is deleted on the server and on all your devices. This can't be undone.",
|
"settingsDeleteMessage": "Everything you have, your studysets, cards and reviews, is deleted on the server and on all your devices. This can't be undone.",
|
||||||
"settingsAbout": "About Halkyon Learn",
|
"settingsAbout": "About Halkyon Learn",
|
||||||
|
"settingsAppearance": "Appearance",
|
||||||
|
"settingsAppearanceDevice": "Device theme",
|
||||||
|
"settingsAppearanceLight": "Light",
|
||||||
|
"settingsAppearanceDark": "Dark",
|
||||||
|
"settingsLanguage": "Language",
|
||||||
|
"settingsLanguageDevice": "Device language",
|
||||||
|
"settingsVersion": "Version {version} (build {build})",
|
||||||
|
"@settingsVersion": {"placeholders": {"version": {"type": "String"}, "build": {"type": "String"}}},
|
||||||
"syncTitle": "Sync",
|
"syncTitle": "Sync",
|
||||||
"syncStateSyncing": "Syncing…",
|
"syncStateSyncing": "Syncing…",
|
||||||
"syncStateSynced": "Everything synced",
|
"syncStateSynced": "Everything synced",
|
||||||
|
|
|
||||||
|
|
@ -136,11 +136,11 @@ abstract class AppLocalizations {
|
||||||
/// **'Library'**
|
/// **'Library'**
|
||||||
String get libraryTitle;
|
String get libraryTitle;
|
||||||
|
|
||||||
/// No description provided for @libraryNewStudyset.
|
/// No description provided for @libraryCreateStudyset.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'New studyset'**
|
/// **'Create studyset'**
|
||||||
String get libraryNewStudyset;
|
String get libraryCreateStudyset;
|
||||||
|
|
||||||
/// No description provided for @libraryEmpty.
|
/// No description provided for @libraryEmpty.
|
||||||
///
|
///
|
||||||
|
|
@ -1498,6 +1498,48 @@ abstract class AppLocalizations {
|
||||||
/// **'About Halkyon Learn'**
|
/// **'About Halkyon Learn'**
|
||||||
String get settingsAbout;
|
String get settingsAbout;
|
||||||
|
|
||||||
|
/// No description provided for @settingsAppearance.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Appearance'**
|
||||||
|
String get settingsAppearance;
|
||||||
|
|
||||||
|
/// No description provided for @settingsAppearanceDevice.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Device theme'**
|
||||||
|
String get settingsAppearanceDevice;
|
||||||
|
|
||||||
|
/// No description provided for @settingsAppearanceLight.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Light'**
|
||||||
|
String get settingsAppearanceLight;
|
||||||
|
|
||||||
|
/// No description provided for @settingsAppearanceDark.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Dark'**
|
||||||
|
String get settingsAppearanceDark;
|
||||||
|
|
||||||
|
/// No description provided for @settingsLanguage.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Language'**
|
||||||
|
String get settingsLanguage;
|
||||||
|
|
||||||
|
/// No description provided for @settingsLanguageDevice.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Device language'**
|
||||||
|
String get settingsLanguageDevice;
|
||||||
|
|
||||||
|
/// No description provided for @settingsVersion.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Version {version} (build {build})'**
|
||||||
|
String settingsVersion(String version, String build);
|
||||||
|
|
||||||
/// No description provided for @syncTitle.
|
/// No description provided for @syncTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get libraryTitle => 'Library';
|
String get libraryTitle => 'Library';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryNewStudyset => 'New studyset';
|
String get libraryCreateStudyset => 'Create studyset';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryEmpty => 'No studysets yet';
|
String get libraryEmpty => 'No studysets yet';
|
||||||
|
|
@ -860,6 +860,29 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get settingsAbout => 'About Halkyon Learn';
|
String get settingsAbout => 'About Halkyon Learn';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAppearance => 'Appearance';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAppearanceDevice => 'Device theme';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAppearanceLight => 'Light';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAppearanceDark => 'Dark';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsLanguage => 'Language';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsLanguageDevice => 'Device language';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settingsVersion(String version, String build) {
|
||||||
|
return 'Version $version (build $build)';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get syncTitle => 'Sync';
|
String get syncTitle => 'Sync';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import 'package:go_router/go_router.dart';
|
||||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/app/app_router.dart';
|
import 'package:halkyon_learn_flutter/src/app/app_router.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/device_settings/widget/device_settings_scope.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/editor/core_blocks.dart';
|
import 'package:halkyon_learn_flutter/src/core/editor/core_blocks.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
|
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||||
|
|
@ -46,34 +48,40 @@ class _PersonAppState() extends State<PersonApp> {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => MaterialApp.router(
|
Widget build(BuildContext context) {
|
||||||
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
|
// The device's look and language, chosen in Settings.
|
||||||
theme: appTheme(Brightness.light),
|
final settings = context.watch<DeviceSettingsBloc>().state;
|
||||||
darkTheme: appTheme(Brightness.dark),
|
return MaterialApp.router(
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
theme: appTheme(Brightness.light),
|
||||||
routerConfig: _router,
|
darkTheme: appTheme(Brightness.dark),
|
||||||
// The person's data opens around the router's navigator, so every screen
|
themeMode: settings.themeMode,
|
||||||
// and dialog is inside it.
|
locale: settings.locale,
|
||||||
//
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
// Packages that still import package:flutter/material.dart (Serverpod's
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
// sign-in widgets, the rich-text editor, go_router's pages) see this app's
|
routerConfig: _router,
|
||||||
// theme and localizations only through the bridge, since material_ui is a
|
// The person's data opens around the router's navigator, so every screen
|
||||||
// separate copy of that library. The bridge is deprecated on purpose, as a
|
// and dialog is inside it.
|
||||||
// reminder: remove it once they have moved.
|
//
|
||||||
// ignore: deprecated_member_use
|
// Packages that still import package:flutter/material.dart (Serverpod's
|
||||||
builder: (context, child) => MaterialUiCompatibilityBridge(
|
// sign-in widgets, the rich-text editor, go_router's pages) see this app's
|
||||||
child: PersonScope(
|
// theme and localizations only through the bridge, since material_ui is a
|
||||||
person: widget.person,
|
// separate copy of that library. The bridge is deprecated on purpose, as a
|
||||||
// Where sync stands: the trouble dot, Settings and Home show it.
|
// reminder: remove it once they have moved.
|
||||||
child: BlocProvider(
|
// ignore: deprecated_member_use
|
||||||
create: (context) =>
|
builder: (context, child) => MaterialUiCompatibilityBridge(
|
||||||
SyncStatusBloc(PersonScope.of(context).sync)
|
child: PersonScope(
|
||||||
..add(const SyncStatusEventStarted()),
|
person: widget.person,
|
||||||
// The editors know the features' blocks too.
|
// Where sync stands: the trouble dot, Settings and Home show it.
|
||||||
child: EditorBlocksScope(blocks: _editorBlocks, child: child!),
|
child: BlocProvider(
|
||||||
|
create: (context) =>
|
||||||
|
SyncStatusBloc(PersonScope.of(context).sync)
|
||||||
|
..add(const SyncStatusEventStarted()),
|
||||||
|
// The editors know the features' blocks too.
|
||||||
|
child: EditorBlocksScope(blocks: _editorBlocks, child: child!),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,12 @@ import 'package:halkyon_learn_flutter/src/core/asset/data/sound_player.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/asset/data/sync_asset_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/core/asset/data/sync_asset_gateway.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
|
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_folders.dart';
|
import 'package:halkyon_learn_flutter/src/core/dependencies/person_folders.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/device_settings/data/device_settings_store.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/files/file_picker.dart';
|
import 'package:halkyon_learn_flutter/src/core/files/file_picker.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/sync/app_compatibility.dart';
|
import 'package:halkyon_learn_flutter/src/core/sync/app_compatibility.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
|
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/version/app_version.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
|
import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/about/data/about_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/about/data/about_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/about/data/serverpod_about_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/feature/about/data/serverpod_about_gateway.dart';
|
||||||
|
|
@ -90,6 +93,16 @@ class Dependencies({
|
||||||
|
|
||||||
/// Reads Anki packages to import.
|
/// Reads Anki packages to import.
|
||||||
required final AnkiPackageReader ankiPackages,
|
required final AnkiPackageReader ankiPackages,
|
||||||
|
|
||||||
|
/// Keeps the device's own settings, such as its appearance.
|
||||||
|
required final DeviceSettingsStore deviceSettingsStore,
|
||||||
|
|
||||||
|
/// The device's settings as they were kept, read before the app shows, so
|
||||||
|
/// that its first frame already has the chosen look and language.
|
||||||
|
required final DeviceSettings deviceSettings,
|
||||||
|
|
||||||
|
/// Which release of the app this is.
|
||||||
|
required final AppVersion appVersion,
|
||||||
final Future<void> Function()? _onDispose,
|
final Future<void> Function()? _onDispose,
|
||||||
}) {
|
}) {
|
||||||
/// Builds the production graph around one Serverpod client.
|
/// Builds the production graph around one Serverpod client.
|
||||||
|
|
@ -100,10 +113,15 @@ class Dependencies({
|
||||||
final client = Client(await getServerUrl())
|
final client = Client(await getServerUrl())
|
||||||
..connectivityMonitor = FlutterConnectivityMonitor()
|
..connectivityMonitor = FlutterConnectivityMonitor()
|
||||||
..authSessionManager = FlutterAuthSessionManager();
|
..authSessionManager = FlutterAuthSessionManager();
|
||||||
|
final package = await PackageInfo.fromPlatform();
|
||||||
|
final appVersion = AppVersion(
|
||||||
|
version: package.version,
|
||||||
|
build: package.buildNumber,
|
||||||
|
);
|
||||||
// Whether the server still works with this build: asked before the
|
// Whether the server still works with this build: asked before the
|
||||||
// first sync of each launch.
|
// first sync of each launch.
|
||||||
final compatibility = AppCompatibility(
|
final compatibility = AppCompatibility(
|
||||||
appBuild: int.tryParse((await PackageInfo.fromPlatform()).buildNumber),
|
appBuild: int.tryParse(appVersion.build),
|
||||||
minimumAppBuild: () async =>
|
minimumAppBuild: () async =>
|
||||||
(await client.instance.info()).minimumAppBuild,
|
(await client.instance.info()).minimumAppBuild,
|
||||||
);
|
);
|
||||||
|
|
@ -112,10 +130,10 @@ class Dependencies({
|
||||||
await client.auth.initialize();
|
await client.auth.initialize();
|
||||||
|
|
||||||
final authGateway = ServerpodAuthGateway(client);
|
final authGateway = ServerpodAuthGateway(client);
|
||||||
final folders = PersonFolders(
|
final support = (await getApplicationSupportDirectory()).path;
|
||||||
Directory(
|
final folders = PersonFolders(Directory(p.join(support, 'people')));
|
||||||
p.join((await getApplicationSupportDirectory()).path, 'people'),
|
final deviceSettingsStore = FileDeviceSettingsStore(
|
||||||
),
|
File(p.join(support, 'device_settings.json')),
|
||||||
);
|
);
|
||||||
// One person at a time: before the next person's data opens, the previous
|
// One person at a time: before the next person's data opens, the previous
|
||||||
// person's database has closed, so their folder can go. If it doesn't
|
// person's database has closed, so their folder can go. If it doesn't
|
||||||
|
|
@ -133,6 +151,9 @@ class Dependencies({
|
||||||
videos: IframeYoutubeVideos(),
|
videos: IframeYoutubeVideos(),
|
||||||
pdfEngine: PdfrxEngine(),
|
pdfEngine: PdfrxEngine(),
|
||||||
ankiPackages: IsolateAnkiPackageReader(),
|
ankiPackages: IsolateAnkiPackageReader(),
|
||||||
|
deviceSettingsStore: deviceSettingsStore,
|
||||||
|
deviceSettings: await deviceSettingsStore.read(),
|
||||||
|
appVersion: appVersion,
|
||||||
openPerson: (person) async {
|
openPerson: (person) async {
|
||||||
await previousClosed.timeout(
|
await previousClosed.timeout(
|
||||||
const Duration(seconds: 10),
|
const Duration(seconds: 10),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import 'dart:developer';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/device_settings/data/device_settings_store.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
|
||||||
|
|
||||||
|
/// The device's own settings, starting at what it kept: a choice applies at
|
||||||
|
/// once and is kept for the next start.
|
||||||
|
class DeviceSettingsBloc(
|
||||||
|
final DeviceSettingsStore _store,
|
||||||
|
super.initialState,
|
||||||
|
) extends Bloc<DeviceSettingsEvent, DeviceSettings> {
|
||||||
|
this {
|
||||||
|
on<DeviceSettingsEventAppearanceChosen>(
|
||||||
|
(event, emit) => _apply(emit, state.withAppearance(event.appearance)),
|
||||||
|
);
|
||||||
|
on<DeviceSettingsEventLanguageChosen>(
|
||||||
|
(event, emit) => _apply(emit, state.withLanguage(event.language)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _apply(
|
||||||
|
Emitter<DeviceSettings> emit,
|
||||||
|
DeviceSettings settings,
|
||||||
|
) async {
|
||||||
|
emit(settings);
|
||||||
|
try {
|
||||||
|
await _store.write(settings);
|
||||||
|
} on FileSystemException catch (error) {
|
||||||
|
// It still applies until the app closes.
|
||||||
|
log('Keeping the device settings failed', name: 'Settings', error: error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the person does with the device's settings.
|
||||||
|
sealed class const DeviceSettingsEvent() with Equatable;
|
||||||
|
|
||||||
|
/// The person chose how the app looks.
|
||||||
|
final class const DeviceSettingsEventAppearanceChosen(
|
||||||
|
final Appearance appearance,
|
||||||
|
) extends DeviceSettingsEvent {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [appearance];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The person chose the app's language: a BCP 47 tag, or null for the
|
||||||
|
/// device's.
|
||||||
|
final class const DeviceSettingsEventLanguageChosen(final String? language)
|
||||||
|
extends DeviceSettingsEvent {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [language];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:developer';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
|
||||||
|
|
||||||
|
/// Where a device keeps its own settings.
|
||||||
|
abstract interface class DeviceSettingsStore() {
|
||||||
|
/// The settings kept; the defaults when there are none or they can't be
|
||||||
|
/// read.
|
||||||
|
Future<DeviceSettings> read();
|
||||||
|
|
||||||
|
/// Keeps [settings] in place of the ones kept before.
|
||||||
|
Future<void> write(DeviceSettings settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps them as JSON in [_file], in the app's support folder.
|
||||||
|
///
|
||||||
|
/// A write goes to a temporary file first and replaces the old one in one
|
||||||
|
/// step, so a crash halfway leaves the settings as they were. What can't be
|
||||||
|
/// read, a damaged file or a value this version doesn't know, falls back to
|
||||||
|
/// what the device does.
|
||||||
|
class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
|
||||||
|
@override
|
||||||
|
Future<DeviceSettings> read() async {
|
||||||
|
final String content;
|
||||||
|
try {
|
||||||
|
content = await _file.readAsString();
|
||||||
|
} on FileSystemException {
|
||||||
|
return const DeviceSettings();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final json = jsonDecode(content);
|
||||||
|
if (json is! Map<String, Object?>) {
|
||||||
|
return const DeviceSettings();
|
||||||
|
}
|
||||||
|
return DeviceSettings(
|
||||||
|
appearance:
|
||||||
|
Appearance.values.asNameMap()[json['appearance']] ??
|
||||||
|
Appearance.device,
|
||||||
|
language: switch (json['language']) {
|
||||||
|
final String tag => tag,
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} on FormatException catch (error) {
|
||||||
|
log('The device settings are unreadable', name: 'Settings', error: error);
|
||||||
|
return const DeviceSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> write(DeviceSettings settings) async {
|
||||||
|
await _file.parent.create(recursive: true);
|
||||||
|
final temporary = File('${_file.path}.new');
|
||||||
|
await temporary.writeAsString(
|
||||||
|
jsonEncode({
|
||||||
|
'appearance': settings.appearance.name,
|
||||||
|
'language': ?settings.language,
|
||||||
|
}),
|
||||||
|
flush: true,
|
||||||
|
);
|
||||||
|
await temporary.rename(_file.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// How the app looks: as the device does, or always light or dark.
|
||||||
|
enum Appearance() {
|
||||||
|
device,
|
||||||
|
light,
|
||||||
|
dark,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The settings of one device, which stay on it (decided 2026-10-07): how the
|
||||||
|
/// app looks and the language it speaks. Unlike the person's settings, they
|
||||||
|
/// don't follow anyone to other devices, and they apply before anyone is
|
||||||
|
/// signed in.
|
||||||
|
final class const DeviceSettings({
|
||||||
|
/// Light, dark, or as the device.
|
||||||
|
final Appearance appearance = Appearance.device,
|
||||||
|
|
||||||
|
/// The language's BCP 47 tag, as `en-GB` or `de`; null for the device's.
|
||||||
|
final String? language,
|
||||||
|
}) with Equatable {
|
||||||
|
/// These settings with [appearance] instead.
|
||||||
|
DeviceSettings withAppearance(Appearance appearance) =>
|
||||||
|
DeviceSettings(appearance: appearance, language: language);
|
||||||
|
|
||||||
|
/// These settings with [language] instead; null for the device's.
|
||||||
|
DeviceSettings withLanguage(String? language) =>
|
||||||
|
DeviceSettings(appearance: appearance, language: language);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [appearance, language];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// Provides the device's settings ([DeviceSettingsBloc]) to [child], starting
|
||||||
|
/// at what the device kept.
|
||||||
|
///
|
||||||
|
/// It sits above every person's app: the settings belong to the device and
|
||||||
|
/// apply before anyone is signed in.
|
||||||
|
class const DeviceSettingsScope({required final Widget child, super.key})
|
||||||
|
extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final dependencies = DependenciesScope.of(context);
|
||||||
|
return BlocProvider(
|
||||||
|
create: (context) => DeviceSettingsBloc(
|
||||||
|
dependencies.deviceSettingsStore,
|
||||||
|
dependencies.deviceSettings,
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the device's settings mean for a `MaterialApp`.
|
||||||
|
extension DeviceSettingsApp on DeviceSettings {
|
||||||
|
/// The app's theme mode.
|
||||||
|
ThemeMode get themeMode => switch (appearance) {
|
||||||
|
Appearance.device => ThemeMode.system,
|
||||||
|
Appearance.light => ThemeMode.light,
|
||||||
|
Appearance.dark => ThemeMode.dark,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The app's locale; null for the device's.
|
||||||
|
Locale? get locale => switch (language?.split('-')) {
|
||||||
|
null => null,
|
||||||
|
[final language] => Locale(language),
|
||||||
|
[final language, final country, ...] => Locale(language, country),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -6,8 +6,8 @@ import 'package:material_ui/material_ui.dart';
|
||||||
/// [child] with a small error-coloured dot while the last sync failed, and
|
/// [child] with a small error-coloured dot while the last sync failed, and
|
||||||
/// as it is otherwise: syncing, or everything synced, shows nothing.
|
/// as it is otherwise: syncing, or everything synced, shows nothing.
|
||||||
///
|
///
|
||||||
/// On the Settings cog, where the Sync card explains it, and on the Profile
|
/// On the Settings cogs (on Home and Profile), which lead to the Sync card
|
||||||
/// tab, which leads there.
|
/// that explains it, and on the Profile tab.
|
||||||
class const SyncTroubleBadge({required final Widget child, super.key})
|
class const SyncTroubleBadge({required final Widget child, super.key})
|
||||||
extends StatelessWidget {
|
extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
14
halkyon_learn_flutter/lib/src/core/version/app_version.dart
Normal file
14
halkyon_learn_flutter/lib/src/core/version/app_version.dart
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// Which release of the app this is, as its pubspec says (`version: 1.2.3+45`).
|
||||||
|
final class const AppVersion({
|
||||||
|
/// The release's semantic version, as `1.2.3`.
|
||||||
|
required final String version,
|
||||||
|
|
||||||
|
/// The build number after the `+`, which every release raises; the server
|
||||||
|
/// knows apps by it (`AppCompatibility`).
|
||||||
|
required final String build,
|
||||||
|
}) with Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [version, build];
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/device_settings/widget/device_settings_scope.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/theme/app_theme.dart';
|
import 'package:halkyon_learn_flutter/src/core/theme/app_theme.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.dart';
|
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.dart';
|
||||||
|
|
@ -14,27 +16,32 @@ import 'package:material_ui/material_ui.dart';
|
||||||
/// one person's app, such as screens, blocs, streams or caches, reaches
|
/// one person's app, such as screens, blocs, streams or caches, reaches
|
||||||
/// another's. Until someone is signed in, a plain app shows the progress, or
|
/// another's. Until someone is signed in, a plain app shows the progress, or
|
||||||
/// the failure with a way to try again.
|
/// the failure with a way to try again.
|
||||||
|
///
|
||||||
|
/// The device's own settings ([DeviceSettingsScope]) sit above it all, since
|
||||||
|
/// they outlive every person.
|
||||||
class const SessionGate({
|
class const SessionGate({
|
||||||
/// The app of a signed-in person.
|
/// The app of a signed-in person.
|
||||||
required final Widget Function(SignedIn person) app,
|
required final Widget Function(SignedIn person) app,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends StatelessWidget {
|
}) extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => BlocProvider(
|
Widget build(BuildContext context) => DeviceSettingsScope(
|
||||||
create: (context) =>
|
child: BlocProvider(
|
||||||
SessionBloc(DependenciesScope.of(context).auth)
|
create: (context) =>
|
||||||
..add(const SessionEventStarted()),
|
SessionBloc(DependenciesScope.of(context).auth)
|
||||||
child: BlocBuilder<SessionBloc, SessionState>(
|
..add(const SessionEventStarted()),
|
||||||
builder: (context, state) => switch (state) {
|
child: BlocBuilder<SessionBloc, SessionState>(
|
||||||
SessionStateLoading() => const _PlainApp(
|
builder: (context, state) => switch (state) {
|
||||||
home: Scaffold(body: Center(child: AppLoadingIndicator())),
|
SessionStateLoading() => const _PlainApp(
|
||||||
),
|
home: Scaffold(body: Center(child: AppLoadingIndicator())),
|
||||||
SessionStateFailed() => const _PlainApp(home: _SignInFailed()),
|
),
|
||||||
SessionStateActive(:final person) => KeyedSubtree(
|
SessionStateFailed() => const _PlainApp(home: _SignInFailed()),
|
||||||
key: ValueKey(person.authUserId),
|
SessionStateActive(:final person) => KeyedSubtree(
|
||||||
child: app(person),
|
key: ValueKey(person.authUserId),
|
||||||
),
|
child: app(person),
|
||||||
},
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -42,14 +49,19 @@ class const SessionGate({
|
||||||
/// The app's look, around a single screen, while nobody is signed in.
|
/// The app's look, around a single screen, while nobody is signed in.
|
||||||
class const _PlainApp({required final Widget home}) extends StatelessWidget {
|
class const _PlainApp({required final Widget home}) extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => MaterialApp(
|
Widget build(BuildContext context) {
|
||||||
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
|
final settings = context.watch<DeviceSettingsBloc>().state;
|
||||||
theme: appTheme(Brightness.light),
|
return MaterialApp(
|
||||||
darkTheme: appTheme(Brightness.dark),
|
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
theme: appTheme(Brightness.light),
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
darkTheme: appTheme(Brightness.dark),
|
||||||
home: home,
|
themeMode: settings.themeMode,
|
||||||
);
|
locale: settings.locale,
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: home,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class const _SignInFailed() extends StatelessWidget {
|
class const _SignInFailed() extends StatelessWidget {
|
||||||
|
|
|
||||||
|
|
@ -406,9 +406,16 @@ class const _Actions({
|
||||||
child: Text(l10n.composerClearFields),
|
child: Text(l10n.composerClearFields),
|
||||||
),
|
),
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
leadingIcon: const Icon(LucideIcons.trash2, size: 18),
|
leadingIcon: Icon(
|
||||||
|
LucideIcons.trash2,
|
||||||
|
size: 18,
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
onPressed: onDelete,
|
onPressed: onDelete,
|
||||||
child: Text(l10n.composerDelete),
|
child: Text(
|
||||||
|
l10n.composerDelete,
|
||||||
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
builder: (context, menu, _) => IconButton(
|
builder: (context, menu, _) => IconButton(
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
|
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/sync/widget/app_mismatch_banner.dart';
|
import 'package:halkyon_learn_flutter/src/core/sync/widget/app_mismatch_banner.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_trouble_badge.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/home/bloc/home_bloc.dart';
|
import 'package:halkyon_learn_flutter/src/feature/home/bloc/home_bloc.dart';
|
||||||
|
|
@ -101,7 +104,14 @@ class _HomeState() extends State<_Home> {
|
||||||
),
|
),
|
||||||
today: today,
|
today: today,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
IconButton(
|
||||||
|
icon: const SyncTroubleBadge(
|
||||||
|
child: Icon(LucideIcons.settings),
|
||||||
|
),
|
||||||
|
tooltip: l10n.settingsTitle,
|
||||||
|
onPressed: () => context.push(RoutePaths.settings),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
|
|
|
||||||
|
|
@ -260,7 +260,10 @@ class _PathEditorScreenState() extends State<PathEditorScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
icon: const Icon(LucideIcons.trash2),
|
icon: Icon(
|
||||||
|
LucideIcons.trash2,
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
tooltip: l10n.pathStepDelete,
|
tooltip: l10n.pathStepDelete,
|
||||||
onPressed: () => _deleteStep(context, step),
|
onPressed: () => _deleteStep(context, step),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/layout/window_size.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// The languages the app speaks, each in its own name (its autonym), as
|
||||||
|
/// language pickers name them: someone looking for their language may not
|
||||||
|
/// read the one the app speaks now.
|
||||||
|
const _languages = {'en': 'English'};
|
||||||
|
|
||||||
|
/// How this device shows the app: its appearance and language, side by side
|
||||||
|
/// where there is room. Both stay on the device.
|
||||||
|
class const DeviceSettingsCard({super.key}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final settings = context.watch<DeviceSettingsBloc>().state;
|
||||||
|
final appearance = _Field(
|
||||||
|
label: l10n.settingsAppearance,
|
||||||
|
field: DropdownButtonFormField<Appearance>(
|
||||||
|
initialValue: settings.appearance,
|
||||||
|
decoration: _decoration,
|
||||||
|
icon: const Icon(LucideIcons.chevronDown),
|
||||||
|
items: [
|
||||||
|
for (final appearance in Appearance.values)
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: appearance,
|
||||||
|
child: Text(switch (appearance) {
|
||||||
|
Appearance.device => l10n.settingsAppearanceDevice,
|
||||||
|
Appearance.light => l10n.settingsAppearanceLight,
|
||||||
|
Appearance.dark => l10n.settingsAppearanceDark,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (appearance) {
|
||||||
|
if (appearance != null) {
|
||||||
|
context.read<DeviceSettingsBloc>().add(
|
||||||
|
DeviceSettingsEventAppearanceChosen(appearance),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final language = _Field(
|
||||||
|
label: l10n.settingsLanguage,
|
||||||
|
field: DropdownButtonFormField<String?>(
|
||||||
|
// A language this version no longer speaks shows as the device's.
|
||||||
|
initialValue: _languages.containsKey(settings.language)
|
||||||
|
? settings.language
|
||||||
|
: null,
|
||||||
|
decoration: _decoration,
|
||||||
|
icon: const Icon(LucideIcons.chevronDown),
|
||||||
|
items: [
|
||||||
|
DropdownMenuItem(child: Text(l10n.settingsLanguageDevice)),
|
||||||
|
for (final MapEntry(key: tag, value: name) in _languages.entries)
|
||||||
|
DropdownMenuItem(value: tag, child: Text(name)),
|
||||||
|
],
|
||||||
|
onChanged: (language) => context.read<DeviceSettingsBloc>().add(
|
||||||
|
DeviceSettingsEventLanguageChosen(language),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return SectionCard(
|
||||||
|
child: context.windowSize.isAtLeastMedium
|
||||||
|
? Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(child: appearance),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(child: language),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [appearance, const SizedBox(height: 16), language],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _decoration = InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// A setting's label above its field.
|
||||||
|
class const _Field({required final String label, required final Widget field})
|
||||||
|
extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
field,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,11 +7,13 @@ import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/about/widget/about.dart';
|
import 'package:halkyon_learn_flutter/src/feature/about/widget/about.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.dart';
|
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/settings/widget/device_settings_card.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
/// Settings, over the tabs: where sync stands, the account (signing out,
|
/// Settings, over the tabs: where sync stands, how this device shows the app,
|
||||||
/// deleting it) and what Halkyon Learn is.
|
/// the account (signing out, deleting it), what Halkyon Learn is, and at the
|
||||||
|
/// bottom which release this is.
|
||||||
class const SettingsScreen({super.key}) extends StatelessWidget {
|
class const SettingsScreen({super.key}) extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -31,6 +33,8 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
const SyncStatusCard(),
|
const SyncStatusCard(),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
const DeviceSettingsCard(),
|
||||||
|
const SizedBox(height: 12),
|
||||||
SectionCard(
|
SectionCard(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -72,6 +76,8 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
|
||||||
showAbout(context, DependenciesScope.of(context).about),
|
showAbout(context, DependenciesScope.of(context).about),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const _Version(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -80,6 +86,27 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which release of the app this is, so that a report can name it.
|
||||||
|
class const _Version() extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final version = DependenciesScope.of(context).appVersion;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Text(
|
||||||
|
AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
).settingsVersion(version.version, version.build),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Signs out; an anonymous account can't be signed into again, so signing out
|
/// Signs out; an anonymous account can't be signed into again, so signing out
|
||||||
/// of one deletes it, and asks first. The app then starts anew.
|
/// of one deletes it, and asks first. The app then starts anew.
|
||||||
Future<void> _signOut(BuildContext context) async {
|
Future<void> _signOut(BuildContext context) async {
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ class _LibraryState() extends State<_Library> {
|
||||||
fab: wide
|
fab: wide
|
||||||
? null
|
? null
|
||||||
: FloatingActionButton(
|
: FloatingActionButton(
|
||||||
tooltip: l10n.libraryNewStudyset,
|
tooltip: l10n.libraryCreateStudyset,
|
||||||
onPressed: () => _create(context),
|
onPressed: () => _create(context),
|
||||||
child: const Icon(LucideIcons.plus),
|
child: const Icon(LucideIcons.plus),
|
||||||
),
|
),
|
||||||
|
|
@ -121,7 +121,7 @@ class _LibraryState() extends State<_Library> {
|
||||||
child: FilledButton.tonalIcon(
|
child: FilledButton.tonalIcon(
|
||||||
onPressed: () => _create(context),
|
onPressed: () => _create(context),
|
||||||
icon: const Icon(LucideIcons.plus),
|
icon: const Icon(LucideIcons.plus),
|
||||||
label: Text(l10n.libraryNewStudyset),
|
label: Text(l10n.libraryCreateStudyset),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -353,6 +353,7 @@ class const _BottomBar({
|
||||||
_Option.delete,
|
_Option.delete,
|
||||||
LucideIcons.trash2,
|
LucideIcons.trash2,
|
||||||
l10n.studysetMenuDelete,
|
l10n.studysetMenuDelete,
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -361,14 +362,19 @@ class const _BottomBar({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
PopupMenuItem<_Option> _item(_Option option, IconData icon, String label) =>
|
/// An option; a [color] marks one that destroys, as deleting.
|
||||||
PopupMenuItem(
|
PopupMenuItem<_Option> _item(
|
||||||
value: option,
|
_Option option,
|
||||||
child: ListTile(
|
IconData icon,
|
||||||
dense: true,
|
String label, {
|
||||||
contentPadding: EdgeInsets.zero,
|
Color? color,
|
||||||
leading: Icon(icon),
|
}) => PopupMenuItem(
|
||||||
title: Text(label),
|
value: option,
|
||||||
),
|
child: ListTile(
|
||||||
);
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: Icon(icon, color: color),
|
||||||
|
title: Text(label, style: TextStyle(color: color)),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue