feat(learn): Markdown files beside the PDFs, read formatted

A studyset's files take Markdown (.md, .markdown) as well as PDFs, in
the same synced rows with a kind (payload schema 3, written only for
Markdown, so older apps skip those rows). Upload a PDF becomes Upload a
file; a Markdown file must be UTF-8 text.

The reader opens one formatted and read-only through the editor's
Markdown reader, keeping fenced code, which that reader drops; the file
itself never changes, and its source text is a tap away, selectable too.
It has neither search, zoom nor screenshots. It reads in 100 parts by
its top-level blocks, so reading progress, deadlines and the cards and
notes tied to a place work as a PDF's pages do, shown as a share; its
outermost headings are its chapters.

Whats-New: Upload Markdown files beside your PDFs: they open formatted, with reading progress, deadlines and flashcards from what you select.
Whats-New-De: Lade Markdown-Dateien neben deine PDFs hoch: Sie öffnen sich formatiert, mit Lesefortschritt, Fristen und Karteikarten aus deiner Auswahl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-10 21:30:46 +01:00
commit 151f848360
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
22 changed files with 1460 additions and 195 deletions

View file

@ -281,7 +281,7 @@
"studysetNotesInTopics": "Die Notizen sind in seinen Themen",
"studysetNotesInTopicsHint": "Oder schreib hier eine: „Material hinzufügen → Notiz erstellen“.",
"studysetFilesInTopics": "Die Dateien sind in seinen Themen",
"studysetFilesInTopicsHint": "Oder füg hier eine hinzu: „Material hinzufügen → PDF hochladen“.",
"studysetFilesInTopicsHint": "Oder füg hier eine hinzu: „Material hinzufügen → Datei hochladen“.",
"studysetBrowseAllFlashcards": "{count, plural, =1{Ansehen} other{Alle {count} ansehen}}",
"studysetCreateFlashcards": "Karteikarten erstellen",
"studysetStudy": "Lernen",
@ -1153,8 +1153,8 @@
"studyButtonGone": "Dieses Lernset gibt es nicht mehr.",
"pdfPageField": "PDF-Seite",
"studysetTabFiles": "Dateien",
"pdfUploadMenu": "PDF hochladen",
"pdfFileType": "PDFs",
"pdfUploadMenu": "Datei hochladen",
"pdfFileType": "PDFs und Markdown",
"pdfAdded": "„{title}“ hinzugefügt.",
"pdfNotPdf": "Diese Datei ist kein PDF.",
"pdfTooLarge": "Dieses PDF ist größer als {megabytes} MB.",
@ -1163,7 +1163,7 @@
"pdfDuplicateKeep": "Vorhandenes behalten",
"pdfDuplicateAdd": "Kopie hinzufügen",
"filesEmpty": "Noch keine Dateien",
"filesEmptyHint": "Nutze „Material hinzufügen → PDF hochladen“, um eines hinzuzufügen.",
"filesEmptyHint": "Nutze „Material hinzufügen → Datei hochladen“, um eines hinzuzufügen.",
"filesSort": "Sortieren",
"filesSortCustom": "Eigene Reihenfolge",
"filesSortName": "Name (A–Z)",
@ -2182,5 +2182,25 @@
"pathViewSteps": "Schritte",
"pathViewScroll": "Scrollen",
"runnerOutline": "Gliederung",
"runnerScrollLocked": "Beantworte die Frage oben, um weiterzumachen."
"runnerScrollLocked": "Beantworte die Frage oben, um weiterzumachen.",
"markdownNotText": "Diese Datei ist kein Markdown-Text in UTF-8.",
"markdownTooLarge": "Diese Markdown-Datei ist größer als {megabytes} MB.",
"markdownShare": "{percent} %",
"markdownAt": "Bei {percent} %",
"readerByPlace": "Stelle",
"proposalsFromPart": "Aus diesem Abschnitt",
"readerReferencedShare": "Referenzierte Stelle: {percent} %",
"readerUseCurrentShare": "Aktuelle verwenden ({percent} %)",
"progressMarkdownShare": "{percent} % gelesen",
"markdownDeadlineUpcoming": "Beginnt in {days, plural, =1{1 Tag} other{{days} Tagen}} – {percent} %/Tag geplant",
"markdownDeadlinePassed": "Frist abgelaufen – noch {percent} %",
"markdownDeadlineToday": "Heute fällig – noch {percent} %",
"markdownDeadlinePace": "{percent} %/Tag, um in {days, plural, =1{1 Tag} other{{days} Tagen}} {target} % zu erreichen",
"markdownDeadlineHelp": "Lies bis zu einem Tag einen Teil der Datei. Der Fortschritt folgt der weitesten Stelle, bis zu der du beim Lesen scrollst.",
"markdownDeadlineReach": "Erreichen (%)",
"markdownDeadlineChapterEntry": "{title} · bis {percent} %",
"markdownToShareToday": "heute bis {percent} %",
"markdownDeadlinesProgress": "{furthest} %/{target} % · {when}",
"readerShowSource": "Quelltext zeigen",
"readerShowFormatted": "Formatiert zeigen"
}

View file

@ -330,7 +330,7 @@
"studysetNotesInTopics": "The notes are in its topics",
"studysetNotesInTopicsHint": "Or write one here: \"Add material → Create a note\".",
"studysetFilesInTopics": "The files are in its topics",
"studysetFilesInTopicsHint": "Or add one here: \"Add material → Upload a PDF\".",
"studysetFilesInTopicsHint": "Or add one here: \"Add material → Upload a file\".",
"studysetBrowseAllFlashcards": "{count, plural, =1{Browse it} other{Browse all {count}}}",
"@studysetBrowseAllFlashcards": {"description": "Opens the card browser on every flashcard of a studyset's topics.", "placeholders": {"count": {"type": "int"}}},
"studysetCreateFlashcards": "Create flashcards",
@ -1326,8 +1326,8 @@
"studyButtonGone": "That studyset no longer exists.",
"pdfPageField": "PDF page",
"studysetTabFiles": "Files",
"pdfUploadMenu": "Upload a PDF",
"pdfFileType": "PDFs",
"pdfUploadMenu": "Upload a file",
"pdfFileType": "PDFs and Markdown",
"pdfAdded": "Added \"{title}\".",
"@pdfAdded": {"placeholders": {"title": {"type": "String"}}},
"pdfNotPdf": "This file isn't a PDF.",
@ -1339,7 +1339,7 @@
"pdfDuplicateKeep": "Keep the one there",
"pdfDuplicateAdd": "Add a copy",
"filesEmpty": "No files yet",
"filesEmptyHint": "Use \"Add material → Upload a PDF\" to add one.",
"filesEmptyHint": "Use \"Add material → Upload a file\" to add one.",
"filesSort": "Sort",
"filesSortCustom": "Custom order",
"filesSortName": "Name (A–Z)",
@ -2690,5 +2690,45 @@
"runnerOutline": "Outline",
"@runnerOutline": {"description": "The list of a learning path's steps beside or over its page in the Scroll view."},
"runnerScrollLocked": "Answer the question above to go on.",
"@runnerScrollLocked": {"description": "Below the question a learning path's page stops at, in the Scroll view."}
"@runnerScrollLocked": {"description": "Below the question a learning path's page stops at, in the Scroll view."},
"markdownNotText": "This file isn't Markdown text in UTF-8.",
"@markdownNotText": {"description": "A file named .md or .markdown that couldn't be read as text."},
"markdownTooLarge": "This Markdown file is larger than {megabytes} MB.",
"@markdownTooLarge": {"description": "A Markdown file over the server's limit for files.", "placeholders": {"megabytes": {"type": "int"}}},
"markdownShare": "{percent}%",
"@markdownShare": {"description": "How much of a Markdown file is read, or where in it something is, in percent", "placeholders": {"percent": {"type": "int"}}},
"markdownAt": "At {percent}%",
"@markdownAt": {"description": "Where in a Markdown file the cards or the note below are: a heading in the reader's panels", "placeholders": {"percent": {"type": "int"}}},
"readerByPlace": "Place",
"@readerByPlace": {"description": "Segment in a Markdown file's cards panel: the cards grouped by where in the file they are (as Page does for PDFs)"},
"proposalsFromPart": "From this part",
"@proposalsFromPart": {"description": "Button in a Markdown file's cards panel: an assistant proposes flashcards from the part of the file in view"},
"readerReferencedShare": "Referenced place: {percent}%",
"@readerReferencedShare": {"description": "Below a card being written beside a Markdown file: where in the file it is tied to", "placeholders": {"percent": {"type": "int"}}},
"readerUseCurrentShare": "Use current ({percent}%)",
"@readerUseCurrentShare": {"description": "Button: ties the card to the place in the Markdown file in view", "placeholders": {"percent": {"type": "int"}}},
"progressMarkdownShare": "{percent}% read",
"@progressMarkdownShare": {"description": "A studyset's Progress: how much of a Markdown file is read", "placeholders": {"percent": {"type": "int"}}},
"markdownDeadlineUpcoming": "Starts in {days, plural, =1{1 day} other{{days} days}} — {percent}%/day planned",
"@markdownDeadlineUpcoming": {"description": "A Markdown file's reading deadline not started yet", "placeholders": {"days": {"type": "int"}, "percent": {"type": "int"}}},
"markdownDeadlinePassed": "Deadline passed — {percent}% left",
"@markdownDeadlinePassed": {"description": "A Markdown file's reading deadline that passed", "placeholders": {"percent": {"type": "int"}}},
"markdownDeadlineToday": "Due today — {percent}% left",
"@markdownDeadlineToday": {"description": "A Markdown file's reading deadline due today", "placeholders": {"percent": {"type": "int"}}},
"markdownDeadlinePace": "{percent}%/day to reach {target}% in {days, plural, =1{1 day} other{{days} days}}",
"@markdownDeadlinePace": {"description": "A Markdown file's reading deadline on its way: how much to read a day", "placeholders": {"percent": {"type": "int"}, "target": {"type": "int"}, "days": {"type": "int"}}},
"markdownDeadlineHelp": "Read a share of the file by a day. Progress follows the furthest you scroll as you read.",
"@markdownDeadlineHelp": {"description": "Help in the deadline sheet of a Markdown file"},
"markdownDeadlineReach": "Reach (%)",
"@markdownDeadlineReach": {"description": "Field in the deadline sheet of a Markdown file: the share to reach, in percent"},
"markdownDeadlineChapterEntry": "{title} · to {percent}%",
"@markdownDeadlineChapterEntry": {"description": "A heading of a Markdown file offered as a deadline's target, with the share it ends at", "placeholders": {"title": {"type": "String"}, "percent": {"type": "int"}}},
"markdownToShareToday": "to {percent}% today",
"@markdownToShareToday": {"description": "Chip on a Markdown file's deadline: how far to read today", "placeholders": {"percent": {"type": "int"}}},
"markdownDeadlinesProgress": "{furthest}%/{target}% · {when}",
"@markdownDeadlinesProgress": {"description": "A Markdown file's deadline: how far it is read of the share to reach, and when", "placeholders": {"furthest": {"type": "int"}, "target": {"type": "int"}, "when": {"type": "String"}}},
"readerShowSource": "Show the source text",
"@readerShowSource": {"description": "Tooltip: shows a Markdown file as written, rather than formatted"},
"readerShowFormatted": "Show it formatted",
"@readerShowFormatted": {"description": "Tooltip: shows a Markdown file formatted again, after its source text"}
}

View file

@ -1729,7 +1729,7 @@ abstract class AppLocalizations {
/// No description provided for @studysetFilesInTopicsHint.
///
/// In en, this message translates to:
/// **'Or add one here: \"Add material → Upload a PDF\".'**
/// **'Or add one here: \"Add material → Upload a file\".'**
String get studysetFilesInTopicsHint;
/// Opens the card browser on every flashcard of a studyset's topics.
@ -6793,13 +6793,13 @@ abstract class AppLocalizations {
/// No description provided for @pdfUploadMenu.
///
/// In en, this message translates to:
/// **'Upload a PDF'**
/// **'Upload a file'**
String get pdfUploadMenu;
/// No description provided for @pdfFileType.
///
/// In en, this message translates to:
/// **'PDFs'**
/// **'PDFs and Markdown'**
String get pdfFileType;
/// No description provided for @pdfAdded.
@ -6853,7 +6853,7 @@ abstract class AppLocalizations {
/// No description provided for @filesEmptyHint.
///
/// In en, this message translates to:
/// **'Use \"Add material → Upload a PDF\" to add one.'**
/// **'Use \"Add material → Upload a file\" to add one.'**
String get filesEmptyHint;
/// No description provided for @filesSort.
@ -12877,6 +12877,126 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Answer the question above to go on.'**
String get runnerScrollLocked;
/// A file named .md or .markdown that couldn't be read as text.
///
/// In en, this message translates to:
/// **'This file isn\'t Markdown text in UTF-8.'**
String get markdownNotText;
/// A Markdown file over the server's limit for files.
///
/// In en, this message translates to:
/// **'This Markdown file is larger than {megabytes} MB.'**
String markdownTooLarge(int megabytes);
/// How much of a Markdown file is read, or where in it something is, in percent
///
/// In en, this message translates to:
/// **'{percent}%'**
String markdownShare(int percent);
/// Where in a Markdown file the cards or the note below are: a heading in the reader's panels
///
/// In en, this message translates to:
/// **'At {percent}%'**
String markdownAt(int percent);
/// Segment in a Markdown file's cards panel: the cards grouped by where in the file they are (as Page does for PDFs)
///
/// In en, this message translates to:
/// **'Place'**
String get readerByPlace;
/// Button in a Markdown file's cards panel: an assistant proposes flashcards from the part of the file in view
///
/// In en, this message translates to:
/// **'From this part'**
String get proposalsFromPart;
/// Below a card being written beside a Markdown file: where in the file it is tied to
///
/// In en, this message translates to:
/// **'Referenced place: {percent}%'**
String readerReferencedShare(int percent);
/// Button: ties the card to the place in the Markdown file in view
///
/// In en, this message translates to:
/// **'Use current ({percent}%)'**
String readerUseCurrentShare(int percent);
/// A studyset's Progress: how much of a Markdown file is read
///
/// In en, this message translates to:
/// **'{percent}% read'**
String progressMarkdownShare(int percent);
/// A Markdown file's reading deadline not started yet
///
/// In en, this message translates to:
/// **'Starts in {days, plural, =1{1 day} other{{days} days}} — {percent}%/day planned'**
String markdownDeadlineUpcoming(int days, int percent);
/// A Markdown file's reading deadline that passed
///
/// In en, this message translates to:
/// **'Deadline passed — {percent}% left'**
String markdownDeadlinePassed(int percent);
/// A Markdown file's reading deadline due today
///
/// In en, this message translates to:
/// **'Due today — {percent}% left'**
String markdownDeadlineToday(int percent);
/// A Markdown file's reading deadline on its way: how much to read a day
///
/// In en, this message translates to:
/// **'{percent}%/day to reach {target}% in {days, plural, =1{1 day} other{{days} days}}'**
String markdownDeadlinePace(int percent, int target, int days);
/// Help in the deadline sheet of a Markdown file
///
/// In en, this message translates to:
/// **'Read a share of the file by a day. Progress follows the furthest you scroll as you read.'**
String get markdownDeadlineHelp;
/// Field in the deadline sheet of a Markdown file: the share to reach, in percent
///
/// In en, this message translates to:
/// **'Reach (%)'**
String get markdownDeadlineReach;
/// A heading of a Markdown file offered as a deadline's target, with the share it ends at
///
/// In en, this message translates to:
/// **'{title} · to {percent}%'**
String markdownDeadlineChapterEntry(String title, int percent);
/// Chip on a Markdown file's deadline: how far to read today
///
/// In en, this message translates to:
/// **'to {percent}% today'**
String markdownToShareToday(int percent);
/// A Markdown file's deadline: how far it is read of the share to reach, and when
///
/// In en, this message translates to:
/// **'{furthest}%/{target}% · {when}'**
String markdownDeadlinesProgress(int furthest, int target, String when);
/// Tooltip: shows a Markdown file as written, rather than formatted
///
/// In en, this message translates to:
/// **'Show the source text'**
String get readerShowSource;
/// Tooltip: shows a Markdown file formatted again, after its source text
///
/// In en, this message translates to:
/// **'Show it formatted'**
String get readerShowFormatted;
}
class _AppLocalizationsDelegate

View file

@ -1039,7 +1039,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get studysetFilesInTopicsHint =>
'Oder füg hier eine hinzu: „Material hinzufügen → PDF hochladen“.';
'Oder füg hier eine hinzu: „Material hinzufügen → Datei hochladen“.';
@override
String studysetBrowseAllFlashcards(int count) {
@ -4216,10 +4216,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get studysetTabFiles => 'Dateien';
@override
String get pdfUploadMenu => 'PDF hochladen';
String get pdfUploadMenu => 'Datei hochladen';
@override
String get pdfFileType => 'PDFs';
String get pdfFileType => 'PDFs und Markdown';
@override
String pdfAdded(String title) {
@ -4253,7 +4253,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get filesEmptyHint =>
'Nutze „Material hinzufügen → PDF hochladen“, um eines hinzuzufügen.';
'Nutze „Material hinzufügen → Datei hochladen“, um eines hinzuzufügen.';
@override
String get filesSort => 'Sortieren';
@ -8224,4 +8224,103 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get runnerScrollLocked =>
'Beantworte die Frage oben, um weiterzumachen.';
@override
String get markdownNotText => 'Diese Datei ist kein Markdown-Text in UTF-8.';
@override
String markdownTooLarge(int megabytes) {
return 'Diese Markdown-Datei ist größer als $megabytes MB.';
}
@override
String markdownShare(int percent) {
return '$percent %';
}
@override
String markdownAt(int percent) {
return 'Bei $percent %';
}
@override
String get readerByPlace => 'Stelle';
@override
String get proposalsFromPart => 'Aus diesem Abschnitt';
@override
String readerReferencedShare(int percent) {
return 'Referenzierte Stelle: $percent %';
}
@override
String readerUseCurrentShare(int percent) {
return 'Aktuelle verwenden ($percent %)';
}
@override
String progressMarkdownShare(int percent) {
return '$percent % gelesen';
}
@override
String markdownDeadlineUpcoming(int days, int percent) {
String _temp0 = intl.Intl.pluralLogic(
days,
locale: localeName,
other: '$days Tagen',
one: '1 Tag',
);
return 'Beginnt in $_temp0 – $percent %/Tag geplant';
}
@override
String markdownDeadlinePassed(int percent) {
return 'Frist abgelaufen – noch $percent %';
}
@override
String markdownDeadlineToday(int percent) {
return 'Heute fällig – noch $percent %';
}
@override
String markdownDeadlinePace(int percent, int target, int days) {
String _temp0 = intl.Intl.pluralLogic(
days,
locale: localeName,
other: '$days Tagen',
one: '1 Tag',
);
return '$percent %/Tag, um in $_temp0 $target % zu erreichen';
}
@override
String get markdownDeadlineHelp =>
'Lies bis zu einem Tag einen Teil der Datei. Der Fortschritt folgt der weitesten Stelle, bis zu der du beim Lesen scrollst.';
@override
String get markdownDeadlineReach => 'Erreichen (%)';
@override
String markdownDeadlineChapterEntry(String title, int percent) {
return '$title · bis $percent %';
}
@override
String markdownToShareToday(int percent) {
return 'heute bis $percent %';
}
@override
String markdownDeadlinesProgress(int furthest, int target, String when) {
return '$furthest %/$target % · $when';
}
@override
String get readerShowSource => 'Quelltext zeigen';
@override
String get readerShowFormatted => 'Formatiert zeigen';
}

View file

@ -1030,7 +1030,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get studysetFilesInTopicsHint =>
'Or add one here: \"Add material → Upload a PDF\".';
'Or add one here: \"Add material → Upload a file\".';
@override
String studysetBrowseAllFlashcards(int count) {
@ -4178,10 +4178,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get studysetTabFiles => 'Files';
@override
String get pdfUploadMenu => 'Upload a PDF';
String get pdfUploadMenu => 'Upload a file';
@override
String get pdfFileType => 'PDFs';
String get pdfFileType => 'PDFs and Markdown';
@override
String pdfAdded(String title) {
@ -4215,7 +4215,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get filesEmptyHint =>
'Use \"Add material → Upload a PDF\" to add one.';
'Use \"Add material → Upload a file\" to add one.';
@override
String get filesSort => 'Sort';
@ -8150,6 +8150,105 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get runnerScrollLocked => 'Answer the question above to go on.';
@override
String get markdownNotText => 'This file isn\'t Markdown text in UTF-8.';
@override
String markdownTooLarge(int megabytes) {
return 'This Markdown file is larger than $megabytes MB.';
}
@override
String markdownShare(int percent) {
return '$percent%';
}
@override
String markdownAt(int percent) {
return 'At $percent%';
}
@override
String get readerByPlace => 'Place';
@override
String get proposalsFromPart => 'From this part';
@override
String readerReferencedShare(int percent) {
return 'Referenced place: $percent%';
}
@override
String readerUseCurrentShare(int percent) {
return 'Use current ($percent%)';
}
@override
String progressMarkdownShare(int percent) {
return '$percent% read';
}
@override
String markdownDeadlineUpcoming(int days, int percent) {
String _temp0 = intl.Intl.pluralLogic(
days,
locale: localeName,
other: '$days days',
one: '1 day',
);
return 'Starts in $_temp0 — $percent%/day planned';
}
@override
String markdownDeadlinePassed(int percent) {
return 'Deadline passed — $percent% left';
}
@override
String markdownDeadlineToday(int percent) {
return 'Due today — $percent% left';
}
@override
String markdownDeadlinePace(int percent, int target, int days) {
String _temp0 = intl.Intl.pluralLogic(
days,
locale: localeName,
other: '$days days',
one: '1 day',
);
return '$percent%/day to reach $target% in $_temp0';
}
@override
String get markdownDeadlineHelp =>
'Read a share of the file by a day. Progress follows the furthest you scroll as you read.';
@override
String get markdownDeadlineReach => 'Reach (%)';
@override
String markdownDeadlineChapterEntry(String title, int percent) {
return '$title · to $percent%';
}
@override
String markdownToShareToday(int percent) {
return 'to $percent% today';
}
@override
String markdownDeadlinesProgress(int furthest, int target, String when) {
return '$furthest%/$target% · $when';
}
@override
String get readerShowSource => 'Show the source text';
@override
String get readerShowFormatted => 'Show it formatted';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -1,5 +1,6 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:markdown/markdown.dart' as md;
/// What Markdown has no words of its own for, in the person's language: a
/// sound's link, a video's link without a caption, a spoiler's summary, a
@ -243,3 +244,30 @@ class const _SpoilerParser(final String _summary) extends NodeParser {
return '<details><summary>$_summary</summary>\n\n$text\n\n</details>\n';
}
}
/// [markdown] read into the editor's document, as the editor's own reader
/// reads it, but keeping fenced and indented code, which that reader drops:
/// each block of it a paragraph of its lines as written, in code's style.
Document markdownToRichDocument(String markdown) =>
markdownToDocument(markdown, markdownParsers: const [_CodeReader()]);
class const _CodeReader() extends CustomMarkdownParser {
@override
List<Node> transform(
md.Node element,
List<CustomMarkdownParser> parsers, {
MarkdownListType listType = MarkdownListType.unknown,
int? startNumber,
}) {
if (element is! md.Element || element.tag != 'pre') {
return const [];
}
final code = element.textContent.replaceFirst(RegExp(r'\n$'), '');
return [
paragraphNode(
delta: Delta()
..insert(code, attributes: {AppFlowyRichTextKeys.code: true}),
),
];
}
}

View file

@ -696,6 +696,7 @@ class CopyUpdater(
pageCount: content.pageCount,
deadline: content.deadline,
published: origin,
kind: content.kind,
);
}

View file

@ -566,6 +566,7 @@ class ReleaseCopier(
assetId: content.assetId,
pageCount: content.pageCount,
published: origin,
kind: content.kind,
);
}

View file

@ -1,16 +1,18 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/markdown_document.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_engine.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_chapter.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
/// A PDF being read: its file found (downloaded if this device lacks it)
/// and opened, its page count noted, where reading starts, and each page
/// A PDF being read, or a Markdown file read as one ([MarkdownDocument]):
/// its file found (downloaded if this device lacks it) and opened, its page count noted, where reading starts, and each page
/// shown recorded, the furthest kept (decided 2026-10-03).
///
/// Reading starts at [page] when asked for, else at the furthest page read
@ -45,7 +47,10 @@ class PdfReaderBloc(
}
final PdfDocumentView document;
try {
document = await _engine.open(file.bytes);
document = switch (source.content.kind) {
FileKind.pdf => await _engine.open(file.bytes),
FileKind.markdown => MarkdownDocument(utf8.decode(file.bytes)),
};
} on FormatException catch (error) {
log('Opening a PDF failed', name: 'PDFs', error: error);
emit(const PdfReaderStateUnreadable());

View file

@ -0,0 +1,232 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_learn_flutter/src/core/editor/core_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_markdown.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_engine.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_chapter.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// A Markdown file read as the PDFs are (decided 10 Nov 2026): rendered by
/// the editor's Markdown reader, read-only, the file itself never changed.
///
/// It has no pages, so it reads in [markdownParts] parts, each top-level
/// block of the rendered file in the part of where it stands among them
/// ([partOf]): reading progress, deadlines and the cards tied to a page take
/// a part as a PDF's page, shown as a share.
class MarkdownDocument(final String source) implements PdfDocumentView {
late final Document _document = markdownToRichDocument(source);
List<Node> get _blocks => _document.root.children;
/// How many top-level blocks it has.
int get blockCount => _blocks.length;
@override
int get pageCount => markdownParts;
/// The part the block at [index] (from 0) is in, from 1.
int partOf(int index) => blockCount == 0
? 1
: 1 + (index * markdownParts ~/ blockCount).clamp(0, markdownParts - 1);
/// The first block in [part] or after it, the last block for a part
/// beyond them.
int firstBlockOf(int part) {
for (var index = 0; index < blockCount; index++) {
if (partOf(index) >= part) {
return index;
}
}
return blockCount == 0 ? 0 : blockCount - 1;
}
@override
Future<List<PdfChapter>> chapters() async {
final headings = [
for (final (index, block) in _blocks.indexed)
if (block.type == HeadingBlockKeys.type)
(
index: index,
level: block.attributes[HeadingBlockKeys.level] as int? ?? 1,
title: _textOf(block).trim(),
),
];
if (headings.isEmpty) {
return const [];
}
final outermost = headings
.map((heading) => heading.level)
.reduce((a, b) => a < b ? a : b);
return [
for (final heading in headings)
if (heading.level == outermost && heading.title.isNotEmpty)
(title: heading.title, page: partOf(heading.index)),
];
}
@override
Future<Uint8List?> renderRegion(int page, Rect region) async => null;
@override
Future<String> pageText(int page) async => [
for (final (index, block) in _blocks.indexed)
if (partOf(index) == page) _textOf(block),
].where((text) => text.isNotEmpty).join('\n');
@override
Widget viewer(PdfReaderController controller) =>
_MarkdownViewer(document: this, controller: controller);
@override
Future<void> dispose() async {}
}
/// The text of [node] and of the blocks inside it, a line each.
String _textOf(Node node) => [
if (node.delta?.toPlainText() case final text? when text.isNotEmpty) text,
for (final child in node.children) _textOf(child),
].where((text) => text.isNotEmpty).join('\n');
class const _MarkdownViewer({
required final MarkdownDocument document,
required final PdfReaderController controller,
}) extends StatefulWidget {
@override
State<_MarkdownViewer> createState() => _MarkdownViewerState();
}
/// The rendered file, its text selectable but not editable; the part in
/// view is that of the first block on screen, or the last part once the
/// last block is.
class _MarkdownViewerState()
extends State<_MarkdownViewer>
implements PdfViewerHandle {
// A document of its own: the editor's state disposes it with itself.
late final _editorState = EditorState(
document: markdownToRichDocument(widget.document.source),
)..editable = false;
late final _scroll = EditorScrollController(editorState: _editorState);
@override
void initState() {
super.initState();
_scroll.visibleRangeNotifier.addListener(_onScroll);
_editorState.selectionNotifier.addListener(_onSelection);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
widget.controller
..attach(this)
..report(zoom: 1);
}
});
}
@override
void dispose() {
widget.controller.attach(null);
_scroll.visibleRangeNotifier.removeListener(_onScroll);
_editorState.selectionNotifier.removeListener(_onSelection);
_scroll.dispose();
_editorState.dispose();
super.dispose();
}
void _onScroll() {
final (first, last) = _scroll.visibleRangeNotifier.value;
if (first < 0) {
return;
}
final document = widget.document;
widget.controller.report(
page: last >= document.blockCount - 1
? markdownParts
: document.partOf(first),
);
}
void _onSelection() {
final selection = _editorState.selection;
widget.controller.report(
selectedText: selection == null || selection.isCollapsed
? ''
: _editorState.getTextInSelection(selection).join('\n'),
);
}
@override
Widget build(BuildContext context) {
final blocks = blocksOf(context);
return ColoredBox(
color: Theme.of(context).colorScheme.surface,
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: AppFlowyEditor(
editorState: _editorState,
editorScrollController: _scroll,
editorStyle: editorStyleOf(
context,
editing: false,
blocks: blocks,
).copyWith(padding: const EdgeInsets.symmetric(vertical: 24)),
blockComponentBuilders: buildersWithUnknown(
blocks,
HalkyonEditorLocalizations.of(context),
),
editable: false,
disableKeyboardService: true,
),
),
),
),
);
}
@override
Future<void> goToPage(int page) async {
if (widget.document.blockCount > 0) {
_scroll.itemScrollController.jumpTo(
index: widget.document.firstBlockOf(page),
);
}
}
// A rendered text has no zoom nor search of its own; the reader offers
// neither for it.
@override
Future<void> zoomIn() async {}
@override
Future<void> zoomOut() async {}
@override
Future<void> fitWidth() async {}
@override
Future<void> fitHeight() async {}
@override
Future<void> setZoom(double zoom) async {}
@override
void search(String text) {}
@override
Future<void> nextMatch() async {}
@override
Future<void> previousMatch() async {}
@override
void clearSearch() {}
@override
Rect? pageRect(int page) => null;
}

View file

@ -9,27 +9,36 @@ import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.da
// count once known, and a reading deadline ({"targetPage", "due", "start"},
// days as YYYY-MM-DD; left out when none). Schema 2 added where a copy's PDF
// came from (`published`, see published_ref.dart), left out for a PDF of its
// own.
// own. Schema 3 added the file's kind (`kind`: `markdown`; decided 10 Nov
// 2026), written only for a file other than a PDF, so that apps from before
// skip it rather than open it as one; a PDF is still written in schema 2.
/// The schema this app writes.
const pdfSourceSchemaVersion = 2;
/// The newest schema this app writes.
const pdfSourceSchemaVersion = 3;
/// Encodes a PDF's content into its payload.
ByteData encodePdfSourcePayload(PdfSourceContent content) =>
encodePayload(pdfSourceSchemaVersion, {
'title': content.title,
'fileName': content.fileName,
'asset': content.assetId,
'pageCount': ?content.pageCount,
if (content.deadline case final deadline?)
'deadline': {
'targetPage': deadline.targetPage,
'due': _day(deadline.due),
if (deadline.start case final start?) 'start': _day(start),
},
if (content.published case final published?)
'published': publishedRefToJson(published),
});
/// The schema a PDF is written in, which apps from before Markdown files
/// read.
const _pdfSchema = 2;
/// Encodes a file's content into its payload.
ByteData encodePdfSourcePayload(PdfSourceContent content) => encodePayload(
content.kind == FileKind.pdf ? _pdfSchema : pdfSourceSchemaVersion,
{
if (content.kind != FileKind.pdf) 'kind': content.kind.name,
'title': content.title,
'fileName': content.fileName,
'asset': content.assetId,
'pageCount': ?content.pageCount,
if (content.deadline case final deadline?)
'deadline': {
'targetPage': deadline.targetPage,
'due': _day(deadline.due),
if (deadline.start case final start?) 'start': _day(start),
},
if (content.published case final published?)
'published': publishedRefToJson(published),
},
);
/// Decodes a PDF's payload.
///
@ -66,6 +75,13 @@ PdfSourceContent decodePdfSourcePayload(ByteData payload) {
_ => throw const FormatException('A malformed deadline.'),
},
published: publishedRefFromJson(json['published']),
kind: switch (json['kind']) {
null => FileKind.pdf,
final String name =>
FileKind.values.where((kind) => kind.name == name).firstOrNull ??
(throw FormatException('A kind of file unknown here.', name)),
_ => throw const FormatException('A malformed kind.'),
},
);
}
throw const FormatException('Not a PDF payload.');

View file

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:typed_data';
@ -34,8 +35,15 @@ enum PdfRefusal() {
/// It isn't a PDF.
notPdf,
/// It is larger than [PdfSourceRepository.maxBytes].
/// It is a PDF larger than [PdfSourceRepository.maxBytes].
tooLarge,
/// It is named a Markdown file but isn't text in UTF-8.
notText,
/// It is a Markdown file larger than the server takes of a file
/// ([AssetRepository.maxBytes]).
textTooLarge,
}
/// The file wasn't taken, for [reason].
@ -120,25 +128,39 @@ class PdfSourceRepository(
return counts;
}
/// Adds the PDF [bytes], a file named [fileName], to [studysetId], titled
/// by its name. A file the studyset holds already is added again only
/// [evenIfThere]; a file that isn't a PDF, or is too large, not at all.
/// Adds the file [bytes], named [fileName], to [studysetId], titled by
/// its name: a Markdown file where the name ends in `.md` or `.markdown`
/// (decided 10 Nov 2026), read by share ([markdownParts]), else a PDF. A
/// file the studyset holds already is added again only [evenIfThere]; a
/// file that isn't what its name says, or is too large, not at all.
Future<PdfImport> import(
String studysetId, {
required Uint8List bytes,
required String fileName,
bool evenIfThere = false,
}) async {
if (!_isPdf(bytes)) {
return const PdfRefused(PdfRefusal.notPdf);
final kind = fileKindOf(fileName);
final mimeType = switch (kind) {
FileKind.pdf => AssetRepository.pdfMediaType,
FileKind.markdown => markdownMediaType,
};
if (bytes.length > AssetRepository.maxBytesOf(mimeType)) {
return PdfRefused(switch (kind) {
FileKind.pdf => PdfRefusal.tooLarge,
FileKind.markdown => PdfRefusal.textTooLarge,
});
}
if (bytes.length > maxBytes) {
return const PdfRefused(PdfRefusal.tooLarge);
switch (kind) {
case FileKind.pdf when !_isPdf(bytes):
return const PdfRefused(PdfRefusal.notPdf);
case FileKind.markdown when !_isText(bytes):
return const PdfRefused(PdfRefusal.notText);
case _:
}
final assetId = await _assets.add(
bytes,
fileName: fileName,
mimeType: AssetRepository.pdfMediaType,
mimeType: mimeType,
studysetId: studysetId,
);
if (!evenIfThere) {
@ -155,6 +177,8 @@ class PdfSourceRepository(
title: titleFromFileName(fileName),
fileName: fileName,
assetId: assetId,
kind: kind,
pageCount: kind == FileKind.markdown ? markdownParts : null,
),
),
);
@ -400,5 +424,15 @@ Map<String, int> furthestOf(List<PdfReadingRow> rows) {
}
/// Whether [bytes] are a PDF's: they start as every PDF does.
/// Whether [bytes] are text in UTF-8.
bool _isText(Uint8List bytes) {
try {
utf8.decode(bytes);
return true;
} on FormatException {
return false;
}
}
bool _isPdf(Uint8List bytes) =>
bytes.length >= 5 && String.fromCharCodes(bytes.sublist(0, 5)) == '%PDF-';

View file

@ -32,8 +32,42 @@ final class const ReadingDeadline({
List<Object?> get props => [targetPage, due, start];
}
/// What a PDF says about itself: its title, the name its file came with, the
/// file, its page count once a device has read it, and a reading deadline.
/// What kind of file a studyset's file is (decided 10 Nov 2026): a PDF, or
/// Markdown, kept as it came and shown rendered.
enum FileKind() {
pdf,
markdown,
}
/// How many parts a Markdown file is read in, as a PDF in pages: its
/// share read, in hundredths.
const markdownParts = 100;
/// How much of a Markdown file is read, in percent, once [part] (from 1)
/// is reached: none at its first part, all at its last.
int markdownShareOf(int part) =>
part >= markdownParts ? 100 : (part - 1).clamp(0, 100);
/// The part (from 1) reached once [share] percent of a Markdown file is
/// read; [markdownShareOf]'s inverse.
int markdownPartOf(int share) =>
share >= 100 ? markdownParts : (share + 1).clamp(1, markdownParts);
/// A Markdown file's media type.
const markdownMediaType = 'text/markdown';
/// The kind of the file named [fileName]: Markdown where it ends in `.md`
/// or `.markdown`, else a PDF.
FileKind fileKindOf(String fileName) {
final name = fileName.trim().toLowerCase();
return name.endsWith('.md') || name.endsWith('.markdown')
? FileKind.markdown
: FileKind.pdf;
}
/// What a file says about itself: its title, the name its file came with,
/// the file, its kind, its page count once a device has read it (a
/// Markdown file's 100, read by share), and a reading deadline.
final class const PdfSourceContent({
/// The title; the file's name without `.pdf` until renamed.
required final String title,
@ -53,6 +87,9 @@ final class const PdfSourceContent({
/// Where it came from, in a copy of a published studyset; null for a PDF
/// of the person's own.
final PublishedRef? published,
/// What kind of file it is.
final FileKind kind = FileKind.pdf,
}) with Equatable {
/// This content titled [title].
PdfSourceContent withTitle(String title) => PdfSourceContent(
@ -62,6 +99,7 @@ final class const PdfSourceContent({
pageCount: pageCount,
deadline: deadline,
published: published,
kind: kind,
);
/// This content with [pageCount] pages.
@ -72,6 +110,7 @@ final class const PdfSourceContent({
pageCount: pageCount,
deadline: deadline,
published: published,
kind: kind,
);
/// This content with [deadline], or none.
@ -82,6 +121,7 @@ final class const PdfSourceContent({
pageCount: pageCount,
deadline: deadline,
published: published,
kind: kind,
);
@override
@ -92,6 +132,7 @@ final class const PdfSourceContent({
pageCount,
deadline,
published,
kind,
];
}
@ -115,10 +156,15 @@ final class const PdfSource({
List<Object?> get props => [id, studysetId, content, position];
}
/// The title a PDF named [fileName] gets: the name without `.pdf`.
/// The title a file named [fileName] gets: the name without `.pdf`, `.md`
/// or `.markdown`.
String titleFromFileName(String fileName) {
final name = fileName.trim();
return name.toLowerCase().endsWith('.pdf')
? name.substring(0, name.length - 4)
: name;
final lower = name.toLowerCase();
for (final extension in const ['.pdf', '.markdown', '.md']) {
if (lower.endsWith(extension)) {
return name.substring(0, name.length - extension.length);
}
}
return name;
}

View file

@ -46,7 +46,15 @@ class _DeadlineSheetState() extends State<_DeadlineSheet> {
late final _today = clock.now();
late final ReadingDeadline? _current = widget.source.content.deadline;
late int _page = _current?.targetPage ?? widget.source.content.pageCount ?? 1;
late final _pageField = TextEditingController(text: '$_page');
/// Whether the target is a share of a Markdown file rather than a page:
/// shown and typed in percent, kept as the part it reaches.
late final _shares = widget.source.content.kind == FileKind.markdown;
/// [page] as shown: its share of a Markdown file, else itself.
int _shown(int page) => _shares ? markdownShareOf(page) : page;
late final _pageField = TextEditingController(text: '${_shown(_page)}');
late DateTime _due = switch (_current?.due) {
final due? => DateTime(due.year, due.month, due.day),
null => DateTime(_today.year, _today.month, _today.day + 14),
@ -66,12 +74,26 @@ class _DeadlineSheetState() extends State<_DeadlineSheet> {
void _setPage(int page) {
setState(() => _page = page.clamp(1, _maxPage));
final text = '${_shown(_page)}';
_pageField.value = TextEditingValue(
text: '$_page',
selection: TextSelection.collapsed(offset: '$_page'.length),
text: text,
selection: TextSelection.collapsed(offset: text.length),
);
}
/// The page or part [typed] stands for.
int _typed(String typed) {
final value = int.tryParse(typed) ?? 1;
return (_shares ? markdownPartOf(value.clamp(1, 100)) : value).clamp(
1,
_maxPage,
);
}
/// The page or part a step before or after.
int _step(int by) =>
_shares ? markdownPartOf(markdownShareOf(_page) + by) : _page + by;
/// The last page of the chapter at [index] of [chapters]: the page before
/// the next one starts, the PDF's last for the last chapter.
int _lastPageOf(List<PdfChapter> chapters, int index) =>
@ -125,7 +147,7 @@ class _DeadlineSheetState() extends State<_DeadlineSheet> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.deadlineSheetHelp,
_shares ? l10n.markdownDeadlineHelp : l10n.deadlineSheetHelp,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
@ -136,7 +158,7 @@ class _DeadlineSheetState() extends State<_DeadlineSheet> {
IconButton.outlined(
icon: const Icon(LucideIcons.minus),
tooltip: l10n.deadlineDecrease,
onPressed: _page > 1 ? () => _setPage(_page - 1) : null,
onPressed: _page > 1 ? () => _setPage(_step(-1)) : null,
),
const SizedBox(width: 8),
Expanded(
@ -146,20 +168,20 @@ class _DeadlineSheetState() extends State<_DeadlineSheet> {
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
textAlign: TextAlign.center,
decoration: InputDecoration(
labelText: pageCount == null
labelText: _shares
? l10n.markdownDeadlineReach
: pageCount == null
? l10n.deadlineReachPage
: l10n.deadlineReachPageOf(pageCount),
),
onChanged: (text) => setState(
() => _page = (int.tryParse(text) ?? 1).clamp(1, _maxPage),
),
onChanged: (text) => setState(() => _page = _typed(text)),
),
),
const SizedBox(width: 8),
IconButton.outlined(
icon: const Icon(LucideIcons.plus),
tooltip: l10n.deadlineIncrease,
onPressed: _page < _maxPage ? () => _setPage(_page + 1) : null,
onPressed: _page < _maxPage ? () => _setPage(_step(1)) : null,
),
],
),
@ -193,7 +215,15 @@ class _DeadlineSheetState() extends State<_DeadlineSheet> {
DropdownMenuItem(
value: last,
child: Text(
l10n.deadlineChapterEntry(chapter.title, last),
_shares
? l10n.markdownDeadlineChapterEntry(
chapter.title,
markdownShareOf(last),
)
: l10n.deadlineChapterEntry(
chapter.title,
last,
),
overflow: TextOverflow.ellipsis,
),
),

View file

@ -0,0 +1,9 @@
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The icon of a file of [kind] wherever files are listed.
IconData fileKindIcon(FileKind kind) => switch (kind) {
FileKind.pdf => LucideIcons.fileText,
FileKind.markdown => LucideIcons.fileCode,
};

View file

@ -16,6 +16,7 @@ import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/deadline_stat
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_chapter.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/deadline_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/file_kind_icon.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -80,7 +81,7 @@ class const SliverPdfList({
itemBuilder: (context, index) => ListTile(
key: ValueKey(rows[index].source.id),
leading: Icon(
LucideIcons.fileText,
fileKindIcon(rows[index].source.content.kind),
color: theme.colorScheme.primary,
),
title: Text(
@ -248,7 +249,7 @@ class const _PdfTile({
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: Icon(LucideIcons.fileText, color: colors.primary),
child: Icon(fileKindIcon(content.kind), color: colors.primary),
),
const SizedBox(width: 16),
Expanded(
@ -263,7 +264,9 @@ class const _PdfTile({
),
ContentCountsLine(
counts: [
(count: pageCount, label: l10n.pdfPages),
// A Markdown file's parts are no pages to count.
if (content.kind == FileKind.pdf)
(count: pageCount, label: l10n.pdfPages),
(count: row.cards, label: l10n.studysetCountFlashcards),
(count: row.notes, label: l10n.studysetCountNotes),
(
@ -281,7 +284,9 @@ class const _PdfTile({
if (status != null) ...[
const SizedBox(height: 4),
Text(
_deadlineLine(l10n, status, deadline!),
content.kind == FileKind.markdown
? _markdownDeadlineLine(l10n, status, deadline!)
: _deadlineLine(l10n, status, deadline!),
style: theme.textTheme.bodySmall?.copyWith(
color: switch (status.state) {
DeadlineState.overdue ||
@ -327,6 +332,26 @@ class const _PdfTile({
),
};
/// [_deadlineLine] for a Markdown file, in shares rather than pages.
String _markdownDeadlineLine(
AppLocalizations l10n,
DeadlineStatus status,
ReadingDeadline deadline,
) => switch (status.state) {
DeadlineState.met => l10n.pdfDeadlineMet,
DeadlineState.upcoming => l10n.markdownDeadlineUpcoming(
status.daysUntilStart,
status.pagesPerDay,
),
DeadlineState.overdue => l10n.markdownDeadlinePassed(status.pagesLeft),
DeadlineState.dueToday => l10n.markdownDeadlineToday(status.pagesLeft),
DeadlineState.onTrack => l10n.markdownDeadlinePace(
status.pagesPerDay,
markdownShareOf(deadline.targetPage),
status.daysLeft,
),
};
List<Widget> _actions(BuildContext context) {
final l10n = AppLocalizations.of(context);
final error = Theme.of(context).colorScheme.error;
@ -450,11 +475,13 @@ class const _PdfTile({
},
copyable: false,
),
(
label: l10n.pdfInfoPages,
value: source.content.pageCount?.toString() ?? l10n.pdfInfoUnknown,
copyable: false,
),
if (source.content.kind == FileKind.pdf)
(
label: l10n.pdfInfoPages,
value:
source.content.pageCount?.toString() ?? l10n.pdfInfoUnknown,
copyable: false,
),
],
),
]);

View file

@ -3,7 +3,7 @@ import 'package:halkyon_learn_flutter/src/core/content/pdf_page_ref.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/file_kind_icon.dart';
import 'package:material_ui/material_ui.dart';
/// The PDF [source] something was made from, small: a document and the
@ -47,7 +47,7 @@ class _PdfPageChipState() extends State<PdfPageChip> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.fileText, size: 14, color: muted),
Icon(fileKindIcon(pdf.content.kind), size: 14, color: muted),
const SizedBox(width: 4),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 140),

View file

@ -154,6 +154,9 @@ class const _CardsPanel({
class _CardsPanelState()
extends State<_CardsPanel>
with AutomaticKeepAliveClientMixin {
/// Whether places are shares of a Markdown file rather than pages.
bool get _shares => widget.source.content.kind == FileKind.markdown;
late final CardComposerBloc _bloc;
/// The card being written, by its draft's id; null while the list shows.
@ -337,6 +340,8 @@ class _CardsPanelState()
final page = draft.saved.source!.page;
final heading = _byChapter
? chapterOf(widget.chapters, page)?.title ?? l10n.readerFrontMatter
: _shares
? l10n.markdownAt(markdownShareOf(page))
: l10n.readerPage(page);
(sections[heading] ??= []).add(draft);
}
@ -348,7 +353,10 @@ class _CardsPanelState()
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
child: SegmentedButton<bool>(
segments: [
ButtonSegment(value: false, label: Text(l10n.readerByPage)),
ButtonSegment(
value: false,
label: Text(_shares ? l10n.readerByPlace : l10n.readerByPage),
),
ButtonSegment(value: true, label: Text(l10n.readerByChapter)),
],
selected: {_byChapter},
@ -426,7 +434,9 @@ class _CardsPanelState()
icon: const Icon(LucideIcons.sparkles),
label: Text(
widget.controller.selectedText.trim().isEmpty
? l10n.proposalsFromPage
? _shares
? l10n.proposalsFromPart
: l10n.proposalsFromPage
: l10n.proposalsFromSelection,
),
onPressed: () => unawaited(_propose()),
@ -498,7 +508,9 @@ class _CardsPanelState()
children: [
Expanded(
child: Text(
l10n.readerReferencedPage(page),
_shares
? l10n.readerReferencedShare(markdownShareOf(page))
: l10n.readerReferencedPage(page),
style: theme.textTheme.bodySmall,
),
),
@ -516,7 +528,11 @@ class _CardsPanelState()
),
),
child: Text(
l10n.readerUseCurrentPage(widget.controller.page),
_shares
? l10n.readerUseCurrentShare(
markdownShareOf(widget.controller.page),
)
: l10n.readerUseCurrentPage(widget.controller.page),
),
),
],
@ -821,7 +837,12 @@ class _NotePanelState()
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Row(
children: [
Text(l10n.readerPage(_page), style: theme.textTheme.titleSmall),
Text(
widget.source.content.kind == FileKind.markdown
? l10n.markdownAt(markdownShareOf(_page))
: l10n.readerPage(_page),
style: theme.textTheme.titleSmall,
),
const Spacer(),
Text(
switch (_status) {

View file

@ -14,8 +14,10 @@ import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_setti
import 'package:halkyon_learn_flutter/src/core/editor/core_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/bloc/pdf_reader_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/markdown_document.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_engine.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_chapter.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/pdf_capture.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/pdf_reader_panels.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_focus.dart';
@ -158,6 +160,16 @@ class _ReaderState() extends State<_Reader> {
var _searching = false;
var _fullscreen = false;
/// A Markdown file shown as written rather than formatted.
var _showSource = false;
/// The Markdown file read, null for a PDF: it has no pages to zoom,
/// search or take screenshots of, and reads in shares.
MarkdownDocument? get _markdown => switch (widget.state.document) {
final MarkdownDocument markdown => markdown,
_ => null,
};
/// The editor's width as dragged here; null until dragged, when it is
/// the share of the room last dragged on this device, else the default.
double? _sidebarWidth;
@ -257,12 +269,30 @@ class _ReaderState() extends State<_Reader> {
}
}
void _toggleSource() => setState(() {
_showSource = !_showSource;
_controller.report(selectedText: '');
});
@override
Widget build(BuildContext context) {
final wide = context.windowSize.isAtLeastMedium;
final viewer = Stack(
children: [
Positioned.fill(child: widget.state.document.viewer(_controller)),
// Kept while the source text shows, so the place read stays.
Positioned.fill(
child: Offstage(
offstage: _showSource,
child: widget.state.document.viewer(_controller),
),
),
if (_markdown case final markdown? when _showSource)
Positioned.fill(
child: _SourceText(
source: markdown.source,
controller: _controller,
),
),
if (!_capture.active)
Positioned(
top: _searching ? 76 : 12,
@ -299,6 +329,7 @@ class _ReaderState() extends State<_Reader> {
controller: _controller,
pageCount: widget.state.document.pageCount,
chapters: widget.state.chapters,
shares: _markdown != null,
),
),
),
@ -310,6 +341,7 @@ class _ReaderState() extends State<_Reader> {
child: _PageScrubber(
controller: _controller,
pageCount: widget.state.document.pageCount,
shares: _markdown != null,
),
),
if (_searching)
@ -367,12 +399,14 @@ class _ReaderState() extends State<_Reader> {
child: SafeArea(
child: Row(
children: [
IconButton.filledTonal(
icon: const Icon(LucideIcons.search),
tooltip: AppLocalizations.of(context).readerSearch,
onPressed: _toggleSearch,
),
const SizedBox(width: 8),
if (_markdown == null) ...[
IconButton.filledTonal(
icon: const Icon(LucideIcons.search),
tooltip: AppLocalizations.of(context).readerSearch,
onPressed: _toggleSearch,
),
const SizedBox(width: 8),
],
IconButton.filledTonal(
icon: const Icon(LucideIcons.minimize2),
tooltip: AppLocalizations.of(context).readerExitFullscreen,
@ -388,9 +422,9 @@ class _ReaderState() extends State<_Reader> {
final appBlocks = blocksOf(context);
if (!identical(appBlocks, _appBlocks)) {
_appBlocks = appBlocks;
_panelBlocks = appBlocks.plus(
screenshotBlocks(_capture, widget.state.source.id),
);
_panelBlocks = _markdown != null
? appBlocks
: appBlocks.plus(screenshotBlocks(_capture, widget.state.source.id));
}
final panels = EditorBlocksScope(
blocks: _panelBlocks!,
@ -435,8 +469,10 @@ class _ReaderState() extends State<_Reader> {
_Rail(
title: widget.state.source.content.title,
controller: _controller,
onSearch: _toggleSearch,
onSearch: _markdown == null ? _toggleSearch : null,
onFullscreen: () => setState(() => _fullscreen = true),
showingSource: _markdown == null ? null : _showSource,
onSource: _toggleSource,
),
const VerticalDivider(width: 1),
Expanded(child: viewer),
@ -478,8 +514,10 @@ class _ReaderState() extends State<_Reader> {
children: [
_TopBar(
title: widget.state.source.content.title,
onSearch: _toggleSearch,
onSearch: _markdown == null ? _toggleSearch : null,
onFullscreen: () => setState(() => _fullscreen = true),
showingSource: _markdown == null ? null : _showSource,
onSource: _toggleSource,
),
Expanded(
child: LayoutBuilder(
@ -518,8 +556,15 @@ class _ReaderState() extends State<_Reader> {
class const _Rail({
required final String title,
required final PdfReaderController controller,
required final VoidCallback onSearch,
required final VoidCallback onFullscreen,
required final VoidCallback onSource,
/// Searching the text; none for a Markdown file, which has no zoom
/// either.
final VoidCallback? onSearch,
/// Whether a Markdown file shows its source text; null for a PDF.
final bool? showingSource,
}) extends StatelessWidget {
/// The room between the rail's groups of buttons.
static const _groupGap = 16.0;
@ -547,46 +592,50 @@ class const _Rail({
),
),
const Divider(indent: 12, endIndent: 12),
IconButton(
icon: const Icon(LucideIcons.search),
tooltip: l10n.readerSearch,
onPressed: onSearch,
),
// Searching, fitting and zooming in groups of their own, set
// apart by space.
const SizedBox(height: _groupGap),
IconButton(
icon: const Icon(LucideIcons.moveHorizontal),
tooltip: l10n.readerFitWidth,
onPressed: controller.fitWidth,
),
IconButton(
icon: const Icon(LucideIcons.moveVertical),
tooltip: l10n.readerFitHeight,
onPressed: controller.fitHeight,
),
const SizedBox(height: _groupGap),
IconButton(
icon: const Icon(LucideIcons.zoomIn),
tooltip: l10n.readerZoomIn,
onPressed: controller.zoomIn,
),
Tooltip(
message: l10n.readerSetZoom,
child: TextButton(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(48, 36),
),
onPressed: () => unawaited(_askZoom(context)),
child: Text('${(controller.zoom * 100).round()}%'),
if (showingSource case final showing?)
_SourceToggle(showing: showing, onPressed: onSource),
if (onSearch case final onSearch?) ...[
IconButton(
icon: const Icon(LucideIcons.search),
tooltip: l10n.readerSearch,
onPressed: onSearch,
),
),
IconButton(
icon: const Icon(LucideIcons.zoomOut),
tooltip: l10n.readerZoomOut,
onPressed: controller.zoomOut,
),
// Searching, fitting and zooming in groups of their own, set
// apart by space.
const SizedBox(height: _groupGap),
IconButton(
icon: const Icon(LucideIcons.moveHorizontal),
tooltip: l10n.readerFitWidth,
onPressed: controller.fitWidth,
),
IconButton(
icon: const Icon(LucideIcons.moveVertical),
tooltip: l10n.readerFitHeight,
onPressed: controller.fitHeight,
),
const SizedBox(height: _groupGap),
IconButton(
icon: const Icon(LucideIcons.zoomIn),
tooltip: l10n.readerZoomIn,
onPressed: controller.zoomIn,
),
Tooltip(
message: l10n.readerSetZoom,
child: TextButton(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(48, 36),
),
onPressed: () => unawaited(_askZoom(context)),
child: Text('${(controller.zoom * 100).round()}%'),
),
),
IconButton(
icon: const Icon(LucideIcons.zoomOut),
tooltip: l10n.readerZoomOut,
onPressed: controller.zoomOut,
),
],
const Spacer(),
IconButton(
icon: const Icon(LucideIcons.maximize2),
@ -660,8 +709,14 @@ class _ZoomDialogState() extends State<_ZoomDialog> {
/// Below 600 dp, the bar above the pages.
class const _TopBar({
required final String title,
required final VoidCallback onSearch,
required final VoidCallback onFullscreen,
required final VoidCallback onSource,
/// Searching the text; none for a Markdown file.
final VoidCallback? onSearch,
/// Whether a Markdown file shows its source text; null for a PDF.
final bool? showingSource,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
@ -683,11 +738,14 @@ class const _TopBar({
style: Theme.of(context).textTheme.titleMedium,
),
),
IconButton(
icon: const Icon(LucideIcons.search),
tooltip: l10n.readerSearch,
onPressed: onSearch,
),
if (showingSource case final showing?)
_SourceToggle(showing: showing, onPressed: onSource),
if (onSearch case final onSearch?)
IconButton(
icon: const Icon(LucideIcons.search),
tooltip: l10n.readerSearch,
onPressed: onSearch,
),
IconButton(
icon: const Icon(LucideIcons.maximize2),
tooltip: l10n.readerFullscreen,
@ -705,6 +763,10 @@ class const _PageCounter({
required final PdfReaderController controller,
required final int pageCount,
required final List<PdfChapter> chapters,
/// Whether it shows how much is read, in percent, rather than a page:
/// a Markdown file's parts.
final bool shares = false,
}) extends StatefulWidget {
@override
State<_PageCounter> createState() => _PageCounterState();
@ -766,49 +828,60 @@ class _PageCounterState() extends State<_PageCounter> {
visualDensity: VisualDensity.compact,
),
),
Material(
elevation: 2,
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(24),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(LucideIcons.chevronLeft),
tooltip: l10n.readerPreviousPage,
onPressed: page > 1
? () => widget.controller.goToPage(page - 1)
: null,
),
SizedBox(
width: 48,
child: TextField(
controller: _field,
focusNode: _focus,
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(
isDense: true,
border: InputBorder.none,
),
onSubmitted: (_) => _commit(),
if (widget.shares)
Material(
elevation: 2,
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(24),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(l10n.markdownShare(markdownShareOf(page))),
),
)
else
Material(
elevation: 2,
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(24),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(LucideIcons.chevronLeft),
tooltip: l10n.readerPreviousPage,
onPressed: page > 1
? () => widget.controller.goToPage(page - 1)
: null,
),
),
Text(l10n.readerPageOf(widget.pageCount)),
IconButton(
icon: const Icon(LucideIcons.chevronRight),
tooltip: l10n.readerNextPage,
onPressed: page < widget.pageCount
? () => widget.controller.goToPage(page + 1)
: null,
),
],
SizedBox(
width: 48,
child: TextField(
controller: _field,
focusNode: _focus,
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(
isDense: true,
border: InputBorder.none,
),
onSubmitted: (_) => _commit(),
),
),
Text(l10n.readerPageOf(widget.pageCount)),
IconButton(
icon: const Icon(LucideIcons.chevronRight),
tooltip: l10n.readerNextPage,
onPressed: page < widget.pageCount
? () => widget.controller.goToPage(page + 1)
: null,
),
],
),
),
),
),
],
);
}
@ -819,6 +892,9 @@ class _PageCounterState() extends State<_PageCounter> {
class const _PageScrubber({
required final PdfReaderController controller,
required final int pageCount,
/// Whether it shows how much is read, in percent, rather than a page.
final bool shares = false,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
@ -861,7 +937,13 @@ class const _PageScrubber({
vertical: 6,
),
child: Text(
'${controller.page}',
shares
? AppLocalizations.of(
context,
).markdownShare(
markdownShareOf(controller.page),
)
: '${controller.page}',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onPrimaryContainer,
),
@ -1231,3 +1313,58 @@ class _SidebarHandleState() extends State<_SidebarHandle> {
);
}
}
/// Switches a Markdown file between formatted and its source text.
class const _SourceToggle({
required final bool showing,
required final VoidCallback onPressed,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return IconButton(
isSelected: showing,
icon: const Icon(LucideIcons.code),
tooltip: showing ? l10n.readerShowFormatted : l10n.readerShowSource,
onPressed: onPressed,
);
}
}
/// A Markdown file as written, its text selectable for cards and notes as
/// the formatted one's.
class const _SourceText({
required final String source,
required final PdfReaderController controller,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ColoredBox(
color: theme.colorScheme.surface,
child: Scrollbar(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: readableContentMaxWidth,
),
child: SelectableText(
source,
style: theme.textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
),
onSelectionChanged: (selection, _) => controller.report(
selectedText: selection.isCollapsed
? ''
: selection.textInside(source),
),
),
),
),
),
),
);
}
}

View file

@ -5,6 +5,7 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/bloc/reading_deadlines_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/deadline_status.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/reading_deadlines.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -129,6 +130,7 @@ class const _Current({required final PdfDeadline deadline})
final theme = Theme.of(context);
final (:pdf, :status) = deadline;
final target = pdf.content.deadline!.targetPage;
final shares = pdf.content.kind == FileKind.markdown;
final late = switch (status.state) {
DeadlineState.overdue || DeadlineState.dueToday => true,
_ => false,
@ -152,7 +154,17 @@ class const _Current({required final PdfDeadline deadline})
),
),
Text(
l10n.deadlinesProgress(target - status.pagesLeft, target, when),
shares
? l10n.markdownDeadlinesProgress(
markdownShareOf(target - status.pagesLeft),
markdownShareOf(target),
when,
)
: l10n.deadlinesProgress(
target - status.pagesLeft,
target,
when,
),
style: theme.textTheme.bodySmall?.copyWith(
color: late
? theme.colorScheme.error
@ -163,7 +175,13 @@ class const _Current({required final PdfDeadline deadline})
Chip(
visualDensity: VisualDensity.compact,
avatar: const Icon(LucideIcons.bookOpen, size: 16),
label: Text(l10n.deadlineToPageToday(status.pageToReachToday)),
label: Text(
shares
? l10n.markdownToShareToday(
markdownShareOf(status.pageToReachToday),
)
: l10n.deadlineToPageToday(status.pageToReachToday),
),
),
],
),

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