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
29 changed files with 943 additions and 112 deletions
|
|
@ -12,7 +12,7 @@
|
|||
"homeTitle": "Home",
|
||||
|
||||
"libraryTitle": "Library",
|
||||
"libraryNewStudyset": "New studyset",
|
||||
"libraryCreateStudyset": "Create studyset",
|
||||
"libraryEmpty": "No studysets yet",
|
||||
"libraryUnreadable": "Couldn't read your studysets.",
|
||||
"libraryArchivedSection": "Archived",
|
||||
|
|
@ -267,6 +267,14 @@
|
|||
"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.",
|
||||
"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",
|
||||
"syncStateSyncing": "Syncing…",
|
||||
"syncStateSynced": "Everything synced",
|
||||
|
|
|
|||
|
|
@ -136,11 +136,11 @@ abstract class AppLocalizations {
|
|||
/// **'Library'**
|
||||
String get libraryTitle;
|
||||
|
||||
/// No description provided for @libraryNewStudyset.
|
||||
/// No description provided for @libraryCreateStudyset.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'New studyset'**
|
||||
String get libraryNewStudyset;
|
||||
/// **'Create studyset'**
|
||||
String get libraryCreateStudyset;
|
||||
|
||||
/// No description provided for @libraryEmpty.
|
||||
///
|
||||
|
|
@ -1498,6 +1498,48 @@ abstract class AppLocalizations {
|
|||
/// **'About Halkyon Learn'**
|
||||
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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get libraryTitle => 'Library';
|
||||
|
||||
@override
|
||||
String get libraryNewStudyset => 'New studyset';
|
||||
String get libraryCreateStudyset => 'Create studyset';
|
||||
|
||||
@override
|
||||
String get libraryEmpty => 'No studysets yet';
|
||||
|
|
@ -860,6 +860,29 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
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
|
||||
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/src/app/app_router.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/editor_blocks.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
|
|
@ -46,34 +48,40 @@ class _PersonAppState() extends State<PersonApp> {
|
|||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => MaterialApp.router(
|
||||
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
|
||||
theme: appTheme(Brightness.light),
|
||||
darkTheme: appTheme(Brightness.dark),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
routerConfig: _router,
|
||||
// The person's data opens around the router's navigator, so every screen
|
||||
// and dialog is inside it.
|
||||
//
|
||||
// Packages that still import package:flutter/material.dart (Serverpod's
|
||||
// sign-in widgets, the rich-text editor, go_router's pages) see this app's
|
||||
// theme and localizations only through the bridge, since material_ui is a
|
||||
// separate copy of that library. The bridge is deprecated on purpose, as a
|
||||
// reminder: remove it once they have moved.
|
||||
// ignore: deprecated_member_use
|
||||
builder: (context, child) => MaterialUiCompatibilityBridge(
|
||||
child: PersonScope(
|
||||
person: widget.person,
|
||||
// Where sync stands: the trouble dot, Settings and Home show it.
|
||||
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!),
|
||||
Widget build(BuildContext context) {
|
||||
// The device's look and language, chosen in Settings.
|
||||
final settings = context.watch<DeviceSettingsBloc>().state;
|
||||
return MaterialApp.router(
|
||||
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
|
||||
theme: appTheme(Brightness.light),
|
||||
darkTheme: appTheme(Brightness.dark),
|
||||
themeMode: settings.themeMode,
|
||||
locale: settings.locale,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
routerConfig: _router,
|
||||
// The person's data opens around the router's navigator, so every screen
|
||||
// and dialog is inside it.
|
||||
//
|
||||
// Packages that still import package:flutter/material.dart (Serverpod's
|
||||
// sign-in widgets, the rich-text editor, go_router's pages) see this app's
|
||||
// theme and localizations only through the bridge, since material_ui is a
|
||||
// separate copy of that library. The bridge is deprecated on purpose, as a
|
||||
// reminder: remove it once they have moved.
|
||||
// ignore: deprecated_member_use
|
||||
builder: (context, child) => MaterialUiCompatibilityBridge(
|
||||
child: PersonScope(
|
||||
person: widget.person,
|
||||
// Where sync stands: the trouble dot, Settings and Home show it.
|
||||
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/dependencies/person_dependencies.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/sync/app_compatibility.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/feature/about/data/about_repository.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.
|
||||
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,
|
||||
}) {
|
||||
/// Builds the production graph around one Serverpod client.
|
||||
|
|
@ -100,10 +113,15 @@ class Dependencies({
|
|||
final client = Client(await getServerUrl())
|
||||
..connectivityMonitor = FlutterConnectivityMonitor()
|
||||
..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
|
||||
// first sync of each launch.
|
||||
final compatibility = AppCompatibility(
|
||||
appBuild: int.tryParse((await PackageInfo.fromPlatform()).buildNumber),
|
||||
appBuild: int.tryParse(appVersion.build),
|
||||
minimumAppBuild: () async =>
|
||||
(await client.instance.info()).minimumAppBuild,
|
||||
);
|
||||
|
|
@ -112,10 +130,10 @@ class Dependencies({
|
|||
await client.auth.initialize();
|
||||
|
||||
final authGateway = ServerpodAuthGateway(client);
|
||||
final folders = PersonFolders(
|
||||
Directory(
|
||||
p.join((await getApplicationSupportDirectory()).path, 'people'),
|
||||
),
|
||||
final support = (await getApplicationSupportDirectory()).path;
|
||||
final folders = PersonFolders(Directory(p.join(support, '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
|
||||
// person's database has closed, so their folder can go. If it doesn't
|
||||
|
|
@ -133,6 +151,9 @@ class Dependencies({
|
|||
videos: IframeYoutubeVideos(),
|
||||
pdfEngine: PdfrxEngine(),
|
||||
ankiPackages: IsolateAnkiPackageReader(),
|
||||
deviceSettingsStore: deviceSettingsStore,
|
||||
deviceSettings: await deviceSettingsStore.read(),
|
||||
appVersion: appVersion,
|
||||
openPerson: (person) async {
|
||||
await previousClosed.timeout(
|
||||
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
|
||||
/// as it is otherwise: syncing, or everything synced, shows nothing.
|
||||
///
|
||||
/// On the Settings cog, where the Sync card explains it, and on the Profile
|
||||
/// tab, which leads there.
|
||||
/// On the Settings cogs (on Home and Profile), which lead to the Sync card
|
||||
/// that explains it, and on the Profile tab.
|
||||
class const SyncTroubleBadge({required final Widget child, super.key})
|
||||
extends StatelessWidget {
|
||||
@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: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/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/widget/app_loading_indicator.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
|
||||
/// another's. Until someone is signed in, a plain app shows the progress, or
|
||||
/// 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({
|
||||
/// The app of a signed-in person.
|
||||
required final Widget Function(SignedIn person) app,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => BlocProvider(
|
||||
create: (context) =>
|
||||
SessionBloc(DependenciesScope.of(context).auth)
|
||||
..add(const SessionEventStarted()),
|
||||
child: BlocBuilder<SessionBloc, SessionState>(
|
||||
builder: (context, state) => switch (state) {
|
||||
SessionStateLoading() => const _PlainApp(
|
||||
home: Scaffold(body: Center(child: AppLoadingIndicator())),
|
||||
),
|
||||
SessionStateFailed() => const _PlainApp(home: _SignInFailed()),
|
||||
SessionStateActive(:final person) => KeyedSubtree(
|
||||
key: ValueKey(person.authUserId),
|
||||
child: app(person),
|
||||
),
|
||||
},
|
||||
Widget build(BuildContext context) => DeviceSettingsScope(
|
||||
child: BlocProvider(
|
||||
create: (context) =>
|
||||
SessionBloc(DependenciesScope.of(context).auth)
|
||||
..add(const SessionEventStarted()),
|
||||
child: BlocBuilder<SessionBloc, SessionState>(
|
||||
builder: (context, state) => switch (state) {
|
||||
SessionStateLoading() => const _PlainApp(
|
||||
home: Scaffold(body: Center(child: AppLoadingIndicator())),
|
||||
),
|
||||
SessionStateFailed() => const _PlainApp(home: _SignInFailed()),
|
||||
SessionStateActive(:final person) => KeyedSubtree(
|
||||
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.
|
||||
class const _PlainApp({required final Widget home}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => MaterialApp(
|
||||
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
|
||||
theme: appTheme(Brightness.light),
|
||||
darkTheme: appTheme(Brightness.dark),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: home,
|
||||
);
|
||||
Widget build(BuildContext context) {
|
||||
final settings = context.watch<DeviceSettingsBloc>().state;
|
||||
return MaterialApp(
|
||||
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
|
||||
theme: appTheme(Brightness.light),
|
||||
darkTheme: appTheme(Brightness.dark),
|
||||
themeMode: settings.themeMode,
|
||||
locale: settings.locale,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: home,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class const _SignInFailed() extends StatelessWidget {
|
||||
|
|
|
|||
|
|
@ -406,9 +406,16 @@ class const _Actions({
|
|||
child: Text(l10n.composerClearFields),
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(LucideIcons.trash2, size: 18),
|
||||
leadingIcon: Icon(
|
||||
LucideIcons.trash2,
|
||||
size: 18,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
onPressed: onDelete,
|
||||
child: Text(l10n.composerDelete),
|
||||
child: Text(
|
||||
l10n.composerDelete,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
builder: (context, menu, _) => IconButton(
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
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/src/core/dependencies/person_scope.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/sync_trouble_badge.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/feature/home/bloc/home_bloc.dart';
|
||||
|
|
@ -101,7 +104,14 @@ class _HomeState() extends State<_Home> {
|
|||
),
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -260,7 +260,10 @@ class _PathEditorScreenState() extends State<PathEditorScreen> {
|
|||
),
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(LucideIcons.trash2),
|
||||
icon: Icon(
|
||||
LucideIcons.trash2,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
tooltip: l10n.pathStepDelete,
|
||||
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/feature/about/widget/about.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:material_ui/material_ui.dart';
|
||||
|
||||
/// Settings, over the tabs: where sync stands, the account (signing out,
|
||||
/// deleting it) and what Halkyon Learn is.
|
||||
/// Settings, over the tabs: where sync stands, how this device shows the app,
|
||||
/// 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 {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -31,6 +33,8 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
|
|||
children: [
|
||||
const SyncStatusCard(),
|
||||
const SizedBox(height: 12),
|
||||
const DeviceSettingsCard(),
|
||||
const SizedBox(height: 12),
|
||||
SectionCard(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
|
|
@ -72,6 +76,8 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
|
|||
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
|
||||
/// of one deletes it, and asks first. The app then starts anew.
|
||||
Future<void> _signOut(BuildContext context) async {
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ class _LibraryState() extends State<_Library> {
|
|||
fab: wide
|
||||
? null
|
||||
: FloatingActionButton(
|
||||
tooltip: l10n.libraryNewStudyset,
|
||||
tooltip: l10n.libraryCreateStudyset,
|
||||
onPressed: () => _create(context),
|
||||
child: const Icon(LucideIcons.plus),
|
||||
),
|
||||
|
|
@ -121,7 +121,7 @@ class _LibraryState() extends State<_Library> {
|
|||
child: FilledButton.tonalIcon(
|
||||
onPressed: () => _create(context),
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
label: Text(l10n.libraryNewStudyset),
|
||||
label: Text(l10n.libraryCreateStudyset),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -353,6 +353,7 @@ class const _BottomBar({
|
|||
_Option.delete,
|
||||
LucideIcons.trash2,
|
||||
l10n.studysetMenuDelete,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -361,14 +362,19 @@ class const _BottomBar({
|
|||
);
|
||||
}
|
||||
|
||||
PopupMenuItem<_Option> _item(_Option option, IconData icon, String label) =>
|
||||
PopupMenuItem(
|
||||
value: option,
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(icon),
|
||||
title: Text(label),
|
||||
),
|
||||
);
|
||||
/// An option; a [color] marks one that destroys, as deleting.
|
||||
PopupMenuItem<_Option> _item(
|
||||
_Option option,
|
||||
IconData icon,
|
||||
String label, {
|
||||
Color? color,
|
||||
}) => PopupMenuItem(
|
||||
value: option,
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(icon, color: color),
|
||||
title: Text(label, style: TextStyle(color: color)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -365,8 +365,16 @@ class const _Topic({
|
|||
child: ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(LucideIcons.trash2),
|
||||
title: Text(l10n.studysetMenuDelete),
|
||||
leading: Icon(
|
||||
LucideIcons.trash2,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
title: Text(
|
||||
l10n.studysetMenuDelete,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue