feat(app): the path editor in two cards, as before

On the tinted ground, one card holds the path's details (title,
secondary title, a line, the description row, a line, its tags behind an
Add tags chip) and one below holds its steps under their header, lines
between them, filling the window at least.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-08 14:33:32 +02:00
commit 252110832a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 255 additions and 120 deletions

View file

@ -99,6 +99,7 @@
"pathDescriptionEditTitle": "Pfadbeschreibung", "pathDescriptionEditTitle": "Pfadbeschreibung",
"pathAddTags": "Tags hinzufügen", "pathAddTags": "Tags hinzufügen",
"pathTags": "Tags", "pathTags": "Tags",
"pathEditTags": "Tags bearbeiten",
"pathSteps": "Schritte", "pathSteps": "Schritte",
"pathStepsEmpty": "Noch keine Schritte", "pathStepsEmpty": "Noch keine Schritte",
"pathStepsEmptyHint": "Wähle einen Schritt-Typ, um mit dem Schreiben der Lektion zu beginnen.", "pathStepsEmptyHint": "Wähle einen Schritt-Typ, um mit dem Schreiben der Lektion zu beginnen.",

View file

@ -108,6 +108,7 @@
"pathDescriptionEditTitle": "Path description", "pathDescriptionEditTitle": "Path description",
"pathAddTags": "Add tags", "pathAddTags": "Add tags",
"pathTags": "Tags", "pathTags": "Tags",
"pathEditTags": "Edit tags",
"pathSteps": "Steps", "pathSteps": "Steps",
"pathStepsEmpty": "No steps yet", "pathStepsEmpty": "No steps yet",
"pathStepsEmptyHint": "Pick a step type to start writing the lesson.", "pathStepsEmptyHint": "Pick a step type to start writing the lesson.",

View file

@ -652,6 +652,12 @@ abstract class AppLocalizations {
/// **'Tags'** /// **'Tags'**
String get pathTags; String get pathTags;
/// No description provided for @pathEditTags.
///
/// In en, this message translates to:
/// **'Edit tags'**
String get pathEditTags;
/// No description provided for @pathSteps. /// No description provided for @pathSteps.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -368,6 +368,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get pathTags => 'Tags'; String get pathTags => 'Tags';
@override
String get pathEditTags => 'Tags bearbeiten';
@override @override
String get pathSteps => 'Schritte'; String get pathSteps => 'Schritte';

View file

@ -365,6 +365,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get pathTags => 'Tags'; String get pathTags => 'Tags';
@override
String get pathEditTags => 'Edit tags';
@override @override
String get pathSteps => 'Steps'; String get pathSteps => 'Steps';

View file

@ -1,11 +1,13 @@
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_text_screen.dart'; import 'package:halkyon_learn_flutter/src/core/editor/rich_text_screen.dart';
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart'; import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart'; import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart'; import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart'; import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.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_editor_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
@ -17,9 +19,9 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
/// Writes a learning path of the studyset [studysetId] in the whole window, /// Writes a learning path of the studyset [studysetId] in the whole window,
/// over the tabs: its title, secondary title, description and tags, and its /// over the tabs: a card of its title, secondary title, description and
/// steps, added by kind, reordered and deleted with an undo /// tags, and a card of its steps, added by kind, reordered and deleted with
/// (`PathEditorBloc`). A step added opens at once. /// an undo (`PathEditorBloc`). A step added opens at once.
class const PathEditorScreen({ class const PathEditorScreen({
required final String studysetId, required final String studysetId,
required final String pathId, required final String pathId,
@ -61,6 +63,7 @@ class _PathEditorScreenState() extends State<PathEditorScreen> {
} }
}, },
builder: (context, state) => Scaffold( builder: (context, state) => Scaffold(
backgroundColor: Theme.of(context).colorScheme.surfaceContainerLow,
appBar: AppBar(title: Text(l10n.pathEditTitle)), appBar: AppBar(title: Text(l10n.pathEditTitle)),
body: switch (state) { body: switch (state) {
PathEditorState(loaded: false) => const Center( PathEditorState(loaded: false) => const Center(
@ -86,92 +89,114 @@ class _PathEditorScreenState() extends State<PathEditorScreen> {
} }
} }
/// The path in two cards on the tinted ground: its details (title,
/// secondary title, description, tags), and below them its steps, filling
/// the window at least.
Widget _form(BuildContext context, PathEditorState state) { Widget _form(BuildContext context, PathEditorState state) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
final bloc = context.read<PathEditorBloc>(); final bloc = context.read<PathEditorBloc>();
final description = state.content.description;
final chosenTags = [ final chosenTags = [
for (final tag in state.tagging.all) for (final tag in state.tagging.all)
if (state.tagIds.contains(tag.id)) tag, if (state.tagIds.contains(tag.id)) tag,
]; ];
return Align( final label = theme.textTheme.labelMedium?.copyWith(
alignment: Alignment.topCenter, color: theme.colorScheme.onSurfaceVariant,
child: ConstrainedBox( );
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth), final details = SectionCard(
child: ListView( padding: EdgeInsets.zero,
padding: const EdgeInsets.all(16), child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.stretch,
TextField( children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: TextField(
controller: _title, controller: _title,
style: theme.textTheme.titleLarge, style: theme.textTheme.headlineSmall,
decoration: InputDecoration( decoration: InputDecoration(
labelText: l10n.pathTitleLabel, hintText: l10n.pathTitleLabel,
border: InputBorder.none,
errorText: state.content.title.trim().isEmpty errorText: state.content.title.trim().isEmpty
? l10n.pathTitleRequired ? l10n.pathTitleRequired
: null, : null,
), ),
onChanged: (text) => bloc.add(PathEditorEventTitleChanged(text)), onChanged: (text) => bloc.add(PathEditorEventTitleChanged(text)),
), ),
const SizedBox(height: 12), ),
TextField( Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: TextField(
controller: _secondary, controller: _secondary,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
decoration: InputDecoration( decoration: InputDecoration(
labelText: l10n.pathSecondaryTitleLabel, hintText: l10n.pathSecondaryTitleLabel,
hintText: l10n.pathSecondaryTitleHint, isDense: true,
border: InputBorder.none,
), ),
onChanged: (text) => onChanged: (text) =>
bloc.add(PathEditorEventSecondaryTitleChanged(text)), bloc.add(PathEditorEventSecondaryTitleChanged(text)),
), ),
const SizedBox(height: 8), ),
ListTile( const Divider(height: 1),
contentPadding: EdgeInsets.zero, _DescriptionRow(
leading: const Icon(LucideIcons.text), description: state.content.description,
title: Text( onTap: () async {
description.isBlank final edited = await editRichText(
? l10n.pathAddDescription context,
: l10n.pathDescription, state.content.description,
), title: l10n.pathDescriptionEditTitle,
subtitle: description.isBlank );
? null if (edited != null) {
: Text( bloc.add(PathEditorEventDescriptionChanged(edited));
description.plainText.trim(), }
maxLines: 2, },
overflow: TextOverflow.ellipsis, ),
), const Divider(height: 1),
onTap: () async { Padding(
final edited = await editRichText( padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
context, child: Column(
description, crossAxisAlignment: CrossAxisAlignment.start,
title: l10n.pathDescriptionEditTitle,
);
if (edited != null) {
bloc.add(PathEditorEventDescriptionChanged(edited));
}
},
),
Wrap(
spacing: 8,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
for (final tag in chosenTags) TagChip(tag: tag), Text(l10n.pathTags, style: label),
TextButton.icon( const SizedBox(height: 8),
icon: const Icon(LucideIcons.tag, size: 16), Wrap(
label: Text( spacing: 8,
chosenTags.isEmpty ? l10n.pathAddTags : l10n.pathTags, runSpacing: 8,
), crossAxisAlignment: WrapCrossAlignment.center,
onPressed: () => showTagPicker( children: [
context, for (final tag in chosenTags) TagChip(tag: tag),
selected: state.tagIds, ActionChip(
onChanged: (tagIds) => avatar: const Icon(LucideIcons.pencil, size: 16),
bloc.add(PathEditorEventTagsChanged(tagIds)), label: Text(
), chosenTags.isEmpty
? l10n.pathAddTags
: l10n.pathEditTags,
),
onPressed: () => showTagPicker(
context,
selected: state.tagIds,
onChanged: (tagIds) =>
bloc.add(PathEditorEventTagsChanged(tagIds)),
),
),
],
), ),
], ],
), ),
const SizedBox(height: 16), ),
Row( ],
),
);
final steps = SectionCard(
padding: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
child: Row(
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
@ -204,70 +229,90 @@ class _PathEditorScreenState() extends State<PathEditorScreen> {
), ),
], ],
), ),
const SizedBox(height: 8), ),
if (state.steps.isEmpty) const Divider(height: 1),
Padding( if (state.steps.isEmpty)
padding: const EdgeInsets.symmetric(vertical: 24), Padding(
child: Column( padding: const EdgeInsets.all(24),
children: [ child: Column(
Text(
l10n.pathStepsEmpty,
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 4),
Text(
l10n.pathStepsEmptyHint,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
)
else if (_reordering)
ReorderableListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
onReorderItem: (from, to) => bloc.add(
PathEditorEventStepMoved(state.steps[from].id, to),
),
children: [ children: [
for (final (index, step) in state.steps.indexed) Text(l10n.pathStepsEmpty, style: theme.textTheme.titleSmall),
ListTile( const SizedBox(height: 4),
key: ValueKey(step.id), Text(
leading: ReorderableDragStartListener( l10n.pathStepsEmptyHint,
index: index, textAlign: TextAlign.center,
child: const Icon(LucideIcons.gripVertical), style: theme.textTheme.bodySmall?.copyWith(
), color: theme.colorScheme.onSurfaceVariant,
title: _StepTitle(step: step),
),
],
)
else
for (final step in state.steps)
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(stepKindIcon(step.kind)),
title: _StepTitle(step: step),
subtitle: Text(stepKindLabel(l10n, step.kind)),
onTap: () => context.push(
RoutePaths.pathStep(
widget.studysetId,
widget.pathId,
step.id,
), ),
), ),
trailing: IconButton( ],
icon: Icon( ),
LucideIcons.trash2, )
color: Theme.of(context).colorScheme.error, else if (_reordering)
ReorderableListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
onReorderItem: (from, to) => bloc.add(
PathEditorEventStepMoved(state.steps[from].id, to),
),
children: [
for (final (index, step) in state.steps.indexed)
ListTile(
key: ValueKey(step.id),
leading: ReorderableDragStartListener(
index: index,
child: const Icon(LucideIcons.gripVertical),
), ),
tooltip: l10n.pathStepDelete, title: _StepTitle(step: step),
onPressed: () => _deleteStep(context, step), ),
],
)
else
for (final (index, step) in state.steps.indexed) ...[
if (index > 0) const Divider(height: 1),
ListTile(
leading: Icon(stepKindIcon(step.kind)),
title: _StepTitle(step: step),
subtitle: Text(stepKindLabel(l10n, step.kind)),
onTap: () => context.push(
RoutePaths.pathStep(
widget.studysetId,
widget.pathId,
step.id,
), ),
), ),
trailing: IconButton(
icon: Icon(
LucideIcons.trash2,
color: theme.colorScheme.error,
),
tooltip: l10n.pathStepDelete,
onPressed: () => _deleteStep(context, step),
),
),
],
],
),
);
return Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth),
child: CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
sliver: SliverToBoxAdapter(child: details),
),
// Padded inside: the sliver fills to the window's edge.
SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: steps,
),
),
], ],
), ),
), ),
@ -286,6 +331,54 @@ class _PathEditorScreenState() extends State<PathEditorScreen> {
} }
} }
/// The path's description, a few lines of it or an invitation to write
/// one, opening its editor.
class const _DescriptionRow({
required final RichContent description,
required final VoidCallback onTap,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final muted = theme.colorScheme.onSurfaceVariant;
final text = description.plainText.trim();
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 12, 12),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.pathDescription,
style: theme.textTheme.labelMedium?.copyWith(color: muted),
),
const SizedBox(height: 4),
Text(
text.isEmpty ? l10n.pathAddDescription : text,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: text.isEmpty ? muted : null,
fontStyle: text.isEmpty ? FontStyle.italic : null,
),
),
],
),
),
const SizedBox(width: 8),
Icon(LucideIcons.chevronRight, size: 18, color: muted),
],
),
),
);
}
}
/// A step's title, or "Untitled step". /// A step's title, or "Untitled step".
class const _StepTitle({required final LearningPathStep step}) class const _StepTitle({required final LearningPathStep step})
extends StatelessWidget { extends StatelessWidget {

View file

@ -6,6 +6,7 @@ import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart'; import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart'; import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart'; import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
@ -186,6 +187,33 @@ void main() {
expect(find.text('Which one?'), findsOneWidget); expect(find.text('Which one?'), findsOneWidget);
}); });
testWidgets("the path editor groups the path's details in one card and its "
'steps in another, filling the window below', (tester) async {
final paths = repository();
final id = await paths.createPath(
'japanese',
LearningPathContent.titled('Particles'),
);
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read'));
await showApp(tester, RoutePaths.editPath('japanese', id));
final cards = find.byType(SectionCard);
expect(cards, findsNWidgets(2));
Finder inCard(int index, Finder part) =>
find.descendant(of: cards.at(index), matching: part);
for (final part in ['Particles', 'Description', 'Add a description']) {
expect(inCard(0, find.text(part)), findsOneWidget, reason: part);
}
expect(inCard(0, find.widgetWithText(ActionChip, 'Add tags')), findsOne);
for (final part in ['Steps', 'Add step', 'Read']) {
expect(inCard(1, find.text(part)), findsOneWidget, reason: part);
}
expect(
tester.getRect(cards.at(1)).bottom,
moreOrLessEquals(1000 - 12, epsilon: 1),
);
});
testWidgets('imports a path from a file, saying what came in and what was ' testWidgets('imports a path from a file, saying what came in and what was '
'left out', (tester) async { 'left out', (tester) async {
files.next = ( files.next = (