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:
parent
690f69fa55
commit
151f848360
31 changed files with 1460 additions and 195 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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`).
|
||||||
|
|
|
||||||
|
|
@ -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}),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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.');
|
||||||
|
|
|
||||||
|
|
@ -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-';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in a new issue