feat(app): a note as one card, as before

A note is one card on the tinted ground: its title, its tags behind an
Add tags chip, a line, then its text. The app bar says only how saving
stands; its "Note in …" title goes.

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:30:30 +02:00
commit 0d46c04a44
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
8 changed files with 123 additions and 119 deletions

View file

@ -210,8 +210,6 @@
"noteDeleteMessage": "Diese Notiz wird aus dem Lernset entfernt. Du kannst dies für einige Sekunden rückgängig machen.", "noteDeleteMessage": "Diese Notiz wird aus dem Lernset entfernt. Du kannst dies für einige Sekunden rückgängig machen.",
"noteDeleteConfirm": "Löschen", "noteDeleteConfirm": "Löschen",
"noteDeleted": "Notiz gelöscht", "noteDeleted": "Notiz gelöscht",
"noteNewTitleFor": "Neue Notiz in „{title}“",
"noteTitleFor": "Notiz in „{title}“",
"noteTitleHint": "Titel", "noteTitleHint": "Titel",
"noteEditing": "Wird bearbeitet…", "noteEditing": "Wird bearbeitet…",
"noteSaving": "Wird gespeichert…", "noteSaving": "Wird gespeichert…",
@ -222,7 +220,7 @@
"noteFieldBody": "Text", "noteFieldBody": "Text",
"noteKeepThis": "Diese behalten", "noteKeepThis": "Diese behalten",
"noteAddTags": "Tags hinzufügen", "noteAddTags": "Tags hinzufügen",
"noteTags": "Tags", "noteEditTags": "Tags bearbeiten",
"studysetFlashcardsEmpty": "Noch keine Karteikarten", "studysetFlashcardsEmpty": "Noch keine Karteikarten",
"studysetFlashcardsEmptyHint": "Füge welche hinzu, um mit dem Lernen zu beginnen.", "studysetFlashcardsEmptyHint": "Füge welche hinzu, um mit dem Lernen zu beginnen.",
"studysetAddMaterial": "Material hinzufügen", "studysetAddMaterial": "Material hinzufügen",

View file

@ -232,10 +232,6 @@
"noteDeleteMessage": "This note will be removed from the studyset. You can undo this for a few seconds.", "noteDeleteMessage": "This note will be removed from the studyset. You can undo this for a few seconds.",
"noteDeleteConfirm": "Delete", "noteDeleteConfirm": "Delete",
"noteDeleted": "Note deleted", "noteDeleted": "Note deleted",
"noteNewTitleFor": "New note in \"{title}\"",
"@noteNewTitleFor": {"placeholders": {"title": {"type": "String"}}},
"noteTitleFor": "Note in \"{title}\"",
"@noteTitleFor": {"placeholders": {"title": {"type": "String"}}},
"noteTitleHint": "Title", "noteTitleHint": "Title",
"noteEditing": "Editing…", "noteEditing": "Editing…",
"noteSaving": "Saving…", "noteSaving": "Saving…",
@ -246,7 +242,7 @@
"noteFieldBody": "Text", "noteFieldBody": "Text",
"noteKeepThis": "Keep this", "noteKeepThis": "Keep this",
"noteAddTags": "Add tags", "noteAddTags": "Add tags",
"noteTags": "Tags", "noteEditTags": "Edit tags",
"studysetFlashcardsEmpty": "No flashcards yet", "studysetFlashcardsEmpty": "No flashcards yet",
"studysetFlashcardsEmptyHint": "Add some to start studying.", "studysetFlashcardsEmptyHint": "Add some to start studying.",
"studysetAddMaterial": "Add material", "studysetAddMaterial": "Add material",

View file

@ -1318,18 +1318,6 @@ abstract class AppLocalizations {
/// **'Note deleted'** /// **'Note deleted'**
String get noteDeleted; String get noteDeleted;
/// No description provided for @noteNewTitleFor.
///
/// In en, this message translates to:
/// **'New note in \"{title}\"'**
String noteNewTitleFor(String title);
/// No description provided for @noteTitleFor.
///
/// In en, this message translates to:
/// **'Note in \"{title}\"'**
String noteTitleFor(String title);
/// No description provided for @noteTitleHint. /// No description provided for @noteTitleHint.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -1390,11 +1378,11 @@ abstract class AppLocalizations {
/// **'Add tags'** /// **'Add tags'**
String get noteAddTags; String get noteAddTags;
/// No description provided for @noteTags. /// No description provided for @noteEditTags.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Tags'** /// **'Edit tags'**
String get noteTags; String get noteEditTags;
/// No description provided for @studysetFlashcardsEmpty. /// No description provided for @studysetFlashcardsEmpty.
/// ///

View file

@ -775,16 +775,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get noteDeleted => 'Notiz gelöscht'; String get noteDeleted => 'Notiz gelöscht';
@override
String noteNewTitleFor(String title) {
return 'Neue Notiz in „$title“';
}
@override
String noteTitleFor(String title) {
return 'Notiz in „$title“';
}
@override @override
String get noteTitleHint => 'Titel'; String get noteTitleHint => 'Titel';
@ -817,7 +807,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get noteAddTags => 'Tags hinzufügen'; String get noteAddTags => 'Tags hinzufügen';
@override @override
String get noteTags => 'Tags'; String get noteEditTags => 'Tags bearbeiten';
@override @override
String get studysetFlashcardsEmpty => 'Noch keine Karteikarten'; String get studysetFlashcardsEmpty => 'Noch keine Karteikarten';

View file

@ -771,16 +771,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get noteDeleted => 'Note deleted'; String get noteDeleted => 'Note deleted';
@override
String noteNewTitleFor(String title) {
return 'New note in \"$title\"';
}
@override
String noteTitleFor(String title) {
return 'Note in \"$title\"';
}
@override @override
String get noteTitleHint => 'Title'; String get noteTitleHint => 'Title';
@ -813,7 +803,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get noteAddTags => 'Add tags'; String get noteAddTags => 'Add tags';
@override @override
String get noteTags => 'Tags'; String get noteEditTags => 'Edit tags';
@override @override
String get studysetFlashcardsEmpty => 'No flashcards yet'; String get studysetFlashcardsEmpty => 'No flashcards yet';

View file

@ -282,11 +282,11 @@ Widget _noteEditor(BuildContext context, String studysetId, {String? noteId}) {
id: studysetId, id: studysetId,
items: person.studysets.watchAll, items: person.studysets.watchAll,
idOf: (studyset) => studyset.id, idOf: (studyset) => studyset.id,
builder: (studyset, _) => BlocProvider( builder: (_, _) => BlocProvider(
create: (context) => create: (context) =>
NoteEditorBloc(person.notes, person.tags, studysetId, noteId: noteId) NoteEditorBloc(person.notes, person.tags, studysetId, noteId: noteId)
..add(const NoteEditorEventStarted()), ..add(const NoteEditorEventStarted()),
child: NoteEditorScreen(studysetTitle: studyset.content.title), child: const NoteEditorScreen(),
), ),
); );
} }

View file

@ -5,6 +5,7 @@ import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart'; import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.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/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/feature/note/bloc/note_editor_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/note/bloc/note_editor_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart'; import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_chip.dart'; import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_chip.dart';
@ -12,16 +13,13 @@ import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_picker_sheet.da
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
/// A note of the studyset [studysetTitle] in the whole window, over the /// A note in the whole window, over the tabs: one card on the tinted
/// tabs: its title, its tags once it exists, and its text, with the format /// ground holding its title, its tags once it exists, a line, and its text,
/// bar while writing; the app bar says how saving stands. It saves as the /// with the format bar while writing; the app bar says only how saving
/// person goes, when the app goes to the background, and when they leave /// stands. It saves as the person goes, when the app goes to the
/// (`NoteEditorBloc`). A note devices changed differently shows the /// background, and when they leave (`NoteEditorBloc`). A note devices
/// versions to keep first. /// changed differently shows the versions to keep first.
class const NoteEditorScreen({ class const NoteEditorScreen({super.key}) extends StatefulWidget {
required final String studysetTitle,
super.key,
}) extends StatefulWidget {
@override @override
State<NoteEditorScreen> createState() => _NoteEditorScreenState(); State<NoteEditorScreen> createState() => _NoteEditorScreenState();
} }
@ -62,14 +60,8 @@ class _NoteEditorScreenState() extends State<NoteEditorScreen> {
} }
}, },
builder: (context, state) => Scaffold( builder: (context, state) => Scaffold(
appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.surfaceContainerLow,
title: Text( appBar: AppBar(actions: [_SaveStatus(status: state.status)]),
state.noteId == null
? l10n.noteNewTitleFor(widget.studysetTitle)
: l10n.noteTitleFor(widget.studysetTitle),
),
actions: [_SaveStatus(status: state.status)],
),
body: switch (state) { body: switch (state) {
NoteEditorState(loaded: false) => const Center( NoteEditorState(loaded: false) => const Center(
child: AppLoadingIndicator(), child: AppLoadingIndicator(),
@ -87,8 +79,8 @@ class _NoteEditorScreenState() extends State<NoteEditorScreen> {
} }
} }
/// The note itself: the versions to keep while devices disagree, the title, /// The note itself: the versions to keep while devices disagree, then one
/// the tags, and the text. /// card of the title, the tags, a line and the text.
class const _Note({ class const _Note({
required final NoteEditorState state, required final NoteEditorState state,
required final TextEditingController title, required final TextEditingController title,
@ -98,16 +90,26 @@ class const _Note({
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
final readOnly = state.unresolved.isNotEmpty; final readOnly = state.unresolved.isNotEmpty;
return Align( return Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: Align(
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth), constraints: const BoxConstraints(
maxWidth: readableContentMaxWidth,
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (readOnly) _Conflicts(state: state), if (readOnly) _Conflicts(state: state),
Expanded(
child: SectionCard(
padding: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: TextField( child: TextField(
controller: title, controller: title,
readOnly: readOnly, readOnly: readOnly,
@ -117,27 +119,31 @@ class const _Note({
border: InputBorder.none, border: InputBorder.none,
), ),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
onChanged: (text) => context.read<NoteEditorBloc>().add( onChanged: (text) => context
NoteEditorEventTitleChanged(text), .read<NoteEditorBloc>()
), .add(NoteEditorEventTitleChanged(text)),
), ),
), ),
if (state.noteId != null) _Tags(state: state), if (state.noteId != null) _Tags(state: state),
const Divider(height: 1),
Expanded( Expanded(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: readOnly child: readOnly
? SingleChildScrollView( ? SingleChildScrollView(
child: RichContentView(content: state.content.body), child: RichContentView(
content: state.content.body,
),
) )
: RichContentEditor( : RichContentEditor(
// Shown anew whenever the text is replaced from // Shown anew whenever the text is replaced
// outside: the editor reads its content once. // from outside: the editor reads its
// content once.
key: ValueKey(state.revision), key: ValueKey(state.revision),
initialContent: state.content.body, initialContent: state.content.body,
onChanged: (body) => context.read<NoteEditorBloc>().add( onChanged: (body) => context
NoteEditorEventBodyChanged(body), .read<NoteEditorBloc>()
), .add(NoteEditorEventBodyChanged(body)),
autofocus: state.noteId == null, autofocus: state.noteId == null,
formatBar: true, formatBar: true,
), ),
@ -146,11 +152,16 @@ class const _Note({
], ],
), ),
), ),
),
],
),
),
),
); );
} }
} }
/// The note's tags as chips, and the way to choose them. /// The note's tags as chips, and a chip to choose them.
class const _Tags({required final NoteEditorState state}) class const _Tags({required final NoteEditorState state})
extends StatelessWidget { extends StatelessWidget {
@override @override
@ -161,16 +172,16 @@ class const _Tags({required final NoteEditorState state})
if (state.tagIds.contains(tag.id)) tag, if (state.tagIds.contains(tag.id)) tag,
]; ];
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Wrap( child: Wrap(
spacing: 8, spacing: 8,
runSpacing: 4, runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
for (final tag in chosen) TagChip(tag: tag), for (final tag in chosen) TagChip(tag: tag),
TextButton.icon( ActionChip(
icon: const Icon(LucideIcons.tag, size: 16), avatar: const Icon(LucideIcons.pencil, size: 16),
label: Text(chosen.isEmpty ? l10n.noteAddTags : l10n.noteTags), label: Text(chosen.isEmpty ? l10n.noteAddTags : l10n.noteEditTags),
onPressed: () => showTagPicker( onPressed: () => showTagPicker(
context, context,
selected: state.tagIds, selected: state.tagIds,
@ -194,7 +205,7 @@ class const _Conflicts({required final NoteEditorState state})
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
return Card( return Card(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 0), margin: const EdgeInsets.only(bottom: 8),
color: theme.colorScheme.errorContainer, color: theme.colorScheme.errorContainer,
child: ConstrainedBox( child: ConstrainedBox(
// Long versions scroll here, so the note stays in view below. // Long versions scroll here, so the note stays in view below.

View file

@ -1,6 +1,8 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.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/editor/rich_content_editor.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/note/data/note_payload.dart'; import 'package:halkyon_learn_flutter/src/feature/note/data/note_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart'; import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
@ -109,7 +111,7 @@ void main() {
await tester.pump(); await tester.pump();
await tester.pump(const Duration(seconds: 1)); await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('New note in "Japanese"'), findsOneWidget); expect(find.byType(RichContentEditor), findsOneWidget);
await tester.enterText(find.byType(TextField), 'Particles'); await tester.enterText(find.byType(TextField), 'Particles');
await tester.pump(); await tester.pump();
@ -121,6 +123,35 @@ void main() {
expect(noteGateway.written, hasLength(1)); expect(noteGateway.written, hasLength(1));
}); });
testWidgets('a note is one card below a bare app bar: its title, its tags '
'behind Add tags, a line, then its text', (tester) async {
final created = await stored('Particles', 'は: topic');
await showApp(tester, RoutePaths.note('japanese', created.noteId));
await tester.pumpAndSettle();
final card = find.byType(SectionCard);
expect(card, findsOneWidget);
for (final part in [
find.text('Particles'),
find.widgetWithText(ActionChip, 'Add tags'),
find.byType(Divider),
find.byType(RichContentEditor),
]) {
expect(find.descendant(of: card, matching: part), findsOneWidget);
}
final title = tester.getRect(find.text('Particles'));
final tags = tester.getRect(find.text('Add tags'));
final line = tester.getRect(find.byType(Divider));
final text = tester.getRect(find.byType(RichContentEditor));
expect(title.top, lessThan(tags.top));
expect(tags.bottom, lessThanOrEqualTo(line.top));
expect(line.bottom, lessThanOrEqualTo(text.top));
expect(
find.descendant(of: find.byType(AppBar), matching: find.byType(Text)),
findsNothing,
);
});
testWidgets('deleting asks, takes the note from the list, and Undo brings ' testWidgets('deleting asks, takes the note from the list, and Undo brings '
'it back', (tester) async { 'it back', (tester) async {
await stored('Particles', 'は'); await stored('Particles', 'は');
@ -190,7 +221,7 @@ void main() {
) async { ) async {
final created = await stored('Particles', 'は'); final created = await stored('Particles', 'は');
await showApp(tester, RoutePaths.note('japanese', created.noteId)); await showApp(tester, RoutePaths.note('japanese', created.noteId));
expect(find.text('Note in "Japanese"'), findsOneWidget); expect(find.text('Particles'), findsOneWidget);
noteGateway.receiveRemoteDeletion(created.noteId); noteGateway.receiveRemoteDeletion(created.noteId);
await tester.pump(); await tester.pump();