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((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( 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 all) builder, { ({String label, VoidCallback onPressed})? wayBack, }) => FoundById( 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, ), ), ), ); }