feat(app): notes in the studyset screen, written in the whole window
- A Notes tab beside Flashcards lists a studyset's notes in order: the
title ("Untitled note" without one) and when each was last edited
("Edited 5 minutes ago", by the calendar from yesterday on, the date
after a week); Edit and Delete behind its options. Deleting asks,
then offers Undo for a moment (`NoteDeletions`). With none, the tab
says so.
- "Create a note" in Add material opens a new note over the tabs; so
does a note of the list. The note: a title, its tags once it exists,
and its text at reading width; the app bar says how saving stands.
- `NoteEditorBloc` saves after 5 seconds without typing, when the app
goes to the background, and when the note is closed (the bloc saves
as it closes, so leaving never waits, and the write outlives the
screen). Each save builds on the revisions the editor started from;
another device's change takes over a note with nothing unsaved, and
merges with one that has. A note changed differently on two devices
is read-only until the person keeps a version of each field in
conflict. A new note is created by its first save, never when left
blank; a note deleted elsewhere says it no longer exists.
- The editor gains a format bar, docked below it while it has the
focus (`RichContentEditor(formatBar: true)`): alignment, bold,
italic, underline, superscript and subscript. It takes no focus, so
the selection stays. Superscript and subscript are inline marks of
their own, drawn by the font's raised and lowered glyphs in editors
and read-only text alike.
- The library's and the topics' counts include notes (a studyset with
what is inside it, as for cards).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
f3b56f2c0c
commit
502ce36a30
27 changed files with 2694 additions and 21 deletions
|
|
@ -47,6 +47,8 @@
|
|||
|
||||
"studysetCountFlashcards": "{count, plural, =1{1 flashcard} other{{count} flashcards}}",
|
||||
"@studysetCountFlashcards": {"placeholders": {"count": {"type": "int"}}},
|
||||
"studysetCountNotes": "{count, plural, =1{1 note} other{{count} notes}}",
|
||||
"@studysetCountNotes": {"placeholders": {"count": {"type": "int"}}},
|
||||
"studysetNoContent": "No content yet",
|
||||
"studysetActionReview": "Review",
|
||||
"studysetActionStudy": "Study",
|
||||
|
|
@ -61,6 +63,42 @@
|
|||
"studysetMenuDelete": "Delete",
|
||||
"studysetMenuMove": "Move to…",
|
||||
"studysetTabFlashcards": "Flashcards",
|
||||
"studysetTabNotes": "Notes",
|
||||
"studysetCreateNote": "Create a note",
|
||||
"notesEmpty": "No notes yet",
|
||||
"notesEmptyHint": "Notes will appear here when you add them.",
|
||||
"noteUntitled": "Untitled note",
|
||||
"noteEdited": "Edited {when}",
|
||||
"@noteEdited": {"placeholders": {"when": {"type": "String"}}},
|
||||
"timeJustNow": "just now",
|
||||
"timeMinutesAgo": "{count, plural, =1{1 minute ago} other{{count} minutes ago}}",
|
||||
"@timeMinutesAgo": {"placeholders": {"count": {"type": "int"}}},
|
||||
"timeHoursAgo": "{count, plural, =1{1 hour ago} other{{count} hours ago}}",
|
||||
"@timeHoursAgo": {"placeholders": {"count": {"type": "int"}}},
|
||||
"timeDaysAgo": "{count, plural, =1{yesterday} other{{count} days ago}}",
|
||||
"@timeDaysAgo": {"placeholders": {"count": {"type": "int"}}},
|
||||
"noteOptions": "Note options",
|
||||
"noteEdit": "Edit",
|
||||
"noteDelete": "Delete",
|
||||
"noteDeleteTitle": "Delete note?",
|
||||
"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…",
|
||||
"noteSaved": "Saved",
|
||||
"noteSaveFailed": "Save failed",
|
||||
"noteConflict": "This note was changed differently on another device. Keep one version to go on writing.",
|
||||
"noteFieldTitle": "Title",
|
||||
"noteFieldBody": "Text",
|
||||
"noteKeepThis": "Keep this",
|
||||
"noteAddTags": "Add tags",
|
||||
"noteTags": "Tags",
|
||||
"studysetFlashcardsEmpty": "No flashcards yet",
|
||||
"studysetFlashcardsEmptyHint": "Add some to start studying.",
|
||||
"studysetAddMaterial": "Add material",
|
||||
|
|
@ -225,6 +263,14 @@
|
|||
"composerDiscardConfirm": "Discard",
|
||||
"composerConflict": "This flashcard was changed differently on two devices. Choose the version to keep of each part below.",
|
||||
"composerKeepThis": "Keep this",
|
||||
"formatAlignLeft": "Align left",
|
||||
"formatAlignCenter": "Center",
|
||||
"formatAlignRight": "Align right",
|
||||
"formatBold": "Bold",
|
||||
"formatItalic": "Italic",
|
||||
"formatUnderline": "Underline",
|
||||
"formatSuperscript": "Superscript",
|
||||
"formatSubscript": "Subscript",
|
||||
"vocabularyReading": "Reading",
|
||||
"vocabularyReadingHint": "Kana reading",
|
||||
"vocabularyMeanings": "Meanings",
|
||||
|
|
|
|||
|
|
@ -310,6 +310,12 @@ abstract class AppLocalizations {
|
|||
/// **'{count, plural, =1{1 flashcard} other{{count} flashcards}}'**
|
||||
String studysetCountFlashcards(int count);
|
||||
|
||||
/// No description provided for @studysetCountNotes.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 note} other{{count} notes}}'**
|
||||
String studysetCountNotes(int count);
|
||||
|
||||
/// No description provided for @studysetNoContent.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -394,6 +400,186 @@ abstract class AppLocalizations {
|
|||
/// **'Flashcards'**
|
||||
String get studysetTabFlashcards;
|
||||
|
||||
/// No description provided for @studysetTabNotes.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Notes'**
|
||||
String get studysetTabNotes;
|
||||
|
||||
/// No description provided for @studysetCreateNote.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Create a note'**
|
||||
String get studysetCreateNote;
|
||||
|
||||
/// No description provided for @notesEmpty.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No notes yet'**
|
||||
String get notesEmpty;
|
||||
|
||||
/// No description provided for @notesEmptyHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Notes will appear here when you add them.'**
|
||||
String get notesEmptyHint;
|
||||
|
||||
/// No description provided for @noteUntitled.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Untitled note'**
|
||||
String get noteUntitled;
|
||||
|
||||
/// No description provided for @noteEdited.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Edited {when}'**
|
||||
String noteEdited(String when);
|
||||
|
||||
/// No description provided for @timeJustNow.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'just now'**
|
||||
String get timeJustNow;
|
||||
|
||||
/// No description provided for @timeMinutesAgo.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 minute ago} other{{count} minutes ago}}'**
|
||||
String timeMinutesAgo(int count);
|
||||
|
||||
/// No description provided for @timeHoursAgo.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 hour ago} other{{count} hours ago}}'**
|
||||
String timeHoursAgo(int count);
|
||||
|
||||
/// No description provided for @timeDaysAgo.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{yesterday} other{{count} days ago}}'**
|
||||
String timeDaysAgo(int count);
|
||||
|
||||
/// No description provided for @noteOptions.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Note options'**
|
||||
String get noteOptions;
|
||||
|
||||
/// No description provided for @noteEdit.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Edit'**
|
||||
String get noteEdit;
|
||||
|
||||
/// No description provided for @noteDelete.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete'**
|
||||
String get noteDelete;
|
||||
|
||||
/// No description provided for @noteDeleteTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete note?'**
|
||||
String get noteDeleteTitle;
|
||||
|
||||
/// No description provided for @noteDeleteMessage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This note will be removed from the studyset. You can undo this for a few seconds.'**
|
||||
String get noteDeleteMessage;
|
||||
|
||||
/// No description provided for @noteDeleteConfirm.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete'**
|
||||
String get noteDeleteConfirm;
|
||||
|
||||
/// No description provided for @noteDeleted.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'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:
|
||||
/// **'Title'**
|
||||
String get noteTitleHint;
|
||||
|
||||
/// No description provided for @noteEditing.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Editing…'**
|
||||
String get noteEditing;
|
||||
|
||||
/// No description provided for @noteSaving.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Saving…'**
|
||||
String get noteSaving;
|
||||
|
||||
/// No description provided for @noteSaved.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Saved'**
|
||||
String get noteSaved;
|
||||
|
||||
/// No description provided for @noteSaveFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save failed'**
|
||||
String get noteSaveFailed;
|
||||
|
||||
/// No description provided for @noteConflict.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This note was changed differently on another device. Keep one version to go on writing.'**
|
||||
String get noteConflict;
|
||||
|
||||
/// No description provided for @noteFieldTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Title'**
|
||||
String get noteFieldTitle;
|
||||
|
||||
/// No description provided for @noteFieldBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Text'**
|
||||
String get noteFieldBody;
|
||||
|
||||
/// No description provided for @noteKeepThis.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Keep this'**
|
||||
String get noteKeepThis;
|
||||
|
||||
/// No description provided for @noteAddTags.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add tags'**
|
||||
String get noteAddTags;
|
||||
|
||||
/// No description provided for @noteTags.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tags'**
|
||||
String get noteTags;
|
||||
|
||||
/// No description provided for @studysetFlashcardsEmpty.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -1258,6 +1444,54 @@ abstract class AppLocalizations {
|
|||
/// **'Keep this'**
|
||||
String get composerKeepThis;
|
||||
|
||||
/// No description provided for @formatAlignLeft.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Align left'**
|
||||
String get formatAlignLeft;
|
||||
|
||||
/// No description provided for @formatAlignCenter.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Center'**
|
||||
String get formatAlignCenter;
|
||||
|
||||
/// No description provided for @formatAlignRight.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Align right'**
|
||||
String get formatAlignRight;
|
||||
|
||||
/// No description provided for @formatBold.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Bold'**
|
||||
String get formatBold;
|
||||
|
||||
/// No description provided for @formatItalic.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Italic'**
|
||||
String get formatItalic;
|
||||
|
||||
/// No description provided for @formatUnderline.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Underline'**
|
||||
String get formatUnderline;
|
||||
|
||||
/// No description provided for @formatSuperscript.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Superscript'**
|
||||
String get formatSuperscript;
|
||||
|
||||
/// No description provided for @formatSubscript.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Subscript'**
|
||||
String get formatSubscript;
|
||||
|
||||
/// No description provided for @vocabularyReading.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -158,6 +158,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String studysetCountNotes(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count notes',
|
||||
one: '1 note',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get studysetNoContent => 'No content yet';
|
||||
|
||||
|
|
@ -200,6 +211,128 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get studysetTabFlashcards => 'Flashcards';
|
||||
|
||||
@override
|
||||
String get studysetTabNotes => 'Notes';
|
||||
|
||||
@override
|
||||
String get studysetCreateNote => 'Create a note';
|
||||
|
||||
@override
|
||||
String get notesEmpty => 'No notes yet';
|
||||
|
||||
@override
|
||||
String get notesEmptyHint => 'Notes will appear here when you add them.';
|
||||
|
||||
@override
|
||||
String get noteUntitled => 'Untitled note';
|
||||
|
||||
@override
|
||||
String noteEdited(String when) {
|
||||
return 'Edited $when';
|
||||
}
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'just now';
|
||||
|
||||
@override
|
||||
String timeMinutesAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes ago',
|
||||
one: '1 minute ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String timeHoursAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hours ago',
|
||||
one: '1 hour ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String timeDaysAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count days ago',
|
||||
one: 'yesterday',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noteOptions => 'Note options';
|
||||
|
||||
@override
|
||||
String get noteEdit => 'Edit';
|
||||
|
||||
@override
|
||||
String get noteDelete => 'Delete';
|
||||
|
||||
@override
|
||||
String get noteDeleteTitle => 'Delete note?';
|
||||
|
||||
@override
|
||||
String get noteDeleteMessage =>
|
||||
'This note will be removed from the studyset. You can undo this for a few seconds.';
|
||||
|
||||
@override
|
||||
String get noteDeleteConfirm => 'Delete';
|
||||
|
||||
@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';
|
||||
|
||||
@override
|
||||
String get noteEditing => 'Editing…';
|
||||
|
||||
@override
|
||||
String get noteSaving => 'Saving…';
|
||||
|
||||
@override
|
||||
String get noteSaved => 'Saved';
|
||||
|
||||
@override
|
||||
String get noteSaveFailed => 'Save failed';
|
||||
|
||||
@override
|
||||
String get noteConflict =>
|
||||
'This note was changed differently on another device. Keep one version to go on writing.';
|
||||
|
||||
@override
|
||||
String get noteFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get noteFieldBody => 'Text';
|
||||
|
||||
@override
|
||||
String get noteKeepThis => 'Keep this';
|
||||
|
||||
@override
|
||||
String get noteAddTags => 'Add tags';
|
||||
|
||||
@override
|
||||
String get noteTags => 'Tags';
|
||||
|
||||
@override
|
||||
String get studysetFlashcardsEmpty => 'No flashcards yet';
|
||||
|
||||
|
|
@ -692,6 +825,30 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get composerKeepThis => 'Keep this';
|
||||
|
||||
@override
|
||||
String get formatAlignLeft => 'Align left';
|
||||
|
||||
@override
|
||||
String get formatAlignCenter => 'Center';
|
||||
|
||||
@override
|
||||
String get formatAlignRight => 'Align right';
|
||||
|
||||
@override
|
||||
String get formatBold => 'Bold';
|
||||
|
||||
@override
|
||||
String get formatItalic => 'Italic';
|
||||
|
||||
@override
|
||||
String get formatUnderline => 'Underline';
|
||||
|
||||
@override
|
||||
String get formatSuperscript => 'Superscript';
|
||||
|
||||
@override
|
||||
String get formatSubscript => 'Subscript';
|
||||
|
||||
@override
|
||||
String get vocabularyReading => 'Reading';
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.d
|
|||
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_composer_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/widget/explore_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/home/widget/home_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/bloc/note_editor_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/widget/note_editor_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/widget/settings_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/bloc/study_session_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_screen.dart';
|
||||
|
|
@ -124,6 +126,20 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
|
|||
cardId: state.pathParameters['cardId'],
|
||||
),
|
||||
),
|
||||
// A note, new or stored; the new one's route first, as for cards.
|
||||
GoRoute(
|
||||
path: '/studysets/:studysetId/notes/new',
|
||||
builder: (context, state) =>
|
||||
_noteEditor(context, state.pathParameters['studysetId']!),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/studysets/:studysetId/notes/:noteId',
|
||||
builder: (context, state) => _noteEditor(
|
||||
context,
|
||||
state.pathParameters['studysetId']!,
|
||||
noteId: state.pathParameters['noteId'],
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: RoutePaths.settings,
|
||||
builder: (context, state) => const SettingsScreen(),
|
||||
|
|
@ -138,6 +154,24 @@ Widget _studysetScreen(BuildContext context, String id) => FoundById<Studyset>(
|
|||
builder: (studyset, all) => StudysetScreen(studyset: studyset, all: all),
|
||||
);
|
||||
|
||||
/// The note [noteId] of the studyset [studysetId], or a new one without; a
|
||||
/// studyset gone (deleted, or an old link) says so. Leaving the note closes
|
||||
/// its bloc, which saves what is unsaved.
|
||||
Widget _noteEditor(BuildContext context, String studysetId, {String? noteId}) {
|
||||
final person = PersonScope.of(context);
|
||||
return FoundById<Studyset>(
|
||||
id: studysetId,
|
||||
items: person.studysets.watchAll,
|
||||
idOf: (studyset) => studyset.id,
|
||||
builder: (studyset, _) => BlocProvider(
|
||||
create: (context) =>
|
||||
NoteEditorBloc(person.notes, person.tags, studysetId, noteId: noteId)
|
||||
..add(const NoteEditorEventStarted()),
|
||||
child: NoteEditorScreen(studysetTitle: studyset.content.title),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The composer of the studyset [studysetId]'s cards, opened on [cardId] if
|
||||
/// given; a studyset gone (deleted, or an old link) says so.
|
||||
Widget _composer(BuildContext context, String studysetId, {String? cardId}) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/scripts.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The rich text's look at [context], editing or showing it: the theme's
|
||||
|
|
@ -7,7 +8,8 @@ import 'package:material_ui/material_ui.dart';
|
|||
/// own, as what holds the text pads it. The editor's defaults are black text
|
||||
/// and 100 dp beside it on desktops.
|
||||
///
|
||||
/// Phones get the touch variant, with handles to drag the selection.
|
||||
/// Phones get the touch variant, with handles to drag the selection. Both
|
||||
/// draw superscript and subscript text (`withScripts`).
|
||||
EditorStyle editorStyleOf(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
|
|
@ -31,12 +33,14 @@ EditorStyle editorStyleOf(BuildContext context) {
|
|||
selectionColor: selection,
|
||||
dragHandleColor: colors.primary,
|
||||
textStyleConfiguration: text,
|
||||
textSpanDecorator: withScripts(mobileTextSpanDecoratorForAttribute),
|
||||
),
|
||||
_ => EditorStyle.desktop(
|
||||
padding: EdgeInsets.zero,
|
||||
cursorColor: colors.primary,
|
||||
selectionColor: selection,
|
||||
textStyleConfiguration: text,
|
||||
textSpanDecorator: withScripts(defaultTextSpanDecoratorForAttribute),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
213
halkyon_learn_flutter/lib/src/core/editor/format_bar.dart
Normal file
213
halkyon_learn_flutter/lib/src/core/editor/format_bar.dart
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/scripts.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Formats the selection of [editorState]: alignment, bold, italic,
|
||||
/// underline, superscript and subscript. Each button shows whether the
|
||||
/// selection has its format already.
|
||||
///
|
||||
/// The bar takes no focus, so tapping a button leaves the editor focused and
|
||||
/// its selection where it was; on phones, where there are no shortcut keys,
|
||||
/// it is the way to format at all. It scrolls sideways when narrow.
|
||||
class const FormatBar({required final EditorState editorState, super.key})
|
||||
extends StatefulWidget {
|
||||
@override
|
||||
State<FormatBar> createState() => _FormatBarState();
|
||||
}
|
||||
|
||||
class _FormatBarState() extends State<FormatBar> {
|
||||
late StreamSubscription<EditorTransactionValue> _changes;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_follow(widget.editorState);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(FormatBar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.editorState != widget.editorState) {
|
||||
_unfollow(oldWidget.editorState);
|
||||
_follow(widget.editorState);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_unfollow(widget.editorState);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// The buttons show the formats of the selection: anew whenever it moves
|
||||
// or the document changes.
|
||||
void _follow(EditorState editorState) {
|
||||
editorState.selectionNotifier.addListener(_changed);
|
||||
_changes = editorState.transactionStream.listen((_) => _changed());
|
||||
}
|
||||
|
||||
void _unfollow(EditorState editorState) {
|
||||
editorState.selectionNotifier.removeListener(_changed);
|
||||
unawaited(_changes.cancel());
|
||||
}
|
||||
|
||||
void _changed() {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether every block in the selection is aligned [align]; no alignment
|
||||
/// is left.
|
||||
bool _isAligned(String align) {
|
||||
final editorState = widget.editorState;
|
||||
final selection = editorState.selection;
|
||||
if (selection == null) {
|
||||
return false;
|
||||
}
|
||||
final nodes = editorState.getNodesInSelection(selection);
|
||||
return nodes.isNotEmpty &&
|
||||
nodes.every(
|
||||
(node) =>
|
||||
((node.attributes[blockComponentAlign] as String?) ?? 'left') ==
|
||||
align,
|
||||
);
|
||||
}
|
||||
|
||||
void _align(String align) {
|
||||
final editorState = widget.editorState;
|
||||
final selection = editorState.selection;
|
||||
if (selection == null) {
|
||||
return;
|
||||
}
|
||||
unawaited(
|
||||
editorState.updateNode(
|
||||
selection,
|
||||
(node) => node.copyWith(
|
||||
attributes: {...node.attributes, blockComponentAlign: align},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether all of the selection has the mark [key]; at a caret, whether
|
||||
/// the next text typed takes it.
|
||||
bool _has(String key) =>
|
||||
widget.editorState.selection != null &&
|
||||
(widget.editorState.getDeltaAttributeValueInSelection<bool>(key) ??
|
||||
false);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final editorState = widget.editorState;
|
||||
return ExcludeFocus(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
_FormatButton(
|
||||
icon: LucideIcons.textAlignStart,
|
||||
tooltip: l10n.formatAlignLeft,
|
||||
active: _isAligned('left'),
|
||||
onPressed: () => _align('left'),
|
||||
),
|
||||
_FormatButton(
|
||||
icon: LucideIcons.textAlignCenter,
|
||||
tooltip: l10n.formatAlignCenter,
|
||||
active: _isAligned('center'),
|
||||
onPressed: () => _align('center'),
|
||||
),
|
||||
_FormatButton(
|
||||
icon: LucideIcons.textAlignEnd,
|
||||
tooltip: l10n.formatAlignRight,
|
||||
active: _isAligned('right'),
|
||||
onPressed: () => _align('right'),
|
||||
),
|
||||
const _Divider(),
|
||||
_FormatButton(
|
||||
icon: LucideIcons.bold,
|
||||
tooltip: l10n.formatBold,
|
||||
active: _has(AppFlowyRichTextKeys.bold),
|
||||
onPressed: () =>
|
||||
editorState.toggleAttribute(AppFlowyRichTextKeys.bold),
|
||||
),
|
||||
_FormatButton(
|
||||
icon: LucideIcons.italic,
|
||||
tooltip: l10n.formatItalic,
|
||||
active: _has(AppFlowyRichTextKeys.italic),
|
||||
onPressed: () =>
|
||||
editorState.toggleAttribute(AppFlowyRichTextKeys.italic),
|
||||
),
|
||||
_FormatButton(
|
||||
icon: LucideIcons.underline,
|
||||
tooltip: l10n.formatUnderline,
|
||||
active: _has(AppFlowyRichTextKeys.underline),
|
||||
onPressed: () =>
|
||||
editorState.toggleAttribute(AppFlowyRichTextKeys.underline),
|
||||
),
|
||||
const _Divider(),
|
||||
_FormatButton(
|
||||
icon: LucideIcons.superscript,
|
||||
tooltip: l10n.formatSuperscript,
|
||||
active: _has(superscriptKey),
|
||||
onPressed: () => toggleScript(editorState, superscriptKey),
|
||||
),
|
||||
_FormatButton(
|
||||
icon: LucideIcons.subscript,
|
||||
tooltip: l10n.formatSubscript,
|
||||
active: _has(subscriptKey),
|
||||
onPressed: () => toggleScript(editorState, subscriptKey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A format's button: its icon tinted, on a faint fill, while [active].
|
||||
class const _FormatButton({
|
||||
required final IconData icon,
|
||||
required final String tooltip,
|
||||
required final bool active,
|
||||
required final VoidCallback onPressed,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return IconButton(
|
||||
icon: Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: active ? colors.primary : colors.onSurfaceVariant,
|
||||
),
|
||||
tooltip: tooltip,
|
||||
isSelected: active,
|
||||
onPressed: onPressed,
|
||||
visualDensity: VisualDensity.compact,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: active
|
||||
? colors.primary.withValues(alpha: 0.12)
|
||||
: Colors.transparent,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Between groups of buttons.
|
||||
class const _Divider() extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => SizedBox(
|
||||
height: 24,
|
||||
child: VerticalDivider(
|
||||
width: 12,
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -1,15 +1,18 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/editor_style.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/file_blocks.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/format_bar.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_document.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Edits [initialContent] and reports each change as new [RichContent].
|
||||
///
|
||||
/// Empty content opens with an empty paragraph, so there is somewhere to type.
|
||||
/// With [formatBar], a [FormatBar] is docked below the editor while it has
|
||||
/// the focus; the editor then takes what height is left.
|
||||
class const RichContentEditor({
|
||||
/// The content the editor opens with; later changes to it are ignored.
|
||||
required final RichContent initialContent,
|
||||
|
|
@ -22,6 +25,9 @@ class const RichContentEditor({
|
|||
|
||||
/// The editor's focus, to follow or move it; one of its own otherwise.
|
||||
final FocusNode? focusNode,
|
||||
|
||||
/// Whether a [FormatBar] shows below the editor while it has the focus.
|
||||
final bool formatBar = false,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
|
|
@ -33,20 +39,43 @@ class _RichContentEditorState() extends State<RichContentEditor> {
|
|||
document: _editable(widget.initialContent.toDocument()),
|
||||
);
|
||||
late final StreamSubscription<EditorTransactionValue> _changes;
|
||||
FocusNode? _ownFocus;
|
||||
var _focused = false;
|
||||
|
||||
FocusNode get _focus => widget.focusNode ?? (_ownFocus ??= FocusNode());
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_changes = _editorState.transactionStream.listen(_onTransaction);
|
||||
_focus.addListener(_onFocus);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(RichContentEditor oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.focusNode != widget.focusNode) {
|
||||
(oldWidget.focusNode ?? _ownFocus)?.removeListener(_onFocus);
|
||||
_focus.addListener(_onFocus);
|
||||
_onFocus();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focus.removeListener(_onFocus);
|
||||
_ownFocus?.dispose();
|
||||
unawaited(_changes.cancel());
|
||||
_editorState.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFocus() {
|
||||
if (_focus.hasFocus != _focused) {
|
||||
setState(() => _focused = _focus.hasFocus);
|
||||
}
|
||||
}
|
||||
|
||||
void _onTransaction(EditorTransactionValue value) {
|
||||
final (time, transaction, _) = value;
|
||||
// Selection changes arrive as transactions too; only report the document.
|
||||
|
|
@ -56,13 +85,31 @@ class _RichContentEditorState() extends State<RichContentEditor> {
|
|||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AppFlowyEditor(
|
||||
editorState: _editorState,
|
||||
editorStyle: editorStyleOf(context),
|
||||
blockComponentBuilders: fileBlockBuilders,
|
||||
autoFocus: widget.autofocus,
|
||||
focusNode: widget.focusNode,
|
||||
);
|
||||
Widget build(BuildContext context) {
|
||||
final editor = AppFlowyEditor(
|
||||
editorState: _editorState,
|
||||
editorStyle: editorStyleOf(context),
|
||||
blockComponentBuilders: fileBlockBuilders,
|
||||
autoFocus: widget.autofocus,
|
||||
focusNode: _focus,
|
||||
);
|
||||
if (!widget.formatBar) {
|
||||
return editor;
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(child: editor),
|
||||
if (_focused) ...[
|
||||
Divider(
|
||||
height: 1,
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
),
|
||||
FormatBar(editorState: _editorState),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Document _editable(Document document) {
|
||||
|
|
|
|||
64
halkyon_learn_flutter/lib/src/core/editor/scripts.dart
Normal file
64
halkyon_learn_flutter/lib/src/core/editor/scripts.dart
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The inline mark of superscript text, which the editor has none of: `true`
|
||||
/// on the text's attributes in the document.
|
||||
const superscriptKey = 'superscript';
|
||||
|
||||
/// The inline mark of subscript text, as [superscriptKey].
|
||||
const subscriptKey = 'subscript';
|
||||
|
||||
/// How much smaller raised or lowered text is.
|
||||
const _scriptScale = 0.75;
|
||||
|
||||
/// Toggles [key] ([superscriptKey] or [subscriptKey]) over the editor's
|
||||
/// selection, clearing the other one: text is never both. At a caret, the
|
||||
/// next text typed takes it, as bold does.
|
||||
void toggleScript(EditorState editorState, String key) {
|
||||
final selection = editorState.selection;
|
||||
if (selection == null) {
|
||||
return;
|
||||
}
|
||||
if (selection.isCollapsed) {
|
||||
editorState.toggleAttribute(key);
|
||||
return;
|
||||
}
|
||||
final other = key == superscriptKey ? subscriptKey : superscriptKey;
|
||||
final isOn =
|
||||
editorState.getDeltaAttributeValueInSelection<bool>(key) ?? false;
|
||||
editorState.formatDelta(selection, {key: isOn ? null : true, other: null});
|
||||
}
|
||||
|
||||
/// [base], the editor's own way of drawing marked text (links, say), with
|
||||
/// superscript and subscript drawn too: by the font's own raised and lowered
|
||||
/// glyphs, a little smaller. A style rather than a widget of its own, since
|
||||
/// the editor maps its caret through the text as drawn.
|
||||
TextSpanDecoratorForAttribute withScripts(
|
||||
TextSpanDecoratorForAttribute base,
|
||||
) => (context, node, index, text, before, after) {
|
||||
final span = base(context, node, index, text, before, after);
|
||||
final feature = switch (text.attributes) {
|
||||
{superscriptKey: true} => const FontFeature.superscripts(),
|
||||
{subscriptKey: true} => const FontFeature.subscripts(),
|
||||
_ => null,
|
||||
};
|
||||
if (feature == null || span is! TextSpan) {
|
||||
return span;
|
||||
}
|
||||
final style = span.style ?? const TextStyle();
|
||||
return TextSpan(
|
||||
text: span.text,
|
||||
children: span.children,
|
||||
style: style.copyWith(
|
||||
fontFeatures: [...?style.fontFeatures, feature],
|
||||
fontSize: (style.fontSize ?? 16) * _scriptScale,
|
||||
),
|
||||
recognizer: span.recognizer,
|
||||
mouseCursor: span.mouseCursor,
|
||||
onEnter: span.onEnter,
|
||||
onExit: span.onExit,
|
||||
semanticsLabel: span.semanticsLabel,
|
||||
locale: span.locale,
|
||||
spellOut: span.spellOut,
|
||||
);
|
||||
};
|
||||
|
|
@ -30,4 +30,12 @@ abstract final class RoutePaths() {
|
|||
/// A card of a studyset, over the tabs.
|
||||
static String card(String studysetId, String cardId) =>
|
||||
'/studysets/$studysetId/cards/$cardId';
|
||||
|
||||
/// A new note in a studyset, over the tabs.
|
||||
static String newNote(String studysetId) =>
|
||||
'/studysets/$studysetId/notes/new';
|
||||
|
||||
/// A note of a studyset, over the tabs.
|
||||
static String note(String studysetId, String noteId) =>
|
||||
'/studysets/$studysetId/notes/$noteId';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,442 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/async/serial_queue.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/model/note.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
|
||||
|
||||
/// Writes one note of a studyset, saving as the person goes (decided
|
||||
/// 2026-10-02): after [_idleSave] without typing, when asked (the app going
|
||||
/// to the background), and when it closes (the person leaving the note),
|
||||
/// without the person waiting for it.
|
||||
///
|
||||
/// Each save builds on the revisions the editor started from, so what
|
||||
/// another device wrote meanwhile stands beside it and merges with it (ADR
|
||||
/// 0003). A note devices changed differently waits, unsaved, until the person
|
||||
/// keeps one version of every field in conflict. A new note is created by
|
||||
/// its first save; one left blank never is.
|
||||
class NoteEditorBloc(
|
||||
final NoteRepository _notes,
|
||||
final TagRepository _tags,
|
||||
final String _studysetId, {
|
||||
|
||||
/// The note to open; a new one without. Set by a new note's first save;
|
||||
/// kept here rather than only in the state, as the save when the bloc
|
||||
/// closes can't wait for a state to be emitted.
|
||||
var String? _noteId,
|
||||
final Duration _idleSave = const Duration(seconds: 5),
|
||||
}) extends Bloc<NoteEditorEvent, NoteEditorState> {
|
||||
this
|
||||
: super(
|
||||
NoteEditorState(
|
||||
noteId: _noteId,
|
||||
content: NoteContent.empty(),
|
||||
saved: NoteContent.empty(),
|
||||
),
|
||||
) {
|
||||
on<NoteEditorEventStarted>(_onStarted);
|
||||
on<NoteEditorEventTitleChanged>(
|
||||
(event, emit) => _change(
|
||||
emit,
|
||||
state.content.withField(NoteField.title, event.title),
|
||||
),
|
||||
);
|
||||
on<NoteEditorEventBodyChanged>(
|
||||
(event, emit) =>
|
||||
_change(emit, state.content.withField(NoteField.body, event.body)),
|
||||
);
|
||||
on<NoteEditorEventVersionChosen>(_onVersionChosen);
|
||||
on<NoteEditorEventTagsChanged>(_onTagsChanged);
|
||||
on<NoteEditorEventSaveRequested>((event, emit) => _save(emit));
|
||||
}
|
||||
|
||||
/// What the next save builds on, kept here for the same reason.
|
||||
var _base = <String>{};
|
||||
|
||||
/// Whether the note was saved since the editor opened.
|
||||
var _savedHere = false;
|
||||
|
||||
/// Whether the note was in the list once: missing afterwards, it is gone.
|
||||
var _seen = false;
|
||||
|
||||
/// Whether the note was deleted, here or elsewhere: nothing saves into it.
|
||||
var _gone = false;
|
||||
|
||||
final _writes = SerialQueue();
|
||||
Timer? _idle;
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
_idle?.cancel();
|
||||
// Leaving the note saves it; the write outlives the screen.
|
||||
if (state.canSave) {
|
||||
try {
|
||||
await _write(state.content.normalized);
|
||||
} on Object catch (error) {
|
||||
log('Saving a note on leaving failed', name: 'Notes', error: error);
|
||||
}
|
||||
}
|
||||
await super.close();
|
||||
}
|
||||
|
||||
Future<void> _onStarted(
|
||||
NoteEditorEventStarted event,
|
||||
Emitter<NoteEditorState> emit,
|
||||
) async {
|
||||
await Future.wait([
|
||||
emit.forEach(
|
||||
_notes.watch(_studysetId),
|
||||
onData: _withStored,
|
||||
onError: (error, _) {
|
||||
log('Reading notes failed', name: 'Notes', error: error);
|
||||
return state.copyWith(loaded: true);
|
||||
},
|
||||
),
|
||||
emit.forEach(
|
||||
_tags.watch(),
|
||||
onData: _withTagging,
|
||||
// Without tags the note can still be written.
|
||||
onError: (_, _) => state,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/// The state with the studyset's stored [notes]. A note with nothing
|
||||
/// unsaved takes the stored one whole (its content only when it differs
|
||||
/// from the person's, so a title being typed keeps its last space); one
|
||||
/// with unsaved changes keeps them and the revisions they started from, so
|
||||
/// its save merges with what came.
|
||||
NoteEditorState _withStored(List<Note> notes) {
|
||||
final id = _noteId;
|
||||
if (id == null) {
|
||||
return state.copyWith(loaded: true);
|
||||
}
|
||||
final note = notes.where((note) => note.id == id).firstOrNull;
|
||||
if (note == null) {
|
||||
// A note opened by its id is missing at once; one created here only
|
||||
// once it was seen, as a list read before the write lacks it.
|
||||
if (_seen || !state.loaded) {
|
||||
_gone = true;
|
||||
_idle?.cancel();
|
||||
return state.copyWith(loaded: true, gone: true);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
_seen = true;
|
||||
if (state.isDirty || state.status == NoteSaveStatus.saving) {
|
||||
return state.copyWith(loaded: true, saved: note.content);
|
||||
}
|
||||
_base = note.heads;
|
||||
final replaced = note.content != state.content.normalized;
|
||||
return state.copyWith(
|
||||
loaded: true,
|
||||
saved: note.content,
|
||||
content: replaced ? note.content : null,
|
||||
conflicts: note.conflicts,
|
||||
resolved: const {},
|
||||
revision: replaced ? state.revision + 1 : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// The state with the person's [tagging]: the note's stored tags, and as
|
||||
/// chosen unless the person chose others.
|
||||
NoteEditorState _withTagging(Tagging tagging) {
|
||||
final id = _noteId;
|
||||
if (id == null) {
|
||||
return state;
|
||||
}
|
||||
final stored = {for (final tag in tagging.ofNote(id)) tag.id};
|
||||
final unchanged = _sameSet(state.tagIds, state.savedTagIds);
|
||||
return state.copyWith(
|
||||
tagging: tagging,
|
||||
savedTagIds: stored,
|
||||
tagIds: unchanged ? stored : null,
|
||||
);
|
||||
}
|
||||
|
||||
void _change(Emitter<NoteEditorState> emit, NoteContent content) {
|
||||
// Read-only until a version of every field in conflict is kept; nothing
|
||||
// goes into a deleted note.
|
||||
if (state.unresolved.isNotEmpty || _gone || content == state.content) {
|
||||
return;
|
||||
}
|
||||
final changed = state.copyWith(content: content);
|
||||
emit(
|
||||
changed.copyWith(
|
||||
status: switch (state.status) {
|
||||
NoteSaveStatus.saving => null,
|
||||
_ when changed.isDirty => NoteSaveStatus.editing,
|
||||
// Back to what is stored, as a space at a title's end is.
|
||||
_ when _savedHere => NoteSaveStatus.saved,
|
||||
_ => NoteSaveStatus.clean,
|
||||
},
|
||||
),
|
||||
);
|
||||
_idle?.cancel();
|
||||
_idle = Timer(_idleSave, () {
|
||||
if (!isClosed) {
|
||||
add(const NoteEditorEventSaveRequested());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onVersionChosen(
|
||||
NoteEditorEventVersionChosen event,
|
||||
Emitter<NoteEditorState> emit,
|
||||
) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
content: state.content.withField(event.field, event.version),
|
||||
resolved: {...state.resolved, event.field},
|
||||
// The editor shows the version kept.
|
||||
revision: state.revision + 1,
|
||||
),
|
||||
);
|
||||
if (state.unresolved.isEmpty) {
|
||||
await _save(emit);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onTagsChanged(
|
||||
NoteEditorEventTagsChanged event,
|
||||
Emitter<NoteEditorState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(tagIds: event.tagIds));
|
||||
if (_noteId case final id? when !_gone) {
|
||||
try {
|
||||
await _tags.setTagsOfNote(id, event.tagIds);
|
||||
} on Object catch (error) {
|
||||
log('Saving tags failed', name: 'Notes', error: error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save(Emitter<NoteEditorState> emit) async {
|
||||
_idle?.cancel();
|
||||
if (!state.canSave || state.status == NoteSaveStatus.saving) {
|
||||
return;
|
||||
}
|
||||
final content = state.content.normalized;
|
||||
final settling = state.conflicts.isNotEmpty;
|
||||
emit(state.copyWith(status: NoteSaveStatus.saving));
|
||||
try {
|
||||
final written = await _write(content);
|
||||
_savedHere = _savedHere || written != null;
|
||||
emit(
|
||||
state.copyWith(
|
||||
noteId: _noteId,
|
||||
saved: written ?? state.saved,
|
||||
conflicts: settling ? const {} : null,
|
||||
resolved: settling ? const {} : null,
|
||||
// Changed again while saving: the pause after it saves that.
|
||||
status: state.content.normalized == content
|
||||
? NoteSaveStatus.saved
|
||||
: NoteSaveStatus.editing,
|
||||
),
|
||||
);
|
||||
} on Object catch (error) {
|
||||
log('Saving a note failed', name: 'Notes', error: error);
|
||||
emit(state.copyWith(status: NoteSaveStatus.failed));
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes [content] on top of [_base], one write after another; returns
|
||||
/// it once written, or null when nothing was (a blank new note, or a
|
||||
/// deleted one).
|
||||
Future<NoteContent?> _write(NoteContent content) => _writes.run(() async {
|
||||
if (_gone) {
|
||||
return null;
|
||||
}
|
||||
if (_noteId case final id?) {
|
||||
_base = {await _notes.saveEdit(id, content, basedOn: _base)};
|
||||
return content;
|
||||
}
|
||||
if (content.isBlank) {
|
||||
return null;
|
||||
}
|
||||
final created = await _notes.create(_studysetId, content);
|
||||
_noteId = created.noteId;
|
||||
_base = {created.revisionId};
|
||||
return content;
|
||||
});
|
||||
}
|
||||
|
||||
bool _sameSet(Set<String> a, Set<String> b) =>
|
||||
a.length == b.length && a.containsAll(b);
|
||||
|
||||
/// How the note's saving stands.
|
||||
enum NoteSaveStatus() {
|
||||
/// As stored: nothing changed yet.
|
||||
clean,
|
||||
|
||||
/// Changed, not saved yet.
|
||||
editing,
|
||||
|
||||
/// Being saved.
|
||||
saving,
|
||||
|
||||
/// Saved as it is now.
|
||||
saved,
|
||||
|
||||
/// The last save failed; the next pause tries again.
|
||||
failed,
|
||||
}
|
||||
|
||||
/// The note as the editor holds it.
|
||||
final class const NoteEditorState({
|
||||
/// The content as the person has it now.
|
||||
required final NoteContent content,
|
||||
|
||||
/// The content as stored.
|
||||
required final NoteContent saved,
|
||||
|
||||
/// The note's id; null for a new note until its first save.
|
||||
final String? noteId,
|
||||
|
||||
/// Whether the studyset's notes were read.
|
||||
final bool loaded = false,
|
||||
|
||||
/// How saving stands.
|
||||
final NoteSaveStatus status = NoteSaveStatus.clean,
|
||||
|
||||
/// The fields devices changed differently, with every version, oldest
|
||||
/// first.
|
||||
final Map<NoteField, List<Object?>> conflicts = const {},
|
||||
|
||||
/// The fields in conflict the person kept a version of.
|
||||
final Set<NoteField> resolved = const {},
|
||||
|
||||
/// The ids of the tags the person gave it.
|
||||
final Set<String> tagIds = const {},
|
||||
|
||||
/// The ids of the tags it has as stored.
|
||||
final Set<String> savedTagIds = const {},
|
||||
|
||||
/// The person's tags.
|
||||
final Tagging tagging = Tagging.empty,
|
||||
|
||||
/// Counts the times the content was replaced from outside the editor
|
||||
/// (another device, a version kept), which the editor then shows anew.
|
||||
final int revision = 0,
|
||||
|
||||
/// Whether the note was deleted, here or on another device.
|
||||
final bool gone = false,
|
||||
}) with Equatable {
|
||||
/// The fields in conflict still waiting for a version.
|
||||
Set<NoteField> get unresolved => conflicts.keys.toSet().difference(resolved);
|
||||
|
||||
/// Whether it differs from what is stored, or settles a conflict.
|
||||
bool get isDirty =>
|
||||
content.normalized != saved ||
|
||||
(conflicts.isNotEmpty && unresolved.isEmpty);
|
||||
|
||||
/// Whether saving it now would write: changed, nothing in conflict left,
|
||||
/// not deleted, and not a new note left blank.
|
||||
bool get canSave =>
|
||||
isDirty &&
|
||||
unresolved.isEmpty &&
|
||||
!gone &&
|
||||
(noteId != null || !content.isBlank);
|
||||
|
||||
/// This state with some fields replaced.
|
||||
NoteEditorState copyWith({
|
||||
NoteContent? content,
|
||||
NoteContent? saved,
|
||||
String? noteId,
|
||||
bool? loaded,
|
||||
NoteSaveStatus? status,
|
||||
Map<NoteField, List<Object?>>? conflicts,
|
||||
Set<NoteField>? resolved,
|
||||
Set<String>? tagIds,
|
||||
Set<String>? savedTagIds,
|
||||
Tagging? tagging,
|
||||
int? revision,
|
||||
bool? gone,
|
||||
}) => NoteEditorState(
|
||||
content: content ?? this.content,
|
||||
saved: saved ?? this.saved,
|
||||
noteId: noteId ?? this.noteId,
|
||||
loaded: loaded ?? this.loaded,
|
||||
status: status ?? this.status,
|
||||
conflicts: conflicts ?? this.conflicts,
|
||||
resolved: resolved ?? this.resolved,
|
||||
tagIds: tagIds ?? this.tagIds,
|
||||
savedTagIds: savedTagIds ?? this.savedTagIds,
|
||||
tagging: tagging ?? this.tagging,
|
||||
revision: revision ?? this.revision,
|
||||
gone: gone ?? this.gone,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
content,
|
||||
saved,
|
||||
noteId,
|
||||
loaded,
|
||||
status,
|
||||
conflicts,
|
||||
resolved,
|
||||
tagIds,
|
||||
savedTagIds,
|
||||
tagging,
|
||||
revision,
|
||||
gone,
|
||||
];
|
||||
}
|
||||
|
||||
/// What happens in the note's editor.
|
||||
sealed class const NoteEditorEvent() with Equatable {
|
||||
@override
|
||||
List<Object?> get props => const [];
|
||||
}
|
||||
|
||||
/// The editor opens: follow the studyset's notes and the tags.
|
||||
final class const NoteEditorEventStarted() extends NoteEditorEvent;
|
||||
|
||||
/// The person changed the title to [title].
|
||||
final class const NoteEditorEventTitleChanged(
|
||||
/// The title now.
|
||||
final String title,
|
||||
) extends NoteEditorEvent {
|
||||
@override
|
||||
List<Object?> get props => [title];
|
||||
}
|
||||
|
||||
/// The person changed the text to [body].
|
||||
final class const NoteEditorEventBodyChanged(
|
||||
/// The text now.
|
||||
final RichContent body,
|
||||
) extends NoteEditorEvent {
|
||||
@override
|
||||
List<Object?> get props => [body];
|
||||
}
|
||||
|
||||
/// For a field devices changed differently, the person keeps [version].
|
||||
final class const NoteEditorEventVersionChosen(
|
||||
/// The field in conflict.
|
||||
final NoteField field,
|
||||
|
||||
/// The version to keep.
|
||||
final Object? version,
|
||||
) extends NoteEditorEvent {
|
||||
@override
|
||||
List<Object?> get props => [field, version];
|
||||
}
|
||||
|
||||
/// The person chose the tags [tagIds].
|
||||
final class const NoteEditorEventTagsChanged(
|
||||
/// The ids of the tags chosen.
|
||||
final Set<String> tagIds,
|
||||
) extends NoteEditorEvent {
|
||||
@override
|
||||
List<Object?> get props => [tagIds];
|
||||
}
|
||||
|
||||
/// Save now, not after the pause: the app goes to the background, say.
|
||||
final class const NoteEditorEventSaveRequested() extends NoteEditorEvent;
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/model/note.dart';
|
||||
|
||||
/// A studyset's notes in order, through every change, leaving out those
|
||||
/// whose deletion can still be undone.
|
||||
class NoteListBloc(
|
||||
final NoteRepository _notes,
|
||||
final NoteDeletions _deletions,
|
||||
final String _studysetId,
|
||||
) extends Bloc<NoteListEvent, NoteListState> {
|
||||
this : super(const NoteListState()) {
|
||||
on<NoteListEventStarted>(_onStarted);
|
||||
}
|
||||
|
||||
/// The notes as last read, and whether they were read at all.
|
||||
List<Note>? _all;
|
||||
var _pending = <String>{};
|
||||
|
||||
Future<void> _onStarted(
|
||||
NoteListEventStarted event,
|
||||
Emitter<NoteListState> emit,
|
||||
) async {
|
||||
await Future.wait([
|
||||
emit.forEach(
|
||||
_notes.watch(_studysetId),
|
||||
onData: (notes) {
|
||||
_all = notes;
|
||||
return _listed();
|
||||
},
|
||||
onError: (error, _) {
|
||||
log('Reading notes failed', name: 'Notes', error: error);
|
||||
return state.copyWith(loaded: true, failed: true);
|
||||
},
|
||||
),
|
||||
emit.forEach(
|
||||
_deletions.watchPending(),
|
||||
onData: (pending) {
|
||||
_pending = pending;
|
||||
return _all == null ? state : _listed();
|
||||
},
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
NoteListState _listed() => NoteListState(
|
||||
loaded: true,
|
||||
notes: [
|
||||
for (final note in _all ?? const <Note>[])
|
||||
if (!_pending.contains(note.id)) note,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// A studyset's notes, as the list shows them.
|
||||
final class const NoteListState({
|
||||
/// Whether the notes were read.
|
||||
final bool loaded = false,
|
||||
|
||||
/// The notes, in order.
|
||||
final List<Note> notes = const [],
|
||||
|
||||
/// Whether the last read failed; the next change reads again.
|
||||
final bool failed = false,
|
||||
}) with Equatable {
|
||||
/// This state with some fields replaced.
|
||||
NoteListState copyWith({bool? loaded, List<Note>? notes, bool? failed}) =>
|
||||
NoteListState(
|
||||
loaded: loaded ?? this.loaded,
|
||||
notes: notes ?? this.notes,
|
||||
failed: failed ?? this.failed,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [loaded, notes, failed];
|
||||
}
|
||||
|
||||
/// What happens in the list.
|
||||
sealed class const NoteListEvent() with Equatable {
|
||||
@override
|
||||
List<Object?> get props => const [];
|
||||
}
|
||||
|
||||
/// The list opens: follow the studyset's notes and the deletions.
|
||||
final class const NoteListEventStarted() extends NoteListEvent;
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// When [editedAt] was, from [now], as people say it: "just now", "5 minutes
|
||||
/// ago" and "3 hours ago" within a day; "yesterday" and "4 days ago" by the
|
||||
/// local calendar after that; the date from a week on.
|
||||
String editedAgo(
|
||||
AppLocalizations l10n,
|
||||
DateTime editedAt, {
|
||||
required DateTime now,
|
||||
}) {
|
||||
final ago = now.difference(editedAt);
|
||||
if (ago.inMinutes < 1) {
|
||||
return l10n.timeJustNow;
|
||||
}
|
||||
if (ago.inHours < 1) {
|
||||
return l10n.timeMinutesAgo(ago.inMinutes);
|
||||
}
|
||||
if (ago.inHours < 24) {
|
||||
return l10n.timeHoursAgo(ago.inHours);
|
||||
}
|
||||
final days = _day(now).difference(_day(editedAt)).inDays;
|
||||
if (days < 7) {
|
||||
return l10n.timeDaysAgo(days);
|
||||
}
|
||||
return DateFormat.yMMMd(l10n.localeName).format(editedAt.toLocal());
|
||||
}
|
||||
|
||||
/// The local calendar day of [time], as a UTC midnight, so days count whole
|
||||
/// across a change of daylight saving time.
|
||||
DateTime _day(DateTime time) {
|
||||
final local = time.toLocal();
|
||||
return DateTime.utc(local.year, local.month, local.day);
|
||||
}
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.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/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/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';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_picker_sheet.dart';
|
||||
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 {
|
||||
@override
|
||||
State<NoteEditorScreen> createState() => _NoteEditorScreenState();
|
||||
}
|
||||
|
||||
class _NoteEditorScreenState() extends State<NoteEditorScreen> {
|
||||
final _title = TextEditingController();
|
||||
late final AppLifecycleListener _lifecycle;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_title.text = context.read<NoteEditorBloc>().state.content.title;
|
||||
// Hidden, the app may be ended without a word: save first.
|
||||
_lifecycle = AppLifecycleListener(
|
||||
onHide: () => context.read<NoteEditorBloc>().add(
|
||||
const NoteEditorEventSaveRequested(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_lifecycle.dispose();
|
||||
_title.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return BlocConsumer<NoteEditorBloc, NoteEditorState>(
|
||||
// The content replaced from outside the editors: show it there.
|
||||
listenWhen: (before, now) =>
|
||||
before.revision != now.revision || before.loaded != now.loaded,
|
||||
listener: (context, state) {
|
||||
if (_title.text != state.content.title) {
|
||||
_title.text = state.content.title;
|
||||
}
|
||||
},
|
||||
builder: (context, state) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
state.noteId == null
|
||||
? l10n.noteNewTitleFor(widget.studysetTitle)
|
||||
: l10n.noteTitleFor(widget.studysetTitle),
|
||||
),
|
||||
actions: [_SaveStatus(status: state.status)],
|
||||
),
|
||||
body: switch (state) {
|
||||
NoteEditorState(loaded: false) => const Center(
|
||||
child: AppLoadingIndicator(),
|
||||
),
|
||||
NoteEditorState(gone: true) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(l10n.notFoundGone, textAlign: TextAlign.center),
|
||||
),
|
||||
),
|
||||
_ => _Note(state: state, title: _title),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The note itself: the versions to keep while devices disagree, the title,
|
||||
/// the tags, and the text.
|
||||
class const _Note({
|
||||
required final NoteEditorState state,
|
||||
required final TextEditingController title,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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),
|
||||
),
|
||||
autofocus: state.noteId == null,
|
||||
formatBar: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The note's tags as chips, and the way to choose them.
|
||||
class const _Tags({required final NoteEditorState state})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final chosen = [
|
||||
for (final tag in state.tagging.all)
|
||||
if (state.tagIds.contains(tag.id)) tag,
|
||||
];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
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),
|
||||
onPressed: () => showTagPicker(
|
||||
context,
|
||||
selected: state.tagIds,
|
||||
onChanged: (tagIds) => context.read<NoteEditorBloc>().add(
|
||||
NoteEditorEventTagsChanged(tagIds),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The fields devices changed differently, each with every version and a
|
||||
/// way to keep one; writing goes on once every field has one.
|
||||
class const _Conflicts({required final NoteEditorState state})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
margin: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
color: theme.colorScheme.errorContainer,
|
||||
child: ConstrainedBox(
|
||||
// Long versions scroll here, so the note stays in view below.
|
||||
constraints: const BoxConstraints(maxHeight: 360),
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.all(12),
|
||||
children: [
|
||||
Text(
|
||||
l10n.noteConflict,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
for (final field in state.unresolved)
|
||||
for (final version in state.conflicts[field]!)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
switch (field) {
|
||||
NoteField.title => l10n.noteFieldTitle,
|
||||
NoteField.body => l10n.noteFieldBody,
|
||||
},
|
||||
style: theme.textTheme.labelLarge,
|
||||
),
|
||||
switch (version) {
|
||||
final RichContent body => RichContentView(
|
||||
content: body,
|
||||
),
|
||||
_ => Text('${version ?? ''}'),
|
||||
},
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: () =>
|
||||
context.read<NoteEditorBloc>().add(
|
||||
NoteEditorEventVersionChosen(
|
||||
field,
|
||||
version,
|
||||
),
|
||||
),
|
||||
child: Text(l10n.noteKeepThis),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// How saving stands, in the app bar: nothing before the first change.
|
||||
class const _SaveStatus({required final NoteSaveStatus status})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final label = switch (status) {
|
||||
NoteSaveStatus.clean => null,
|
||||
NoteSaveStatus.editing => l10n.noteEditing,
|
||||
NoteSaveStatus.saving => l10n.noteSaving,
|
||||
NoteSaveStatus.saved => l10n.noteSaved,
|
||||
NoteSaveStatus.failed => l10n.noteSaveFailed,
|
||||
};
|
||||
if (label == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Center(
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: status == NoteSaveStatus.failed
|
||||
? theme.colorScheme.error
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
139
halkyon_learn_flutter/lib/src/feature/note/widget/note_list.dart
Normal file
139
halkyon_learn_flutter/lib/src/feature/note/widget/note_list.dart
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import 'package:clock/clock.dart';
|
||||
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/dependencies/person_scope.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/confirm_dialog.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/bloc/note_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/model/note.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/widget/edited_ago.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The notes of the studyset [studysetId], in order: each with its title and
|
||||
/// when it was last edited, opening on a tap. With none, it says so.
|
||||
class const NoteList({required final String studysetId, super.key})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => BlocProvider(
|
||||
create: (context) {
|
||||
final person = PersonScope.of(context);
|
||||
return NoteListBloc(person.notes, person.noteDeletions, studysetId)
|
||||
..add(const NoteListEventStarted());
|
||||
},
|
||||
child: BlocBuilder<NoteListBloc, NoteListState>(
|
||||
builder: (context, state) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
if (!state.loaded) {
|
||||
return const Center(child: AppLoadingIndicator());
|
||||
}
|
||||
if (state.notes.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(l10n.notesEmpty, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.notesEmptyHint,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
for (final (index, note) in state.notes.indexed) ...[
|
||||
if (index > 0) const Divider(height: 1),
|
||||
_NoteTile(studysetId: studysetId, note: note),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// One note: its title ("Untitled note" without one) and when it was last
|
||||
/// edited; its options behind the … button, or a long press.
|
||||
class const _NoteTile({
|
||||
required final String studysetId,
|
||||
required final Note note,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final title = note.content.title;
|
||||
final editedAt = note.editedAt;
|
||||
void open() => context.push(RoutePaths.note(studysetId, note.id));
|
||||
return MenuAnchor(
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(LucideIcons.pencil),
|
||||
onPressed: open,
|
||||
child: Text(l10n.noteEdit),
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: Icon(
|
||||
LucideIcons.trash2,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
onPressed: () => _delete(context),
|
||||
child: Text(
|
||||
l10n.noteDelete,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
builder: (context, menu, _) => ListTile(
|
||||
title: Text(
|
||||
title.isEmpty ? l10n.noteUntitled : title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: editedAt == null
|
||||
? null
|
||||
: Text(
|
||||
l10n.noteEdited(editedAgo(l10n, editedAt, now: clock.now())),
|
||||
),
|
||||
onTap: open,
|
||||
onLongPress: menu.open,
|
||||
trailing: IconButton(
|
||||
icon: const Icon(LucideIcons.ellipsis),
|
||||
tooltip: l10n.noteOptions,
|
||||
onPressed: () => menu.isOpen ? menu.close() : menu.open(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Asks, then hands the deletion to the person's note deletions and offers
|
||||
/// to undo it for a moment; the deletion outlives this tile.
|
||||
Future<void> _delete(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final deletions = PersonScope.of(context).noteDeletions;
|
||||
if (!await confirmDestructiveAction(
|
||||
context,
|
||||
title: l10n.noteDeleteTitle,
|
||||
message: l10n.noteDeleteMessage,
|
||||
confirmLabel: l10n.noteDeleteConfirm,
|
||||
) ||
|
||||
!context.mounted) {
|
||||
return;
|
||||
}
|
||||
deletions.delete(note.id);
|
||||
showUndoSnackBar(
|
||||
context,
|
||||
message: l10n.noteDeleted,
|
||||
onUndo: () => deletions.undo(note.id),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,8 @@ import 'package:equatable/equatable.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_queue.dart';
|
||||
|
||||
/// Where the studying of some cards stands, as the library shows it.
|
||||
/// Where the studying of some cards stands, as the library shows it, with
|
||||
/// how many notes there are beside them.
|
||||
final class const StudyProgress({
|
||||
/// How many cards there are.
|
||||
required final int cards,
|
||||
|
|
@ -16,12 +17,25 @@ final class const StudyProgress({
|
|||
|
||||
/// How many cards are mature ([CardSchedule.isMature]).
|
||||
required final int mature,
|
||||
|
||||
/// How many notes there are with the cards. Notes aren't studied; the
|
||||
/// library counts them here as what a studyset holds besides its cards.
|
||||
final int notes = 0,
|
||||
}) with Equatable {
|
||||
/// The share of mature cards, from 0 to 1; null without cards.
|
||||
double? get mastery => cards == 0 ? null : mature / cards;
|
||||
|
||||
/// This progress with [notes] notes beside the cards.
|
||||
StudyProgress withNotes(int notes) => StudyProgress(
|
||||
cards: cards,
|
||||
due: due,
|
||||
reviewsDue: reviewsDue,
|
||||
mature: mature,
|
||||
notes: notes,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cards, due, reviewsDue, mature];
|
||||
List<Object?> get props => [cards, due, reviewsDue, mature, notes];
|
||||
}
|
||||
|
||||
/// The progress of the cards in [groups] (each studyset's, in tree order, as
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'package:clock/clock.dart';
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_queue.dart';
|
||||
|
|
@ -144,8 +145,8 @@ final class const StudysetListStateLoaded(
|
|||
List<Object?> get props => [studysets, progress];
|
||||
}
|
||||
|
||||
/// The library: the person's studysets with the progress of their cards,
|
||||
/// following every change, local or synced; creates, edits, stars, archives
|
||||
/// The library: the person's studysets with the progress of their cards
|
||||
/// and how many notes they hold, following every change, local or synced; creates, edits, stars, archives
|
||||
/// and deletes studysets. Given a [_parentId], it lists that studyset's
|
||||
/// children instead, for its topics, and creates studysets inside it.
|
||||
///
|
||||
|
|
@ -155,6 +156,7 @@ final class const StudysetListStateLoaded(
|
|||
class StudysetListBloc(
|
||||
final StudysetRepository _studysets,
|
||||
final CardRepository _cards,
|
||||
final NoteRepository _notes,
|
||||
final ReviewRepository _reviews,
|
||||
final StudysetDeletions _deletions, {
|
||||
|
||||
|
|
@ -253,11 +255,16 @@ class StudysetListBloc(
|
|||
},
|
||||
onError: (_, _) => const StudysetListStateFailed(),
|
||||
),
|
||||
// Changed cards, here or on another device, change the progress too.
|
||||
// Changed cards and notes, here or on another device, change the
|
||||
// progress too.
|
||||
emit.onEach(
|
||||
_cards.changes,
|
||||
onData: (_) => add(const StudysetListEventProgressRefreshRequested()),
|
||||
),
|
||||
emit.onEach(
|
||||
_notes.changes,
|
||||
onData: (_) => add(const StudysetListEventProgressRefreshRequested()),
|
||||
),
|
||||
emit.onEach(
|
||||
_deletions.watchPending(),
|
||||
onData: (pending) {
|
||||
|
|
@ -281,6 +288,7 @@ class StudysetListBloc(
|
|||
final parent = _studysetsRead
|
||||
.where((studyset) => studyset.id == _parentId)
|
||||
.firstOrNull;
|
||||
final notes = await _notes.countsByStudyset();
|
||||
for (final studyset in [...studysets, ?parent]) {
|
||||
// What a session on the studyset would study; an archived one
|
||||
// rests, paused, and still counts all it holds.
|
||||
|
|
@ -298,7 +306,7 @@ class StudysetListBloc(
|
|||
]),
|
||||
now: now,
|
||||
startOfToday: startOfLocalDay(now),
|
||||
);
|
||||
).withNotes(studied.fold(0, (sum, id) => sum + (notes[id] ?? 0)));
|
||||
}
|
||||
} on Object catch (error) {
|
||||
// The last progress stands until the next change works it out.
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ class const LibraryScreen({super.key}) extends StatelessWidget {
|
|||
create: (context) => StudysetListBloc(
|
||||
person.studysets,
|
||||
person.cards,
|
||||
person.notes,
|
||||
person.reviews,
|
||||
person.deletions,
|
||||
)..add(const StudysetListEventStarted()),
|
||||
|
|
|
|||
|
|
@ -168,7 +168,10 @@ class const _Holds({required final StudyProgress? progress})
|
|||
);
|
||||
}
|
||||
return ContentCountsLine(
|
||||
counts: [(count: progress.cards, label: l10n.studysetCountFlashcards)],
|
||||
counts: [
|
||||
(count: progress.cards, label: l10n.studysetCountFlashcards),
|
||||
(count: progress.notes, label: l10n.studysetCountNotes),
|
||||
],
|
||||
placeholder: l10n.studysetNoContent,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -259,7 +259,10 @@ class const _AllTopics({
|
|||
),
|
||||
title: Text(l10n.studysetAllTopics),
|
||||
subtitle: ContentCountsLine(
|
||||
counts: [(count: progress?.cards, label: l10n.studysetCountFlashcards)],
|
||||
counts: [
|
||||
(count: progress?.cards, label: l10n.studysetCountFlashcards),
|
||||
(count: progress?.notes, label: l10n.studysetCountNotes),
|
||||
],
|
||||
placeholder: progress == null ? '' : l10n.studysetNoContent,
|
||||
),
|
||||
onTap: due > 0 && !parent.isArchived ? onStudy : null,
|
||||
|
|
@ -327,6 +330,10 @@ class const _Topic({
|
|||
count: progress.cards,
|
||||
label: l10n.studysetCountFlashcards,
|
||||
),
|
||||
(
|
||||
count: progress.notes,
|
||||
label: l10n.studysetCountNotes,
|
||||
),
|
||||
],
|
||||
placeholder: l10n.studysetNoContent,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import 'package:halkyon_learn_flutter/src/core/widget/title_with_kicker.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_list.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/widget/note_list.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
|
|
@ -53,6 +54,7 @@ class const StudysetScreen({
|
|||
create: (context) => StudysetListBloc(
|
||||
person.studysets,
|
||||
person.cards,
|
||||
person.notes,
|
||||
person.reviews,
|
||||
person.deletions,
|
||||
parentId: studyset.id,
|
||||
|
|
@ -206,21 +208,24 @@ class const _StudysetView({
|
|||
}
|
||||
|
||||
/// The studyset's content, in tabs that appear as their features land:
|
||||
/// flashcards for now.
|
||||
/// flashcards and notes for now.
|
||||
class const _Content({required final String studysetId})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return DefaultTabController(
|
||||
length: 1,
|
||||
length: 2,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TabBar(
|
||||
isScrollable: true,
|
||||
tabAlignment: TabAlignment.start,
|
||||
tabs: [Tab(text: l10n.studysetTabFlashcards)],
|
||||
tabs: [
|
||||
Tab(text: l10n.studysetTabFlashcards),
|
||||
Tab(text: l10n.studysetTabNotes),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
|
|
@ -236,6 +241,10 @@ class const _Content({required final String studysetId})
|
|||
),
|
||||
],
|
||||
),
|
||||
ListView(
|
||||
padding: const EdgeInsets.fromLTRB(0, 4, 0, 96),
|
||||
children: [NoteList(studysetId: studysetId)],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -355,7 +364,7 @@ class const _StudyPill({
|
|||
}
|
||||
|
||||
/// What can be added to the studyset, behind [builder]'s trigger:
|
||||
/// flashcards for now; paths, notes and files come with their features.
|
||||
/// flashcards and notes for now; paths and files come with their features.
|
||||
class const _AddMaterial({
|
||||
required final String studysetId,
|
||||
required final Widget Function(BuildContext context, MenuController menu)
|
||||
|
|
@ -371,6 +380,11 @@ class const _AddMaterial({
|
|||
onPressed: () => context.push(RoutePaths.newCard(studysetId)),
|
||||
child: Text(l10n.studysetCreateFlashcards),
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(LucideIcons.notebookPen),
|
||||
onPressed: () => context.push(RoutePaths.newNote(studysetId)),
|
||||
child: Text(l10n.studysetCreateNote),
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(LucideIcons.fileInput),
|
||||
onPressed: () => importFromAnki(context, studysetId: studysetId),
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue