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.",
"noteDeleteConfirm": "Löschen",
"noteDeleted": "Notiz gelöscht",
"noteNewTitleFor": "Neue Notiz in „{title}“",
"noteTitleFor": "Notiz in „{title}“",
"noteTitleHint": "Titel",
"noteEditing": "Wird bearbeitet…",
"noteSaving": "Wird gespeichert…",
@ -222,7 +220,7 @@
"noteFieldBody": "Text",
"noteKeepThis": "Diese behalten",
"noteAddTags": "Tags hinzufügen",
"noteTags": "Tags",
"noteEditTags": "Tags bearbeiten",
"studysetFlashcardsEmpty": "Noch keine Karteikarten",
"studysetFlashcardsEmptyHint": "Füge welche hinzu, um mit dem Lernen zu beginnen.",
"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.",
"noteDeleteConfirm": "Delete",
"noteDeleted": "Note deleted",
"noteNewTitleFor": "New note in \"{title}\"",
"@noteNewTitleFor": {"placeholders": {"title": {"type": "String"}}},
"noteTitleFor": "Note in \"{title}\"",
"@noteTitleFor": {"placeholders": {"title": {"type": "String"}}},
"noteTitleHint": "Title",
"noteEditing": "Editing…",
"noteSaving": "Saving…",
@ -246,7 +242,7 @@
"noteFieldBody": "Text",
"noteKeepThis": "Keep this",
"noteAddTags": "Add tags",
"noteTags": "Tags",
"noteEditTags": "Edit tags",
"studysetFlashcardsEmpty": "No flashcards yet",
"studysetFlashcardsEmptyHint": "Add some to start studying.",
"studysetAddMaterial": "Add material",

View file

@ -1318,18 +1318,6 @@ abstract class AppLocalizations {
/// **'Note deleted'**
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.
///
/// In en, this message translates to:
@ -1390,11 +1378,11 @@ abstract class AppLocalizations {
/// **'Add tags'**
String get noteAddTags;
/// No description provided for @noteTags.
/// No description provided for @noteEditTags.
///
/// In en, this message translates to:
/// **'Tags'**
String get noteTags;
/// **'Edit tags'**
String get noteEditTags;
/// No description provided for @studysetFlashcardsEmpty.
///

View file

@ -775,16 +775,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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
String get noteTitleHint => 'Titel';
@ -817,7 +807,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get noteAddTags => 'Tags hinzufügen';
@override
String get noteTags => 'Tags';
String get noteEditTags => 'Tags bearbeiten';
@override
String get studysetFlashcardsEmpty => 'Noch keine Karteikarten';

View file

@ -771,16 +771,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
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
String get noteTitleHint => 'Title';
@ -813,7 +803,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get noteAddTags => 'Add tags';
@override
String get noteTags => 'Tags';
String get noteEditTags => 'Edit tags';
@override
String get studysetFlashcardsEmpty => 'No flashcards yet';

View file

@ -282,11 +282,11 @@ Widget _noteEditor(BuildContext context, String studysetId, {String? noteId}) {
id: studysetId,
items: person.studysets.watchAll,
idOf: (studyset) => studyset.id,
builder: (studyset, _) => BlocProvider(
builder: (_, _) => BlocProvider(
create: (context) =>
NoteEditorBloc(person.notes, person.tags, studysetId, noteId: noteId)
..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/layout/readable_width.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/model/note_content.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:material_ui/material_ui.dart';
/// A note of the studyset [studysetTitle] in the whole window, over the
/// tabs: its title, its tags once it exists, and its text, with the format
/// bar while writing; the app bar says how saving stands. It saves as the
/// person goes, when the app goes to the background, and when they leave
/// (`NoteEditorBloc`). A note devices changed differently shows the
/// versions to keep first.
class const NoteEditorScreen({
required final String studysetTitle,
super.key,
}) extends StatefulWidget {
/// A note in the whole window, over the tabs: one card on the tinted
/// ground holding its title, its tags once it exists, a line, and its text,
/// with the format bar while writing; the app bar says only how saving
/// stands. It saves as the person goes, when the app goes to the
/// background, and when they leave (`NoteEditorBloc`). A note devices
/// changed differently shows the versions to keep first.
class const NoteEditorScreen({super.key}) extends StatefulWidget {
@override
State<NoteEditorScreen> createState() => _NoteEditorScreenState();
}
@ -62,14 +60,8 @@ class _NoteEditorScreenState() extends State<NoteEditorScreen> {
}
},
builder: (context, state) => Scaffold(
appBar: AppBar(
title: Text(
state.noteId == null
? l10n.noteNewTitleFor(widget.studysetTitle)
: l10n.noteTitleFor(widget.studysetTitle),
),
actions: [_SaveStatus(status: state.status)],
),
backgroundColor: Theme.of(context).colorScheme.surfaceContainerLow,
appBar: AppBar(actions: [_SaveStatus(status: state.status)]),
body: switch (state) {
NoteEditorState(loaded: false) => const Center(
child: AppLoadingIndicator(),
@ -87,8 +79,8 @@ class _NoteEditorScreenState() extends State<NoteEditorScreen> {
}
}
/// The note itself: the versions to keep while devices disagree, the title,
/// the tags, and the text.
/// The note itself: the versions to keep while devices disagree, then one
/// card of the title, the tags, a line and the text.
class const _Note({
required final NoteEditorState state,
required final TextEditingController title,
@ -98,59 +90,78 @@ class const _Note({
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final readOnly = state.unresolved.isNotEmpty;
return Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (readOnly) _Conflicts(state: state),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: TextField(
controller: title,
readOnly: readOnly,
style: theme.textTheme.headlineSmall,
decoration: InputDecoration(
hintText: l10n.noteTitleHint,
border: InputBorder.none,
),
textInputAction: TextInputAction.next,
onChanged: (text) => context.read<NoteEditorBloc>().add(
NoteEditorEventTitleChanged(text),
),
),
),
if (state.noteId != null) _Tags(state: state),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: readOnly
? SingleChildScrollView(
child: RichContentView(content: state.content.body),
)
: RichContentEditor(
// Shown anew whenever the text is replaced from
// outside: the editor reads its content once.
key: ValueKey(state.revision),
initialContent: state.content.body,
onChanged: (body) => context.read<NoteEditorBloc>().add(
NoteEditorEventBodyChanged(body),
return Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: readableContentMaxWidth,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (readOnly) _Conflicts(state: state),
Expanded(
child: SectionCard(
padding: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: TextField(
controller: title,
readOnly: readOnly,
style: theme.textTheme.headlineSmall,
decoration: InputDecoration(
hintText: l10n.noteTitleHint,
border: InputBorder.none,
),
textInputAction: TextInputAction.next,
onChanged: (text) => context
.read<NoteEditorBloc>()
.add(NoteEditorEventTitleChanged(text)),
),
autofocus: state.noteId == null,
formatBar: true,
),
if (state.noteId != null) _Tags(state: state),
const Divider(height: 1),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: readOnly
? SingleChildScrollView(
child: RichContentView(
content: state.content.body,
),
)
: RichContentEditor(
// Shown anew whenever the text is replaced
// from outside: the editor reads its
// content once.
key: ValueKey(state.revision),
initialContent: state.content.body,
onChanged: (body) => context
.read<NoteEditorBloc>()
.add(NoteEditorEventBodyChanged(body)),
autofocus: state.noteId == null,
formatBar: true,
),
),
),
],
),
),
),
),
],
],
),
),
),
);
}
}
/// 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})
extends StatelessWidget {
@override
@ -161,16 +172,16 @@ class const _Tags({required final NoteEditorState state})
if (state.tagIds.contains(tag.id)) tag,
];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Wrap(
spacing: 8,
runSpacing: 4,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
for (final tag in chosen) TagChip(tag: tag),
TextButton.icon(
icon: const Icon(LucideIcons.tag, size: 16),
label: Text(chosen.isEmpty ? l10n.noteAddTags : l10n.noteTags),
ActionChip(
avatar: const Icon(LucideIcons.pencil, size: 16),
label: Text(chosen.isEmpty ? l10n.noteAddTags : l10n.noteEditTags),
onPressed: () => showTagPicker(
context,
selected: state.tagIds,
@ -194,7 +205,7 @@ class const _Conflicts({required final NoteEditorState state})
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 0),
margin: const EdgeInsets.only(bottom: 8),
color: theme.colorScheme.errorContainer,
child: ConstrainedBox(
// 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: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/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_repository.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(const Duration(seconds: 1));
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.pump();
@ -121,6 +123,35 @@ void main() {
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 '
'it back', (tester) async {
await stored('Particles', 'は');
@ -190,7 +221,7 @@ void main() {
) async {
final created = await stored('Particles', 'は');
await showApp(tester, RoutePaths.note('japanese', created.noteId));
expect(find.text('Note in "Japanese"'), findsOneWidget);
expect(find.text('Particles'), findsOneWidget);
noteGateway.receiveRemoteDeletion(created.noteId);
await tester.pump();