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:
parent
11abd9afce
commit
649499be23
478 changed files with 0 additions and 0 deletions
15
apps/learn/halkyon_learn_flutter/lib/driver.dart
Normal file
15
apps/learn/halkyon_learn_flutter/lib/driver.dart
Normal 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();
|
||||
}
|
||||
2097
apps/learn/halkyon_learn_flutter/lib/l10n/app_de.arb
Normal file
2097
apps/learn/halkyon_learn_flutter/lib/l10n/app_de.arb
Normal file
File diff suppressed because it is too large
Load diff
2467
apps/learn/halkyon_learn_flutter/lib/l10n/app_en.arb
Normal file
2467
apps/learn/halkyon_learn_flutter/lib/l10n/app_en.arb
Normal file
File diff suppressed because it is too large
Load diff
3
apps/learn/halkyon_learn_flutter/lib/l10n/app_en_GB.arb
Normal file
3
apps/learn/halkyon_learn_flutter/lib/l10n/app_en_GB.arb
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"@@locale": "en_GB"
|
||||
}
|
||||
26
apps/learn/halkyon_learn_flutter/lib/l10n/app_en_US.arb
Normal file
26
apps/learn/halkyon_learn_flutter/lib/l10n/app_en_US.arb
Normal 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."
|
||||
}
|
||||
12407
apps/learn/halkyon_learn_flutter/lib/l10n/app_localizations.dart
Normal file
12407
apps/learn/halkyon_learn_flutter/lib/l10n/app_localizations.dart
Normal file
File diff suppressed because it is too large
Load diff
7829
apps/learn/halkyon_learn_flutter/lib/l10n/app_localizations_de.dart
Normal file
7829
apps/learn/halkyon_learn_flutter/lib/l10n/app_localizations_de.dart
Normal file
File diff suppressed because it is too large
Load diff
7842
apps/learn/halkyon_learn_flutter/lib/l10n/app_localizations_en.dart
Normal file
7842
apps/learn/halkyon_learn_flutter/lib/l10n/app_localizations_en.dart
Normal file
File diff suppressed because it is too large
Load diff
55
apps/learn/halkyon_learn_flutter/lib/main.dart
Normal file
55
apps/learn/halkyon_learn_flutter/lib/main.dart
Normal 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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
473
apps/learn/halkyon_learn_flutter/lib/src/app/app_router.dart
Normal file
473
apps/learn/halkyon_learn_flutter/lib/src/app/app_router.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
129
apps/learn/halkyon_learn_flutter/lib/src/app/person_app.dart
Normal file
129
apps/learn/halkyon_learn_flutter/lib/src/app/person_app.dart
Normal 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!,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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}$');
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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.');
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue