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
31 changed files with 1460 additions and 195 deletions

File diff suppressed because one or more lines are too long

View file

@ -281,7 +281,7 @@
"studysetNotesInTopics": "Die Notizen sind in seinen Themen", "studysetNotesInTopics": "Die Notizen sind in seinen Themen",
"studysetNotesInTopicsHint": "Oder schreib hier eine: „Material hinzufügen → Notiz erstellen“.", "studysetNotesInTopicsHint": "Oder schreib hier eine: „Material hinzufügen → Notiz erstellen“.",
"studysetFilesInTopics": "Die Dateien sind in seinen Themen", "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}}", "studysetBrowseAllFlashcards": "{count, plural, =1{Ansehen} other{Alle {count} ansehen}}",
"studysetCreateFlashcards": "Karteikarten erstellen", "studysetCreateFlashcards": "Karteikarten erstellen",
"studysetStudy": "Lernen", "studysetStudy": "Lernen",
@ -1153,8 +1153,8 @@
"studyButtonGone": "Dieses Lernset gibt es nicht mehr.", "studyButtonGone": "Dieses Lernset gibt es nicht mehr.",
"pdfPageField": "PDF-Seite", "pdfPageField": "PDF-Seite",
"studysetTabFiles": "Dateien", "studysetTabFiles": "Dateien",
"pdfUploadMenu": "PDF hochladen", "pdfUploadMenu": "Datei hochladen",
"pdfFileType": "PDFs", "pdfFileType": "PDFs und Markdown",
"pdfAdded": "„{title}“ hinzugefügt.", "pdfAdded": "„{title}“ hinzugefügt.",
"pdfNotPdf": "Diese Datei ist kein PDF.", "pdfNotPdf": "Diese Datei ist kein PDF.",
"pdfTooLarge": "Dieses PDF ist größer als {megabytes} MB.", "pdfTooLarge": "Dieses PDF ist größer als {megabytes} MB.",
@ -1163,7 +1163,7 @@
"pdfDuplicateKeep": "Vorhandenes behalten", "pdfDuplicateKeep": "Vorhandenes behalten",
"pdfDuplicateAdd": "Kopie hinzufügen", "pdfDuplicateAdd": "Kopie hinzufügen",
"filesEmpty": "Noch keine Dateien", "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", "filesSort": "Sortieren",
"filesSortCustom": "Eigene Reihenfolge", "filesSortCustom": "Eigene Reihenfolge",
"filesSortName": "Name (A–Z)", "filesSortName": "Name (A–Z)",
@ -2182,5 +2182,25 @@
"pathViewSteps": "Schritte", "pathViewSteps": "Schritte",
"pathViewScroll": "Scrollen", "pathViewScroll": "Scrollen",
"runnerOutline": "Gliederung", "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", "studysetNotesInTopics": "The notes are in its topics",
"studysetNotesInTopicsHint": "Or write one here: \"Add material → Create a note\".", "studysetNotesInTopicsHint": "Or write one here: \"Add material → Create a note\".",
"studysetFilesInTopics": "The files are in its topics", "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": "{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"}}}, "@studysetBrowseAllFlashcards": {"description": "Opens the card browser on every flashcard of a studyset's topics.", "placeholders": {"count": {"type": "int"}}},
"studysetCreateFlashcards": "Create flashcards", "studysetCreateFlashcards": "Create flashcards",
@ -1326,8 +1326,8 @@
"studyButtonGone": "That studyset no longer exists.", "studyButtonGone": "That studyset no longer exists.",
"pdfPageField": "PDF page", "pdfPageField": "PDF page",
"studysetTabFiles": "Files", "studysetTabFiles": "Files",
"pdfUploadMenu": "Upload a PDF", "pdfUploadMenu": "Upload a file",
"pdfFileType": "PDFs", "pdfFileType": "PDFs and Markdown",
"pdfAdded": "Added \"{title}\".", "pdfAdded": "Added \"{title}\".",
"@pdfAdded": {"placeholders": {"title": {"type": "String"}}}, "@pdfAdded": {"placeholders": {"title": {"type": "String"}}},
"pdfNotPdf": "This file isn't a PDF.", "pdfNotPdf": "This file isn't a PDF.",
@ -1339,7 +1339,7 @@
"pdfDuplicateKeep": "Keep the one there", "pdfDuplicateKeep": "Keep the one there",
"pdfDuplicateAdd": "Add a copy", "pdfDuplicateAdd": "Add a copy",
"filesEmpty": "No files yet", "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", "filesSort": "Sort",
"filesSortCustom": "Custom order", "filesSortCustom": "Custom order",
"filesSortName": "Name (A–Z)", "filesSortName": "Name (A–Z)",
@ -2690,5 +2690,45 @@
"runnerOutline": "Outline", "runnerOutline": "Outline",
"@runnerOutline": {"description": "The list of a learning path's steps beside or over its page in the Scroll view."}, "@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": "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. /// No description provided for @studysetFilesInTopicsHint.
/// ///
/// In en, this message translates to: /// 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; String get studysetFilesInTopicsHint;
/// Opens the card browser on every flashcard of a studyset's topics. /// Opens the card browser on every flashcard of a studyset's topics.
@ -6793,13 +6793,13 @@ abstract class AppLocalizations {
/// No description provided for @pdfUploadMenu. /// No description provided for @pdfUploadMenu.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Upload a PDF'** /// **'Upload a file'**
String get pdfUploadMenu; String get pdfUploadMenu;
/// No description provided for @pdfFileType. /// No description provided for @pdfFileType.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'PDFs'** /// **'PDFs and Markdown'**
String get pdfFileType; String get pdfFileType;
/// No description provided for @pdfAdded. /// No description provided for @pdfAdded.
@ -6853,7 +6853,7 @@ abstract class AppLocalizations {
/// No description provided for @filesEmptyHint. /// No description provided for @filesEmptyHint.
/// ///
/// In en, this message translates to: /// 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; String get filesEmptyHint;
/// No description provided for @filesSort. /// No description provided for @filesSort.
@ -12877,6 +12877,126 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Answer the question above to go on.'** /// **'Answer the question above to go on.'**
String get runnerScrollLocked; 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 class _AppLocalizationsDelegate

View file

@ -1039,7 +1039,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get studysetFilesInTopicsHint => 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 @override
String studysetBrowseAllFlashcards(int count) { String studysetBrowseAllFlashcards(int count) {
@ -4216,10 +4216,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get studysetTabFiles => 'Dateien'; String get studysetTabFiles => 'Dateien';
@override @override
String get pdfUploadMenu => 'PDF hochladen'; String get pdfUploadMenu => 'Datei hochladen';
@override @override
String get pdfFileType => 'PDFs'; String get pdfFileType => 'PDFs und Markdown';
@override @override
String pdfAdded(String title) { String pdfAdded(String title) {
@ -4253,7 +4253,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get filesEmptyHint => String get filesEmptyHint =>
'Nutze „Material hinzufügen → PDF hochladen“, um eines hinzuzufügen.'; 'Nutze „Material hinzufügen → Datei hochladen“, um eines hinzuzufügen.';
@override @override
String get filesSort => 'Sortieren'; String get filesSort => 'Sortieren';
@ -8224,4 +8224,103 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get runnerScrollLocked => String get runnerScrollLocked =>
'Beantworte die Frage oben, um weiterzumachen.'; '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 @override
String get studysetFilesInTopicsHint => String get studysetFilesInTopicsHint =>
'Or add one here: \"Add material → Upload a PDF\".'; 'Or add one here: \"Add material → Upload a file\".';
@override @override
String studysetBrowseAllFlashcards(int count) { String studysetBrowseAllFlashcards(int count) {
@ -4178,10 +4178,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get studysetTabFiles => 'Files'; String get studysetTabFiles => 'Files';
@override @override
String get pdfUploadMenu => 'Upload a PDF'; String get pdfUploadMenu => 'Upload a file';
@override @override
String get pdfFileType => 'PDFs'; String get pdfFileType => 'PDFs and Markdown';
@override @override
String pdfAdded(String title) { String pdfAdded(String title) {
@ -4215,7 +4215,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get filesEmptyHint => String get filesEmptyHint =>
'Use \"Add material → Upload a PDF\" to add one.'; 'Use \"Add material → Upload a file\" to add one.';
@override @override
String get filesSort => 'Sort'; String get filesSort => 'Sort';
@ -8150,6 +8150,105 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get runnerScrollLocked => 'Answer the question above to go on.'; 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`). /// 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:appflowy_editor/appflowy_editor.dart';
import 'package:halkyon_editor/halkyon_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 /// 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 /// 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'; 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, pageCount: content.pageCount,
deadline: content.deadline, deadline: content.deadline,
published: origin, published: origin,
kind: content.kind,
); );
} }

View file

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

View file

@ -1,16 +1,18 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:developer'; import 'dart:developer';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.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/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_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/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_chapter.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.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) /// A PDF being read, or a Markdown file read as one ([MarkdownDocument]):
/// and opened, its page count noted, where reading starts, and each page /// 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). /// shown recorded, the furthest kept (decided 2026-10-03).
/// ///
/// Reading starts at [page] when asked for, else at the furthest page read /// Reading starts at [page] when asked for, else at the furthest page read
@ -45,7 +47,10 @@ class PdfReaderBloc(
} }
final PdfDocumentView document; final PdfDocumentView document;
try { 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) { } on FormatException catch (error) {
log('Opening a PDF failed', name: 'PDFs', error: error); log('Opening a PDF failed', name: 'PDFs', error: error);
emit(const PdfReaderStateUnreadable()); 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"}, // 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 // 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 // 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. /// The newest schema this app writes.
const pdfSourceSchemaVersion = 2; const pdfSourceSchemaVersion = 3;
/// Encodes a PDF's content into its payload. /// The schema a PDF is written in, which apps from before Markdown files
ByteData encodePdfSourcePayload(PdfSourceContent content) => /// read.
encodePayload(pdfSourceSchemaVersion, { const _pdfSchema = 2;
'title': content.title,
'fileName': content.fileName, /// Encodes a file's content into its payload.
'asset': content.assetId, ByteData encodePdfSourcePayload(PdfSourceContent content) => encodePayload(
'pageCount': ?content.pageCount, content.kind == FileKind.pdf ? _pdfSchema : pdfSourceSchemaVersion,
if (content.deadline case final deadline?) {
'deadline': { if (content.kind != FileKind.pdf) 'kind': content.kind.name,
'targetPage': deadline.targetPage, 'title': content.title,
'due': _day(deadline.due), 'fileName': content.fileName,
if (deadline.start case final start?) 'start': _day(start), 'asset': content.assetId,
}, 'pageCount': ?content.pageCount,
if (content.published case final published?) if (content.deadline case final deadline?)
'published': publishedRefToJson(published), '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. /// Decodes a PDF's payload.
/// ///
@ -66,6 +75,13 @@ PdfSourceContent decodePdfSourcePayload(ByteData payload) {
_ => throw const FormatException('A malformed deadline.'), _ => throw const FormatException('A malformed deadline.'),
}, },
published: publishedRefFromJson(json['published']), 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.'); throw const FormatException('Not a PDF payload.');

View file

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:developer'; import 'dart:developer';
import 'dart:typed_data'; import 'dart:typed_data';
@ -34,8 +35,15 @@ enum PdfRefusal() {
/// It isn't a PDF. /// It isn't a PDF.
notPdf, notPdf,
/// It is larger than [PdfSourceRepository.maxBytes]. /// It is a PDF larger than [PdfSourceRepository.maxBytes].
tooLarge, 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]. /// The file wasn't taken, for [reason].
@ -120,25 +128,39 @@ class PdfSourceRepository(
return counts; return counts;
} }
/// Adds the PDF [bytes], a file named [fileName], to [studysetId], titled /// Adds the file [bytes], named [fileName], to [studysetId], titled by
/// by its name. A file the studyset holds already is added again only /// its name: a Markdown file where the name ends in `.md` or `.markdown`
/// [evenIfThere]; a file that isn't a PDF, or is too large, not at all. /// (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( Future<PdfImport> import(
String studysetId, { String studysetId, {
required Uint8List bytes, required Uint8List bytes,
required String fileName, required String fileName,
bool evenIfThere = false, bool evenIfThere = false,
}) async { }) async {
if (!_isPdf(bytes)) { final kind = fileKindOf(fileName);
return const PdfRefused(PdfRefusal.notPdf); 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) { switch (kind) {
return const PdfRefused(PdfRefusal.tooLarge); 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( final assetId = await _assets.add(
bytes, bytes,
fileName: fileName, fileName: fileName,
mimeType: AssetRepository.pdfMediaType, mimeType: mimeType,
studysetId: studysetId, studysetId: studysetId,
); );
if (!evenIfThere) { if (!evenIfThere) {
@ -155,6 +177,8 @@ class PdfSourceRepository(
title: titleFromFileName(fileName), title: titleFromFileName(fileName),
fileName: fileName, fileName: fileName,
assetId: assetId, 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 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) => bool _isPdf(Uint8List bytes) =>
bytes.length >= 5 && String.fromCharCodes(bytes.sublist(0, 5)) == '%PDF-'; 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]; List<Object?> get props => [targetPage, due, start];
} }
/// What a PDF says about itself: its title, the name its file came with, the /// What kind of file a studyset's file is (decided 10 Nov 2026): a PDF, or
/// file, its page count once a device has read it, and a reading deadline. /// 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({ final class const PdfSourceContent({
/// The title; the file's name without `.pdf` until renamed. /// The title; the file's name without `.pdf` until renamed.
required final String title, 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 /// Where it came from, in a copy of a published studyset; null for a PDF
/// of the person's own. /// of the person's own.
final PublishedRef? published, final PublishedRef? published,
/// What kind of file it is.
final FileKind kind = FileKind.pdf,
}) with Equatable { }) with Equatable {
/// This content titled [title]. /// This content titled [title].
PdfSourceContent withTitle(String title) => PdfSourceContent( PdfSourceContent withTitle(String title) => PdfSourceContent(
@ -62,6 +99,7 @@ final class const PdfSourceContent({
pageCount: pageCount, pageCount: pageCount,
deadline: deadline, deadline: deadline,
published: published, published: published,
kind: kind,
); );
/// This content with [pageCount] pages. /// This content with [pageCount] pages.
@ -72,6 +110,7 @@ final class const PdfSourceContent({
pageCount: pageCount, pageCount: pageCount,
deadline: deadline, deadline: deadline,
published: published, published: published,
kind: kind,
); );
/// This content with [deadline], or none. /// This content with [deadline], or none.
@ -82,6 +121,7 @@ final class const PdfSourceContent({
pageCount: pageCount, pageCount: pageCount,
deadline: deadline, deadline: deadline,
published: published, published: published,
kind: kind,
); );
@override @override
@ -92,6 +132,7 @@ final class const PdfSourceContent({
pageCount, pageCount,
deadline, deadline,
published, published,
kind,
]; ];
} }
@ -115,10 +156,15 @@ final class const PdfSource({
List<Object?> get props => [id, studysetId, content, position]; 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) { String titleFromFileName(String fileName) {
final name = fileName.trim(); final name = fileName.trim();
return name.toLowerCase().endsWith('.pdf') final lower = name.toLowerCase();
? name.substring(0, name.length - 4) for (final extension in const ['.pdf', '.markdown', '.md']) {
: name; 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 _today = clock.now();
late final ReadingDeadline? _current = widget.source.content.deadline; late final ReadingDeadline? _current = widget.source.content.deadline;
late int _page = _current?.targetPage ?? widget.source.content.pageCount ?? 1; 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) { late DateTime _due = switch (_current?.due) {
final due? => DateTime(due.year, due.month, due.day), final due? => DateTime(due.year, due.month, due.day),
null => DateTime(_today.year, _today.month, _today.day + 14), null => DateTime(_today.year, _today.month, _today.day + 14),
@ -66,12 +74,26 @@ class _DeadlineSheetState() extends State<_DeadlineSheet> {
void _setPage(int page) { void _setPage(int page) {
setState(() => _page = page.clamp(1, _maxPage)); setState(() => _page = page.clamp(1, _maxPage));
final text = '${_shown(_page)}';
_pageField.value = TextEditingValue( _pageField.value = TextEditingValue(
text: '$_page', text: text,
selection: TextSelection.collapsed(offset: '$_page'.length), 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 last page of the chapter at [index] of [chapters]: the page before
/// the next one starts, the PDF's last for the last chapter. /// the next one starts, the PDF's last for the last chapter.
int _lastPageOf(List<PdfChapter> chapters, int index) => int _lastPageOf(List<PdfChapter> chapters, int index) =>
@ -125,7 +147,7 @@ class _DeadlineSheetState() extends State<_DeadlineSheet> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Text(
l10n.deadlineSheetHelp, _shares ? l10n.markdownDeadlineHelp : l10n.deadlineSheetHelp,
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
@ -136,7 +158,7 @@ class _DeadlineSheetState() extends State<_DeadlineSheet> {
IconButton.outlined( IconButton.outlined(
icon: const Icon(LucideIcons.minus), icon: const Icon(LucideIcons.minus),
tooltip: l10n.deadlineDecrease, tooltip: l10n.deadlineDecrease,
onPressed: _page > 1 ? () => _setPage(_page - 1) : null, onPressed: _page > 1 ? () => _setPage(_step(-1)) : null,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
@ -146,20 +168,20 @@ class _DeadlineSheetState() extends State<_DeadlineSheet> {
inputFormatters: [FilteringTextInputFormatter.digitsOnly], inputFormatters: [FilteringTextInputFormatter.digitsOnly],
textAlign: TextAlign.center, textAlign: TextAlign.center,
decoration: InputDecoration( decoration: InputDecoration(
labelText: pageCount == null labelText: _shares
? l10n.markdownDeadlineReach
: pageCount == null
? l10n.deadlineReachPage ? l10n.deadlineReachPage
: l10n.deadlineReachPageOf(pageCount), : l10n.deadlineReachPageOf(pageCount),
), ),
onChanged: (text) => setState( onChanged: (text) => setState(() => _page = _typed(text)),
() => _page = (int.tryParse(text) ?? 1).clamp(1, _maxPage),
),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
IconButton.outlined( IconButton.outlined(
icon: const Icon(LucideIcons.plus), icon: const Icon(LucideIcons.plus),
tooltip: l10n.deadlineIncrease, 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( DropdownMenuItem(
value: last, value: last,
child: Text( child: Text(
l10n.deadlineChapterEntry(chapter.title, last), _shares
? l10n.markdownDeadlineChapterEntry(
chapter.title,
markdownShareOf(last),
)
: l10n.deadlineChapterEntry(
chapter.title,
last,
),
overflow: TextOverflow.ellipsis, 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_chapter.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.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/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:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -80,7 +81,7 @@ class const SliverPdfList({
itemBuilder: (context, index) => ListTile( itemBuilder: (context, index) => ListTile(
key: ValueKey(rows[index].source.id), key: ValueKey(rows[index].source.id),
leading: Icon( leading: Icon(
LucideIcons.fileText, fileKindIcon(rows[index].source.content.kind),
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
), ),
title: Text( title: Text(
@ -248,7 +249,7 @@ class const _PdfTile({
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.only(top: 2), 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), const SizedBox(width: 16),
Expanded( Expanded(
@ -263,7 +264,9 @@ class const _PdfTile({
), ),
ContentCountsLine( ContentCountsLine(
counts: [ 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.cards, label: l10n.studysetCountFlashcards),
(count: row.notes, label: l10n.studysetCountNotes), (count: row.notes, label: l10n.studysetCountNotes),
( (
@ -281,7 +284,9 @@ class const _PdfTile({
if (status != null) ...[ if (status != null) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
_deadlineLine(l10n, status, deadline!), content.kind == FileKind.markdown
? _markdownDeadlineLine(l10n, status, deadline!)
: _deadlineLine(l10n, status, deadline!),
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: switch (status.state) { color: switch (status.state) {
DeadlineState.overdue || 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) { List<Widget> _actions(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final error = Theme.of(context).colorScheme.error; final error = Theme.of(context).colorScheme.error;
@ -450,11 +475,13 @@ class const _PdfTile({
}, },
copyable: false, copyable: false,
), ),
( if (source.content.kind == FileKind.pdf)
label: l10n.pdfInfoPages, (
value: source.content.pageCount?.toString() ?? l10n.pdfInfoUnknown, label: l10n.pdfInfoPages,
copyable: false, 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/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.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: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'; import 'package:material_ui/material_ui.dart';
/// The PDF [source] something was made from, small: a document and the /// The PDF [source] something was made from, small: a document and the
@ -47,7 +47,7 @@ class _PdfPageChipState() extends State<PdfPageChip> {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(LucideIcons.fileText, size: 14, color: muted), Icon(fileKindIcon(pdf.content.kind), size: 14, color: muted),
const SizedBox(width: 4), const SizedBox(width: 4),
ConstrainedBox( ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 140), constraints: const BoxConstraints(maxWidth: 140),

View file

@ -154,6 +154,9 @@ class const _CardsPanel({
class _CardsPanelState() class _CardsPanelState()
extends State<_CardsPanel> extends State<_CardsPanel>
with AutomaticKeepAliveClientMixin { 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; late final CardComposerBloc _bloc;
/// The card being written, by its draft's id; null while the list shows. /// 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 page = draft.saved.source!.page;
final heading = _byChapter final heading = _byChapter
? chapterOf(widget.chapters, page)?.title ?? l10n.readerFrontMatter ? chapterOf(widget.chapters, page)?.title ?? l10n.readerFrontMatter
: _shares
? l10n.markdownAt(markdownShareOf(page))
: l10n.readerPage(page); : l10n.readerPage(page);
(sections[heading] ??= []).add(draft); (sections[heading] ??= []).add(draft);
} }
@ -348,7 +353,10 @@ class _CardsPanelState()
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0), padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
child: SegmentedButton<bool>( child: SegmentedButton<bool>(
segments: [ 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)), ButtonSegment(value: true, label: Text(l10n.readerByChapter)),
], ],
selected: {_byChapter}, selected: {_byChapter},
@ -426,7 +434,9 @@ class _CardsPanelState()
icon: const Icon(LucideIcons.sparkles), icon: const Icon(LucideIcons.sparkles),
label: Text( label: Text(
widget.controller.selectedText.trim().isEmpty widget.controller.selectedText.trim().isEmpty
? l10n.proposalsFromPage ? _shares
? l10n.proposalsFromPart
: l10n.proposalsFromPage
: l10n.proposalsFromSelection, : l10n.proposalsFromSelection,
), ),
onPressed: () => unawaited(_propose()), onPressed: () => unawaited(_propose()),
@ -498,7 +508,9 @@ class _CardsPanelState()
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
l10n.readerReferencedPage(page), _shares
? l10n.readerReferencedShare(markdownShareOf(page))
: l10n.readerReferencedPage(page),
style: theme.textTheme.bodySmall, style: theme.textTheme.bodySmall,
), ),
), ),
@ -516,7 +528,11 @@ class _CardsPanelState()
), ),
), ),
child: Text( 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), padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Row( child: Row(
children: [ 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(), const Spacer(),
Text( Text(
switch (_status) { 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/editor/core_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.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/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/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_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_capture.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/pdf_reader_panels.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'; 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 _searching = false;
var _fullscreen = 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 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. /// the share of the room last dragged on this device, else the default.
double? _sidebarWidth; double? _sidebarWidth;
@ -257,12 +269,30 @@ class _ReaderState() extends State<_Reader> {
} }
} }
void _toggleSource() => setState(() {
_showSource = !_showSource;
_controller.report(selectedText: '');
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final wide = context.windowSize.isAtLeastMedium; final wide = context.windowSize.isAtLeastMedium;
final viewer = Stack( final viewer = Stack(
children: [ 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) if (!_capture.active)
Positioned( Positioned(
top: _searching ? 76 : 12, top: _searching ? 76 : 12,
@ -299,6 +329,7 @@ class _ReaderState() extends State<_Reader> {
controller: _controller, controller: _controller,
pageCount: widget.state.document.pageCount, pageCount: widget.state.document.pageCount,
chapters: widget.state.chapters, chapters: widget.state.chapters,
shares: _markdown != null,
), ),
), ),
), ),
@ -310,6 +341,7 @@ class _ReaderState() extends State<_Reader> {
child: _PageScrubber( child: _PageScrubber(
controller: _controller, controller: _controller,
pageCount: widget.state.document.pageCount, pageCount: widget.state.document.pageCount,
shares: _markdown != null,
), ),
), ),
if (_searching) if (_searching)
@ -367,12 +399,14 @@ class _ReaderState() extends State<_Reader> {
child: SafeArea( child: SafeArea(
child: Row( child: Row(
children: [ children: [
IconButton.filledTonal( if (_markdown == null) ...[
icon: const Icon(LucideIcons.search), IconButton.filledTonal(
tooltip: AppLocalizations.of(context).readerSearch, icon: const Icon(LucideIcons.search),
onPressed: _toggleSearch, tooltip: AppLocalizations.of(context).readerSearch,
), onPressed: _toggleSearch,
const SizedBox(width: 8), ),
const SizedBox(width: 8),
],
IconButton.filledTonal( IconButton.filledTonal(
icon: const Icon(LucideIcons.minimize2), icon: const Icon(LucideIcons.minimize2),
tooltip: AppLocalizations.of(context).readerExitFullscreen, tooltip: AppLocalizations.of(context).readerExitFullscreen,
@ -388,9 +422,9 @@ class _ReaderState() extends State<_Reader> {
final appBlocks = blocksOf(context); final appBlocks = blocksOf(context);
if (!identical(appBlocks, _appBlocks)) { if (!identical(appBlocks, _appBlocks)) {
_appBlocks = appBlocks; _appBlocks = appBlocks;
_panelBlocks = appBlocks.plus( _panelBlocks = _markdown != null
screenshotBlocks(_capture, widget.state.source.id), ? appBlocks
); : appBlocks.plus(screenshotBlocks(_capture, widget.state.source.id));
} }
final panels = EditorBlocksScope( final panels = EditorBlocksScope(
blocks: _panelBlocks!, blocks: _panelBlocks!,
@ -435,8 +469,10 @@ class _ReaderState() extends State<_Reader> {
_Rail( _Rail(
title: widget.state.source.content.title, title: widget.state.source.content.title,
controller: _controller, controller: _controller,
onSearch: _toggleSearch, onSearch: _markdown == null ? _toggleSearch : null,
onFullscreen: () => setState(() => _fullscreen = true), onFullscreen: () => setState(() => _fullscreen = true),
showingSource: _markdown == null ? null : _showSource,
onSource: _toggleSource,
), ),
const VerticalDivider(width: 1), const VerticalDivider(width: 1),
Expanded(child: viewer), Expanded(child: viewer),
@ -478,8 +514,10 @@ class _ReaderState() extends State<_Reader> {
children: [ children: [
_TopBar( _TopBar(
title: widget.state.source.content.title, title: widget.state.source.content.title,
onSearch: _toggleSearch, onSearch: _markdown == null ? _toggleSearch : null,
onFullscreen: () => setState(() => _fullscreen = true), onFullscreen: () => setState(() => _fullscreen = true),
showingSource: _markdown == null ? null : _showSource,
onSource: _toggleSource,
), ),
Expanded( Expanded(
child: LayoutBuilder( child: LayoutBuilder(
@ -518,8 +556,15 @@ class _ReaderState() extends State<_Reader> {
class const _Rail({ class const _Rail({
required final String title, required final String title,
required final PdfReaderController controller, required final PdfReaderController controller,
required final VoidCallback onSearch,
required final VoidCallback onFullscreen, 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 { }) extends StatelessWidget {
/// The room between the rail's groups of buttons. /// The room between the rail's groups of buttons.
static const _groupGap = 16.0; static const _groupGap = 16.0;
@ -547,46 +592,50 @@ class const _Rail({
), ),
), ),
const Divider(indent: 12, endIndent: 12), const Divider(indent: 12, endIndent: 12),
IconButton( if (showingSource case final showing?)
icon: const Icon(LucideIcons.search), _SourceToggle(showing: showing, onPressed: onSource),
tooltip: l10n.readerSearch, if (onSearch case final onSearch?) ...[
onPressed: onSearch, IconButton(
), icon: const Icon(LucideIcons.search),
// Searching, fitting and zooming in groups of their own, set tooltip: l10n.readerSearch,
// apart by space. onPressed: onSearch,
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()}%'),
), ),
), // Searching, fitting and zooming in groups of their own, set
IconButton( // apart by space.
icon: const Icon(LucideIcons.zoomOut), const SizedBox(height: _groupGap),
tooltip: l10n.readerZoomOut, IconButton(
onPressed: controller.zoomOut, 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(), const Spacer(),
IconButton( IconButton(
icon: const Icon(LucideIcons.maximize2), icon: const Icon(LucideIcons.maximize2),
@ -660,8 +709,14 @@ class _ZoomDialogState() extends State<_ZoomDialog> {
/// Below 600 dp, the bar above the pages. /// Below 600 dp, the bar above the pages.
class const _TopBar({ class const _TopBar({
required final String title, required final String title,
required final VoidCallback onSearch,
required final VoidCallback onFullscreen, 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 { }) extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -683,11 +738,14 @@ class const _TopBar({
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
), ),
IconButton( if (showingSource case final showing?)
icon: const Icon(LucideIcons.search), _SourceToggle(showing: showing, onPressed: onSource),
tooltip: l10n.readerSearch, if (onSearch case final onSearch?)
onPressed: onSearch, IconButton(
), icon: const Icon(LucideIcons.search),
tooltip: l10n.readerSearch,
onPressed: onSearch,
),
IconButton( IconButton(
icon: const Icon(LucideIcons.maximize2), icon: const Icon(LucideIcons.maximize2),
tooltip: l10n.readerFullscreen, tooltip: l10n.readerFullscreen,
@ -705,6 +763,10 @@ class const _PageCounter({
required final PdfReaderController controller, required final PdfReaderController controller,
required final int pageCount, required final int pageCount,
required final List<PdfChapter> chapters, 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 { }) extends StatefulWidget {
@override @override
State<_PageCounter> createState() => _PageCounterState(); State<_PageCounter> createState() => _PageCounterState();
@ -766,49 +828,60 @@ class _PageCounterState() extends State<_PageCounter> {
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
), ),
), ),
Material( if (widget.shares)
elevation: 2, Material(
color: theme.colorScheme.surfaceContainerHigh, elevation: 2,
borderRadius: BorderRadius.circular(24), color: theme.colorScheme.surfaceContainerHigh,
child: Padding( borderRadius: BorderRadius.circular(24),
padding: const EdgeInsets.symmetric(horizontal: 4), child: Padding(
child: Row( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
mainAxisSize: MainAxisSize.min, child: Text(l10n.markdownShare(markdownShareOf(page))),
children: [ ),
IconButton( )
icon: const Icon(LucideIcons.chevronLeft), else
tooltip: l10n.readerPreviousPage, Material(
onPressed: page > 1 elevation: 2,
? () => widget.controller.goToPage(page - 1) color: theme.colorScheme.surfaceContainerHigh,
: null, borderRadius: BorderRadius.circular(24),
), child: Padding(
SizedBox( padding: const EdgeInsets.symmetric(horizontal: 4),
width: 48, child: Row(
child: TextField( mainAxisSize: MainAxisSize.min,
controller: _field, children: [
focusNode: _focus, IconButton(
textAlign: TextAlign.center, icon: const Icon(LucideIcons.chevronLeft),
keyboardType: TextInputType.number, tooltip: l10n.readerPreviousPage,
inputFormatters: [FilteringTextInputFormatter.digitsOnly], onPressed: page > 1
decoration: const InputDecoration( ? () => widget.controller.goToPage(page - 1)
isDense: true, : null,
border: InputBorder.none,
),
onSubmitted: (_) => _commit(),
), ),
), SizedBox(
Text(l10n.readerPageOf(widget.pageCount)), width: 48,
IconButton( child: TextField(
icon: const Icon(LucideIcons.chevronRight), controller: _field,
tooltip: l10n.readerNextPage, focusNode: _focus,
onPressed: page < widget.pageCount textAlign: TextAlign.center,
? () => widget.controller.goToPage(page + 1) keyboardType: TextInputType.number,
: null, 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({ class const _PageScrubber({
required final PdfReaderController controller, required final PdfReaderController controller,
required final int pageCount, required final int pageCount,
/// Whether it shows how much is read, in percent, rather than a page.
final bool shares = false,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -861,7 +937,13 @@ class const _PageScrubber({
vertical: 6, vertical: 6,
), ),
child: Text( child: Text(
'${controller.page}', shares
? AppLocalizations.of(
context,
).markdownShare(
markdownShareOf(controller.page),
)
: '${controller.page}',
style: theme.textTheme.labelLarge?.copyWith( style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onPrimaryContainer, 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),
),
),
),
),
),
),
);
}
}

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