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:
parent
4cd047f81a
commit
0d46c04a44
8 changed files with 123 additions and 119 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue