chore: Halkyon Learn moves into apps/learn

Its server, client, app and curator, and the tools that write its
assets (maps, Anki fixtures, icons), as they are; paths are fixed in
the commits that follow.

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-19 20:08:06 +02:00
commit 649499be23
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
478 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,15 @@
// This file is never meant to be used on real apps, just for development.
// ignore: depend_on_referenced_packages
import 'package:flutter_driver/driver_extension.dart';
import 'package:halkyon_learn_flutter/main.dart' as app;
void main() {
enableFlutterDriverExtension(
// Text entry emulation is disabled by default to allow manual usage of the
// app, but will be required for agents to type through the app. Set this
// parameter to true to switch from manual to automated usage.
enableTextEntryEmulation: false,
);
app.main();
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,3 @@
{
"@@locale": "en_GB"
}

View file

@ -0,0 +1,26 @@
{
"@@locale": "en_US",
"studysetSheetColor": "Color",
"tagSheetColor": "Color",
"formatAlignCenter": "Center",
"homeCustomise": "Customize",
"studysetSheetLicense": "License",
"studysetSheetLicenseLabel": "License",
"studysetSheetLicenseHolder": "License holder",
"studysetSheetLicenseHolderHelper": "Who to credit: a name or an organization.",
"studysetSheetLicenseNewer": "{license} is an older Creative Commons license. Its current version, {current}, may fit better.",
"licenseChooserTitle": "Choose a license",
"licenseChooserSubtitle": "A few questions to find the Creative Commons license that fits how you want your work shared.",
"licenseChooserIntroYes": "Yes, share it under a license",
"licenseChooserAdaptationsShareAlikeDetail": "Adaptations carry this same license (SA).",
"licenseChooserRecommended": "Recommended license",
"licenseChooserUse": "Use this license",
"licenseSummaryBySa": "Others may share and adapt it, even commercially, if they credit you and license their adaptations the same way.",
"licenseSummaryByNcSa": "Others may share and adapt it non-commercially, with credit and the same license on adaptations.",
"licenseSummaryByNcNd": "Others may share it non-commercially, with credit, but may not adapt it: the strictest Creative Commons license.",
"publishLicence": "License",
"publishLicenceNotOpen": "These studysets' licenses don't allow passing them on: {titles}",
"exploreReadLicence": "Read the license",
"suggestNoDerivatives": "Its license forbids adaptations: no suggestions."
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,55 @@
import 'dart:io';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/src/app/learn_frame.dart';
import 'package:halkyon_learn_flutter/src/app/learn_sessions.dart';
import 'package:halkyon_learn_flutter/src/core/bloc/app_bloc_observer.dart';
import 'package:halkyon_learn_flutter/src/core/country/country_names.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/widget/device_settings_scope.dart';
import 'package:halkyon_learn_flutter/src/core/theme/cjk_fonts.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/mecab_license.dart';
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/cldr_license.dart';
import 'package:halkyon_learn_flutter/src/feature/kanji/data/kanjivg_license.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
import 'package:path/path.dart' as p;
import 'package:serverpod_flutter/serverpod_flutter.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
Bloc.observer = const AppBlocObserver();
registerFontLicenses();
registerCjkFontLicenses();
registerKanjiVgLicense();
// The flags' notice and Unicode CLDR's for the subdivisions' names.
registerHalkyonAppLicenses();
// Unicode CLDR's for the world map's country names, as that copy gives it.
registerWorldMapCldrLicense();
registerMecabLicenses();
// The instance the build names (`--dart-define=SERVER_URL=…`, or
// `assets/config.json`): the first offered, and the one installs from
// before the choice go on with.
final builtIn = Instance.parse(await getServerUrl())!;
final device = await DeviceParts.create(builtIn: builtIn);
final instances = InstanceChoice(
FileInstanceStore(File(p.join(device.support, 'instances.json'))),
builtIn: builtIn,
forget: device.storage.forget,
);
await instances.load(usedBefore: device.storage.usedBefore());
runApp(
InstanceGate(
frame: learnFrame,
choice: instances,
// Only an instance of Halkyon Learn: one of another Halkyon app's
// answers too, but holds nothing of this app's.
checker: ServerpodInstanceChecker(app: 'learn'),
countryNames: AssetCountryNames(),
open: (instance) => Dependencies.create(instance, device),
readLook: () async => (await device.deviceSettingsStore.read()).look,
app: const LearnSessions(),
),
);
}

View file

@ -0,0 +1,473 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/router/app_shell.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/router/tab_visibility.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/connect_claude_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/widget/profile_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_browser_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_composer_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/dictionary_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/model/discussion.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/widget/discussion_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/widget/discussions_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/widget/explore_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/bloc/inbox_count_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/home/widget/home_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/jellyfin_browser_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/watch_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_editor_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_runner_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/step_editor_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_editor_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_runner_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/step_editor_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/note/bloc/note_editor_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/note/widget/note_editor_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/pdf_reader_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/reading_timeline_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/widget/settings_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/study/bloc/study_session_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_focus.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/library_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_language_scope.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/bloc/jellyfin_tags_bloc.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// The router of one person's app: the four tabs in a shell, each with its own
/// stack of screens, and the focused surfaces over them. Every route is built
/// inside the person's scope, so screens read their repositories from
/// [PersonScope]. The app opens at [initialLocation].
GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
initialLocation: initialLocation,
routes: [
StatefulShellRoute(
builder: (context, state, navigationShell) => AppShell(
navigationShell: navigationShell,
// Inside a studyset, its own bar at the bottom takes the
// navigation bar's place.
hidesNavigationBar: RoutePaths.isInsideStudyset(state.uri.path),
// The inbox's threads with something new.
profileBadge: (context) =>
context.select<InboxCountBloc?, int>((bloc) => bloc?.state ?? 0),
),
navigatorContainerBuilder: tabsContainer,
// In the order of the shell's destinations.
branches: [
StatefulShellBranch(
routes: [
GoRoute(
path: RoutePaths.home,
builder: (context, state) => const HomeScreen(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: RoutePaths.library,
builder: (context, state) => const LibraryScreen(),
// Nested, so a studyset's screen sits above the library in the
// tab's stack.
routes: [
GoRoute(
path: 'studysets/:id',
// Keyed by the id: several studysets' screens can be on
// the stack at once, and must not share one page. Named by
// its location, so the way up finds it among them.
pageBuilder: (context, state) {
final id = state.pathParameters['id']!;
return MaterialPage<void>(
key: ValueKey('studyset-$id'),
name: RoutePaths.studyset(id),
child: _studysetScreen(
context,
id,
importOnOpen:
state.uri.queryParameters['import'] == 'content',
),
);
},
),
],
),
// A studyset's notes and paths keep the tabs beside them, as
// its screen does: reading and writing them is part of being in
// the library. What needs the whole window (studying, a card,
// a PDF) stays over the tabs.
// A note, new or stored; the new one's route first, as for cards.
GoRoute(
path: '/studysets/:studysetId/notes/new',
builder: (context, state) =>
_noteEditor(context, state.pathParameters['studysetId']!),
),
GoRoute(
path: '/studysets/:studysetId/notes/:noteId',
builder: (context, state) => _noteEditor(
context,
state.pathParameters['studysetId']!,
noteId: state.pathParameters['noteId'],
),
),
// The dictionary of a studyset's language, to add words from.
GoRoute(
path: '/studysets/:studysetId/dictionary',
builder: (context, state) {
final studysetId = state.pathParameters['studysetId']!;
return _inStudyset(
context,
studysetId,
(_, _) => DictionaryScreen(studysetId: studysetId),
);
},
),
// Watching from Jellyfin for a studyset: the libraries, or what
// an item of them holds.
GoRoute(
path: '/studysets/:studysetId/jellyfin',
builder: (context, state) {
final studysetId = state.pathParameters['studysetId']!;
return _inStudyset(
context,
studysetId,
(_, _) => JellyfinBrowserScreen(
key: ValueKey(state.uri.queryParameters['in']),
studysetId: studysetId,
parentId: state.uri.queryParameters['in'],
name: state.uri.queryParameters['name'],
),
);
},
),
// A learning path: worked through, written, and one of its steps
// written.
GoRoute(
path: '/studysets/:studysetId/paths/:pathId',
builder: (context, state) => _pathRunner(
context,
state.pathParameters['studysetId']!,
state.pathParameters['pathId']!,
),
),
GoRoute(
path: '/studysets/:studysetId/paths/:pathId/edit',
builder: (context, state) => _pathEditor(
context,
state.pathParameters['studysetId']!,
state.pathParameters['pathId']!,
),
),
GoRoute(
path: '/studysets/:studysetId/paths/:pathId/steps/:stepId',
builder: (context, state) => _stepEditor(
context,
state.pathParameters['studysetId']!,
state.pathParameters['pathId']!,
state.pathParameters['stepId']!,
),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: RoutePaths.explore,
builder: (context, state) => const ExploreScreen(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: RoutePaths.profile,
builder: (context, state) => ProfileScreen(
signsInWithEmail:
state.uri.queryParameters['signIn'] == 'email',
),
),
],
),
],
),
// Over the tabs: they need the whole window.
GoRoute(
path: '/study/:studysetId',
builder: (context, state) {
final person = PersonDependencies.of(context);
final studysetId = state.pathParameters['studysetId']!;
return _inStudyset(
context,
studysetId,
(studyset, _) => FramedScreen(
child: MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => StudySessionBloc(
person.studysets,
person.cards,
person.cardDeletions,
person.reviews,
person.tags,
person.paths,
person.settings,
studysetId,
focus: StudyFocus.fromQuery(state.uri.queryParameters),
)..add(const StudySessionEventStarted()),
),
BlocProvider(
create: (context) =>
JellyfinTagsBloc(person.settings)
..add(const JellyfinTagsEventStarted()),
),
],
child: StudyScreen(
studysetId: studysetId,
title: studyset.content.title,
),
),
),
);
},
),
// Browsing a studyset's cards: searching, filtering and changing many at
// once.
GoRoute(
path: '/studysets/:studysetId/flashcards',
builder: (context, state) => _inStudyset(
context,
state.pathParameters['studysetId']!,
(studyset, all) => FramedScreen(
child: CardBrowserScreen(
studyset: studyset,
all: all,
subtree: state.uri.queryParameters['subtree'] == '1',
),
),
),
),
// The composer: writing a studyset's cards, opened on a new card or on
// one of them. Before the card route: its `new` would otherwise read as
// a card's id.
GoRoute(
path: '/studysets/:studysetId/cards/new',
builder: (context, state) =>
_composer(context, state.pathParameters['studysetId']!),
),
GoRoute(
path: '/studysets/:studysetId/cards/:cardId',
builder: (context, state) => _composer(
context,
state.pathParameters['studysetId']!,
cardId: state.pathParameters['cardId'],
),
),
GoRoute(
path: RoutePaths.readingTimeline,
builder: (context, state) => const ReadingTimelineScreen(),
),
// A video of the Jellyfin server, watched in the whole window to learn
// the words of its subtitles into a studyset.
GoRoute(
path: '/watch/:studysetId/:itemId',
builder: (context, state) {
final studysetId = state.pathParameters['studysetId']!;
return _inStudyset(
context,
studysetId,
(_, _) => WatchScreen(
studysetId: studysetId,
itemId: state.pathParameters['itemId']!,
),
);
},
),
// A PDF, read in the whole window; at a page when asked.
GoRoute(
path: '/pdfs/:pdfId',
builder: (context, state) => PdfReaderScreen(
pdfId: state.pathParameters['pdfId']!,
page: int.tryParse(state.uri.queryParameters['page'] ?? ''),
),
),
// A published studyset's discussions, and each of them (ADR 0013).
GoRoute(
path: '/discussions/:publicId',
builder: (context, state) => FramedScreen(
child: DiscussionsScreen(
publicId: state.pathParameters['publicId']!,
topic: state.uri.queryParameters['topic'],
version: int.tryParse(state.uri.queryParameters['version'] ?? ''),
title: state.uri.queryParameters['title'],
ask: targetFromQuery(state.uri.queryParameters['ask']),
),
),
routes: [
GoRoute(
path: ':questionId',
builder: (context, state) => FramedScreen(
child: DiscussionScreen(
questionId: state.pathParameters['questionId']!,
),
),
),
],
),
GoRoute(
path: RoutePaths.settings,
builder: (context, state) => const FramedScreen(child: SettingsScreen()),
routes: [
GoRoute(
path: 'assistant',
builder: (context, state) =>
const FramedScreen(child: ConnectClaudeScreen()),
),
],
),
],
);
/// The learning path [pathId] of the studyset [studysetId], to work
/// through; a studyset gone says so.
Widget _pathRunner(BuildContext context, String studysetId, String pathId) {
final person = PersonDependencies.of(context);
return _inStudyset(
context,
studysetId,
(studyset, _) => BlocProvider(
create: (context) =>
PathRunnerBloc(person.paths, person.pathProgress, studysetId, pathId)
..add(const PathRunnerEventStarted()),
child: PathRunnerScreen(studysetId: studysetId, pathId: pathId),
),
);
}
/// The learning path [pathId] of the studyset [studysetId], to write.
/// Leaving closes its bloc, which saves what is unsaved.
Widget _pathEditor(BuildContext context, String studysetId, String pathId) {
final person = PersonDependencies.of(context);
return _inStudyset(
context,
studysetId,
(studyset, _) => BlocProvider(
create: (context) => PathEditorBloc(
person.paths,
person.tags,
person.stepDeletions,
studysetId,
pathId,
)..add(const PathEditorEventStarted()),
child: PathEditorScreen(studysetId: studysetId, pathId: pathId),
),
);
}
/// The step [stepId] of the learning path [pathId], to write. Leaving
/// closes its bloc, which saves what is unsaved.
Widget _stepEditor(
BuildContext context,
String studysetId,
String pathId,
String stepId,
) {
final person = PersonDependencies.of(context);
return _inStudyset(
context,
studysetId,
(studyset, _) => BlocProvider(
create: (context) =>
StepEditorBloc(person.paths, pathId, stepId)
..add(const StepEditorEventStarted()),
child: const StepEditorScreen(),
),
);
}
Widget _studysetScreen(
BuildContext context,
String id, {
bool importOnOpen = false,
}) => _inStudyset(
context,
id,
(studyset, all) =>
StudysetScreen(studyset: studyset, all: all, importOnOpen: importOnOpen),
wayBack: (
label: AppLocalizations.of(context).studysetBackToLibrary,
onPressed: () => context.go(RoutePaths.library),
),
);
/// The studyset [id] among the person's, for [builder]'s screen, its text
/// set for the studyset's languages ([StudysetLanguageScope]); a studyset
/// gone (deleted, or an old link) says so, leading back by [wayBack].
Widget _inStudyset(
BuildContext context,
String id,
Widget Function(Studyset studyset, List<Studyset> all) builder, {
({String label, VoidCallback onPressed})? wayBack,
}) => FoundById<Studyset>(
id: id,
items: PersonDependencies.of(context).studysets.watchAll,
idOf: (studyset) => studyset.id,
wayBack: wayBack,
builder: (studyset, all) => StudysetLanguageScope(
studyset: studyset,
all: all,
// Every screen of a studyset washes its colour, its own or the nearest
// above it.
child: ScreenColor(
color:
PaletteColor.byName(colorNameOf(studyset.id, all))?.background ??
Brand.primary,
child: builder(studyset, all),
),
),
);
/// The note [noteId] of the studyset [studysetId], or a new one without; a
/// studyset gone (deleted, or an old link) says so. Leaving the note closes
/// its bloc, which saves what is unsaved.
Widget _noteEditor(BuildContext context, String studysetId, {String? noteId}) {
final person = PersonDependencies.of(context);
return _inStudyset(
context,
studysetId,
(_, _) => BlocProvider(
create: (context) =>
NoteEditorBloc(person.notes, person.tags, studysetId, noteId: noteId)
..add(const NoteEditorEventStarted()),
child: const NoteEditorScreen(),
),
);
}
/// The composer of the studyset [studysetId]'s cards, opened on [cardId] if
/// given; a studyset gone (deleted, or an old link) says so.
Widget _composer(BuildContext context, String studysetId, {String? cardId}) {
final person = PersonDependencies.of(context);
return _inStudyset(
context,
studysetId,
(studyset, _) => FramedScreen(
child: BlocProvider(
create: (context) =>
CardComposerBloc(person.cards, person.tags, studysetId)
..add(const CardComposerEventStarted()),
child: CardComposerScreen(
studysetTitle: studyset.content.title,
initialCardId: cardId,
),
),
),
);
}

View file

@ -0,0 +1,35 @@
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/l10n/app_languages.dart';
import 'package:halkyon_learn_flutter/src/core/theme/learn_theme.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_launch_screen.dart';
import 'package:material_ui/material_ui.dart';
/// Halkyon Learn as halkyon_app's own screens know it (the first start, the
/// start screen, the account switcher, signing in): its name and word, its
/// kingfisher, its look and languages, and the two sentences only it can
/// say, in its own words, so every text reads as it did before they moved.
const learnFrame = AppFrame(
name: _name,
product: _product,
icon: AssetImage('assets/icon/halkyon_icon.png'),
exampleAddress: 'learn.example.org',
firstStartSubtitle: _firstStartSubtitle,
switchLeavesBehind: _switchLeavesBehind,
theme: learnTheme,
localizationsDelegates: appLocalizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
localeListResolutionCallback: resolveAppLocale,
launchScreen: AppLaunchScreen(),
);
String _name(BuildContext context) => AppLocalizations.of(context).appTitle;
String _product(BuildContext context) =>
AppLocalizations.of(context).launchProduct;
String _firstStartSubtitle(BuildContext context) =>
AppLocalizations.of(context).instanceFirstStartSubtitle;
String _switchLeavesBehind(BuildContext context) =>
AppLocalizations.of(context).emailSignInSwitchMessage;

View file

@ -0,0 +1,26 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/src/app/person_app.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/router/route_paths.dart';
import 'package:material_ui/material_ui.dart';
/// Halkyon Learn on the instance in use, below halkyon_app's `InstanceGate`:
/// the device's own settings ([DeviceSettingsScope]), which outlive every
/// person, around halkyon_app's [SessionGate], which shows each signed-in
/// person their own [PersonApp], opened at [initialLocation].
class const LearnSessions({
/// Where every person's app opens.
final String initialLocation = RoutePaths.home,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => DeviceSettingsScope(
child: SessionGate(
look: (context) => context.watch<DeviceSettingsBloc>().state.look,
app: (person) =>
PersonApp(person: person, initialLocation: initialLocation),
),
);
}

View file

@ -0,0 +1,129 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_app/halkyon_app.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/dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.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/l10n/app_languages.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/theme/learn_theme.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/bloc/inbox_count_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/bloc/pomodoro_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/widget/pomodoro_presenter.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_button_block.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// The blocks of the person's editors: the core's and the features'.
final _editorBlocks = coreBlocks.plus(studyEditorBlocks);
/// The app of one signed-in [person]: its own router, around the person's data
/// ([PersonScope]).
///
/// `SessionGate` builds it anew for every person, keyed by the person, so a
/// change of person disposes all of it, the router with its stacks of screens,
/// every bloc and stream, and starts again at home. Nothing of one person's
/// app can reach another's.
class const PersonApp({
/// Whose app this is.
required final SignedIn person,
/// Where the app opens; home unless a test, say, starts elsewhere.
final String initialLocation = RoutePaths.home,
super.key,
}) extends StatefulWidget {
@override
State<PersonApp> createState() => _PersonAppState();
}
class _PersonAppState() extends State<PersonApp> {
late final GoRouter _router = buildRouter(
// Started with an email address: Profile asks for it at once.
initialLocation: switch (context.read<SessionBloc>().state) {
SessionStateActive(signsInWithEmail: true) =>
RoutePaths.profileEmailSignIn,
_ => widget.initialLocation,
},
);
@override
void dispose() {
_router.dispose();
super.dispose();
}
@override
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: learnTheme(Brightness.light),
darkTheme: learnTheme(Brightness.dark),
themeMode: settings.themeMode,
locale: settings.locale,
localeListResolutionCallback: resolveAppLocale,
localizationsDelegates: appLocalizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
routerConfig: _router,
// The person's data opens around the router's navigator, so every screen
// and dialog is inside it; snack bars fit the window around it all, and
// a field focused when the app is left has its keyboard back on return.
builder: (context, child) => KeyboardBackOnResume(
child: FittedSnackBars(
// 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
child: MaterialUiCompatibilityBridge(
child: PersonScope(
person: widget.person,
child: MultiBlocProvider(
providers: [
// Where sync stands: the trouble dot, Settings and Home show it.
BlocProvider(
create: (context) =>
SyncStatusBloc(PersonDependencies.of(context).sync)
..add(const SyncStatusEventStarted()),
),
// What is new in the inbox, for the Profile tab's badge.
BlocProvider(
create: (context) => InboxCountBloc(
PersonDependencies.of(context).feedback,
PersonDependencies.of(context).discussions,
)..add(const InboxCountEventRefreshed()),
),
// The Pomodoro timer goes on across every screen.
BlocProvider(
create: (context) => PomodoroBloc(
PersonDependencies.of(context).settings,
PersonDependencies.of(context).pomodoroRuns,
stoppedOutside: Dependencies.of(
context,
).pomodoro.takeStoppedOutside,
)..add(const PomodoroEventStarted()),
),
],
child: PomodoroPresenter(
// The editors know the features' blocks too.
child: EditorBlocksScope(
blocks: _editorBlocks,
child: child!,
),
),
),
),
),
),
),
);
}
}

View file

@ -0,0 +1,109 @@
import 'dart:async';
import 'dart:developer';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/sound_player.dart';
/// One sound in content: plays it when asked, and stops it again; it also
/// stops when it ends or another sound starts.
class SoundBloc(
final AssetRepository _assets,
final SoundPlayer _player,
/// The sound's asset row.
final String _assetId,
) extends Bloc<SoundEvent, SoundState> {
this : super(SoundState.idle) {
on<SoundEventToggled>(_onToggled);
on<SoundEventPlayingChanged>((event, emit) {
if (event.path != null && event.path == _path) {
emit(SoundState.playing);
} else if (state == SoundState.playing) {
emit(SoundState.idle);
}
});
_playing = _player.playing.listen(
(path) => add(SoundEventPlayingChanged(path)),
);
}
late final StreamSubscription<String?> _playing;
/// Where this sound's file is, once it was played.
String? _path;
Future<void> _onToggled(
SoundEventToggled event,
Emitter<SoundState> emit,
) async {
switch (state) {
case SoundState.playing:
await _player.stop();
case SoundState.loading:
break;
case SoundState.idle || SoundState.missing || SoundState.unplayable:
emit(SoundState.loading);
final file = await _assets.fileOf(_assetId);
if (file == null) {
emit(SoundState.missing);
return;
}
_path = file.path;
try {
await _player.play(file.path, mimeType: file.mimeType);
} on Object catch (error) {
log('Playing a sound failed', name: 'Sound', error: error);
emit(SoundState.unplayable);
}
// Playing shows once the player says so.
}
}
@override
Future<void> close() async {
await _playing.cancel();
if (state == SoundState.playing) {
await _player.stop();
}
await super.close();
}
}
/// What happens to a sound.
sealed class const SoundEvent() with Equatable {
@override
List<Object?> get props => const [];
}
/// Play the sound, or stop it if it plays.
final class const SoundEventToggled() extends SoundEvent;
/// The player plays the file at [path] now, or nothing.
final class const SoundEventPlayingChanged(
/// The file playing, or null.
final String? path,
) extends SoundEvent {
@override
List<Object?> get props => [path];
}
/// How a sound is.
enum SoundState() {
/// Not playing.
idle,
/// Getting its file.
loading,
/// Playing.
playing,
/// Its file can't be had now: not uploaded yet, or no connection.
missing,
/// Its file is here but didn't play: damaged, or in a format the
/// platform can't decode.
unplayable,
}

View file

@ -0,0 +1,101 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:path/path.dart' as p;
/// The files on this device, by their SHA-256, and which of them wait to be
/// uploaded.
abstract interface class AssetFileCache() {
/// The file with [sha256]; null if this device hasn't got it.
Future<Uint8List?> read(String sha256);
/// Keeps [bytes], whose SHA-256 is [sha256].
Future<void> write(String sha256, Uint8List bytes);
/// Where the file with [sha256] is, or would be.
String pathOf(String sha256);
/// The files that wait to be uploaded.
Future<Set<String>> pendingUploads();
/// Notes that the file with [sha256] waits to be uploaded.
Future<void> markPending(String sha256);
/// Notes that the file with [sha256] doesn't wait any more.
Future<void> clearPending(String sha256);
}
/// [AssetFileCache] in a folder of the person's: each file named by its hash,
/// and an empty file of the same name in `uploads/` for each that waits.
///
/// A hash is part of a path here, and rows synced from elsewhere carry them,
/// so anything but 64 lowercase hex digits is refused.
class DirectoryAssetFileCache(final Directory _folder)
implements AssetFileCache {
Directory get _uploads => Directory(p.join(_folder.path, 'uploads'));
@override
Future<Uint8List?> read(String sha256) async {
try {
return await File(pathOf(sha256)).readAsBytes();
} on PathNotFoundException {
return null;
}
}
@override
Future<void> write(String sha256, Uint8List bytes) async {
final file = File(pathOf(sha256));
if (file.existsSync()) {
return;
}
await _folder.create(recursive: true);
// Written aside and renamed, so no half-written file ever has the name.
final partial = File('${file.path}.partial');
await partial.writeAsBytes(bytes, flush: true);
await partial.rename(file.path);
}
@override
String pathOf(String sha256) => p.join(_folder.path, checkedSha256(sha256));
@override
Future<Set<String>> pendingUploads() async {
if (!_uploads.existsSync()) {
return {};
}
return {
await for (final entry in _uploads.list())
if (entry is File && isSha256(p.basename(entry.path)))
p.basename(entry.path),
};
}
@override
Future<void> markPending(String sha256) async {
await _uploads.create(recursive: true);
await File(
p.join(_uploads.path, checkedSha256(sha256)),
).writeAsBytes(const []);
}
@override
Future<void> clearPending(String sha256) async {
try {
await File(p.join(_uploads.path, checkedSha256(sha256))).delete();
} on PathNotFoundException {
// Not waiting.
}
}
}
/// Whether [value] is a SHA-256 as files are named by: 64 lowercase hex
/// digits.
bool isSha256(String value) => _sha256.hasMatch(value);
/// [value], if it [isSha256]; throws an [ArgumentError] otherwise.
String checkedSha256(String value) => isSha256(value)
? value
: throw ArgumentError.value(value, 'sha256', 'is no SHA-256 in hex');
final _sha256 = RegExp(r'^[0-9a-f]{64}$');

View file

@ -0,0 +1,34 @@
import 'dart:typed_data';
/// An asset row as stored: the file's id, SHA-256 (lowercase hex), media type
/// and size, and its payload.
typedef AssetRow = ({
String id,
String sha256,
String mimeType,
int byteSize,
ByteData payload,
});
/// The person's asset rows, one per file of their content (ADR 0004,
/// rule 5); the files themselves are on the server and in the
/// device's cache.
abstract interface class AssetGateway() {
/// The row [id]; null if there is none.
Future<AssetRow?> byId(String id);
/// A row of the file with [sha256]; null if there is none.
Future<AssetRow?> bySha256(String sha256);
/// Stores a new row and returns its id.
Future<String> insert({
required String sha256,
required String mimeType,
required int byteSize,
required ByteData payload,
});
/// Blanks the row [id]'s payload, then deletes the row: its file goes from
/// the server once nothing refers to it any more.
Future<void> delete(String id);
}

View file

@ -0,0 +1,27 @@
import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
// A file's content as its synced asset row stores it (see core/content/payload);
// the hash, media type and size are columns of their own.
//
// Schema 1 holds the name the file came with.
/// The schema this app writes.
const assetSchemaVersion = 1;
/// Encodes a file's [fileName] into its payload.
ByteData encodeAssetPayload({required String fileName}) =>
encodePayload(assetSchemaVersion, {'fileName': fileName});
/// Decodes a file's payload into the name it came with.
///
/// Throws a [FormatException] for anything else, including content written
/// by a newer app version in a schema this one doesn't know.
String decodeAssetPayload(ByteData payload) {
final json = decodePayload(payload, newestSchema: assetSchemaVersion);
if (json case {'fileName': final String fileName}) {
return fileName;
}
throw const FormatException('Not a file payload.');
}

View file

@ -0,0 +1,217 @@
import 'dart:async';
import 'dart:developer';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:halkyon_core/halkyon_core.dart'
show CoalescingRunner, SerialQueue;
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_file_cache.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_gateway.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_payload.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_server_gateway.dart';
import 'package:halkyon_learn_flutter/src/core/asset/model/stored_file.dart';
/// The files of the person's content (ADR 0004, rule 5): an asset row each,
/// synced like all content, and the file itself on this device and the
/// server, by its SHA-256.
///
/// A file added here is kept on this device and waits to be uploaded; the
/// uploads run whenever `uploadTriggers` fires (after each sync, so the row
/// is usually on the server before its file), one at a time, and what fails
/// waits for the next time. A file this device lacks is downloaded when asked
/// for, checked against its hash and kept.
class AssetRepository(
final AssetGateway _rows,
final AssetServerGateway _server,
final AssetFileCache _files, {
Stream<void> uploadTriggers = const Stream.empty(),
}) {
this {
_triggers = uploadTriggers.listen((_) => unawaited(uploadPending()));
}
/// The largest file the server takes, of any kind but a PDF.
static const maxBytes = 20 * 1024 * 1024;
/// The largest PDF the server takes (decided 2026-10-03).
static const maxPdfBytes = 50 * 1024 * 1024;
/// A PDF's media type.
static const pdfMediaType = 'application/pdf';
/// The largest file of [mimeType] the server takes.
static int maxBytesOf(String mimeType) =>
mimeType == pdfMediaType ? maxPdfBytes : maxBytes;
late final StreamSubscription<void> _triggers;
/// Adding one file at a time, so a file added twice at once is one row.
final _adding = SerialQueue();
/// The downloads under way, by hash, so a file asked for twice at once is
/// downloaded once.
final _downloads = <String, Future<Uint8List?>>{};
/// Uploads what waits, one round at a time: a call during a round runs
/// one more after it.
late final _uploads = CoalescingRunner(
_uploadAll,
onError: (error, _) =>
log('Uploading files failed', name: 'Assets', error: error),
);
/// Whether it stopped: no round starts any more.
var _stopped = false;
/// Adds [bytes], a file named [fileName] of [mimeType], and returns the id
/// of its asset row: the row of the same file if there is one, else a new
/// one. Throws an [ArgumentError] for an empty file or one too large for
/// its kind ([maxBytesOf]).
Future<String> add(
Uint8List bytes, {
required String fileName,
required String mimeType,
}) async {
if (bytes.isEmpty || bytes.length > maxBytesOf(mimeType)) {
throw ArgumentError.value(bytes.length, 'bytes', 'is out of range');
}
return await _adding.run(() async {
final hash = sha256.convert(bytes).toString();
await _files.write(hash, bytes);
if (await _rows.bySha256(hash) case final row?) {
return row.id;
}
final id = await _rows.insert(
sha256: hash,
mimeType: mimeType,
byteSize: bytes.length,
payload: encodeAssetPayload(fileName: fileName),
);
// Every new row asks the server, even for a file uploaded before: the
// server may have pruned it once no row referred to it.
await _files.markPending(hash);
return id;
});
}
/// The id of the asset row of the file with [sha256]; null while the
/// person has none.
Future<String?> idOf(String sha256) async =>
(await _rows.bySha256(sha256))?.id;
/// The file of the asset row [id]: from this device, else downloaded.
/// Null when it can't be had now: no such row, no connection, or not
/// uploaded yet.
Future<StoredFile?> fileOf(String id) async {
final row = await _rows.byId(id);
if (row == null || !isSha256(row.sha256)) {
return null;
}
final bytes =
await _files.read(row.sha256) ??
await (_downloads[row.sha256] ??=
_download(
id,
row.sha256,
).whenComplete(() {
_downloads.remove(row.sha256);
}));
if (bytes == null) {
return null;
}
String fileName;
try {
fileName = decodeAssetPayload(row.payload);
} on FormatException {
fileName = '';
}
return StoredFile(
bytes: bytes,
mimeType: row.mimeType,
fileName: fileName,
path: _files.pathOf(row.sha256),
);
}
Future<Uint8List?> _download(String id, String hash) async {
try {
final bytes = await _server.download(id);
if (bytes == null || sha256.convert(bytes).toString() != hash) {
return null;
}
await _files.write(hash, bytes);
return bytes;
} on Object catch (error) {
log('Downloading a file failed', name: 'Assets', error: error);
return null;
}
}
/// Uploads the files that wait; what fails waits for the next time. One
/// run at a time: a call during a run runs once more after it.
Future<void> uploadPending() => _uploads.run();
Future<void> _uploadAll() async {
if (_stopped) {
return;
}
for (final hash in await _files.pendingUploads()) {
try {
if (!await _upload(hash)) {
continue;
}
await _files.clearPending(hash);
} on FileLimitReached catch (error) {
// The others wouldn't fit either: they stay on this device, and
// the next round asks again, once room may have been made.
log('No room for more files', name: 'Assets', error: error);
return;
} on Object catch (error) {
// Without a connection the others fail too: next time.
log('Uploading a file failed', name: 'Assets', error: error);
return;
}
}
}
/// Uploads the file with [hash]; whether it no longer waits.
Future<bool> _upload(String hash) async {
final bytes = await _files.read(hash);
if (bytes == null) {
// Gone from this device: nothing to upload, ever.
return true;
}
final description = await _server.startUpload(hash, bytes.length);
if (description == null) {
return true;
}
return await _server.upload(description, bytes) &&
await _server.finishUpload(hash);
}
/// How much room the person's files take on the server, and may take; null
/// when that can't be asked now (no connection).
Future<FileRoom?> room() async {
try {
return await _server.room();
} on Exception catch (error) {
log('Asking for the room of files failed', name: 'Assets', error: error);
return null;
}
}
/// The size of the file [id] in bytes, from its row; null without one.
Future<int?> byteSizeOf(String id) async => (await _rows.byId(id))?.byteSize;
/// Deletes the row of the file [id], blanked first: the file goes from the
/// server once nothing refers to it, and stops taking the person's room.
/// Only for files no content refers to any more.
Future<void> delete(String id) => _rows.delete(id);
/// Stops uploading; an upload under way finishes first.
Future<void> dispose() async {
await _triggers.cancel();
_stopped = true;
await _uploads.idle;
}
}

View file

@ -0,0 +1,41 @@
import 'dart:typed_data';
/// How much room the person's files take on the server, and may take, in
/// bytes.
typedef FileRoom = ({int usedBytes, int limitBytes});
/// The server keeps no more of the person's files: they would take more
/// room than the person may use, or the instance has left ([instanceFull]).
class const FileLimitReached({
/// Whether the instance is full, rather than the person's own room.
required final bool instanceFull,
/// What the person may keep, in bytes.
required final int limitBytes,
}) implements Exception {
@override
String toString() =>
'FileLimitReached(instanceFull: $instanceFull, limit: $limitBytes)';
}
/// The server's copy of the person's files.
abstract interface class AssetServerGateway() {
/// How to upload the file with [sha256] of [byteSize] bytes; null when the
/// server has it already. Throws [FileLimitReached] when it doesn't fit.
Future<String?> startUpload(String sha256, int byteSize);
/// Uploads [bytes] as [description] says; whether that worked.
Future<bool> upload(String description, Uint8List bytes);
/// Completes the upload of the file with [sha256]; whether the server keeps
/// it, which it does only if its content has that hash. Throws
/// [FileLimitReached] when it no longer fits.
Future<bool> finishUpload(String sha256);
/// How much room the person's files take, and may take.
Future<FileRoom> room();
/// The file of the asset [id]; null when the server has none for this
/// person (not uploaded yet, or not theirs).
Future<Uint8List?> download(String id);
}

View file

@ -0,0 +1,35 @@
/// The media type of a file named [name], by its extension: the images and
/// sounds the app shows and plays; null for any other.
String? mediaTypeOf(String name) {
final dot = name.lastIndexOf('.');
return dot < 0 ? null : _mediaTypes[name.substring(dot + 1).toLowerCase()];
}
/// The extensions of the images the app shows.
final imageExtensions = [
for (final MapEntry(:key, :value) in _mediaTypes.entries)
if (value.startsWith('image/')) key,
];
/// The extensions of the sounds the app plays.
final soundExtensions = [
for (final MapEntry(:key, :value) in _mediaTypes.entries)
if (value.startsWith('audio/')) key,
];
const _mediaTypes = {
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'gif': 'image/gif',
'webp': 'image/webp',
'bmp': 'image/bmp',
'mp3': 'audio/mpeg',
'm4a': 'audio/mp4',
'aac': 'audio/aac',
'ogg': 'audio/ogg',
'oga': 'audio/ogg',
'opus': 'audio/ogg',
'wav': 'audio/wav',
'flac': 'audio/flac',
};

Some files were not shown because too many files have changed in this diff Show more