feat(app): editor blocks: callout, spoiler, formulas, files, video, study button
All checks were successful
CI / check (push) Successful in 8m34s
CI / deploy (push) Successful in 1m1s

Blocks beyond the editor's own, each built once for editor and view:
- callout: eight kinds in theme colours, kind, title and "folded at
  first" from its header; written in place, left with Enter on its
  empty last line; never inside another
- spoiler: written in plain sight, hidden behind Reveal read-only
- formula on its own line, written on its own screen with a preview
  and snippet buttons, and formulas within a line (Σ in the format
  bar); set by flutter_math_fork behind MathView, LaTeX's numbered
  environments read as their aligned forms
- image and sound picked from the device, kept as synced files
- YouTube video from its link, with its start and a caption: a card
  until played, nothing fetched before; played in place where a web
  view exists (youtube_player_iframe, privacy-enhanced), else opened
  in the browser, behind YoutubeVideos in Dependencies
- study button: a studyset, a tag or all cards, with or without its
  topics, back first; its StudyFocus rides on the study route and
  narrows that session only
- tables, already in the editor, now insertable everywhere

An Insert button opens on the format bar, so blocks can be inserted on
phones too; the desktop's / menu lists the same blocks, translated.
Card faces now dock the format bar while written, so cards get blocks
and formatting on phones as well. The blocks are listed in
EditorBlocks: the core's, plus the features' through
EditorBlocksScope. Blocks of a type the app doesn't know say they need
a newer version and stay as they are, so formatVersion stays 1.

Their types and keys are in content_blocks.dart, which RichContent
reads: a block's text counts as text, and a video or study button
isn't blank. The path import now makes formulas real ones.

integration_test/youtube_player_test.dart plays a real video through
the app's player on a device, which no widget test can: YouTube
refuses embeds that don't say where they are embedded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-03 12:20:41 +02:00
commit ec34bb2334
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
51 changed files with 5602 additions and 284 deletions

View file

@ -0,0 +1,48 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
import 'package:integration_test/integration_test.dart';
import 'package:material_ui/material_ui.dart';
import 'package:youtube_player_iframe/youtube_player_iframe.dart';
// Plays a real YouTube video through the app's player, on a device where
// videos play inside the app, online:
//
// flutter test integration_test/youtube_player_test.dart -d <device>
//
// YouTube refuses embeds that don't say where they are embedded (its error
// 153), which shows only in a real web view; such a player may also never
// start, without an error. So this passes only once the video plays.
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('a YouTube video plays inside the app', (tester) async {
final videos = IframeYoutubeVideos();
expect(videos.playInApp, isTrue, reason: 'Run it where videos play.');
// YouTube's own sample video for its embedded player.
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(child: videos.player((id: 'M7lc1UVf-VE', start: 10))),
),
),
);
final controller = tester
.widget<YoutubePlayer>(find.byType(YoutubePlayer))
.controller;
final waited = Stopwatch()..start();
while (waited.elapsed < const Duration(minutes: 1)) {
await Future<void>.delayed(const Duration(milliseconds: 500));
await tester.pump();
final value = controller.value;
expect(value.error, YoutubeError.none, reason: 'YouTube refused it.');
if (value.playerState == PlayerState.playing) {
return;
}
}
fail(
'The video did not start within a minute; the player was '
'${controller.value.playerState.name}.',
);
});
}

View file

@ -529,6 +529,7 @@
"@heatmapDay": {"placeholders": {"count": {"type": "int"}, "date": {"type": "String"}}},
"commonCancel": "Cancel",
"commonSave": "Save",
"commonClose": "Close",
"commonUndo": "Undo",
"commonTryAgain": "Try again",
@ -543,6 +544,117 @@
"fileStopSound": "Stop sound",
"fileSoundUnavailable": "This sound isn't on this device yet. It plays once it's uploaded and you're online.",
"fileSoundUnplayable": "This sound can't be played. Its file may be damaged, or in a format this device can't play.",
"fileImages": "Images",
"fileSounds": "Sounds",
"fileNotAnImage": "This file isn't an image the app can show.",
"fileNotASound": "This file isn't a sound the app can play.",
"fileTooLarge": "This file is larger than {megabytes} MB.",
"@fileTooLarge": {
"placeholders": {
"megabytes": {"type": "int"}
}
},
"fileNotKept": "The file couldn't be kept on this device.",
"fileRemoveImage": "Remove image",
"fileRemoveSound": "Remove sound",
"editorInsert": "Insert",
"blockGroupText": "Text",
"blockGroupBlocks": "Blocks",
"blockGroupMedia": "Media",
"blockHeading": "Heading {level}",
"@blockHeading": {
"placeholders": {
"level": {"type": "int"}
}
},
"blockBulletedList": "Bulleted list",
"blockNumberedList": "Numbered list",
"blockChecklist": "Checklist",
"blockQuote": "Quote",
"blockDivider": "Divider",
"blockTable": "Table",
"blockRemove": "Remove",
"blockUnknown": "This part needs a newer version of the app.",
"blockCallout": "Callout",
"blockSpoiler": "Spoiler",
"calloutNote": "Note",
"calloutInfo": "Info",
"calloutTip": "Tip",
"calloutSuccess": "Success",
"calloutQuestion": "Question",
"calloutWarning": "Warning",
"calloutDanger": "Danger",
"calloutQuote": "Quote",
"calloutOptions": "Callout options",
"calloutEditTitle": "Edit title…",
"calloutFoldedAtFirst": "Folded at first",
"calloutRemove": "Remove callout",
"calloutShow": "Show",
"calloutHide": "Hide",
"calloutWriteInside": "Write inside",
"calloutTitleDialog": "Callout title",
"calloutTitleLabel": "Title",
"calloutTitleHelper": "Leave it empty to show the kind's name.",
"spoilerReveal": "Reveal",
"spoilerPlaceholder": "Hidden text, revealed on a tap",
"blockFormula": "Formula",
"formatInlineMath": "Formula in the line",
"formulaTitle": "Formula",
"formulaInLineTitle": "Formula in the line",
"formulaPreview": "Preview",
"formulaPreviewEmpty": "The formula shows here",
"formulaLatex": "LaTeX",
"formulaLatexHelper": "\\\\ starts a new line in aligned, cases or a matrix.",
"formulaEmpty": "An empty formula. Tap to write it.",
"formulaEdit": "Edit formula",
"formulaRemove": "Remove formula",
"blockYoutube": "YouTube video",
"youtubeLink": "YouTube link",
"youtubeLinkHelper": "Paste a link from YouTube.",
"youtubeNotFound": "No YouTube video in this link.",
"youtubeFound": "Found the video.",
"youtubeFoundFrom": "Found the video, starting at {time}.",
"@youtubeFoundFrom": {
"placeholders": {
"time": {"type": "String"}
}
},
"youtubeCaption": "Caption",
"youtubeCaptionHelper": "Shown in place of the video until it plays.",
"youtubeVideo": "YouTube video",
"youtubeSource": "YouTube",
"youtubeFrom": "YouTube · from {time}",
"@youtubeFrom": {
"placeholders": {
"time": {"type": "String"}
}
},
"youtubeEdit": "Edit video",
"youtubePlay": "Play video",
"youtubeOpen": "Open on YouTube",
"youtubeRemove": "Remove video",
"youtubeBroken": "This video's link can't be read.",
"youtubeNotOpened": "Couldn't open YouTube.",
"blockStudyButton": "Study button",
"studyButtonDefaultLabel": "Study",
"studyButtonStudyset": "Studyset to study",
"studyButtonTag": "Cards",
"studyButtonAllCards": "All cards",
"studyButtonLabel": "Label",
"studyButtonTopics": "With its topics",
"studyButtonBackFirst": "Back first",
"studyButtonBackFirstHelper": "Front/back and vocabulary cards ask their back.",
"studyButtonWithTag": "Tagged {tag}",
"@studyButtonWithTag": {
"placeholders": {
"tag": {"type": "String"}
}
},
"studyButtonAlone": "Without its topics",
"studyButtonEdit": "Edit study button",
"studyButtonRemove": "Remove study button",
"studyButtonBroken": "This study button doesn't say what to study.",
"studyButtonGone": "That studyset no longer exists.",
"ankiImportMenu": "Import from Anki",
"ankiImportFileType": "Anki decks",
"ankiImportTitle": "Import Anki deck",

View file

@ -2842,6 +2842,12 @@ abstract class AppLocalizations {
/// **'Cancel'**
String get commonCancel;
/// No description provided for @commonSave.
///
/// In en, this message translates to:
/// **'Save'**
String get commonSave;
/// No description provided for @commonClose.
///
/// In en, this message translates to:
@ -2926,6 +2932,522 @@ abstract class AppLocalizations {
/// **'This sound can\'t be played. Its file may be damaged, or in a format this device can\'t play.'**
String get fileSoundUnplayable;
/// No description provided for @fileImages.
///
/// In en, this message translates to:
/// **'Images'**
String get fileImages;
/// No description provided for @fileSounds.
///
/// In en, this message translates to:
/// **'Sounds'**
String get fileSounds;
/// No description provided for @fileNotAnImage.
///
/// In en, this message translates to:
/// **'This file isn\'t an image the app can show.'**
String get fileNotAnImage;
/// No description provided for @fileNotASound.
///
/// In en, this message translates to:
/// **'This file isn\'t a sound the app can play.'**
String get fileNotASound;
/// No description provided for @fileTooLarge.
///
/// In en, this message translates to:
/// **'This file is larger than {megabytes} MB.'**
String fileTooLarge(int megabytes);
/// No description provided for @fileNotKept.
///
/// In en, this message translates to:
/// **'The file couldn\'t be kept on this device.'**
String get fileNotKept;
/// No description provided for @fileRemoveImage.
///
/// In en, this message translates to:
/// **'Remove image'**
String get fileRemoveImage;
/// No description provided for @fileRemoveSound.
///
/// In en, this message translates to:
/// **'Remove sound'**
String get fileRemoveSound;
/// No description provided for @editorInsert.
///
/// In en, this message translates to:
/// **'Insert'**
String get editorInsert;
/// No description provided for @blockGroupText.
///
/// In en, this message translates to:
/// **'Text'**
String get blockGroupText;
/// No description provided for @blockGroupBlocks.
///
/// In en, this message translates to:
/// **'Blocks'**
String get blockGroupBlocks;
/// No description provided for @blockGroupMedia.
///
/// In en, this message translates to:
/// **'Media'**
String get blockGroupMedia;
/// No description provided for @blockHeading.
///
/// In en, this message translates to:
/// **'Heading {level}'**
String blockHeading(int level);
/// No description provided for @blockBulletedList.
///
/// In en, this message translates to:
/// **'Bulleted list'**
String get blockBulletedList;
/// No description provided for @blockNumberedList.
///
/// In en, this message translates to:
/// **'Numbered list'**
String get blockNumberedList;
/// No description provided for @blockChecklist.
///
/// In en, this message translates to:
/// **'Checklist'**
String get blockChecklist;
/// No description provided for @blockQuote.
///
/// In en, this message translates to:
/// **'Quote'**
String get blockQuote;
/// No description provided for @blockDivider.
///
/// In en, this message translates to:
/// **'Divider'**
String get blockDivider;
/// No description provided for @blockTable.
///
/// In en, this message translates to:
/// **'Table'**
String get blockTable;
/// No description provided for @blockRemove.
///
/// In en, this message translates to:
/// **'Remove'**
String get blockRemove;
/// No description provided for @blockUnknown.
///
/// In en, this message translates to:
/// **'This part needs a newer version of the app.'**
String get blockUnknown;
/// No description provided for @blockCallout.
///
/// In en, this message translates to:
/// **'Callout'**
String get blockCallout;
/// No description provided for @blockSpoiler.
///
/// In en, this message translates to:
/// **'Spoiler'**
String get blockSpoiler;
/// No description provided for @calloutNote.
///
/// In en, this message translates to:
/// **'Note'**
String get calloutNote;
/// No description provided for @calloutInfo.
///
/// In en, this message translates to:
/// **'Info'**
String get calloutInfo;
/// No description provided for @calloutTip.
///
/// In en, this message translates to:
/// **'Tip'**
String get calloutTip;
/// No description provided for @calloutSuccess.
///
/// In en, this message translates to:
/// **'Success'**
String get calloutSuccess;
/// No description provided for @calloutQuestion.
///
/// In en, this message translates to:
/// **'Question'**
String get calloutQuestion;
/// No description provided for @calloutWarning.
///
/// In en, this message translates to:
/// **'Warning'**
String get calloutWarning;
/// No description provided for @calloutDanger.
///
/// In en, this message translates to:
/// **'Danger'**
String get calloutDanger;
/// No description provided for @calloutQuote.
///
/// In en, this message translates to:
/// **'Quote'**
String get calloutQuote;
/// No description provided for @calloutOptions.
///
/// In en, this message translates to:
/// **'Callout options'**
String get calloutOptions;
/// No description provided for @calloutEditTitle.
///
/// In en, this message translates to:
/// **'Edit title…'**
String get calloutEditTitle;
/// No description provided for @calloutFoldedAtFirst.
///
/// In en, this message translates to:
/// **'Folded at first'**
String get calloutFoldedAtFirst;
/// No description provided for @calloutRemove.
///
/// In en, this message translates to:
/// **'Remove callout'**
String get calloutRemove;
/// No description provided for @calloutShow.
///
/// In en, this message translates to:
/// **'Show'**
String get calloutShow;
/// No description provided for @calloutHide.
///
/// In en, this message translates to:
/// **'Hide'**
String get calloutHide;
/// No description provided for @calloutWriteInside.
///
/// In en, this message translates to:
/// **'Write inside'**
String get calloutWriteInside;
/// No description provided for @calloutTitleDialog.
///
/// In en, this message translates to:
/// **'Callout title'**
String get calloutTitleDialog;
/// No description provided for @calloutTitleLabel.
///
/// In en, this message translates to:
/// **'Title'**
String get calloutTitleLabel;
/// No description provided for @calloutTitleHelper.
///
/// In en, this message translates to:
/// **'Leave it empty to show the kind\'s name.'**
String get calloutTitleHelper;
/// No description provided for @spoilerReveal.
///
/// In en, this message translates to:
/// **'Reveal'**
String get spoilerReveal;
/// No description provided for @spoilerPlaceholder.
///
/// In en, this message translates to:
/// **'Hidden text, revealed on a tap'**
String get spoilerPlaceholder;
/// No description provided for @blockFormula.
///
/// In en, this message translates to:
/// **'Formula'**
String get blockFormula;
/// No description provided for @formatInlineMath.
///
/// In en, this message translates to:
/// **'Formula in the line'**
String get formatInlineMath;
/// No description provided for @formulaTitle.
///
/// In en, this message translates to:
/// **'Formula'**
String get formulaTitle;
/// No description provided for @formulaInLineTitle.
///
/// In en, this message translates to:
/// **'Formula in the line'**
String get formulaInLineTitle;
/// No description provided for @formulaPreview.
///
/// In en, this message translates to:
/// **'Preview'**
String get formulaPreview;
/// No description provided for @formulaPreviewEmpty.
///
/// In en, this message translates to:
/// **'The formula shows here'**
String get formulaPreviewEmpty;
/// No description provided for @formulaLatex.
///
/// In en, this message translates to:
/// **'LaTeX'**
String get formulaLatex;
/// No description provided for @formulaLatexHelper.
///
/// In en, this message translates to:
/// **'\\\\ starts a new line in aligned, cases or a matrix.'**
String get formulaLatexHelper;
/// No description provided for @formulaEmpty.
///
/// In en, this message translates to:
/// **'An empty formula. Tap to write it.'**
String get formulaEmpty;
/// No description provided for @formulaEdit.
///
/// In en, this message translates to:
/// **'Edit formula'**
String get formulaEdit;
/// No description provided for @formulaRemove.
///
/// In en, this message translates to:
/// **'Remove formula'**
String get formulaRemove;
/// No description provided for @blockYoutube.
///
/// In en, this message translates to:
/// **'YouTube video'**
String get blockYoutube;
/// No description provided for @youtubeLink.
///
/// In en, this message translates to:
/// **'YouTube link'**
String get youtubeLink;
/// No description provided for @youtubeLinkHelper.
///
/// In en, this message translates to:
/// **'Paste a link from YouTube.'**
String get youtubeLinkHelper;
/// No description provided for @youtubeNotFound.
///
/// In en, this message translates to:
/// **'No YouTube video in this link.'**
String get youtubeNotFound;
/// No description provided for @youtubeFound.
///
/// In en, this message translates to:
/// **'Found the video.'**
String get youtubeFound;
/// No description provided for @youtubeFoundFrom.
///
/// In en, this message translates to:
/// **'Found the video, starting at {time}.'**
String youtubeFoundFrom(String time);
/// No description provided for @youtubeCaption.
///
/// In en, this message translates to:
/// **'Caption'**
String get youtubeCaption;
/// No description provided for @youtubeCaptionHelper.
///
/// In en, this message translates to:
/// **'Shown in place of the video until it plays.'**
String get youtubeCaptionHelper;
/// No description provided for @youtubeVideo.
///
/// In en, this message translates to:
/// **'YouTube video'**
String get youtubeVideo;
/// No description provided for @youtubeSource.
///
/// In en, this message translates to:
/// **'YouTube'**
String get youtubeSource;
/// No description provided for @youtubeFrom.
///
/// In en, this message translates to:
/// **'YouTube · from {time}'**
String youtubeFrom(String time);
/// No description provided for @youtubeEdit.
///
/// In en, this message translates to:
/// **'Edit video'**
String get youtubeEdit;
/// No description provided for @youtubePlay.
///
/// In en, this message translates to:
/// **'Play video'**
String get youtubePlay;
/// No description provided for @youtubeOpen.
///
/// In en, this message translates to:
/// **'Open on YouTube'**
String get youtubeOpen;
/// No description provided for @youtubeRemove.
///
/// In en, this message translates to:
/// **'Remove video'**
String get youtubeRemove;
/// No description provided for @youtubeBroken.
///
/// In en, this message translates to:
/// **'This video\'s link can\'t be read.'**
String get youtubeBroken;
/// No description provided for @youtubeNotOpened.
///
/// In en, this message translates to:
/// **'Couldn\'t open YouTube.'**
String get youtubeNotOpened;
/// No description provided for @blockStudyButton.
///
/// In en, this message translates to:
/// **'Study button'**
String get blockStudyButton;
/// No description provided for @studyButtonDefaultLabel.
///
/// In en, this message translates to:
/// **'Study'**
String get studyButtonDefaultLabel;
/// No description provided for @studyButtonStudyset.
///
/// In en, this message translates to:
/// **'Studyset to study'**
String get studyButtonStudyset;
/// No description provided for @studyButtonTag.
///
/// In en, this message translates to:
/// **'Cards'**
String get studyButtonTag;
/// No description provided for @studyButtonAllCards.
///
/// In en, this message translates to:
/// **'All cards'**
String get studyButtonAllCards;
/// No description provided for @studyButtonLabel.
///
/// In en, this message translates to:
/// **'Label'**
String get studyButtonLabel;
/// No description provided for @studyButtonTopics.
///
/// In en, this message translates to:
/// **'With its topics'**
String get studyButtonTopics;
/// No description provided for @studyButtonBackFirst.
///
/// In en, this message translates to:
/// **'Back first'**
String get studyButtonBackFirst;
/// No description provided for @studyButtonBackFirstHelper.
///
/// In en, this message translates to:
/// **'Front/back and vocabulary cards ask their back.'**
String get studyButtonBackFirstHelper;
/// No description provided for @studyButtonWithTag.
///
/// In en, this message translates to:
/// **'Tagged {tag}'**
String studyButtonWithTag(String tag);
/// No description provided for @studyButtonAlone.
///
/// In en, this message translates to:
/// **'Without its topics'**
String get studyButtonAlone;
/// No description provided for @studyButtonEdit.
///
/// In en, this message translates to:
/// **'Edit study button'**
String get studyButtonEdit;
/// No description provided for @studyButtonRemove.
///
/// In en, this message translates to:
/// **'Remove study button'**
String get studyButtonRemove;
/// No description provided for @studyButtonBroken.
///
/// In en, this message translates to:
/// **'This study button doesn\'t say what to study.'**
String get studyButtonBroken;
/// No description provided for @studyButtonGone.
///
/// In en, this message translates to:
/// **'That studyset no longer exists.'**
String get studyButtonGone;
/// No description provided for @ankiImportMenu.
///
/// In en, this message translates to:

View file

@ -1683,6 +1683,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get commonCancel => 'Cancel';
@override
String get commonSave => 'Save';
@override
String get commonClose => 'Close';
@ -1730,6 +1733,278 @@ class AppLocalizationsEn extends AppLocalizations {
String get fileSoundUnplayable =>
'This sound can\'t be played. Its file may be damaged, or in a format this device can\'t play.';
@override
String get fileImages => 'Images';
@override
String get fileSounds => 'Sounds';
@override
String get fileNotAnImage => 'This file isn\'t an image the app can show.';
@override
String get fileNotASound => 'This file isn\'t a sound the app can play.';
@override
String fileTooLarge(int megabytes) {
return 'This file is larger than $megabytes MB.';
}
@override
String get fileNotKept => 'The file couldn\'t be kept on this device.';
@override
String get fileRemoveImage => 'Remove image';
@override
String get fileRemoveSound => 'Remove sound';
@override
String get editorInsert => 'Insert';
@override
String get blockGroupText => 'Text';
@override
String get blockGroupBlocks => 'Blocks';
@override
String get blockGroupMedia => 'Media';
@override
String blockHeading(int level) {
return 'Heading $level';
}
@override
String get blockBulletedList => 'Bulleted list';
@override
String get blockNumberedList => 'Numbered list';
@override
String get blockChecklist => 'Checklist';
@override
String get blockQuote => 'Quote';
@override
String get blockDivider => 'Divider';
@override
String get blockTable => 'Table';
@override
String get blockRemove => 'Remove';
@override
String get blockUnknown => 'This part needs a newer version of the app.';
@override
String get blockCallout => 'Callout';
@override
String get blockSpoiler => 'Spoiler';
@override
String get calloutNote => 'Note';
@override
String get calloutInfo => 'Info';
@override
String get calloutTip => 'Tip';
@override
String get calloutSuccess => 'Success';
@override
String get calloutQuestion => 'Question';
@override
String get calloutWarning => 'Warning';
@override
String get calloutDanger => 'Danger';
@override
String get calloutQuote => 'Quote';
@override
String get calloutOptions => 'Callout options';
@override
String get calloutEditTitle => 'Edit title…';
@override
String get calloutFoldedAtFirst => 'Folded at first';
@override
String get calloutRemove => 'Remove callout';
@override
String get calloutShow => 'Show';
@override
String get calloutHide => 'Hide';
@override
String get calloutWriteInside => 'Write inside';
@override
String get calloutTitleDialog => 'Callout title';
@override
String get calloutTitleLabel => 'Title';
@override
String get calloutTitleHelper => 'Leave it empty to show the kind\'s name.';
@override
String get spoilerReveal => 'Reveal';
@override
String get spoilerPlaceholder => 'Hidden text, revealed on a tap';
@override
String get blockFormula => 'Formula';
@override
String get formatInlineMath => 'Formula in the line';
@override
String get formulaTitle => 'Formula';
@override
String get formulaInLineTitle => 'Formula in the line';
@override
String get formulaPreview => 'Preview';
@override
String get formulaPreviewEmpty => 'The formula shows here';
@override
String get formulaLatex => 'LaTeX';
@override
String get formulaLatexHelper =>
'\\\\ starts a new line in aligned, cases or a matrix.';
@override
String get formulaEmpty => 'An empty formula. Tap to write it.';
@override
String get formulaEdit => 'Edit formula';
@override
String get formulaRemove => 'Remove formula';
@override
String get blockYoutube => 'YouTube video';
@override
String get youtubeLink => 'YouTube link';
@override
String get youtubeLinkHelper => 'Paste a link from YouTube.';
@override
String get youtubeNotFound => 'No YouTube video in this link.';
@override
String get youtubeFound => 'Found the video.';
@override
String youtubeFoundFrom(String time) {
return 'Found the video, starting at $time.';
}
@override
String get youtubeCaption => 'Caption';
@override
String get youtubeCaptionHelper =>
'Shown in place of the video until it plays.';
@override
String get youtubeVideo => 'YouTube video';
@override
String get youtubeSource => 'YouTube';
@override
String youtubeFrom(String time) {
return 'YouTube · from $time';
}
@override
String get youtubeEdit => 'Edit video';
@override
String get youtubePlay => 'Play video';
@override
String get youtubeOpen => 'Open on YouTube';
@override
String get youtubeRemove => 'Remove video';
@override
String get youtubeBroken => 'This video\'s link can\'t be read.';
@override
String get youtubeNotOpened => 'Couldn\'t open YouTube.';
@override
String get blockStudyButton => 'Study button';
@override
String get studyButtonDefaultLabel => 'Study';
@override
String get studyButtonStudyset => 'Studyset to study';
@override
String get studyButtonTag => 'Cards';
@override
String get studyButtonAllCards => 'All cards';
@override
String get studyButtonLabel => 'Label';
@override
String get studyButtonTopics => 'With its topics';
@override
String get studyButtonBackFirst => 'Back first';
@override
String get studyButtonBackFirstHelper =>
'Front/back and vocabulary cards ask their back.';
@override
String studyButtonWithTag(String tag) {
return 'Tagged $tag';
}
@override
String get studyButtonAlone => 'Without its topics';
@override
String get studyButtonEdit => 'Edit study button';
@override
String get studyButtonRemove => 'Remove study button';
@override
String get studyButtonBroken =>
'This study button doesn\'t say what to study.';
@override
String get studyButtonGone => 'That studyset no longer exists.';
@override
String get ankiImportMenu => 'Import from Anki';

View file

@ -20,6 +20,7 @@ import 'package:halkyon_learn_flutter/src/feature/note/bloc/note_editor_bloc.dar
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/model/study_focus.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/library_screen.dart';
@ -107,6 +108,7 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
person.tags,
person.settings,
studysetId,
focus: StudyFocus.fromQuery(state.uri.queryParameters),
)..add(const StudySessionEventStarted()),
child: StudyScreen(
studysetId: studysetId,

View file

@ -2,11 +2,17 @@ import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/app/app_router.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/editor/core_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/theme/app_theme.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_button_block.dart';
import 'package:material_ui/material_ui.dart';
/// The blocks of the person's editors: the core's and the features'.
final _editorBlocks = coreBlocks.plus(studyEditorBlocks);
/// The app of one signed-in [person]: its own router, around the person's data
/// ([PersonScope]).
///
@ -55,7 +61,11 @@ class _PersonAppState() extends State<PersonApp> {
// reminder: remove it once they have moved.
// ignore: deprecated_member_use
builder: (context, child) => MaterialUiCompatibilityBridge(
child: PersonScope(person: widget.person, child: child!),
child: PersonScope(
person: widget.person,
// The editors know the features' blocks too.
child: EditorBlocksScope(blocks: _editorBlocks, child: child!),
),
),
);
}

View file

@ -0,0 +1,35 @@
/// The media type of a file named [name], by its extension: the images and
/// sounds the app shows and plays; null for any other.
String? mediaTypeOf(String name) {
final dot = name.lastIndexOf('.');
return dot < 0 ? null : _mediaTypes[name.substring(dot + 1).toLowerCase()];
}
/// The extensions of the images the app shows.
final imageExtensions = [
for (final MapEntry(:key, :value) in _mediaTypes.entries)
if (value.startsWith('image/')) key,
];
/// The extensions of the sounds the app plays.
final soundExtensions = [
for (final MapEntry(:key, :value) in _mediaTypes.entries)
if (value.startsWith('audio/')) key,
];
const _mediaTypes = {
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'gif': 'image/gif',
'webp': 'image/webp',
'bmp': 'image/bmp',
'mp3': 'audio/mpeg',
'm4a': 'audio/mp4',
'aac': 'audio/aac',
'ogg': 'audio/ogg',
'oga': 'audio/ogg',
'opus': 'audio/ogg',
'wav': 'audio/wav',
'flac': 'audio/flac',
};

View file

@ -0,0 +1,80 @@
// The blocks of ours in rich text, beyond the editor's own: their types and
// the attributes they keep, in the editor's document JSON. Pure Dart, so
// `RichContent` and the server read them as the editor writes them.
//
// Every block keeps its type for good: content with a type an app doesn't
// know stays as it is there, shown as needing a newer app.
/// A sound: the person's file at `url` (`assetUrl`), played on a tap.
const audioBlockType = 'audio';
/// A boxed aside of a kind (a note, a tip, a warning…), holding blocks of
/// its own as children.
const calloutBlockType = 'callout';
/// The callout's kind, one of [calloutKinds]; any other reads as `note`.
const calloutKindKey = 'kind';
/// The callout's title; empty or missing for its kind's name.
const calloutTitleKey = 'title';
/// Whether the callout starts folded, its blocks hidden until opened.
const calloutFoldedKey = 'folded';
/// The kinds of callouts, in the order they are offered.
const calloutKinds = [
'note',
'info',
'tip',
'success',
'question',
'warning',
'danger',
'quote',
];
/// Text hidden until it is revealed, in the block's own `delta`.
const spoilerBlockType = 'spoiler';
/// A formula set on its own line.
const mathBlockType = 'math';
/// The formula in LaTeX, of a [mathBlockType] block.
const mathFormulaKey = 'formula';
/// The text mark of a formula within a line: its text is the LaTeX.
const inlineMathKey = 'math';
/// A YouTube video, played only once someone asks for it.
const youtubeBlockType = 'youtube';
/// The video's id: the 11 characters YouTube names it by.
const youtubeVideoKey = 'video';
/// Where the video starts, in whole seconds; missing for its beginning.
const youtubeStartKey = 'start';
/// The caption shown in place of the video; optional.
const youtubeCaptionKey = 'caption';
/// A button starting a study session of a studyset's cards.
const studyButtonBlockType = 'study_button';
/// The id of the studyset studied.
const studyButtonStudysetKey = 'studyset';
/// The id of the tag whose cards alone are studied; missing for all.
const studyButtonTagKey = 'tag';
/// The name of that tag, to find it by once its id is gone (a tag folded
/// into another of its name).
const studyButtonTagNameKey = 'tagName';
/// The button's label; empty or missing for a plain "Study".
const studyButtonLabelKey = 'label';
/// Whether the studyset's topics are studied too; true when missing.
const studyButtonTopicsKey = 'topics';
/// Whether front/back and vocabulary cards ask their back first.
const studyButtonBackFirstKey = 'backFirst';

View file

@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
/// Rich text as the editor stores it: the editor's document JSON plus the
/// version of that format, so stored content can be migrated when it changes.
@ -68,16 +69,23 @@ final class RichContent(
List<Object?> get props => [formatVersion, editorJson];
/// The text without its formatting, one line per block; for previews.
/// A block's text kept beside its blocks counts too: a formula, a
/// callout's title, a video's caption, a study button's label.
String get plainText {
final blocks = <String>[];
void visit(Object? node) {
if (node case {'data': {'delta': final List<Object?> delta}}) {
blocks.add(
[
for (final operation in delta)
if (operation case {'insert': final String text}) text,
].join(),
);
if (node case {'data': final Map<String, Object?> data}) {
if (data[_textKeys[node['type']]] case final String text) {
blocks.add(text);
}
if (data case {'delta': final List<Object?> delta}) {
blocks.add(
[
for (final operation in delta)
if (operation case {'insert': final String text}) text,
].join(),
);
}
}
if (node case {'children': final List<Object?> children}) {
children.forEach(visit);
@ -88,14 +96,20 @@ final class RichContent(
return blocks.where((block) => block.isNotEmpty).join('\n');
}
/// Whether it holds nothing: no text, no image and no sound.
bool get isBlank => plainText.trim().isEmpty && !hasImages && !hasSounds;
/// Whether it holds nothing: no text, and no block that shows something
/// without text (an image, a sound, a video, a study button).
bool get isBlank =>
plainText.trim().isEmpty &&
!_nodes.any((node) => _somethingWithoutText.contains(node['type']));
/// Whether it shows an image, the person's or another.
bool get hasImages => _hasBlock('image');
/// Whether it plays a sound.
bool get hasSounds => _hasBlock('audio');
bool get hasSounds => _hasBlock(audioBlockType);
/// Whether it plays a video.
bool get hasVideos => _hasBlock(youtubeBlockType);
/// The person's files it shows or plays, by their asset rows' ids.
Set<String> get assetIds => {
@ -142,6 +156,23 @@ String? assetIdOf(Object? url) =>
const _assetScheme = 'asset:';
/// The attribute holding the text of a block that keeps it beside its
/// blocks, by the block's type.
const _textKeys = {
mathBlockType: mathFormulaKey,
calloutBlockType: calloutTitleKey,
youtubeBlockType: youtubeCaptionKey,
studyButtonBlockType: studyButtonLabelKey,
};
/// The blocks that show something without any text.
const _somethingWithoutText = {
'image',
audioBlockType,
youtubeBlockType,
studyButtonBlockType,
};
Map<String, Object?> _unmodifiableMap(Map<String, Object?> map) =>
Map.unmodifiable({
for (final MapEntry(:key, :value) in map.entries)

View file

@ -12,6 +12,7 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.
import 'package:halkyon_learn_flutter/src/core/dependencies/person_folders.dart';
import 'package:halkyon_learn_flutter/src/core/files/file_picker.dart';
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
import 'package:halkyon_learn_flutter/src/feature/about/data/about_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/about/data/serverpod_about_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package_reader.dart';
@ -71,6 +72,9 @@ class Dependencies({
/// Lets the person pick files to import.
required final FilePicker files,
/// Plays the YouTube videos in content.
required final YoutubeVideos videos,
/// Reads Anki packages to import.
required final AnkiPackageReader ankiPackages,
final Future<void> Function()? _onDispose,
@ -104,6 +108,7 @@ class Dependencies({
maps: GeoMapRepository(),
sounds: sounds,
files: FileSelectorFilePicker(),
videos: IframeYoutubeVideos(),
ankiPackages: IsolateAnkiPackageReader(),
openPerson: (person) async {
await previousClosed.timeout(

View file

@ -0,0 +1,186 @@
import 'dart:async';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// A block taken as a whole, like the editor's own image block: selected,
/// copied and deleted as one, with no caret inside it. Its state mixes this
/// in after [SelectableMixin] and [BlockComponentConfigurable], and builds
/// with [buildAtomic].
///
/// In an editor it has a remove button, top right: on hover with a mouse,
/// always on touch screens, which have no hover. Read-only, it is only what
/// it shows.
mixin AtomicBlock<T extends BlockComponentStatefulWidget>
on State<T>, SelectableMixin<T>, BlockComponentConfigurable<T> {
final _contentKey = GlobalKey();
var _hovered = false;
/// The editor it is in.
late final EditorState editorState = context.read<EditorState>();
/// Whether it is in an editor rather than shown read-only.
bool get editing => editorState.editable;
RenderBox? get _renderBox => context.findRenderObject() as RenderBox?;
/// Removes the block from the document.
void remove() {
final transaction = editorState.transaction..deleteNode(node);
unawaited(editorState.apply(transaction));
}
/// The block around [child]: selectable as a whole, with the remove button
/// labelled [removeLabel] while [editing].
Widget buildAtomic({required Widget child, required String removeLabel}) {
final touch = switch (defaultTargetPlatform) {
TargetPlatform.android || TargetPlatform.iOS => true,
_ => false,
};
final content = Padding(key: _contentKey, padding: padding, child: child);
return Padding(
padding: margin,
child: BlockSelectionContainer(
node: node,
delegate: this,
listenable: editorState.selectionNotifier,
remoteSelection: editorState.remoteSelections,
blockColor: editorState.editorStyle.selectionColor,
supportTypes: const [BlockSelectionType.block],
child: !editing
? content
: MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Stack(
children: [
// Room beside the button, so it covers nothing.
Padding(
padding: const EdgeInsetsDirectional.only(end: 40),
child: content,
),
PositionedDirectional(
top: 0,
end: 0,
child: AnimatedOpacity(
opacity: touch || _hovered ? 1 : 0,
duration: const Duration(milliseconds: 120),
child: IconButton.filledTonal(
icon: const Icon(LucideIcons.x, size: 16),
tooltip: removeLabel,
visualDensity: VisualDensity.compact,
onPressed: remove,
),
),
),
],
),
),
),
);
}
@override
Position start() => Position(path: node.path);
@override
Position end() => Position(path: node.path, offset: 1);
@override
Position getPositionInOffset(Offset start) => end();
@override
bool get shouldCursorBlink => false;
@override
CursorStyle get cursorStyle => CursorStyle.cover;
@override
Rect getBlockRect({bool shiftWithBaseOffset = false}) {
final box = _contentKey.currentContext?.findRenderObject();
return box is RenderBox ? Offset.zero & box.size : Rect.zero;
}
@override
Rect? getCursorRectInPosition(
Position position, {
bool shiftWithBaseOffset = false,
}) {
final size = _renderBox?.size;
return size == null
? null
: Rect.fromLTWH(-size.width / 2, 0, size.width, size.height);
}
@override
List<Rect> getRectsInSelection(
Selection selection, {
bool shiftWithBaseOffset = false,
}) {
final parent = _renderBox;
if (parent == null) {
return [];
}
final box = _contentKey.currentContext?.findRenderObject();
return [
if (box is RenderBox)
box.localToGlobal(Offset.zero, ancestor: parent) & box.size
else
Offset.zero & parent.size,
];
}
@override
Selection getSelectionInRange(Offset start, Offset end) =>
Selection.single(path: node.path, startOffset: 0, endOffset: 1);
@override
Offset localToGlobal(Offset offset, {bool shiftWithBaseOffset = false}) =>
_renderBox!.localToGlobal(offset);
}
/// A builder of atomic blocks: valid without text or children of their own.
abstract class AtomicBlockBuilder() extends BlockComponentBuilder {
@override
BlockComponentValidate get validate =>
(node) => node.delta == null && node.children.isEmpty;
}
/// The box an atomic block shows when it holds nothing yet, or what it holds
/// can't be shown: [icon] and [text], and [action] if there is something to
/// do about it.
class const BlockNotice({
required final IconData icon,
required final String text,
final Widget? action,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = theme.colorScheme.onSurfaceVariant;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(icon, color: color),
const SizedBox(width: 12),
Expanded(
child: Text(
text,
style: theme.textTheme.bodyMedium?.copyWith(color: color),
),
),
?action,
],
),
);
}
}

View file

@ -0,0 +1,54 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:material_ui/material_ui.dart';
/// Lets the person choose one of [blocks] to insert, listed by their
/// [InsertGroup]; completes with it, or with null when they close the menu.
Future<InsertableBlock?> chooseBlock(
BuildContext context,
List<InsertableBlock> blocks,
) => showAdaptiveSheet<InsertableBlock>(
context,
builder: (context) => _BlockMenu(blocks: blocks),
);
class const _BlockMenu({required final List<InsertableBlock> blocks})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return SheetScaffold(
title: l10n.editorInsert,
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final group in InsertGroup.values)
if (blocks.where((block) => block.group == group).toList()
case final inGroup when inGroup.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
switch (group) {
InsertGroup.text => l10n.blockGroupText,
InsertGroup.blocks => l10n.blockGroupBlocks,
InsertGroup.media => l10n.blockGroupMedia,
},
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.primary,
),
),
),
for (final block in inGroup)
ListTile(
leading: Icon(block.icon),
title: Text(block.name(l10n)),
onTap: () => Navigator.of(context).pop(block),
),
],
],
),
);
}
}

View file

@ -0,0 +1,427 @@
import 'dart:async';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// A callout: a box of a kind (a note, a tip, a warning…) whose blocks are
/// written in place below its header, as a quote's text is. Its kind, title
/// and whether it starts folded are chosen from the header's menu.
///
/// In the editor it always shows what it holds; shown read-only, a folded
/// one opens and closes with the header's button, for that view alone.
/// A callout can't go inside another.
final calloutBlock = InsertableBlock(
name: (l10n) => l10n.blockCallout,
icon: LucideIcons.messageSquareWarning,
group: InsertGroup.blocks,
keywords: const ['callout', 'note', 'info', 'tip', 'warning', 'admonition'],
insideCallouts: false,
insert: (context, editorState, at) => insertBlock(
editorState,
Node(
type: calloutBlockType,
attributes: {calloutKindKey: calloutKinds.first},
children: [paragraphNode()],
),
at: at,
caretIn: const [0],
lineAfter: true,
insideCallouts: false,
),
);
/// How callouts are built.
final Map<String, BlockComponentBuilder> calloutBlockBuilders = {
calloutBlockType: _CalloutBuilder(),
};
/// Enter on an empty last line of a callout leaves it, as on an empty item
/// of a list: the line moves out after the callout, the caret with it.
final leaveCalloutOnEnter = CharacterShortcutEvent(
key: 'leave a callout',
character: '\n',
handler: (editorState) async {
final selection = editorState.selection;
final node = selection == null || !selection.isCollapsed
? null
: editorState.getNodeAtPath(selection.end.path);
final callout = node?.parent;
if (node == null ||
callout == null ||
callout.type != calloutBlockType ||
node.next != null ||
callout.children.length < 2 ||
!isEmptyLine(node)) {
return false;
}
final after = callout.path.next;
final transaction = editorState.transaction..deleteNode(node);
if (!(callout.next != null && isEmptyLine(callout.next!))) {
transaction.insertNode(after, paragraphNode());
}
transaction.afterSelection = Selection.collapsed(Position(path: after));
await editorState.apply(transaction);
return true;
},
);
/// A callout's kind: its icon, name and colour.
enum _Kind(final IconData icon) {
note(LucideIcons.pin),
info(LucideIcons.info),
tip(LucideIcons.lightbulb),
success(LucideIcons.circleCheck),
question(LucideIcons.circleQuestionMark),
warning(LucideIcons.triangleAlert),
danger(LucideIcons.octagonAlert),
quote(LucideIcons.quote);
/// The kind [name] names; a note for any other.
static _Kind of(Object? name) =>
_Kind.values.where((kind) => kind.name == name).firstOrNull ?? note;
String label(AppLocalizations l10n) => switch (this) {
note => l10n.calloutNote,
info => l10n.calloutInfo,
tip => l10n.calloutTip,
success => l10n.calloutSuccess,
question => l10n.calloutQuestion,
warning => l10n.calloutWarning,
danger => l10n.calloutDanger,
quote => l10n.calloutQuote,
};
/// The colour of its icon, title and bar: theme colours that keep their
/// contrast on the surface in light and dark alike.
Color color(ThemeData theme) {
final colors = theme.colorScheme;
final extras = theme.extras;
return switch (this) {
note => colors.primary,
info => extras.info,
tip => colors.tertiary,
success => extras.success,
question => colors.secondary,
warning => extras.warning,
danger => colors.error,
quote => colors.onSurfaceVariant,
};
}
}
class _CalloutBuilder() extends BlockComponentBuilder {
@override
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
final node = blockComponentContext.node;
return _Callout(key: node.key, node: node, configuration: configuration);
}
@override
BlockComponentValidate get validate =>
(node) => node.delta == null;
}
class const _Callout({
required super.node,
required super.configuration,
super.key,
}) extends BlockComponentStatefulWidget {
@override
State<_Callout> createState() => _CalloutState();
}
class _CalloutState()
extends State<_Callout>
with SelectableMixin, BlockComponentConfigurable {
final _boxKey = GlobalKey();
late final _editorState = context.read<EditorState>();
/// Whether it shows its blocks, read-only.
late var _open = widget.node.attributes[calloutFoldedKey] != true;
@override
BlockComponentConfiguration get configuration => widget.configuration;
@override
Node get node => widget.node;
RenderBox? get _renderBox => context.findRenderObject() as RenderBox?;
void _update(Map<String, Object?> attributes) {
final transaction = _editorState.transaction..updateNode(node, attributes);
unawaited(_editorState.apply(transaction));
}
void _remove() {
final transaction = _editorState.transaction..deleteNode(node);
unawaited(_editorState.apply(transaction));
}
/// Puts a line into an emptied callout, the caret on it.
void _writeInside() {
final transaction = _editorState.transaction
..insertNode([...node.path, 0], paragraphNode())
..afterSelection = Selection.collapsed(Position(path: [...node.path, 0]));
unawaited(_editorState.apply(transaction));
}
Future<void> _editTitle(_Kind kind) async {
final title = await showDialog<String>(
context: context,
builder: (context) => _TitleDialog(
title: node.attributes[calloutTitleKey] as String? ?? '',
kindLabel: kind.label(AppLocalizations.of(context)),
),
);
if (title != null && mounted) {
_update({calloutTitleKey: title.trim()});
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final editing = _editorState.editable;
final kind = _Kind.of(node.attributes[calloutKindKey]);
final accent = kind.color(theme);
final title = (node.attributes[calloutTitleKey] as String? ?? '').trim();
final folded = node.attributes[calloutFoldedKey] == true;
final open = editing || _open;
final header = Row(
children: [
Icon(kind.icon, size: 20, color: accent),
const SizedBox(width: 8),
Expanded(
child: Text(
title.isEmpty ? kind.label(l10n) : title,
style: theme.textTheme.titleSmall?.copyWith(
color: accent,
fontWeight: FontWeight.w700,
),
),
),
if (editing)
_CalloutMenu(
kind: kind,
folded: folded,
onKind: (kind) => _update({calloutKindKey: kind.name}),
onEditTitle: () => _editTitle(kind),
onFolded: (folded) => _update({calloutFoldedKey: folded}),
onRemove: _remove,
)
else
IconButton(
icon: Icon(
open ? LucideIcons.chevronDown : LucideIcons.chevronRight,
size: 20,
),
tooltip: open ? l10n.calloutHide : l10n.calloutShow,
visualDensity: VisualDensity.compact,
onPressed: () => setState(() => _open = !_open),
),
],
);
return Padding(
padding: margin,
child: ClipRRect(
key: _boxKey,
borderRadius: BorderRadius.circular(8),
child: ColoredBox(
color: accent.withValues(alpha: 0.10),
child: Stack(
children: [
Padding(
padding: const EdgeInsetsDirectional.fromSTEB(16, 4, 8, 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
header,
if (open)
for (final child in node.children)
_editorState.renderer.build(context, child),
if (editing && node.children.isEmpty)
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton(
onPressed: _writeInside,
child: Text(l10n.calloutWriteInside),
),
),
],
),
),
// The bar along its start.
PositionedDirectional(
start: 0,
top: 0,
bottom: 0,
child: SizedBox(width: 4, child: ColoredBox(color: accent)),
),
],
),
),
),
);
}
@override
Position start() => Position(path: node.path);
@override
Position end() => Position(path: node.path, offset: 1);
@override
Position getPositionInOffset(Offset start) => end();
@override
bool get shouldCursorBlink => false;
@override
CursorStyle get cursorStyle => CursorStyle.cover;
@override
Rect getBlockRect({bool shiftWithBaseOffset = false}) =>
getRectsInSelection(Selection.invalid()).first;
@override
Rect? getCursorRectInPosition(
Position position, {
bool shiftWithBaseOffset = false,
}) => getRectsInSelection(Selection.collapsed(position)).firstOrNull;
@override
List<Rect> getRectsInSelection(
Selection selection, {
bool shiftWithBaseOffset = false,
}) {
final parent = _renderBox;
if (parent == null) {
return [];
}
final box = _boxKey.currentContext?.findRenderObject();
return [
if (box is RenderBox)
box.localToGlobal(Offset.zero, ancestor: parent) & box.size
else
Offset.zero & parent.size,
];
}
@override
Selection getSelectionInRange(Offset start, Offset end) =>
Selection.single(path: node.path, startOffset: 0, endOffset: 1);
@override
Offset localToGlobal(Offset offset, {bool shiftWithBaseOffset = false}) =>
_renderBox!.localToGlobal(offset);
}
/// The header's menu, in the editor: the kind, the title, whether it starts
/// folded, and removing it.
class const _CalloutMenu({
required final _Kind kind,
required final bool folded,
required final ValueChanged<_Kind> onKind,
required final VoidCallback onEditTitle,
required final ValueChanged<bool> onFolded,
required final VoidCallback onRemove,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return MenuAnchor(
menuChildren: [
for (final option in _Kind.values)
MenuItemButton(
leadingIcon: Icon(option.icon, color: option.color(theme)),
trailingIcon: option == kind
? const Icon(LucideIcons.check, size: 18)
: null,
onPressed: () => onKind(option),
child: Text(option.label(l10n)),
),
const Divider(),
MenuItemButton(
leadingIcon: const Icon(LucideIcons.pencil),
onPressed: onEditTitle,
child: Text(l10n.calloutEditTitle),
),
CheckboxMenuButton(
value: folded,
onChanged: (value) => onFolded(value ?? false),
child: Text(l10n.calloutFoldedAtFirst),
),
const Divider(),
MenuItemButton(
leadingIcon: Icon(LucideIcons.trash2, color: theme.colorScheme.error),
onPressed: onRemove,
child: Text(l10n.calloutRemove),
),
],
builder: (context, controller, child) => IconButton(
icon: const Icon(LucideIcons.ellipsis, size: 20),
tooltip: l10n.calloutOptions,
visualDensity: VisualDensity.compact,
onPressed: () =>
controller.isOpen ? controller.close() : controller.open(),
),
);
}
}
/// Asks for a callout's title; empty for its kind's name.
class const _TitleDialog({
required final String title,
required final String kindLabel,
}) extends StatefulWidget {
@override
State<_TitleDialog> createState() => _TitleDialogState();
}
class _TitleDialogState() extends State<_TitleDialog> {
late final _field = TextEditingController(text: widget.title);
@override
void dispose() {
_field.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
void save() => Navigator.of(context).pop(_field.text);
return AlertDialog(
title: Text(l10n.calloutTitleDialog),
content: TextField(
controller: _field,
autofocus: true,
decoration: InputDecoration(
labelText: l10n.calloutTitleLabel,
hintText: widget.kindLabel,
helperText: l10n.calloutTitleHelper,
),
onSubmitted: (_) => save(),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.commonCancel),
),
FilledButton(onPressed: save, child: Text(l10n.commonSave)),
],
);
}
}

View file

@ -0,0 +1,113 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/editor/callout_block.dart';
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/editor/file_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/editor/math_block.dart';
import 'package:halkyon_learn_flutter/src/core/editor/spoiler_block.dart';
import 'package:halkyon_learn_flutter/src/core/editor/youtube_block.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The blocks every editor knows: the editor's own, and the core's.
final coreBlocks = EditorBlocks(
builders: {
...standardBlockComponentBuilderMap,
...fileBlockBuilders,
...calloutBlockBuilders,
...spoilerBlockBuilders,
...mathBlockBuilders,
...youtubeBlockBuilders,
},
insertable: [
for (final level in [1, 2, 3])
_textBlock(
name: (l10n) => l10n.blockHeading(level),
icon: const [
LucideIcons.heading1,
LucideIcons.heading2,
LucideIcons.heading3,
][level - 1],
keywords: ['heading', 'h$level', 'title'],
node: () => headingNode(level: level),
),
_textBlock(
name: (l10n) => l10n.blockBulletedList,
icon: LucideIcons.list,
keywords: const ['bulleted list', 'bullet', 'unordered'],
node: bulletedListNode,
),
_textBlock(
name: (l10n) => l10n.blockNumberedList,
icon: LucideIcons.listOrdered,
keywords: const ['numbered list', 'ordered', 'number'],
node: numberedListNode,
),
_textBlock(
name: (l10n) => l10n.blockChecklist,
icon: LucideIcons.listTodo,
keywords: const ['checklist', 'todo', 'checkbox', 'task'],
node: () => todoListNode(checked: false),
),
_textBlock(
name: (l10n) => l10n.blockQuote,
icon: LucideIcons.textQuote,
keywords: const ['quote', 'citation'],
node: quoteNode,
),
InsertableBlock(
name: (l10n) => l10n.blockDivider,
icon: LucideIcons.separatorHorizontal,
group: InsertGroup.blocks,
keywords: const ['divider', 'horizontal rule', 'line', 'separator'],
insert: (context, editorState, at) =>
insertBlock(editorState, dividerNode(), at: at),
),
InsertableBlock(
name: (l10n) => l10n.blockTable,
icon: LucideIcons.table,
group: InsertGroup.blocks,
keywords: const ['table', 'grid', 'rows', 'columns'],
insideCallouts: false,
// Two rows of two cells, the caret in the first.
insert: (context, editorState, at) => insertBlock(
editorState,
TableNode.fromList([
['', ''],
['', ''],
]).node,
at: at,
caretIn: const [0, 0],
lineAfter: true,
insideCallouts: false,
),
),
calloutBlock,
spoilerBlock,
mathBlock,
imageBlock,
soundBlock,
youtubeBlock,
],
shortcuts: [leaveCalloutOnEnter],
);
/// The blocks of the editors below [context]: the core's, and the
/// features' too where the app gives them ([EditorBlocksScope]).
EditorBlocks blocksOf(BuildContext context) =>
EditorBlocksScope.maybeOf(context) ?? coreBlocks;
/// A line of text of a kind, which the caret goes into.
InsertableBlock _textBlock({
required String Function(AppLocalizations l10n) name,
required IconData icon,
required List<String> keywords,
required Node Function() node,
}) => InsertableBlock(
name: name,
icon: icon,
group: InsertGroup.text,
keywords: keywords,
insert: (context, editorState, at) =>
insertBlock(editorState, node(), at: at, caretIn: const []),
);

View file

@ -0,0 +1,241 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/editor/atomic_block.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Where a block is listed in the Insert menu.
enum InsertGroup() {
/// Lines of text of a kind: headings, lists, quotes.
text,
/// Blocks laying out or adding to the text: tables, callouts, formulas.
blocks,
/// Files and videos.
media,
}
/// A block people can insert, as the Insert menu and the `/` menu offer it.
final class const InsertableBlock({
/// Its name in the menus.
required final String Function(AppLocalizations l10n) name,
/// Its icon in the menus.
required final IconData icon,
/// Where the Insert menu lists it.
required final InsertGroup group,
/// Words besides its name that find it after a `/`.
required final List<String> keywords,
/// Inserts it into the editor's state at the caret (null when there is
/// none), with the editor's context, for what the block asks first.
required final Future<void> Function(
BuildContext context,
EditorState editorState,
Selection? at,
)
insert,
/// Whether it may go inside a callout; else it goes after the callout.
final bool insideCallouts = true,
});
/// The blocks an editor knows: how each type is built, which of them
/// people can insert, in the menus' order, and what typing does in them
/// besides what the editor does anyway.
final class const EditorBlocks({
required final Map<String, BlockComponentBuilder> builders,
required final List<InsertableBlock> insertable,
/// Tried before the editor's own, so they may take a key from it.
final List<CharacterShortcutEvent> shortcuts = const [],
}) {
/// These blocks and [more]'s, [more]'s after these.
EditorBlocks plus(EditorBlocks more) => EditorBlocks(
builders: {...builders, ...more.builders},
insertable: [...insertable, ...more.insertable],
shortcuts: [...shortcuts, ...more.shortcuts],
);
}
/// Gives the editors below the blocks of the features too (a study button,
/// say), which the core can't know: the core's blocks plus theirs.
class const EditorBlocksScope({
required final EditorBlocks blocks,
required super.child,
super.key,
}) extends InheritedWidget {
/// The blocks the scope above [context] gives, if there is one.
static EditorBlocks? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<EditorBlocksScope>()?.blocks;
@override
bool updateShouldNotify(EditorBlocksScope oldWidget) =>
blocks != oldWidget.blocks;
}
/// The builders of [blocks] with a box for any type they don't know, as
/// content from a newer app may hold: it says so, and the block stays.
Map<String, BlockComponentBuilder> buildersWithUnknown(EditorBlocks blocks) => {
...blocks.builders,
errorBlockComponentBuilderKey: _UnknownBlockBuilder(),
};
/// The `/` menu of [blocks] in place of the editor's own, whose items can't
/// be translated and whose Image item makes images that never show. Desktop
/// only, as the editor's (phones use the Insert button).
CharacterShortcutEvent slashMenu(BuildContext context, EditorBlocks blocks) {
final l10n = AppLocalizations.of(context);
final dark = Theme.of(context).brightness == Brightness.dark;
return customSlashCommand(
[
for (final block in blocks.insertable)
SelectionMenuItem(
getName: () => block.name(l10n),
icon: (editorState, selected, style) => Icon(
block.icon,
size: 18,
color: selected
? style.selectionMenuItemSelectedIconColor
: style.selectionMenuItemIconColor,
),
keywords: block.keywords,
// The menu's context is in the overlay; the editor's is the one
// with its surroundings.
handler: (editorState, menuService, menuContext) =>
block.insert(context, editorState, editorState.selection),
),
],
style: dark ? SelectionMenuStyle.dark : SelectionMenuStyle.light,
);
}
/// The character shortcuts of [blocks], then the editor's own, its `/`
/// menu replaced by [slash].
List<CharacterShortcutEvent> characterShortcutsOf(
EditorBlocks blocks,
CharacterShortcutEvent slash,
) => [
...blocks.shortcuts,
for (final event in standardCharacterShortcutEvents)
if (event.key != slash.key) event,
slash,
];
/// Inserts [block] at [at] (the caret; the end of the document without
/// one): in place of the empty line the caret is on, else after the block
/// the caret is in. A block may not go inside a callout unless
/// [insideCallouts], and never inside a table: then it goes after them.
///
/// The caret then goes to [caretIn], a path inside the block (`[]` for the
/// block itself, a heading say), or else to an empty line after the block.
/// That line is added unless one follows already; with a [caretIn] only if
/// [lineAfter], for a block that is typed into but can't be left by typing,
/// such as a table.
Future<void> insertBlock(
EditorState editorState,
Node block, {
Selection? at,
Path? caretIn,
bool lineAfter = false,
bool insideCallouts = true,
}) async {
final root = editorState.document.root;
var current = at == null ? null : editorState.getNodeAtPath(at.end.path);
// Up to a block directly in the page, or in a callout if allowed.
while (current != null &&
current.parent != null &&
current.parent != root &&
!(insideCallouts && current.parent!.type == calloutBlockType)) {
current = current.parent;
}
final transaction = editorState.transaction;
final Path path;
final Node? after;
if (current == null) {
path = [root.children.length];
after = null;
} else if (isEmptyLine(current)) {
path = current.path;
after = current.next;
transaction
..insertNode(path, block)
..deleteNode(current);
} else {
path = current.path.next;
after = current.next;
}
if (current == null || !isEmptyLine(current)) {
transaction.insertNode(path, block);
}
if ((caretIn == null || lineAfter) &&
(after == null || !isEmptyLine(after))) {
transaction.insertNode(path.next, paragraphNode());
}
transaction.afterSelection = Selection.collapsed(
Position(path: caretIn == null ? path.next : [...path, ...caretIn]),
);
await editorState.apply(transaction);
}
/// Whether [node] is an empty paragraph, nothing in it.
bool isEmptyLine(Node node) =>
node.type == ParagraphBlockKeys.type &&
(node.delta?.isEmpty ?? false) &&
node.children.isEmpty;
class _UnknownBlockBuilder() extends BlockComponentBuilder {
@override
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
final node = blockComponentContext.node;
return _UnknownBlock(
key: node.key,
node: node,
configuration: configuration,
);
}
@override
BlockComponentValidate get validate =>
(_) => true;
}
/// A block this app can't show, from a newer one: it says so, and stays as
/// it is unless the person removes it.
class const _UnknownBlock({
required super.node,
required super.configuration,
super.key,
}) extends BlockComponentStatefulWidget {
@override
State<_UnknownBlock> createState() => _UnknownBlockState();
}
class _UnknownBlockState()
extends State<_UnknownBlock>
with
SelectableMixin,
BlockComponentConfigurable,
AtomicBlock<_UnknownBlock> {
@override
BlockComponentConfiguration get configuration => widget.configuration;
@override
Node get node => widget.node;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return buildAtomic(
removeLabel: l10n.blockRemove,
child: BlockNotice(
icon: LucideIcons.circleFadingArrowUp,
text: l10n.blockUnknown,
),
);
}
}

View file

@ -1,5 +1,6 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/foundation.dart';
import 'package:halkyon_learn_flutter/src/core/editor/math_block.dart';
import 'package:halkyon_learn_flutter/src/core/editor/scripts.dart';
import 'package:material_ui/material_ui.dart';
@ -9,8 +10,9 @@ import 'package:material_ui/material_ui.dart';
/// and 100 dp beside it on desktops.
///
/// Phones get the touch variant, with handles to drag the selection. Both
/// draw superscript and subscript text (`withScripts`).
EditorStyle editorStyleOf(BuildContext context) {
/// draw superscript and subscript text (`withScripts`), and formulas within
/// lines (`withInlineMath`): as their source while [editing], else set.
EditorStyle editorStyleOf(BuildContext context, {bool editing = true}) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final text = TextStyleConfiguration(
@ -33,14 +35,20 @@ EditorStyle editorStyleOf(BuildContext context) {
selectionColor: selection,
dragHandleColor: colors.primary,
textStyleConfiguration: text,
textSpanDecorator: withScripts(mobileTextSpanDecoratorForAttribute),
textSpanDecorator: withInlineMath(
withScripts(mobileTextSpanDecoratorForAttribute),
editing: editing,
),
),
_ => EditorStyle.desktop(
padding: EdgeInsets.zero,
cursorColor: colors.primary,
selectionColor: selection,
textStyleConfiguration: text,
textSpanDecorator: withScripts(defaultTextSpanDecoratorForAttribute),
textSpanDecorator: withInlineMath(
withScripts(defaultTextSpanDecoratorForAttribute),
editing: editing,
),
),
};
}

View file

@ -1,11 +1,18 @@
import 'dart:developer';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/asset/bloc/sound_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/media_type.dart';
import 'package:halkyon_learn_flutter/src/core/asset/widget/stored_file_image.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/editor/atomic_block.dart';
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -15,9 +22,6 @@ import 'package:material_ui/material_ui.dart';
// fetched: content from elsewhere could otherwise make every device that
// shows it call a server of its choice.
/// The block type of a sound.
const audioBlockType = 'audio';
/// The block attribute holding a file's URL, in images and sounds alike.
const fileUrlKey = ImageBlockKeys.url;
@ -39,22 +43,90 @@ String previewOf(RichContent content, AppLocalizations l10n) {
? l10n.fileImage
: content.hasSounds
? l10n.fileSound
: content.hasVideos
? l10n.youtubeVideo
: '';
}
/// The editor's blocks, with images and sounds shown as above.
/// Images and sounds, shown as above.
final Map<String, BlockComponentBuilder> fileBlockBuilders = {
...standardBlockComponentBuilderMap,
ImageBlockKeys.type: _FileBlockBuilder(_FileKind.image),
audioBlockType: _FileBlockBuilder(_FileKind.sound),
};
/// An image from the person's device, kept as a file of theirs.
final imageBlock = InsertableBlock(
name: (l10n) => l10n.fileImage,
icon: LucideIcons.image,
group: InsertGroup.media,
keywords: const ['image', 'picture', 'photo'],
insert: (context, editorState, at) =>
_insertFile(context, editorState, at, _FileKind.image),
);
/// A sound from the person's device, kept as a file of theirs.
final soundBlock = InsertableBlock(
name: (l10n) => l10n.fileSound,
icon: LucideIcons.volume2,
group: InsertGroup.media,
keywords: const ['sound', 'audio', 'pronunciation'],
insert: (context, editorState, at) =>
_insertFile(context, editorState, at, _FileKind.sound),
);
/// Lets the person pick a file of [kind] and inserts it at [at]; says why
/// when it can't be taken.
Future<void> _insertFile(
BuildContext context,
EditorState editorState,
Selection? at,
_FileKind kind,
) async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.maybeOf(context);
final assets = PersonScope.of(context).assets;
final image = kind == _FileKind.image;
final file = await DependenciesScope.of(context).files.pick(
label: image ? l10n.fileImages : l10n.fileSounds,
extensions: image ? imageExtensions : soundExtensions,
);
if (file == null) {
return;
}
void tell(String message) =>
messenger?.showSnackBar(SnackBar(content: Text(message)));
final mediaType = mediaTypeOf(file.name);
if (mediaType == null ||
!mediaType.startsWith(image ? 'image/' : 'audio/') ||
file.bytes.isEmpty) {
tell(image ? l10n.fileNotAnImage : l10n.fileNotASound);
return;
}
if (file.bytes.length > AssetRepository.maxBytes) {
tell(l10n.fileTooLarge(AssetRepository.maxBytes ~/ (1024 * 1024)));
return;
}
final String id;
try {
id = await assets.add(file.bytes, fileName: file.name, mimeType: mediaType);
} on Exception catch (error) {
log('Keeping a picked file failed', name: 'Editor', error: error);
tell(l10n.fileNotKept);
return;
}
await insertBlock(
editorState,
image ? fileImageNode(id) : audioNode(id),
at: at,
);
}
enum _FileKind() {
image,
sound,
}
class _FileBlockBuilder(final _FileKind _kind) extends BlockComponentBuilder {
class _FileBlockBuilder(final _FileKind _kind) extends AtomicBlockBuilder {
@override
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
final node = blockComponentContext.node;
@ -65,10 +137,6 @@ class _FileBlockBuilder(final _FileKind _kind) extends BlockComponentBuilder {
configuration: configuration,
);
}
@override
BlockComponentValidate get validate =>
(node) => node.delta == null && node.children.isEmpty;
}
/// A file in the document: selected, copied and deleted as a whole, like the
@ -85,26 +153,21 @@ class const _FileBlock({
class _FileBlockState()
extends State<_FileBlock>
with SelectableMixin, BlockComponentConfigurable {
final _contentKey = GlobalKey();
with SelectableMixin, BlockComponentConfigurable, AtomicBlock<_FileBlock> {
@override
BlockComponentConfiguration get configuration => widget.configuration;
@override
Node get node => widget.node;
late final _editorState = context.read<EditorState>();
RenderBox? get _renderBox => context.findRenderObject() as RenderBox?;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final assetId = assetIdOf(node.attributes[fileUrlKey]);
final content = switch ((widget.kind, assetId)) {
(_, null) => _Unavailable(
(_, null) => BlockNotice(
icon: LucideIcons.imageOff,
text: AppLocalizations.of(context).fileFromElsewhere,
text: l10n.fileFromElsewhere,
),
(_FileKind.image, final id?) => _FileImage(
assetId: id,
@ -112,81 +175,14 @@ class _FileBlockState()
),
(_FileKind.sound, final id?) => _Sound(assetId: id),
};
return Padding(
padding: margin,
child: BlockSelectionContainer(
node: node,
delegate: this,
listenable: _editorState.selectionNotifier,
remoteSelection: _editorState.remoteSelections,
blockColor: _editorState.editorStyle.selectionColor,
supportTypes: const [BlockSelectionType.block],
child: Padding(
key: _contentKey,
padding: padding,
child: content,
),
),
return buildAtomic(
removeLabel: switch (widget.kind) {
_FileKind.image => l10n.fileRemoveImage,
_FileKind.sound => l10n.fileRemoveSound,
},
child: content,
);
}
@override
Position start() => Position(path: node.path);
@override
Position end() => Position(path: node.path, offset: 1);
@override
Position getPositionInOffset(Offset start) => end();
@override
bool get shouldCursorBlink => false;
@override
CursorStyle get cursorStyle => CursorStyle.cover;
@override
Rect getBlockRect({bool shiftWithBaseOffset = false}) {
final box = _contentKey.currentContext?.findRenderObject();
return box is RenderBox ? Offset.zero & box.size : Rect.zero;
}
@override
Rect? getCursorRectInPosition(
Position position, {
bool shiftWithBaseOffset = false,
}) {
final size = _renderBox?.size;
return size == null
? null
: Rect.fromLTWH(-size.width / 2, 0, size.width, size.height);
}
@override
List<Rect> getRectsInSelection(
Selection selection, {
bool shiftWithBaseOffset = false,
}) {
final parent = _renderBox;
if (parent == null) {
return [];
}
final box = _contentKey.currentContext?.findRenderObject();
return [
if (box is RenderBox)
box.localToGlobal(Offset.zero, ancestor: parent) & box.size
else
Offset.zero & parent.size,
];
}
@override
Selection getSelectionInRange(Offset start, Offset end) =>
Selection.single(path: node.path, startOffset: 0, endOffset: 1);
@override
Offset localToGlobal(Offset offset, {bool shiftWithBaseOffset = false}) =>
_renderBox!.localToGlobal(offset);
}
/// An image of the person's, at most as wide as the text and a little taller
@ -206,7 +202,7 @@ class const _FileImage({
image: StoredFileImage(PersonScope.of(context).assets, assetId),
fit: BoxFit.contain,
semanticLabel: AppLocalizations.of(context).fileImage,
errorBuilder: (context, error, stackTrace) => _Unavailable(
errorBuilder: (context, error, stackTrace) => BlockNotice(
icon: LucideIcons.imageOff,
text: error is FileNotHereException
? AppLocalizations.of(context).fileImageUnavailable
@ -237,15 +233,18 @@ class const _SoundButton() extends StatelessWidget {
final state = context.watch<SoundBloc>().state;
switch (state) {
case SoundState.missing:
return _Unavailable(
return BlockNotice(
icon: LucideIcons.volumeOff,
text: l10n.fileSoundUnavailable,
onRetry: () =>
context.read<SoundBloc>().add(const SoundEventToggled()),
action: TextButton(
onPressed: () =>
context.read<SoundBloc>().add(const SoundEventToggled()),
child: Text(l10n.commonTryAgain),
),
);
// Trying again wouldn't change the file.
case SoundState.unplayable:
return _Unavailable(
return BlockNotice(
icon: LucideIcons.volumeOff,
text: l10n.fileSoundUnplayable,
);
@ -276,40 +275,3 @@ class const _SoundButton() extends StatelessWidget {
);
}
}
/// In place of a file that can't be shown.
class const _Unavailable({
required final IconData icon,
required final String text,
final VoidCallback? onRetry,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = theme.colorScheme.onSurfaceVariant;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(icon, color: color),
const SizedBox(width: 12),
Expanded(
child: Text(
text,
style: theme.textTheme.bodyMedium?.copyWith(color: color),
),
),
if (onRetry != null)
TextButton(
onPressed: onRetry,
child: Text(AppLocalizations.of(context).commonTryAgain),
),
],
),
);
}
}

View file

@ -2,19 +2,32 @@ 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/content/content_blocks.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.
/// underline, superscript and subscript, and with [onFormula] formulas
/// within the line. Each button shows whether the selection has its format
/// already. With [onInsert], an Insert button comes first, for blocks.
///
/// 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 {
class const FormatBar({
required final EditorState editorState,
/// Called by the Insert button, which shows only with it.
final VoidCallback? onInsert,
/// Called by the formula button (Σ), which shows only with it.
final VoidCallback? onFormula,
super.key,
}) extends StatefulWidget {
/// How tall the bar is, so what docks it can make room for it.
static const height = 44.0;
@override
State<FormatBar> createState() => _FormatBarState();
}
@ -106,65 +119,84 @@ class _FormatBarState() extends State<FormatBar> {
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),
),
],
child: SizedBox(
height: FormatBar.height,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Row(
children: [
if (widget.onInsert case final onInsert?) ...[
_FormatButton(
icon: LucideIcons.plus,
tooltip: l10n.editorInsert,
active: false,
onPressed: onInsert,
),
const _Divider(),
],
_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),
),
if (widget.onFormula case final onFormula?)
_FormatButton(
icon: LucideIcons.sigma,
tooltip: l10n.formatInlineMath,
active: _has(inlineMathKey),
onPressed: onFormula,
),
],
),
),
),
);

View file

@ -0,0 +1,235 @@
import 'package:flutter/services.dart';
import 'package:flutter_math_fork/flutter_math.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/editor/math_view.dart';
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
import 'package:material_ui/material_ui.dart';
/// Edits a formula in LaTeX in the whole window, previewed as it is typed,
/// with buttons for its usual pieces. Completes with the formula once saved
/// (Ctrl+Enter saves too), or with null when left without saving.
Future<String?> editFormula(
BuildContext context, {
required String initial,
/// The app bar's title, saying what is being written.
required String title,
}) => Navigator.of(context).push<String>(
MaterialPageRoute(
fullscreenDialog: true,
builder: (context) => _FormulaScreen(initial: initial, title: title),
),
);
/// A piece of LaTeX a button puts in: `#` marks where what is selected goes,
/// or the caret when nothing is.
typedef _Snippet = ({String latex, String label});
/// The buttons, in groups: structures, big operators, symbols, layout.
const List<List<_Snippet>> _snippets = [
[
(latex: r'\frac{#}{}', label: r'\frac{a}{b}'),
(latex: r'\sqrt{#}', label: r'\sqrt{x}'),
(latex: r'\sqrt[]{#}', label: r'\sqrt[n]{x}'),
(latex: '{#}^{}', label: 'x^{n}'),
(latex: '{#}_{}', label: 'x_{i}'),
],
[
(latex: r'\sum_{#}^{}', label: r'\sum'),
(latex: r'\prod_{#}^{}', label: r'\prod'),
(latex: r'\int_{#}^{}', label: r'\int'),
(latex: r'\lim_{# \to }', label: r'\lim'),
],
[
(latex: r'\times #', label: r'\times'),
(latex: r'\cdot #', label: r'\cdot'),
(latex: r'\pm #', label: r'\pm'),
(latex: r'\to #', label: r'\to'),
(latex: r'\infty #', label: r'\infty'),
(latex: r'\pi #', label: r'\pi'),
(latex: r'\ne #', label: r'\ne'),
(latex: r'\le #', label: r'\le'),
(latex: r'\ge #', label: r'\ge'),
],
[
(latex: r'\begin{aligned}#\end{aligned}', label: r'\text{aligned}'),
(latex: r'\begin{cases}#\end{cases}', label: r'\text{cases}'),
(latex: r'\begin{pmatrix}#\end{pmatrix}', label: r'\text{matrix}'),
(latex: r' \\ #', label: r'\text{new line}'),
],
];
class const _FormulaScreen({
required final String initial,
required final String title,
}) extends StatefulWidget {
@override
State<_FormulaScreen> createState() => _FormulaScreenState();
}
class _FormulaScreenState() extends State<_FormulaScreen> {
late final _field = TextEditingController(text: widget.initial)
..addListener(() => setState(() {}));
final _focus = FocusNode();
@override
void dispose() {
_field.dispose();
_focus.dispose();
super.dispose();
}
void _save() => Navigator.of(context).pop(_field.text);
/// Puts [snippet] in place of the selection, the selection where its `#`
/// is; the caret then goes into its next empty group when there was a
/// selection, else to where the `#` was.
void _insert(_Snippet snippet) {
final value = _field.value;
final selection = value.selection.isValid
? value.selection
: TextSelection.collapsed(offset: value.text.length);
final selected = selection.textInside(value.text);
final hash = snippet.latex.indexOf('#');
final inserted = snippet.latex.replaceFirst('#', selected);
final emptyGroup = selected.isEmpty
? -1
: inserted.indexOf('{}', hash + selected.length);
final caret =
selection.start +
(emptyGroup >= 0 ? emptyGroup + 1 : hash + selected.length);
_field.value = TextEditingValue(
text: value.text.replaceRange(selection.start, selection.end, inserted),
selection: TextSelection.collapsed(offset: caret),
);
_focus.requestFocus();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final colors = theme.colorScheme;
final formula = _field.text;
return CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.enter, control: true): _save,
const SingleActivator(LogicalKeyboardKey.enter, meta: true): _save,
},
child: Scaffold(
appBar: AppBar(
title: Text(widget.title),
actions: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: FilledButton(
onPressed: _save,
child: Text(l10n.commonSave),
),
),
],
),
body: Column(
children: [
Expanded(
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: readableContentMaxWidth,
),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
l10n.formulaPreview,
style: theme.textTheme.labelLarge,
),
const SizedBox(height: 8),
Container(
constraints: const BoxConstraints(minHeight: 64),
padding: const EdgeInsets.all(12),
alignment: Alignment.center,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: formula.trim().isEmpty
? Text(
l10n.formulaPreviewEmpty,
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
)
: MathView(formula: formula),
),
const SizedBox(height: 16),
TextField(
controller: _field,
focusNode: _focus,
autofocus: true,
minLines: 3,
maxLines: null,
style: const TextStyle(fontFamily: 'monospace'),
decoration: InputDecoration(
labelText: l10n.formulaLatex,
// LaTeX reads the same in every language.
hintText: r'\frac{a}{b} = c^2',
helperText: l10n.formulaLatexHelper,
border: const OutlineInputBorder(),
),
),
],
),
),
),
),
Divider(height: 1, color: colors.outlineVariant),
_SnippetBar(onSnippet: _insert),
],
),
),
);
}
}
/// The snippets' buttons, scrolling sideways, each showing what it writes.
class const _SnippetBar({required final ValueChanged<_Snippet> onSnippet})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return SafeArea(
top: false,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
child: Row(
children: [
for (final (index, group) in _snippets.indexed) ...[
if (index > 0)
SizedBox(
height: 24,
child: VerticalDivider(
width: 12,
color: colors.outlineVariant,
),
),
for (final snippet in group)
Tooltip(
message: snippet.label,
child: TextButton(
onPressed: () => onSnippet(snippet),
child: Math.tex(
snippet.label,
textStyle: TextStyle(color: colors.onSurface),
),
),
),
],
],
),
),
);
}
}

View file

@ -0,0 +1,176 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/editor/atomic_block.dart';
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/editor/formula_screen.dart';
import 'package:halkyon_learn_flutter/src/core/editor/math_view.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// A formula on a line of its own, written on its own screen
/// ([editFormula]) and inserted once saved; a tap on it in the editor opens
/// it again.
final mathBlock = InsertableBlock(
name: (l10n) => l10n.blockFormula,
icon: LucideIcons.sigma,
group: InsertGroup.blocks,
keywords: const ['formula', 'math', 'equation', 'latex', 'tex'],
insert: (context, editorState, at) async {
final formula = await editFormula(
context,
initial: '',
title: AppLocalizations.of(context).formulaTitle,
);
if (formula == null || formula.trim().isEmpty) {
return;
}
await insertBlock(
editorState,
Node(type: mathBlockType, attributes: {mathFormulaKey: formula}),
at: at,
);
},
);
/// How formulas on their own lines are built.
final Map<String, BlockComponentBuilder> mathBlockBuilders = {
mathBlockType: _MathBlockBuilder(),
};
/// Makes the selection of [editorState] a formula within its line, or
/// plain text again if it is one; at a caret, writes a new formula on its
/// own screen and puts it there. A formula within a line is text marked
/// [inlineMathKey], its text the LaTeX: written as its source, shown set.
Future<void> toggleInlineMath(
BuildContext context,
EditorState editorState,
) async {
final selection = editorState.selection;
if (selection == null) {
return;
}
if (!selection.isCollapsed) {
await editorState.toggleAttribute(inlineMathKey);
return;
}
final formula = await editFormula(
context,
initial: '',
title: AppLocalizations.of(context).formulaInLineTitle,
);
final node = editorState.getNodeAtPath(selection.end.path);
if (formula == null || formula.trim().isEmpty || node?.delta == null) {
editorState.selection = selection;
return;
}
final offset = selection.end.offset;
final transaction = editorState.transaction
..insertText(node!, offset, formula, attributes: {inlineMathKey: true})
..afterSelection = Selection.collapsed(
Position(path: node.path, offset: offset + formula.length),
);
await editorState.apply(transaction);
}
/// [base], the way marked text is drawn, with formulas within lines drawn
/// too: while [editing], as their LaTeX in the code font, which the caret
/// moves through as text; else set, as a widget within the line.
TextSpanDecoratorForAttribute withInlineMath(
TextSpanDecoratorForAttribute base, {
required bool editing,
}) => (context, node, index, text, before, after) {
final span = base(context, node, index, text, before, after);
if (text.attributes?[inlineMathKey] != true) {
return span;
}
final colors = Theme.of(context).colorScheme;
final style = span.style ?? before.style ?? const TextStyle();
if (editing) {
return TextSpan(
text: text.text,
style: style.copyWith(fontFamily: 'monospace', color: colors.primary),
);
}
return WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: MathView(
formula: text.text,
display: false,
fontSize: style.fontSize,
),
);
};
class _MathBlockBuilder() extends AtomicBlockBuilder {
@override
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
final node = blockComponentContext.node;
return _MathBlock(key: node.key, node: node, configuration: configuration);
}
}
class const _MathBlock({
required super.node,
required super.configuration,
super.key,
}) extends BlockComponentStatefulWidget {
@override
State<_MathBlock> createState() => _MathBlockState();
}
class _MathBlockState()
extends State<_MathBlock>
with SelectableMixin, BlockComponentConfigurable, AtomicBlock<_MathBlock> {
@override
BlockComponentConfiguration get configuration => widget.configuration;
@override
Node get node => widget.node;
Future<void> _edit() async {
final formula = await editFormula(
context,
initial: node.attributes[mathFormulaKey] as String? ?? '',
title: AppLocalizations.of(context).formulaTitle,
);
if (formula != null && mounted) {
final transaction = editorState.transaction
..updateNode(node, {mathFormulaKey: formula});
await editorState.apply(transaction);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final colors = Theme.of(context).colorScheme;
final formula = node.attributes[mathFormulaKey] as String? ?? '';
final Widget shown = formula.trim().isEmpty
? BlockNotice(icon: LucideIcons.sigma, text: l10n.formulaEmpty)
: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: MathView(formula: formula),
);
return buildAtomic(
removeLabel: l10n.formulaRemove,
child: !editing
? shown
: Tooltip(
message: l10n.formulaEdit,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: _edit,
child: Ink(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: colors.surfaceContainerLow,
borderRadius: BorderRadius.circular(8),
),
child: shown,
),
),
),
);
}
}

Some files were not shown because too many files have changed in this diff Show more