feat(app): learning paths in the studyset screen: worked through and written

- A Paths tab before Flashcards and Notes: each path with its title and
  secondary title, dimmed once completed; Edit, Reset progress (with an
  undo) and Delete (with an undo) behind its options. The studyset opens
  on its paths while one is unfinished, on its flashcards otherwise.
- "Create a path" in Add material asks for the titles in a sheet and
  opens the new path to write; "Import a path (JSON)" reads a file in
  the import format, says what came in and how many blocks were left out
  (images and animations: the app never fetches from the web).
- The player: one step at a time at reading width, a bar of the steps
  above, Back and "Check answers" / "Continue" / "Finish" below. It
  opens at the first unfinished step; an exercise passes only once
  answered right, as often as it takes; after a check, only the
  learner's own picks are marked, with the author's feedback; sources
  behind a chip; a finish screen leading to the next path. Words and
  items are dragged into place, or tapped into place for phones.
- Writing: the path's screen (titles, description, tags, steps added by
  kind, reordered, deleted with an undo) and the step's (title, text
  with the format bar, its kind's exercise, feedback for right and
  wrong in the whole window, sources). The step saves on what it
  started from, as notes do, read-only while devices disagree until a
  version is kept.
- The library and the topics count paths; steps done count on the
  dashboard, for the streak and the activity.
- The rich-text screen for a studyset's description moves to core, for
  the steps' feedback too, and gains the format bar.

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-03 09:25:08 +02:00
commit e4bfe3bdeb
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -10,6 +10,12 @@ import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.d
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_composer_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/widget/explore_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/home/widget/home_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/settings/widget/settings_screen.dart';
@ -140,6 +146,33 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
noteId: state.pathParameters['noteId'],
),
),
// 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']!,
),
),
GoRoute(
path: RoutePaths.settings,
builder: (context, state) => const SettingsScreen(),
@ -147,6 +180,66 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
],
);
/// 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 = PersonScope.of(context);
return FoundById<Studyset>(
id: studysetId,
items: person.studysets.watchAll,
idOf: (studyset) => studyset.id,
builder: (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 = PersonScope.of(context);
return FoundById<Studyset>(
id: studysetId,
items: person.studysets.watchAll,
idOf: (studyset) => studyset.id,
builder: (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 = PersonScope.of(context);
return FoundById<Studyset>(
id: studysetId,
items: person.studysets.watchAll,
idOf: (studyset) => studyset.id,
builder: (studyset, _) => BlocProvider(
create: (context) =>
StepEditorBloc(person.paths, pathId, stepId)
..add(const StepEditorEventStarted()),
child: const StepEditorScreen(),
),
);
}
Widget _studysetScreen(BuildContext context, String id) => FoundById<Studyset>(
id: id,
items: PersonScope.of(context).studysets.watchAll,