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:
Tizian 「ティツィアーン」 2026-10-07 01:43:28 +02:00
commit 55a0a7fdf0
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
29 changed files with 943 additions and 112 deletions

View file

@ -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",

View file

@ -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:

View file

@ -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';

View file

@ -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!),
),
),
),
),
);
);
}
}

View file

@ -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),

View file

@ -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];
}

View file

@ -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);
}
}

View file

@ -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];
}

View file

@ -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,
};
}

View file

@ -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

View 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];
}

View file

@ -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 {

View file

@ -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(

View file

@ -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(

View file

@ -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),
),

View file

@ -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,
],
);
}
}

View file

@ -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 {

View file

@ -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),
),
),
],

View file

@ -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)),
),
);
}

View file

@ -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