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",
"pathAddTags": "Tags hinzufügen",
"pathTags": "Tags",
"pathEditTags": "Tags bearbeiten",
"pathSteps": "Schritte",
"pathStepsEmpty": "Noch keine Schritte",
"pathStepsEmptyHint": "Wähle einen Schritt-Typ, um mit dem Schreiben der Lektion zu beginnen.",

View file

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

View file

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

View file

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

View file

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

View file

@ -1,11 +1,13 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.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/editor/rich_text_screen.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/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/feature/learning_path/bloc/path_editor_bloc.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';
/// Writes a learning path of the studyset [studysetId] in the whole window,
/// over the tabs: its title, secondary title, description and tags, and its
/// steps, added by kind, reordered and deleted with an undo
/// (`PathEditorBloc`). A step added opens at once.
/// over the tabs: a card of its title, secondary title, description and
/// tags, and a card of its steps, added by kind, reordered and deleted with
/// an undo (`PathEditorBloc`). A step added opens at once.
class const PathEditorScreen({
required final String studysetId,
required final String pathId,
@ -61,6 +63,7 @@ class _PathEditorScreenState() extends State<PathEditorScreen> {
}
},
builder: (context, state) => Scaffold(
backgroundColor: Theme.of(context).colorScheme.surfaceContainerLow,
appBar: AppBar(title: Text(l10n.pathEditTitle)),
body: switch (state) {
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) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final bloc = context.read<PathEditorBloc>();
final description = state.content.description;
final chosenTags = [
for (final tag in state.tagging.all)
if (state.tagIds.contains(tag.id)) tag,
];
return Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
TextField(
final label = theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
final details = SectionCard(
padding: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: TextField(
controller: _title,
style: theme.textTheme.titleLarge,
style: theme.textTheme.headlineSmall,
decoration: InputDecoration(
labelText: l10n.pathTitleLabel,
hintText: l10n.pathTitleLabel,
border: InputBorder.none,
errorText: state.content.title.trim().isEmpty
? l10n.pathTitleRequired
: null,
),
onChanged: (text) => bloc.add(PathEditorEventTitleChanged(text)),
),
const SizedBox(height: 12),
TextField(
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: TextField(
controller: _secondary,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
decoration: InputDecoration(
labelText: l10n.pathSecondaryTitleLabel,
hintText: l10n.pathSecondaryTitleHint,
hintText: l10n.pathSecondaryTitleLabel,
isDense: true,
border: InputBorder.none,
),
onChanged: (text) =>
bloc.add(PathEditorEventSecondaryTitleChanged(text)),
),
const SizedBox(height: 8),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.text),
title: Text(
description.isBlank
? l10n.pathAddDescription
: l10n.pathDescription,
),
subtitle: description.isBlank
? null
: Text(
description.plainText.trim(),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
onTap: () async {
final edited = await editRichText(
context,
description,
title: l10n.pathDescriptionEditTitle,
);
if (edited != null) {
bloc.add(PathEditorEventDescriptionChanged(edited));
}
},
),
Wrap(
spacing: 8,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
),
const Divider(height: 1),
_DescriptionRow(
description: state.content.description,
onTap: () async {
final edited = await editRichText(
context,
state.content.description,
title: l10n.pathDescriptionEditTitle,
);
if (edited != null) {
bloc.add(PathEditorEventDescriptionChanged(edited));
}
},
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final tag in chosenTags) TagChip(tag: tag),
TextButton.icon(
icon: const Icon(LucideIcons.tag, size: 16),
label: Text(
chosenTags.isEmpty ? l10n.pathAddTags : l10n.pathTags,
),
onPressed: () => showTagPicker(
context,
selected: state.tagIds,
onChanged: (tagIds) =>
bloc.add(PathEditorEventTagsChanged(tagIds)),
),
Text(l10n.pathTags, style: label),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
for (final tag in chosenTags) TagChip(tag: tag),
ActionChip(
avatar: const Icon(LucideIcons.pencil, size: 16),
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: [
Expanded(
child: Text(
@ -204,70 +229,90 @@ class _PathEditorScreenState() extends State<PathEditorScreen> {
),
],
),
const SizedBox(height: 8),
if (state.steps.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Column(
children: [
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),
),
),
const Divider(height: 1),
if (state.steps.isEmpty)
Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
for (final (index, step) in state.steps.indexed)
ListTile(
key: ValueKey(step.id),
leading: ReorderableDragStartListener(
index: index,
child: const Icon(LucideIcons.gripVertical),
),
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,
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,
),
),
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,
onPressed: () => _deleteStep(context, step),
title: _StepTitle(step: 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".
class const _StepTitle({required final LearningPathStep step})
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/device_settings/model/device_settings.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/model/exercise.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);
});
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 '
'left out', (tester) async {
files.next = (