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:
parent
0d46c04a44
commit
252110832a
7 changed files with 255 additions and 120 deletions
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -368,6 +368,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get pathTags => 'Tags';
|
||||
|
||||
@override
|
||||
String get pathEditTags => 'Tags bearbeiten';
|
||||
|
||||
@override
|
||||
String get pathSteps => 'Schritte';
|
||||
|
||||
|
|
|
|||
|
|
@ -365,6 +365,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get pathTags => 'Tags';
|
||||
|
||||
@override
|
||||
String get pathEditTags => 'Edit tags';
|
||||
|
||||
@override
|
||||
String get pathSteps => 'Steps';
|
||||
|
||||
|
|
|
|||
|
|
@ -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,63 +89,63 @@ 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),
|
||||
final label = theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
);
|
||||
final details = SectionCard(
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
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,
|
||||
),
|
||||
const Divider(height: 1),
|
||||
_DescriptionRow(
|
||||
description: state.content.description,
|
||||
onTap: () async {
|
||||
final edited = await editRichText(
|
||||
context,
|
||||
description,
|
||||
state.content.description,
|
||||
title: l10n.pathDescriptionEditTitle,
|
||||
);
|
||||
if (edited != null) {
|
||||
|
|
@ -150,16 +153,26 @@ class _PathEditorScreenState() extends State<PathEditorScreen> {
|
|||
}
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.pathTags, style: label),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
for (final tag in chosenTags) TagChip(tag: tag),
|
||||
TextButton.icon(
|
||||
icon: const Icon(LucideIcons.tag, size: 16),
|
||||
ActionChip(
|
||||
avatar: const Icon(LucideIcons.pencil, size: 16),
|
||||
label: Text(
|
||||
chosenTags.isEmpty ? l10n.pathAddTags : l10n.pathTags,
|
||||
chosenTags.isEmpty
|
||||
? l10n.pathAddTags
|
||||
: l10n.pathEditTags,
|
||||
),
|
||||
onPressed: () => showTagPicker(
|
||||
context,
|
||||
|
|
@ -170,8 +183,20 @@ class _PathEditorScreenState() extends State<PathEditorScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
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,16 +229,14 @@ class _PathEditorScreenState() extends State<PathEditorScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
if (state.steps.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
l10n.pathStepsEmpty,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
Text(l10n.pathStepsEmpty, style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.pathStepsEmptyHint,
|
||||
|
|
@ -246,9 +269,9 @@ class _PathEditorScreenState() extends State<PathEditorScreen> {
|
|||
],
|
||||
)
|
||||
else
|
||||
for (final step in state.steps)
|
||||
for (final (index, step) in state.steps.indexed) ...[
|
||||
if (index > 0) const Divider(height: 1),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(stepKindIcon(step.kind)),
|
||||
title: _StepTitle(step: step),
|
||||
subtitle: Text(stepKindLabel(l10n, step.kind)),
|
||||
|
|
@ -262,13 +285,35 @@ class _PathEditorScreenState() extends State<PathEditorScreen> {
|
|||
trailing: IconButton(
|
||||
icon: Icon(
|
||||
LucideIcons.trash2,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
Loading…
Reference in a new issue