halkyon/halkyon_learn_flutter/lib/src/app/app_router.dart
Tizian 「ティツィアーン」 9486b3586e
refactor(app): instances, sign-in and sync plumbing come from halkyon_app
The instances a device keeps an account on, the first start, the
account switcher, signing in and switching person, the gates that build
the dependencies per instance and per person, and the sync service with
its status, card and banner move to halkyon_app (halkyon_shared, ADR
0014), on halkyon_base's client. Learn supplies its frame (name, word,
icon, theme, launch screen, and the two texts only it can say), its
dependencies, whether the device holds content, and its world map's
country names; every text reads as before. An instance counts only
where its basics name the app `learn`, which learnBase now sets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-22 18:06:15 +02:00

473 lines
18 KiB
Dart

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