feat(app): cite sources in APA, MLA or Chicago, and give studysets a bibliography
A source is now shared by learning paths and studysets (core/citation/): authors as full names, year, title, what it is a part of, publisher, ISBN, link, and a citation kept as a file brought it. formatCitation cites it in APA 7, MLA 9 or Chicago 18: it inverts and abbreviates names itself (particles, suffixes, names written as already cited stay), sets a work of its own in italics and a part of something apart from the whole, and writes a missing year as "n.d." in APA and Chicago. - Settings: a Citations card per device, with a preview and a link to the style's own guide. - Studysets: a Bibliography button opens the works cited, to read and, behind its pencil, to change (studyset schema 4). - Steps: sources edited through the same sheet, opened from an "N sources" button in the runner (step schema 2; schema 1 reads on). - Path import: steps' sources, text kept as written, the old format's "citation" read as what a source is part of. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
186a4f2a5d
commit
65976ae63a
38 changed files with 2580 additions and 282 deletions
|
|
@ -83,7 +83,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
||||||
- **Manual dependency injection.** One `Dependencies` object (`lib/src/core/dependencies/`) is the composition root, built at startup (`Dependencies.create()`) and exposed through the `DependenciesScope` InheritedWidget. It hands features repositories only. No service locator, no DI framework, no globals (the scaffold's global `client` moved into it). Why: every dependency is a typed, greppable field, and nothing is resolved by magic.
|
- **Manual dependency injection.** One `Dependencies` object (`lib/src/core/dependencies/`) is the composition root, built at startup (`Dependencies.create()`) and exposed through the `DependenciesScope` InheritedWidget. It hands features repositories only. No service locator, no DI framework, no globals (the scaffold's global `client` moved into it). Why: every dependency is a typed, greppable field, and nothing is resolved by magic.
|
||||||
- **One graph per context.** The dependency graph belongs to one instance and one signed-in person; when either changes, a `KeyedSubtree` disposes the whole subtree and rebuilds it. Why: nothing from one person's session (blocs, streams, caches) can leak into another's.
|
- **One graph per context.** The dependency graph belongs to one instance and one signed-in person; when either changes, a `KeyedSubtree` disposes the whole subtree and rebuilds it. Why: nothing from one person's session (blocs, streams, caches) can leak into another's.
|
||||||
- **App-wide vs. per person.** `Dependencies` holds what outlives a person (the client, auth). Everything of the signed-in person (their local database, its sync, their repositories) is `PersonDependencies`, opened by `PersonScope` inside `SessionGate`'s keyed subtree (`Dependencies.openPerson`) and disposed with it; widgets reach it through `PersonScope.of(context)`.
|
- **App-wide vs. per person.** `Dependencies` holds what outlives a person (the client, auth). Everything of the signed-in person (their local database, its sync, their repositories) is `PersonDependencies`, opened by `PersonScope` inside `SessionGate`'s keyed subtree (`Dependencies.openPerson`) and disposed with it; widgets reach it through `PersonScope.of(context)`.
|
||||||
- **The device's own settings** (appearance, language; `core/device_settings/`) belong to the device, not to a person: kept in `<app support>/device_settings.json`, read in `Dependencies.create()` so the first frame already has them, and provided by `DeviceSettingsScope` above `SessionGate`'s keyed subtree, which both its plain app and `PersonApp` read. What should follow the person to their other devices goes into their synced `PersonSettings` instead.
|
- **The device's own settings** (appearance, language, citation style; `core/device_settings/`) belong to the device, not to a person: kept in `<app support>/device_settings.json`, read in `Dependencies.create()` so the first frame already has them, and provided by `DeviceSettingsScope` above `SessionGate`'s keyed subtree, which both its plain app and `PersonApp` read. What should follow the person to their other devices goes into their synced `PersonSettings` instead.
|
||||||
- **Every person has an app of their own** (`PersonApp`, `lib/src/app/`), which `SessionGate` builds inside the keyed subtree: a `MaterialApp.router` with its own `GoRouter`, and `PersonScope` around the router's navigator (`MaterialApp.builder`). So every screen and dialog is inside the person's scope and reads their repositories from `PersonScope.of(context)` wherever it is built, and a change of person disposes the router with every screen on it and starts again at home. Until someone is signed in, `SessionGate` shows a plain `MaterialApp`: the spinner, or the failure with a way to try again.
|
- **Every person has an app of their own** (`PersonApp`, `lib/src/app/`), which `SessionGate` builds inside the keyed subtree: a `MaterialApp.router` with its own `GoRouter`, and `PersonScope` around the router's navigator (`MaterialApp.builder`). So every screen and dialog is inside the person's scope and reads their repositories from `PersonScope.of(context)` wherever it is built, and a change of person disposes the router with every screen on it and starts again at home. Until someone is signed in, `SessionGate` shows a plain `MaterialApp`: the spinner, or the failure with a way to try again.
|
||||||
- **Platform plugins sit behind small interfaces in `Dependencies`**, with fakes in tests: picking files (`FilePicker`, `file_selector`), playing sounds (`SoundPlayer`, `audioplayers`), playing YouTube videos (`YoutubeVideos`, `youtube_player_iframe` where a web view exists, else the browser), reading PDFs (`PdfEngine`, pdfrx on PDFium, which `flutter test` can't load: widget tests use `FakePdfEngine`, whose PDF bytes say their pages and chapters), reading Anki packages on another isolate (`AnkiPackageReader`; a widget test's fake clock can't wait for one), and the Pomodoro timer's side of the system (`PomodoroPlatform`, `FakePomodoroPlatform` in tests).
|
- **Platform plugins sit behind small interfaces in `Dependencies`**, with fakes in tests: picking files (`FilePicker`, `file_selector`), playing sounds (`SoundPlayer`, `audioplayers`), playing YouTube videos (`YoutubeVideos`, `youtube_player_iframe` where a web view exists, else the browser), reading PDFs (`PdfEngine`, pdfrx on PDFium, which `flutter test` can't load: widget tests use `FakePdfEngine`, whose PDF bytes say their pages and chapters), reading Anki packages on another isolate (`AnkiPackageReader`; a widget test's fake clock can't wait for one), and the Pomodoro timer's side of the system (`PomodoroPlatform`, `FakePomodoroPlatform` in tests).
|
||||||
- **Material's `Card` widget clashes with the domain's `Card`**: files that need both import `material_ui` with `hide Card`.
|
- **Material's `Card` widget clashes with the domain's `Card`**: files that need both import `material_ui` with `hide Card`.
|
||||||
|
|
@ -144,6 +144,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
||||||
- **Files** (ADR 0004, rule 5): `AssetEndpoint` (`requireLogin`) takes uploads into the caller's own space by SHA-256 (`startUpload` gives Serverpod's upload description, or null when the file is there already and claims it anew; `finishUpload` keeps it only if its content has that hash) and serves an asset row's file to its space's owner and members only, `NotFoundException` otherwise, also for a deleted row (the sync engine hides tombstones). Files live in the private database storage at `spaces/<space>/<sha256>`, at most 20 MB each, and within limits (`file_limits.dart`, from the environment: an anonymous person 100 MB, an identified one 500 MB or what a `file_allowance` row grants them, the instance 15 GB), checked when an upload starts (unfinished uploads count, or they could fill the disk unnoticed) and again when it finishes (only what is kept counts), refused with `FileLimitExceededException`; `deletePerson` deletes the person's files and allowance, and `FilePruningFutureCall` deletes daily those no live row refers to (or never finished), 7 days after they were last claimed. In the app, `AssetRepository` (`core/asset/`) keeps files by hash in the person's folder (`files/`, uploads waiting marked in `files/uploads/`), uploads after each sync that went through, and downloads on demand, checking the hash; every hash is checked to be one before it becomes part of a path. Content names a file by the URL `asset:<asset row id>` (`assetUrl`, `RichContent.assetIds`): images are the editor's `image` block, sounds an `audio` block (`core/editor/file_blocks.dart`), and any other URL is never fetched. `StoredFileImage` loads through the repository like Flutter's `NetworkImage`, keyed by the person's repository too, so no image crosses people.
|
- **Files** (ADR 0004, rule 5): `AssetEndpoint` (`requireLogin`) takes uploads into the caller's own space by SHA-256 (`startUpload` gives Serverpod's upload description, or null when the file is there already and claims it anew; `finishUpload` keeps it only if its content has that hash) and serves an asset row's file to its space's owner and members only, `NotFoundException` otherwise, also for a deleted row (the sync engine hides tombstones). Files live in the private database storage at `spaces/<space>/<sha256>`, at most 20 MB each, and within limits (`file_limits.dart`, from the environment: an anonymous person 100 MB, an identified one 500 MB or what a `file_allowance` row grants them, the instance 15 GB), checked when an upload starts (unfinished uploads count, or they could fill the disk unnoticed) and again when it finishes (only what is kept counts), refused with `FileLimitExceededException`; `deletePerson` deletes the person's files and allowance, and `FilePruningFutureCall` deletes daily those no live row refers to (or never finished), 7 days after they were last claimed. In the app, `AssetRepository` (`core/asset/`) keeps files by hash in the person's folder (`files/`, uploads waiting marked in `files/uploads/`), uploads after each sync that went through, and downloads on demand, checking the hash; every hash is checked to be one before it becomes part of a path. Content names a file by the URL `asset:<asset row id>` (`assetUrl`, `RichContent.assetIds`): images are the editor's `image` block, sounds an `audio` block (`core/editor/file_blocks.dart`), and any other URL is never fetched. `StoredFileImage` loads through the repository like Flutter's `NetworkImage`, keyed by the person's repository too, so no image crosses people.
|
||||||
- **PDFs** (`feature/pdf_source/`): a `pdf_source` row per PDF in a studyset (payload: title, file name, the asset of its file, page count, reading deadline; last writer wins) and an append-only `pdf_reading` log (`reached` with a page, `reset`): the furthest page is the furthest reached since the last reset, so devices never conflict. A PDF's file is an asset of up to 50 MB (other files 20 MB): the server takes up to 50 MB and deletes a stored file over 20 MB that doesn't start with `%PDF-`. Deleting a PDF deletes its asset row too unless another PDF has the same file (asset rows are otherwise never deleted). Cards and notes tied to a page keep it in their payload (`source`, `core/content/pdf_page_ref.dart`; card schema 4, note schema 2); a screenshot is an `image` block with `pdf` and `page` attributes.
|
- **PDFs** (`feature/pdf_source/`): a `pdf_source` row per PDF in a studyset (payload: title, file name, the asset of its file, page count, reading deadline; last writer wins) and an append-only `pdf_reading` log (`reached` with a page, `reset`): the furthest page is the furthest reached since the last reset, so devices never conflict. A PDF's file is an asset of up to 50 MB (other files 20 MB): the server takes up to 50 MB and deletes a stored file over 20 MB that doesn't start with `%PDF-`. Deleting a PDF deletes its asset row too unless another PDF has the same file (asset rows are otherwise never deleted). Cards and notes tied to a page keep it in their payload (`source`, `core/content/pdf_page_ref.dart`; card schema 4, note schema 2); a screenshot is an `image` block with `pdf` and `page` attributes.
|
||||||
- **The Pomodoro timer** (`feature/pomodoro/`): the profiles follow the person (`PersonSettings.pomodoro`, settings schema 4), the running timer is the device's own (`PomodoroRun` in `<person folder>/pomodoro.json`, `PomodoroRunStore`). `PomodoroRun` is a pure state machine on the time: a phase that ends leads to the next, running if its profile starts it by itself, else paused at its full length; a timer kept while the app was closed catches up (`caughtUpTo`). `PomodoroBloc` sits above the router (`PersonApp`), and `PomodoroPresenter` hands every change to `PomodoroPlatform` as a `PomodoroNotice` in the app's language, and its buttons back. **Android:** the app's own Kotlin (`PomodoroService.kt`; MethodChannel `app.halkyon.learn/pomodoro`, EventChannel `…/buttons`): a `specialUse` foreground service with a countdown notification (chronometer), Pause/Resume, Skip, Stop, on Android 16 a Live Update (`NotificationCompat.ProgressStyle`, the cycle's phases as segments, `setRequestPromotedOngoing`); an exact alarm (`USE_EXACT_ALARM`) alerts at the phase's end with the app closed; Do Not Disturb through the interruption filter, the one before kept in shared preferences and restored. With the app swiped away its buttons open it (a service may not start an activity from a notification's button since Android 12, so the notification is posted again with activity intents), and Stop works alone, leaving a mark the app takes at its next start (`takeStoppedOutside`) to forget its timer; a profile's next phase only starts by itself while the app runs. Dart's timers sleep with the device, so `PomodoroBloc` also looks every 15 s while a phase runs, and the alarm's receiver wakes it (`phaseEnds`); a kept timer is caught up before it first shows, or its ended phase would alert again. No notification plugin: flutter_local_notifications had Live Updates only in a prerelease needing compileSdk 37 (checked 2026-10-07). **Linux:** `LinuxPomodoroPlatform`, over package:dbus: where the top bar shows icons (GNOME with the AppIndicator extension, built into Ubuntu; KDE) the timer counts down there with its buttons in the icon's menu (`StatusNotifierItem`, written in Dart and laid out as libayatana-appindicator lays itself out, within the snap's rules, `desktop-legacy` of snapd v2.77: the item at `/org/ayatana/NotificationItem/<id>`, the only path the label's signal `XAyatanaNewLabel` may come from, registered with the watcher by that path, since a snap may bind no `org.freedesktop.StatusNotifierItem-*` name; the menu at `/MenuBar`; registered again when the watcher restarts); elsewhere the timer shows in the app alone, and on GNOME the Pomodoro card suggests the extension until dismissed, asking GNOME Shell to install or switch it on (`InstallRemoteExtension`, `EnableExtension`) or opening its page (decided 2026-10-07: no notification stands in; xdg-desktop-portal's Background status is for Flatpaks only). An alert with sound comes through `org.freedesktop.Notifications` when a phase ends while the app runs, and GNOME's `show-banners` goes off while focusing, through `gsettings`; the value before and the dismissed suggestion are kept in `<app support>/`. Calls to the bus go one at a time (`SerialQueue`).
|
- **The Pomodoro timer** (`feature/pomodoro/`): the profiles follow the person (`PersonSettings.pomodoro`, settings schema 4), the running timer is the device's own (`PomodoroRun` in `<person folder>/pomodoro.json`, `PomodoroRunStore`). `PomodoroRun` is a pure state machine on the time: a phase that ends leads to the next, running if its profile starts it by itself, else paused at its full length; a timer kept while the app was closed catches up (`caughtUpTo`). `PomodoroBloc` sits above the router (`PersonApp`), and `PomodoroPresenter` hands every change to `PomodoroPlatform` as a `PomodoroNotice` in the app's language, and its buttons back. **Android:** the app's own Kotlin (`PomodoroService.kt`; MethodChannel `app.halkyon.learn/pomodoro`, EventChannel `…/buttons`): a `specialUse` foreground service with a countdown notification (chronometer), Pause/Resume, Skip, Stop, on Android 16 a Live Update (`NotificationCompat.ProgressStyle`, the cycle's phases as segments, `setRequestPromotedOngoing`); an exact alarm (`USE_EXACT_ALARM`) alerts at the phase's end with the app closed; Do Not Disturb through the interruption filter, the one before kept in shared preferences and restored. With the app swiped away its buttons open it (a service may not start an activity from a notification's button since Android 12, so the notification is posted again with activity intents), and Stop works alone, leaving a mark the app takes at its next start (`takeStoppedOutside`) to forget its timer; a profile's next phase only starts by itself while the app runs. Dart's timers sleep with the device, so `PomodoroBloc` also looks every 15 s while a phase runs, and the alarm's receiver wakes it (`phaseEnds`); a kept timer is caught up before it first shows, or its ended phase would alert again. No notification plugin: flutter_local_notifications had Live Updates only in a prerelease needing compileSdk 37 (checked 2026-10-07). **Linux:** `LinuxPomodoroPlatform`, over package:dbus: where the top bar shows icons (GNOME with the AppIndicator extension, built into Ubuntu; KDE) the timer counts down there with its buttons in the icon's menu (`StatusNotifierItem`, written in Dart and laid out as libayatana-appindicator lays itself out, within the snap's rules, `desktop-legacy` of snapd v2.77: the item at `/org/ayatana/NotificationItem/<id>`, the only path the label's signal `XAyatanaNewLabel` may come from, registered with the watcher by that path, since a snap may bind no `org.freedesktop.StatusNotifierItem-*` name; the menu at `/MenuBar`; registered again when the watcher restarts); elsewhere the timer shows in the app alone, and on GNOME the Pomodoro card suggests the extension until dismissed, asking GNOME Shell to install or switch it on (`InstallRemoteExtension`, `EnableExtension`) or opening its page (decided 2026-10-07: no notification stands in; xdg-desktop-portal's Background status is for Flatpaks only). An alert with sound comes through `org.freedesktop.Notifications` when a phase ends while the app runs, and GNOME's `show-banners` goes off while focusing, through `gsettings`; the value before and the dismissed suggestion are kept in `<app support>/`. Calls to the bus go one at a time (`SerialQueue`).
|
||||||
|
- **Citations** (`core/citation/`): a `Source` is plain text parts as the person typed them (authors as full names, one per line; year; title; `container`, what it is a part of; publisher; ISBN; link; `raw`, a citation a file brought, shown while nothing else is cited), and `formatCitation` cites it in the device's `CitationStyle` (APA 7, MLA 9, Chicago 18 notes and bibliography, decided 2026-10-07): it splits and abbreviates the names itself (inverted names, particles, suffixes; names written with `&` or "et al." stay as written), sets a work of its own in italics and a part of something apart from the whole by `container`, and writes a missing year as "n.d." in APA and Chicago. A step's sources (step schema 2) and a studyset's bibliography (`bibliography`, studyset schema 4, last writer wins like the rest of its payload) are lists of them, shown and changed through `SourceList` and `showSourcesSheet`, in reference-list order (alphabetical, whatever order they were added in); the path import reads them too, text as `raw` and the old format's `citation` as `container`.
|
||||||
- **Imported cards** say where they came from in their first revision's payload (`origin`, card schema 3, e.g. `anki`); the dashboard doesn't count them as written. The Anki import (`feature/anki/`) reads `.apkg`/`.colpkg` packages of every format on an isolate of its own (`AnkiPackageReader`), zstd through our fork of `pure_zstd` (Anki's frames don't record their size), and its fixtures come from Anki itself (`tool/anki/make_fixtures.py`).
|
- **Imported cards** say where they came from in their first revision's payload (`origin`, card schema 3, e.g. `anki`); the dashboard doesn't count them as written. The Anki import (`feature/anki/`) reads `.apkg`/`.colpkg` packages of every format on an isolate of its own (`AnkiPackageReader`), zstd through our fork of `pure_zstd` (Anki's frames don't record their size), and its fixtures come from Anki itself (`tool/anki/make_fixtures.py`).
|
||||||
- **Every client migration keeps a device's rows** (`test/core/sync/device_migration_test.dart` replays each on a database with a row in every synced table of ours; SQLite rebuilds tables to add foreign keys, which Serverpod's runner does with foreign keys off). A new synced table needs a row in that test, which fails until it has one.
|
- **Every client migration keeps a device's rows** (`test/core/sync/device_migration_test.dart` replays each on a database with a row in every synced table of ours; SQLite rebuilds tables to add foreign keys, which Serverpod's runner does with foreign keys off). A new synced table needs a row in that test, which fails until it has one.
|
||||||
- **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables.
|
- **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables.
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,8 @@
|
||||||
"studysetStar": "Mit Stern markieren",
|
"studysetStar": "Mit Stern markieren",
|
||||||
"studysetUnstar": "Markierung entfernen",
|
"studysetUnstar": "Markierung entfernen",
|
||||||
"studysetOptions": "Optionen",
|
"studysetOptions": "Optionen",
|
||||||
|
"studysetBibliography": "Literaturverzeichnis",
|
||||||
|
"studysetBibliographyIntro": "Die in diesem Lernset zitierten Werke, im Zitierstil aus den Einstellungen.",
|
||||||
"studysetMenuEdit": "Bearbeiten",
|
"studysetMenuEdit": "Bearbeiten",
|
||||||
"studysetMenuArchive": "Archivieren",
|
"studysetMenuArchive": "Archivieren",
|
||||||
"studysetMenuUnarchive": "Aus Archiv holen",
|
"studysetMenuUnarchive": "Aus Archiv holen",
|
||||||
|
|
@ -128,14 +130,38 @@
|
||||||
"stepFeedbackRight": "Feedback nach einer richtigen Antwort",
|
"stepFeedbackRight": "Feedback nach einer richtigen Antwort",
|
||||||
"stepFeedbackWrong": "Feedback nach einer falschen Antwort",
|
"stepFeedbackWrong": "Feedback nach einer falschen Antwort",
|
||||||
"stepFeedbackNone": "Keines",
|
"stepFeedbackNone": "Keines",
|
||||||
"stepSources": "Quellen",
|
"sources": "Quellen",
|
||||||
"stepAddSource": "Quelle hinzufügen",
|
"sourcesCount": "{count, plural, =1{1 Quelle} other{{count} Quellen}}",
|
||||||
"stepRemoveSource": "Quelle entfernen",
|
"sourcesNone": "Noch keine Quellen angegeben.",
|
||||||
"stepSourceAuthors": "Autoren",
|
"sourcesEdit": "Quellen bearbeiten",
|
||||||
"stepSourceYear": "Jahr",
|
"sourcesEditDone": "Bearbeiten beenden",
|
||||||
"stepSourceTitle": "Titel",
|
"sourceAdd": "Quelle hinzufügen",
|
||||||
"stepSourceUrl": "Link",
|
"sourceEdit": "Quelle bearbeiten",
|
||||||
"stepSourcesCount": "{count, plural, =1{1 Quelle} other{{count} Quellen}}",
|
"sourceRemove": "Quelle entfernen",
|
||||||
|
"sourceRemoved": "Quelle entfernt",
|
||||||
|
"sourceSheetAdd": "Hinzufügen",
|
||||||
|
"sourceAuthors": "Autoren",
|
||||||
|
"sourceAuthorsHint": "Vollständige Namen, einer pro Zeile, z. B. Mark A. McDaniel",
|
||||||
|
"sourceYear": "Jahr",
|
||||||
|
"sourceYearHint": "z. B. 2014",
|
||||||
|
"sourceTitle": "Titel",
|
||||||
|
"sourceTitleHint": "Titel des Werks",
|
||||||
|
"sourceContainer": "Erschienen in",
|
||||||
|
"sourceContainerHint": "Zeitschrift, Buch oder Website, mit Seiten",
|
||||||
|
"sourceContainerHelper": "Leer lassen bei einem eigenständigen Werk wie einem Buch.",
|
||||||
|
"sourcePublisher": "Verlag",
|
||||||
|
"sourcePublisherHint": "z. B. Belknap Press",
|
||||||
|
"sourceIsbn": "ISBN",
|
||||||
|
"sourceIsbnHint": "z. B. 978-0-674-72901-8",
|
||||||
|
"sourceUrl": "Link",
|
||||||
|
"sourceUrlHint": "https://…",
|
||||||
|
"sourceImportedAs": "Importiert als: {citation}",
|
||||||
|
"sourceIsbnLine": "ISBN: {isbn}",
|
||||||
|
"sourceAvailableAt": "Verfügbar unter:",
|
||||||
|
"sourceEmpty": "Leere Quellenangabe",
|
||||||
|
"sourceDiscardTitle": "Änderungen verwerfen?",
|
||||||
|
"sourceDiscardMessage": "Was du für diese Quelle eingegeben hast, geht verloren.",
|
||||||
|
"sourceDiscardConfirm": "Verwerfen",
|
||||||
"stepConflict": "Dieser Schritt wurde auf einem anderen Gerät anders geändert. Behalte eine Version, um weiterzuschreiben.",
|
"stepConflict": "Dieser Schritt wurde auf einem anderen Gerät anders geändert. Behalte eine Version, um weiterzuschreiben.",
|
||||||
"stepFieldTitle": "Titel",
|
"stepFieldTitle": "Titel",
|
||||||
"stepFieldBody": "Text",
|
"stepFieldBody": "Text",
|
||||||
|
|
@ -253,6 +279,14 @@
|
||||||
"settingsAppearanceDark": "Dunkel",
|
"settingsAppearanceDark": "Dunkel",
|
||||||
"settingsLanguage": "Sprache",
|
"settingsLanguage": "Sprache",
|
||||||
"settingsLanguageDevice": "Gerätesprache",
|
"settingsLanguageDevice": "Gerätesprache",
|
||||||
|
"settingsCitations": "Quellenangaben",
|
||||||
|
"settingsCitationsDescription": "Wie Quellen und die Literaturverzeichnisse von Lernsets auf diesem Gerät zitiert werden. Gib die vollständigen Namen der Autoren ein: Jeder Stil kürzt und ordnet sie selbst.",
|
||||||
|
"settingsCitationStyle": "Stil",
|
||||||
|
"settingsCitationStyleApa": "APA (7. Auflage)",
|
||||||
|
"settingsCitationStyleMla": "MLA (9. Auflage)",
|
||||||
|
"settingsCitationStyleChicago": "Chicago (18. Auflage)",
|
||||||
|
"settingsCitationPreview": "Vorschau",
|
||||||
|
"settingsCitationGuide": "Offizieller Leitfaden",
|
||||||
"settingsVersion": "Version {version} (Build {build})",
|
"settingsVersion": "Version {version} (Build {build})",
|
||||||
"syncTitle": "Synchronisierung",
|
"syncTitle": "Synchronisierung",
|
||||||
"syncStateSyncing": "Wird synchronisiert…",
|
"syncStateSyncing": "Wird synchronisiert…",
|
||||||
|
|
@ -860,6 +894,7 @@
|
||||||
"pathImportTooFew": "es braucht mindestens zwei „{detail}“.",
|
"pathImportTooFew": "es braucht mindestens zwei „{detail}“.",
|
||||||
"pathImportMalformedNumber": "eine fehlerhafte Zahl.",
|
"pathImportMalformedNumber": "eine fehlerhafte Zahl.",
|
||||||
"pathImportTitleNotText": "ein Titel muss Text sein.",
|
"pathImportTitleNotText": "ein Titel muss Text sein.",
|
||||||
|
"pathImportMalformedSource": "eine Quelle muss ein Objekt oder Text sein und ihre Teile Text.",
|
||||||
"pomodoroTopBarHint": "Der Timer kann in der oberen Leiste von GNOME herunterzählen, mit seinen Knöpfen, sobald die Erweiterung „AppIndicator“ an ist.",
|
"pomodoroTopBarHint": "Der Timer kann in der oberen Leiste von GNOME herunterzählen, mit seinen Knöpfen, sobald die Erweiterung „AppIndicator“ an ist.",
|
||||||
"pomodoroTopBarSetUp": "Einrichten",
|
"pomodoroTopBarSetUp": "Einrichten",
|
||||||
"pomodoroTopBarDismiss": "Nicht mehr vorschlagen"
|
"pomodoroTopBarDismiss": "Nicht mehr vorschlagen"
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,8 @@
|
||||||
"studysetStar": "Star",
|
"studysetStar": "Star",
|
||||||
"studysetUnstar": "Unstar",
|
"studysetUnstar": "Unstar",
|
||||||
"studysetOptions": "Options",
|
"studysetOptions": "Options",
|
||||||
|
"studysetBibliography": "Bibliography",
|
||||||
|
"studysetBibliographyIntro": "Works cited across this studyset, in the citation style chosen in Settings.",
|
||||||
"studysetMenuEdit": "Edit",
|
"studysetMenuEdit": "Edit",
|
||||||
"studysetMenuArchive": "Archive",
|
"studysetMenuArchive": "Archive",
|
||||||
"studysetMenuUnarchive": "Unarchive",
|
"studysetMenuUnarchive": "Unarchive",
|
||||||
|
|
@ -140,15 +142,41 @@
|
||||||
"stepFeedbackRight": "Feedback after a right answer",
|
"stepFeedbackRight": "Feedback after a right answer",
|
||||||
"stepFeedbackWrong": "Feedback after a wrong answer",
|
"stepFeedbackWrong": "Feedback after a wrong answer",
|
||||||
"stepFeedbackNone": "None",
|
"stepFeedbackNone": "None",
|
||||||
"stepSources": "Sources",
|
"sources": "Sources",
|
||||||
"stepAddSource": "Add source",
|
"sourcesCount": "{count, plural, =1{1 source} other{{count} sources}}",
|
||||||
"stepRemoveSource": "Remove source",
|
"@sourcesCount": {"placeholders": {"count": {"type": "int"}}},
|
||||||
"stepSourceAuthors": "Authors",
|
"sourcesNone": "No sources cited yet.",
|
||||||
"stepSourceYear": "Year",
|
"sourcesEdit": "Edit sources",
|
||||||
"stepSourceTitle": "Title",
|
"sourcesEditDone": "Done editing",
|
||||||
"stepSourceUrl": "Link",
|
"sourceAdd": "Add a source",
|
||||||
"stepSourcesCount": "{count, plural, =1{1 source} other{{count} sources}}",
|
"sourceEdit": "Edit source",
|
||||||
"@stepSourcesCount": {"placeholders": {"count": {"type": "int"}}},
|
"sourceRemove": "Remove source",
|
||||||
|
"sourceRemoved": "Source removed",
|
||||||
|
"sourceSheetAdd": "Add",
|
||||||
|
"sourceAuthors": "Authors",
|
||||||
|
"sourceAuthorsHint": "Full names, one per line, e.g. Mark A. McDaniel",
|
||||||
|
"sourceYear": "Year",
|
||||||
|
"sourceYearHint": "e.g. 2014",
|
||||||
|
"sourceTitle": "Title",
|
||||||
|
"sourceTitleHint": "Title of the work",
|
||||||
|
"sourceContainer": "Published in",
|
||||||
|
"sourceContainerHint": "Journal, book or website it is part of, with pages",
|
||||||
|
"sourceContainerHelper": "Leave empty for a work of its own, such as a book.",
|
||||||
|
"sourcePublisher": "Publisher",
|
||||||
|
"sourcePublisherHint": "e.g. Belknap Press",
|
||||||
|
"sourceIsbn": "ISBN",
|
||||||
|
"sourceIsbnHint": "e.g. 978-0-674-72901-8",
|
||||||
|
"sourceUrl": "Link",
|
||||||
|
"sourceUrlHint": "https://…",
|
||||||
|
"sourceImportedAs": "Imported as: {citation}",
|
||||||
|
"@sourceImportedAs": {"placeholders": {"citation": {"type": "String"}}},
|
||||||
|
"sourceIsbnLine": "ISBN: {isbn}",
|
||||||
|
"@sourceIsbnLine": {"placeholders": {"isbn": {"type": "String"}}},
|
||||||
|
"sourceAvailableAt": "Available at:",
|
||||||
|
"sourceEmpty": "Empty reference",
|
||||||
|
"sourceDiscardTitle": "Discard changes?",
|
||||||
|
"sourceDiscardMessage": "What you typed for this source will be lost.",
|
||||||
|
"sourceDiscardConfirm": "Discard",
|
||||||
"stepConflict": "This step was changed differently on another device. Keep one version to go on writing.",
|
"stepConflict": "This step was changed differently on another device. Keep one version to go on writing.",
|
||||||
"stepFieldTitle": "Title",
|
"stepFieldTitle": "Title",
|
||||||
"stepFieldBody": "Text",
|
"stepFieldBody": "Text",
|
||||||
|
|
@ -276,6 +304,14 @@
|
||||||
"settingsAppearanceDark": "Dark",
|
"settingsAppearanceDark": "Dark",
|
||||||
"settingsLanguage": "Language",
|
"settingsLanguage": "Language",
|
||||||
"settingsLanguageDevice": "Device language",
|
"settingsLanguageDevice": "Device language",
|
||||||
|
"settingsCitations": "Citations",
|
||||||
|
"settingsCitationsDescription": "How sources and studysets' bibliographies are cited on this device. Type authors' full names: each style abbreviates and orders them itself.",
|
||||||
|
"settingsCitationStyle": "Style",
|
||||||
|
"settingsCitationStyleApa": "APA (7th edition)",
|
||||||
|
"settingsCitationStyleMla": "MLA (9th edition)",
|
||||||
|
"settingsCitationStyleChicago": "Chicago (18th edition)",
|
||||||
|
"settingsCitationPreview": "Preview",
|
||||||
|
"settingsCitationGuide": "Official guide",
|
||||||
"settingsVersion": "Version {version} (build {build})",
|
"settingsVersion": "Version {version} (build {build})",
|
||||||
"@settingsVersion": {"placeholders": {"version": {"type": "String"}, "build": {"type": "String"}}},
|
"@settingsVersion": {"placeholders": {"version": {"type": "String"}, "build": {"type": "String"}}},
|
||||||
"syncTitle": "Sync",
|
"syncTitle": "Sync",
|
||||||
|
|
@ -1034,6 +1070,7 @@
|
||||||
"@pathImportTooFew": {"placeholders": {"detail": {"type": "String"}}},
|
"@pathImportTooFew": {"placeholders": {"detail": {"type": "String"}}},
|
||||||
"pathImportMalformedNumber": "a malformed number.",
|
"pathImportMalformedNumber": "a malformed number.",
|
||||||
"pathImportTitleNotText": "a title must be text.",
|
"pathImportTitleNotText": "a title must be text.",
|
||||||
|
"pathImportMalformedSource": "a source must be an object or text, and its parts text.",
|
||||||
"pomodoroTopBarHint": "The timer can count down in GNOME's top bar, with its buttons, once the AppIndicator extension is on.",
|
"pomodoroTopBarHint": "The timer can count down in GNOME's top bar, with its buttons, once the AppIndicator extension is on.",
|
||||||
"pomodoroTopBarSetUp": "Set up",
|
"pomodoroTopBarSetUp": "Set up",
|
||||||
"pomodoroTopBarDismiss": "Don't suggest again"
|
"pomodoroTopBarDismiss": "Don't suggest again"
|
||||||
|
|
|
||||||
|
|
@ -370,6 +370,18 @@ abstract class AppLocalizations {
|
||||||
/// **'Options'**
|
/// **'Options'**
|
||||||
String get studysetOptions;
|
String get studysetOptions;
|
||||||
|
|
||||||
|
/// No description provided for @studysetBibliography.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Bibliography'**
|
||||||
|
String get studysetBibliography;
|
||||||
|
|
||||||
|
/// No description provided for @studysetBibliographyIntro.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Works cited across this studyset, in the citation style chosen in Settings.'**
|
||||||
|
String get studysetBibliographyIntro;
|
||||||
|
|
||||||
/// No description provided for @studysetMenuEdit.
|
/// No description provided for @studysetMenuEdit.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -826,53 +838,197 @@ abstract class AppLocalizations {
|
||||||
/// **'None'**
|
/// **'None'**
|
||||||
String get stepFeedbackNone;
|
String get stepFeedbackNone;
|
||||||
|
|
||||||
/// No description provided for @stepSources.
|
/// No description provided for @sources.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Sources'**
|
/// **'Sources'**
|
||||||
String get stepSources;
|
String get sources;
|
||||||
|
|
||||||
/// No description provided for @stepAddSource.
|
/// No description provided for @sourcesCount.
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Add source'**
|
|
||||||
String get stepAddSource;
|
|
||||||
|
|
||||||
/// No description provided for @stepRemoveSource.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Remove source'**
|
|
||||||
String get stepRemoveSource;
|
|
||||||
|
|
||||||
/// No description provided for @stepSourceAuthors.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Authors'**
|
|
||||||
String get stepSourceAuthors;
|
|
||||||
|
|
||||||
/// No description provided for @stepSourceYear.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Year'**
|
|
||||||
String get stepSourceYear;
|
|
||||||
|
|
||||||
/// No description provided for @stepSourceTitle.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Title'**
|
|
||||||
String get stepSourceTitle;
|
|
||||||
|
|
||||||
/// No description provided for @stepSourceUrl.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Link'**
|
|
||||||
String get stepSourceUrl;
|
|
||||||
|
|
||||||
/// No description provided for @stepSourcesCount.
|
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'{count, plural, =1{1 source} other{{count} sources}}'**
|
/// **'{count, plural, =1{1 source} other{{count} sources}}'**
|
||||||
String stepSourcesCount(int count);
|
String sourcesCount(int count);
|
||||||
|
|
||||||
|
/// No description provided for @sourcesNone.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No sources cited yet.'**
|
||||||
|
String get sourcesNone;
|
||||||
|
|
||||||
|
/// No description provided for @sourcesEdit.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Edit sources'**
|
||||||
|
String get sourcesEdit;
|
||||||
|
|
||||||
|
/// No description provided for @sourcesEditDone.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Done editing'**
|
||||||
|
String get sourcesEditDone;
|
||||||
|
|
||||||
|
/// No description provided for @sourceAdd.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add a source'**
|
||||||
|
String get sourceAdd;
|
||||||
|
|
||||||
|
/// No description provided for @sourceEdit.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Edit source'**
|
||||||
|
String get sourceEdit;
|
||||||
|
|
||||||
|
/// No description provided for @sourceRemove.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Remove source'**
|
||||||
|
String get sourceRemove;
|
||||||
|
|
||||||
|
/// No description provided for @sourceRemoved.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Source removed'**
|
||||||
|
String get sourceRemoved;
|
||||||
|
|
||||||
|
/// No description provided for @sourceSheetAdd.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add'**
|
||||||
|
String get sourceSheetAdd;
|
||||||
|
|
||||||
|
/// No description provided for @sourceAuthors.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Authors'**
|
||||||
|
String get sourceAuthors;
|
||||||
|
|
||||||
|
/// No description provided for @sourceAuthorsHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Full names, one per line, e.g. Mark A. McDaniel'**
|
||||||
|
String get sourceAuthorsHint;
|
||||||
|
|
||||||
|
/// No description provided for @sourceYear.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Year'**
|
||||||
|
String get sourceYear;
|
||||||
|
|
||||||
|
/// No description provided for @sourceYearHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'e.g. 2014'**
|
||||||
|
String get sourceYearHint;
|
||||||
|
|
||||||
|
/// No description provided for @sourceTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Title'**
|
||||||
|
String get sourceTitle;
|
||||||
|
|
||||||
|
/// No description provided for @sourceTitleHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Title of the work'**
|
||||||
|
String get sourceTitleHint;
|
||||||
|
|
||||||
|
/// No description provided for @sourceContainer.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Published in'**
|
||||||
|
String get sourceContainer;
|
||||||
|
|
||||||
|
/// No description provided for @sourceContainerHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Journal, book or website it is part of, with pages'**
|
||||||
|
String get sourceContainerHint;
|
||||||
|
|
||||||
|
/// No description provided for @sourceContainerHelper.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Leave empty for a work of its own, such as a book.'**
|
||||||
|
String get sourceContainerHelper;
|
||||||
|
|
||||||
|
/// No description provided for @sourcePublisher.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Publisher'**
|
||||||
|
String get sourcePublisher;
|
||||||
|
|
||||||
|
/// No description provided for @sourcePublisherHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'e.g. Belknap Press'**
|
||||||
|
String get sourcePublisherHint;
|
||||||
|
|
||||||
|
/// No description provided for @sourceIsbn.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'ISBN'**
|
||||||
|
String get sourceIsbn;
|
||||||
|
|
||||||
|
/// No description provided for @sourceIsbnHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'e.g. 978-0-674-72901-8'**
|
||||||
|
String get sourceIsbnHint;
|
||||||
|
|
||||||
|
/// No description provided for @sourceUrl.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Link'**
|
||||||
|
String get sourceUrl;
|
||||||
|
|
||||||
|
/// No description provided for @sourceUrlHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'https://…'**
|
||||||
|
String get sourceUrlHint;
|
||||||
|
|
||||||
|
/// No description provided for @sourceImportedAs.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Imported as: {citation}'**
|
||||||
|
String sourceImportedAs(String citation);
|
||||||
|
|
||||||
|
/// No description provided for @sourceIsbnLine.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'ISBN: {isbn}'**
|
||||||
|
String sourceIsbnLine(String isbn);
|
||||||
|
|
||||||
|
/// No description provided for @sourceAvailableAt.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Available at:'**
|
||||||
|
String get sourceAvailableAt;
|
||||||
|
|
||||||
|
/// No description provided for @sourceEmpty.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Empty reference'**
|
||||||
|
String get sourceEmpty;
|
||||||
|
|
||||||
|
/// No description provided for @sourceDiscardTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Discard changes?'**
|
||||||
|
String get sourceDiscardTitle;
|
||||||
|
|
||||||
|
/// No description provided for @sourceDiscardMessage.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'What you typed for this source will be lost.'**
|
||||||
|
String get sourceDiscardMessage;
|
||||||
|
|
||||||
|
/// No description provided for @sourceDiscardConfirm.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Discard'**
|
||||||
|
String get sourceDiscardConfirm;
|
||||||
|
|
||||||
/// No description provided for @stepConflict.
|
/// No description provided for @stepConflict.
|
||||||
///
|
///
|
||||||
|
|
@ -1558,6 +1714,54 @@ abstract class AppLocalizations {
|
||||||
/// **'Device language'**
|
/// **'Device language'**
|
||||||
String get settingsLanguageDevice;
|
String get settingsLanguageDevice;
|
||||||
|
|
||||||
|
/// No description provided for @settingsCitations.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Citations'**
|
||||||
|
String get settingsCitations;
|
||||||
|
|
||||||
|
/// No description provided for @settingsCitationsDescription.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'How sources and studysets\' bibliographies are cited on this device. Type authors\' full names: each style abbreviates and orders them itself.'**
|
||||||
|
String get settingsCitationsDescription;
|
||||||
|
|
||||||
|
/// No description provided for @settingsCitationStyle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Style'**
|
||||||
|
String get settingsCitationStyle;
|
||||||
|
|
||||||
|
/// No description provided for @settingsCitationStyleApa.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'APA (7th edition)'**
|
||||||
|
String get settingsCitationStyleApa;
|
||||||
|
|
||||||
|
/// No description provided for @settingsCitationStyleMla.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'MLA (9th edition)'**
|
||||||
|
String get settingsCitationStyleMla;
|
||||||
|
|
||||||
|
/// No description provided for @settingsCitationStyleChicago.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Chicago (18th edition)'**
|
||||||
|
String get settingsCitationStyleChicago;
|
||||||
|
|
||||||
|
/// No description provided for @settingsCitationPreview.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Preview'**
|
||||||
|
String get settingsCitationPreview;
|
||||||
|
|
||||||
|
/// No description provided for @settingsCitationGuide.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Official guide'**
|
||||||
|
String get settingsCitationGuide;
|
||||||
|
|
||||||
/// No description provided for @settingsVersion.
|
/// No description provided for @settingsVersion.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -5151,6 +5355,12 @@ abstract class AppLocalizations {
|
||||||
/// **'a title must be text.'**
|
/// **'a title must be text.'**
|
||||||
String get pathImportTitleNotText;
|
String get pathImportTitleNotText;
|
||||||
|
|
||||||
|
/// No description provided for @pathImportMalformedSource.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'a source must be an object or text, and its parts text.'**
|
||||||
|
String get pathImportMalformedSource;
|
||||||
|
|
||||||
/// No description provided for @pomodoroTopBarHint.
|
/// No description provided for @pomodoroTopBarHint.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get studysetOptions => 'Optionen';
|
String get studysetOptions => 'Optionen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetBibliography => 'Literaturverzeichnis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetBibliographyIntro =>
|
||||||
|
'Die in diesem Lernset zitierten Werke, im Zitierstil aus den Einstellungen.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get studysetMenuEdit => 'Bearbeiten';
|
String get studysetMenuEdit => 'Bearbeiten';
|
||||||
|
|
||||||
|
|
@ -457,28 +464,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get stepFeedbackNone => 'Keines';
|
String get stepFeedbackNone => 'Keines';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stepSources => 'Quellen';
|
String get sources => 'Quellen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stepAddSource => 'Quelle hinzufügen';
|
String sourcesCount(int count) {
|
||||||
|
|
||||||
@override
|
|
||||||
String get stepRemoveSource => 'Quelle entfernen';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get stepSourceAuthors => 'Autoren';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get stepSourceYear => 'Jahr';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get stepSourceTitle => 'Titel';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get stepSourceUrl => 'Link';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String stepSourcesCount(int count) {
|
|
||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
count,
|
count,
|
||||||
locale: localeName,
|
locale: localeName,
|
||||||
|
|
@ -488,6 +477,104 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourcesNone => 'Noch keine Quellen angegeben.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourcesEdit => 'Quellen bearbeiten';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourcesEditDone => 'Bearbeiten beenden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceAdd => 'Quelle hinzufügen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceEdit => 'Quelle bearbeiten';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceRemove => 'Quelle entfernen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceRemoved => 'Quelle entfernt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceSheetAdd => 'Hinzufügen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceAuthors => 'Autoren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceAuthorsHint =>
|
||||||
|
'Vollständige Namen, einer pro Zeile, z. B. Mark A. McDaniel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceYear => 'Jahr';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceYearHint => 'z. B. 2014';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceTitle => 'Titel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceTitleHint => 'Titel des Werks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceContainer => 'Erschienen in';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceContainerHint =>
|
||||||
|
'Zeitschrift, Buch oder Website, mit Seiten';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceContainerHelper =>
|
||||||
|
'Leer lassen bei einem eigenständigen Werk wie einem Buch.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourcePublisher => 'Verlag';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourcePublisherHint => 'z. B. Belknap Press';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceIsbn => 'ISBN';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceIsbnHint => 'z. B. 978-0-674-72901-8';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceUrl => 'Link';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceUrlHint => 'https://…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String sourceImportedAs(String citation) {
|
||||||
|
return 'Importiert als: $citation';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String sourceIsbnLine(String isbn) {
|
||||||
|
return 'ISBN: $isbn';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceAvailableAt => 'Verfügbar unter:';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceEmpty => 'Leere Quellenangabe';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceDiscardTitle => 'Änderungen verwerfen?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceDiscardMessage =>
|
||||||
|
'Was du für diese Quelle eingegeben hast, geht verloren.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceDiscardConfirm => 'Verwerfen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stepConflict =>
|
String get stepConflict =>
|
||||||
'Dieser Schritt wurde auf einem anderen Gerät anders geändert. Behalte eine Version, um weiterzuschreiben.';
|
'Dieser Schritt wurde auf einem anderen Gerät anders geändert. Behalte eine Version, um weiterzuschreiben.';
|
||||||
|
|
@ -897,6 +984,31 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get settingsLanguageDevice => 'Gerätesprache';
|
String get settingsLanguageDevice => 'Gerätesprache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCitations => 'Quellenangaben';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCitationsDescription =>
|
||||||
|
'Wie Quellen und die Literaturverzeichnisse von Lernsets auf diesem Gerät zitiert werden. Gib die vollständigen Namen der Autoren ein: Jeder Stil kürzt und ordnet sie selbst.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCitationStyle => 'Stil';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCitationStyleApa => 'APA (7. Auflage)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCitationStyleMla => 'MLA (9. Auflage)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCitationStyleChicago => 'Chicago (18. Auflage)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCitationPreview => 'Vorschau';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCitationGuide => 'Offizieller Leitfaden';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String settingsVersion(String version, String build) {
|
String settingsVersion(String version, String build) {
|
||||||
return 'Version $version (Build $build)';
|
return 'Version $version (Build $build)';
|
||||||
|
|
@ -3122,6 +3234,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get pathImportTitleNotText => 'ein Titel muss Text sein.';
|
String get pathImportTitleNotText => 'ein Titel muss Text sein.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathImportMalformedSource =>
|
||||||
|
'eine Quelle muss ein Objekt oder Text sein und ihre Teile Text.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pomodoroTopBarHint =>
|
String get pomodoroTopBarHint =>
|
||||||
'Der Timer kann in der oberen Leiste von GNOME herunterzählen, mit seinen Knöpfen, sobald die Erweiterung „AppIndicator“ an ist.';
|
'Der Timer kann in der oberen Leiste von GNOME herunterzählen, mit seinen Knöpfen, sobald die Erweiterung „AppIndicator“ an ist.';
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get studysetOptions => 'Options';
|
String get studysetOptions => 'Options';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetBibliography => 'Bibliography';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetBibliographyIntro =>
|
||||||
|
'Works cited across this studyset, in the citation style chosen in Settings.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get studysetMenuEdit => 'Edit';
|
String get studysetMenuEdit => 'Edit';
|
||||||
|
|
||||||
|
|
@ -454,28 +461,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get stepFeedbackNone => 'None';
|
String get stepFeedbackNone => 'None';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stepSources => 'Sources';
|
String get sources => 'Sources';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stepAddSource => 'Add source';
|
String sourcesCount(int count) {
|
||||||
|
|
||||||
@override
|
|
||||||
String get stepRemoveSource => 'Remove source';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get stepSourceAuthors => 'Authors';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get stepSourceYear => 'Year';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get stepSourceTitle => 'Title';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get stepSourceUrl => 'Link';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String stepSourcesCount(int count) {
|
|
||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
count,
|
count,
|
||||||
locale: localeName,
|
locale: localeName,
|
||||||
|
|
@ -485,6 +474,104 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourcesNone => 'No sources cited yet.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourcesEdit => 'Edit sources';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourcesEditDone => 'Done editing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceAdd => 'Add a source';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceEdit => 'Edit source';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceRemove => 'Remove source';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceRemoved => 'Source removed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceSheetAdd => 'Add';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceAuthors => 'Authors';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceAuthorsHint =>
|
||||||
|
'Full names, one per line, e.g. Mark A. McDaniel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceYear => 'Year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceYearHint => 'e.g. 2014';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceTitle => 'Title';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceTitleHint => 'Title of the work';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceContainer => 'Published in';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceContainerHint =>
|
||||||
|
'Journal, book or website it is part of, with pages';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceContainerHelper =>
|
||||||
|
'Leave empty for a work of its own, such as a book.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourcePublisher => 'Publisher';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourcePublisherHint => 'e.g. Belknap Press';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceIsbn => 'ISBN';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceIsbnHint => 'e.g. 978-0-674-72901-8';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceUrl => 'Link';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceUrlHint => 'https://…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String sourceImportedAs(String citation) {
|
||||||
|
return 'Imported as: $citation';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String sourceIsbnLine(String isbn) {
|
||||||
|
return 'ISBN: $isbn';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceAvailableAt => 'Available at:';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceEmpty => 'Empty reference';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceDiscardTitle => 'Discard changes?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceDiscardMessage =>
|
||||||
|
'What you typed for this source will be lost.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceDiscardConfirm => 'Discard';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stepConflict =>
|
String get stepConflict =>
|
||||||
'This step was changed differently on another device. Keep one version to go on writing.';
|
'This step was changed differently on another device. Keep one version to go on writing.';
|
||||||
|
|
@ -890,6 +977,31 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get settingsLanguageDevice => 'Device language';
|
String get settingsLanguageDevice => 'Device language';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCitations => 'Citations';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCitationsDescription =>
|
||||||
|
'How sources and studysets\' bibliographies are cited on this device. Type authors\' full names: each style abbreviates and orders them itself.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCitationStyle => 'Style';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCitationStyleApa => 'APA (7th edition)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCitationStyleMla => 'MLA (9th edition)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCitationStyleChicago => 'Chicago (18th edition)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCitationPreview => 'Preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCitationGuide => 'Official guide';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String settingsVersion(String version, String build) {
|
String settingsVersion(String version, String build) {
|
||||||
return 'Version $version (build $build)';
|
return 'Version $version (build $build)';
|
||||||
|
|
@ -3091,6 +3203,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get pathImportTitleNotText => 'a title must be text.';
|
String get pathImportTitleNotText => 'a title must be text.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathImportMalformedSource =>
|
||||||
|
'a source must be an object or text, and its parts text.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pomodoroTopBarHint =>
|
String get pomodoroTopBarHint =>
|
||||||
'The timer can count down in GNOME\'s top bar, with its buttons, once the AppIndicator extension is on.';
|
'The timer can count down in GNOME\'s top bar, with its buttons, once the AppIndicator extension is on.';
|
||||||
|
|
|
||||||
317
halkyon_learn_flutter/lib/src/core/citation/model/citation.dart
Normal file
317
halkyon_learn_flutter/lib/src/core/citation/model/citation.dart
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
|
||||||
|
|
||||||
|
/// How sources are cited, as the device chose (decided 2026-10-07), with
|
||||||
|
/// the address of the style's own guide to it.
|
||||||
|
enum CitationStyle(final String guide) {
|
||||||
|
/// APA, 7th edition: "Brown, P. C. (2014). *Title*. Publisher."
|
||||||
|
apa('https://apastyle.apa.org/style-grammar-guidelines/references'),
|
||||||
|
|
||||||
|
/// MLA, 9th edition: "Brown, Peter C. *Title*. Publisher, 2014."
|
||||||
|
mla('https://style.mla.org/works-cited/'),
|
||||||
|
|
||||||
|
/// Chicago, 18th edition, notes and bibliography: "Brown, Peter C.
|
||||||
|
/// *Title*. Publisher, 2014."
|
||||||
|
chicago('https://www.chicagomanualofstyle.org/tools_citationguide.html'),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stretch of a citation, set in italics or not.
|
||||||
|
final class const CitationRun(
|
||||||
|
/// The text.
|
||||||
|
final String text, {
|
||||||
|
|
||||||
|
/// Whether it is set in italics: a title, as the style asks.
|
||||||
|
final bool italic = false,
|
||||||
|
}) with Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [text, italic];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [source] cited in [style]: its authors, year, title, what it is a part
|
||||||
|
/// of and its publisher, in the style's order and punctuation; nothing when
|
||||||
|
/// it has none of these. Its ISBN and link aren't part of it: they go on
|
||||||
|
/// lines of their own.
|
||||||
|
///
|
||||||
|
/// A work of its own has its title in italics. A part of something (an
|
||||||
|
/// article, a chapter, a page) has the whole's title in italics and its own
|
||||||
|
/// set apart: plain in APA, in quotation marks in MLA and Chicago.
|
||||||
|
///
|
||||||
|
/// What is missing is left out, but for a missing year, which APA and
|
||||||
|
/// Chicago write as "n.d.". Authors written as already cited (with `&` or
|
||||||
|
/// "et al.") stay as written.
|
||||||
|
List<CitationRun> formatCitation(Source source, CitationStyle style) {
|
||||||
|
final citation = _Citation();
|
||||||
|
final names = source.authors.trim();
|
||||||
|
final authors = switch (style) {
|
||||||
|
_ when names.isEmpty => '',
|
||||||
|
_ when _asCited.hasMatch(names) => names,
|
||||||
|
CitationStyle.apa => _apaAuthors(_names(names)),
|
||||||
|
CitationStyle.mla => _mlaAuthors(_names(names)),
|
||||||
|
CitationStyle.chicago => _chicagoAuthors(_names(names)),
|
||||||
|
};
|
||||||
|
final title = source.title.trim();
|
||||||
|
final container = source.container.trim();
|
||||||
|
final publisher = source.publisher.trim();
|
||||||
|
final isPart = container.isNotEmpty;
|
||||||
|
final year = switch (source.year.trim()) {
|
||||||
|
// An entry of names alone is still being written.
|
||||||
|
''
|
||||||
|
when style != CitationStyle.mla &&
|
||||||
|
(title.isNotEmpty ||
|
||||||
|
container.isNotEmpty ||
|
||||||
|
publisher.isNotEmpty) =>
|
||||||
|
'n.d.',
|
||||||
|
final year => year,
|
||||||
|
};
|
||||||
|
switch (style) {
|
||||||
|
case CitationStyle.apa:
|
||||||
|
// Without authors, the title takes their place.
|
||||||
|
if (authors.isNotEmpty) {
|
||||||
|
citation.part([CitationRun(authors)]);
|
||||||
|
} else if (title.isNotEmpty) {
|
||||||
|
citation.part([CitationRun(title, italic: !isPart)]);
|
||||||
|
}
|
||||||
|
if (year.isNotEmpty) {
|
||||||
|
citation.part([CitationRun('($year)')]);
|
||||||
|
}
|
||||||
|
if (authors.isNotEmpty && title.isNotEmpty) {
|
||||||
|
citation.part([CitationRun(title, italic: !isPart)]);
|
||||||
|
}
|
||||||
|
citation
|
||||||
|
..part([CitationRun(container, italic: true)])
|
||||||
|
..part([CitationRun(publisher)]);
|
||||||
|
case CitationStyle.mla || CitationStyle.chicago:
|
||||||
|
citation.part([CitationRun(authors)]);
|
||||||
|
if (title.isNotEmpty) {
|
||||||
|
if (isPart) {
|
||||||
|
citation.quoted(title);
|
||||||
|
} else {
|
||||||
|
citation.part([CitationRun(title, italic: true)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
citation.part([
|
||||||
|
CitationRun(container, italic: true),
|
||||||
|
CitationRun(publisher),
|
||||||
|
CitationRun(year),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return citation.runs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authors written as already cited: a list joined by `&`, or cut short
|
||||||
|
/// with "et al.".
|
||||||
|
final _asCited = RegExp(r'&|\bet al\b', caseSensitive: false);
|
||||||
|
|
||||||
|
/// A citation as it is put together: parts separated by a space, each
|
||||||
|
/// closed with a period unless it ends with a mark of its own.
|
||||||
|
class _Citation() {
|
||||||
|
final runs = <CitationRun>[];
|
||||||
|
|
||||||
|
/// Adds the [pieces] that aren't empty as one part, separated by commas:
|
||||||
|
/// "*Journal*, Press, 2020."
|
||||||
|
void part(List<CitationRun> pieces) {
|
||||||
|
final written = pieces.where((piece) => piece.text.isNotEmpty).toList();
|
||||||
|
if (written.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_separate();
|
||||||
|
for (final (index, piece) in written.indexed) {
|
||||||
|
if (index > 0) {
|
||||||
|
_add(', ');
|
||||||
|
}
|
||||||
|
_add(piece.text, italic: piece.italic);
|
||||||
|
}
|
||||||
|
if (!_endsWithMark(runs.last.text)) {
|
||||||
|
_add('.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds [text] in quotation marks, its period inside them.
|
||||||
|
void quoted(String text) {
|
||||||
|
_separate();
|
||||||
|
_add('“$text${_endsWithMark(text) ? '' : '.'}”');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _separate() {
|
||||||
|
if (runs.isNotEmpty) {
|
||||||
|
_add(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _add(String text, {bool italic = false}) {
|
||||||
|
if (runs.lastOrNull case final last? when last.italic == italic) {
|
||||||
|
runs.last = CitationRun(last.text + text, italic: italic);
|
||||||
|
} else {
|
||||||
|
runs.add(CitationRun(text, italic: italic));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _endsWithMark(String text) =>
|
||||||
|
text.endsWith('.') || text.endsWith('?') || text.endsWith('!');
|
||||||
|
|
||||||
|
/// A person's name, split as the styles need it.
|
||||||
|
typedef _Name = ({List<String> given, String family, String suffix});
|
||||||
|
|
||||||
|
/// The names in [authors]: one per line or separated by `;`, each in its
|
||||||
|
/// natural order ("Jonathan Gregory Pevsner") or inverted ("Pevsner,
|
||||||
|
/// Jonathan Gregory"). A single word is a name of its own, as an
|
||||||
|
/// organisation's.
|
||||||
|
List<_Name> _names(String authors) => [
|
||||||
|
for (final name in authors.split(RegExp('[\n;]')))
|
||||||
|
if (name.trim() case final name when name.isNotEmpty) _name(name),
|
||||||
|
];
|
||||||
|
|
||||||
|
_Name _name(String text) {
|
||||||
|
final parts = text.split(',').map((part) => part.trim()).toList();
|
||||||
|
if (parts.length > 1) {
|
||||||
|
// "Martin Luther King, Jr." is in its natural order.
|
||||||
|
if (parts.length == 2 && _isSuffix(parts[1])) {
|
||||||
|
final name = _natural(_words(parts[0]));
|
||||||
|
return (
|
||||||
|
given: name.given,
|
||||||
|
family: name.family,
|
||||||
|
suffix: _suffix(parts[1]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
given: _words(parts[1]),
|
||||||
|
family: parts[0],
|
||||||
|
suffix: parts.length > 2 ? _suffix(parts.sublist(2).join(', ')) : '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _natural(_words(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
_Name _natural(List<String> words) {
|
||||||
|
if (words.length > 1 && _isSuffix(words.last)) {
|
||||||
|
final name = _natural(words.sublist(0, words.length - 1));
|
||||||
|
return (
|
||||||
|
given: name.given,
|
||||||
|
family: name.family,
|
||||||
|
suffix: _suffix(words.last),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (words.length < 2) {
|
||||||
|
return (given: const [], family: words.join(), suffix: '');
|
||||||
|
}
|
||||||
|
// "van", "de" and the like belong to the family name, but never the
|
||||||
|
// first word: that one is given.
|
||||||
|
var first = words.length - 1;
|
||||||
|
while (first > 1 && _particles.contains(words[first - 1].toLowerCase())) {
|
||||||
|
first--;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
given: words.sublist(0, first),
|
||||||
|
family: words.sublist(first).join(' '),
|
||||||
|
suffix: '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _words(String text) =>
|
||||||
|
text.split(RegExp(r'\s+')).where((word) => word.isNotEmpty).toList();
|
||||||
|
|
||||||
|
bool _isSuffix(String word) => _suffixes.contains(word.toLowerCase());
|
||||||
|
|
||||||
|
/// [word] as a suffix is written: "Jr.", "Sr.", "III".
|
||||||
|
String _suffix(String word) => switch (word.toLowerCase()) {
|
||||||
|
'jr' || 'jr.' => 'Jr.',
|
||||||
|
'sr' || 'sr.' => 'Sr.',
|
||||||
|
final other when _suffixes.contains(other) => other.toUpperCase(),
|
||||||
|
_ => word,
|
||||||
|
};
|
||||||
|
|
||||||
|
const _suffixes = {'jr', 'jr.', 'sr', 'sr.', 'ii', 'iii', 'iv', 'v'};
|
||||||
|
|
||||||
|
const _particles = {
|
||||||
|
'van',
|
||||||
|
'von',
|
||||||
|
'der',
|
||||||
|
'den',
|
||||||
|
'de',
|
||||||
|
'del',
|
||||||
|
'della',
|
||||||
|
'di',
|
||||||
|
'da',
|
||||||
|
'das',
|
||||||
|
'dos',
|
||||||
|
'du',
|
||||||
|
'la',
|
||||||
|
'le',
|
||||||
|
'lo',
|
||||||
|
'ten',
|
||||||
|
'ter',
|
||||||
|
'op',
|
||||||
|
'bin',
|
||||||
|
'ibn',
|
||||||
|
'al',
|
||||||
|
'st',
|
||||||
|
'st.',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// "Family, Given Middle, Jr.", or the family name alone without a given
|
||||||
|
/// one.
|
||||||
|
String _inverted(_Name name, {String Function(String word)? given}) => [
|
||||||
|
name.family,
|
||||||
|
if (name.given.isNotEmpty) name.given.map(given ?? (word) => word).join(' '),
|
||||||
|
if (name.suffix.isNotEmpty) name.suffix,
|
||||||
|
].join(', ');
|
||||||
|
|
||||||
|
/// "Given Middle Family Jr."
|
||||||
|
String _naturalOrder(_Name name) => [
|
||||||
|
...name.given,
|
||||||
|
name.family,
|
||||||
|
name.suffix,
|
||||||
|
].where((word) => word.isNotEmpty).join(' ');
|
||||||
|
|
||||||
|
/// A given name as APA's initial: "J.", "J.-P." for "Jean-Paul"; one
|
||||||
|
/// written with a period already stays.
|
||||||
|
String _initial(String word) => word.endsWith('.')
|
||||||
|
? word
|
||||||
|
: word
|
||||||
|
.split('-')
|
||||||
|
.where((part) => part.isNotEmpty)
|
||||||
|
.map(
|
||||||
|
(part) => '${String.fromCharCode(part.runes.first).toUpperCase()}.',
|
||||||
|
)
|
||||||
|
.join('-');
|
||||||
|
|
||||||
|
/// APA lists up to 20 names, "&" before the last; from 21 on the first 19,
|
||||||
|
/// an ellipsis and the last.
|
||||||
|
String _apaAuthors(List<_Name> names) {
|
||||||
|
final cited = [for (final name in names) _inverted(name, given: _initial)];
|
||||||
|
return switch (cited) {
|
||||||
|
[] => '',
|
||||||
|
[final one] => one,
|
||||||
|
_ when cited.length > 20 => '${cited.take(19).join(', ')}, … ${cited.last}',
|
||||||
|
_ => '${cited.take(cited.length - 1).join(', ')}, & ${cited.last}',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MLA inverts the first name only, joins two with "and", and cuts three or
|
||||||
|
/// more short with "et al.".
|
||||||
|
String _mlaAuthors(List<_Name> names) => switch (names) {
|
||||||
|
[final one] => _inverted(one),
|
||||||
|
[final first, final second] =>
|
||||||
|
'${_inverted(first)}, and ${_naturalOrder(second)}',
|
||||||
|
[final first, ...] => '${_inverted(first)}, et al.',
|
||||||
|
[] => '',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Chicago inverts the first name only and lists up to six, "and" before
|
||||||
|
/// the last; from seven on the first three and "et al.".
|
||||||
|
String _chicagoAuthors(List<_Name> names) {
|
||||||
|
if (names.isEmpty) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
final cited = [
|
||||||
|
_inverted(names.first),
|
||||||
|
for (final name in names.skip(1)) _naturalOrder(name),
|
||||||
|
];
|
||||||
|
return switch (cited) {
|
||||||
|
[final one] => one,
|
||||||
|
[final first, final second] => '$first, and $second',
|
||||||
|
_ when cited.length > 6 => '${cited.take(3).join(', ')}, et al.',
|
||||||
|
_ => '${cited.take(cited.length - 1).join(', ')}, and ${cited.last}',
|
||||||
|
};
|
||||||
|
}
|
||||||
113
halkyon_learn_flutter/lib/src/core/citation/model/source.dart
Normal file
113
halkyon_learn_flutter/lib/src/core/citation/model/source.dart
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// A work cited: by a step of a learning path, or in a studyset's
|
||||||
|
/// bibliography. Every part is optional and plain text, as the person wrote
|
||||||
|
/// it; `formatCitation` puts them together in the style the device uses.
|
||||||
|
final class const Source({
|
||||||
|
/// Who wrote it, one full name per line (or separated by `;`), as
|
||||||
|
/// "Jonathan Gregory Pevsner"; each style abbreviates and orders them
|
||||||
|
/// itself.
|
||||||
|
final String authors = '',
|
||||||
|
|
||||||
|
/// When it appeared, as "2020"; empty when unknown.
|
||||||
|
final String year = '',
|
||||||
|
|
||||||
|
/// Its title.
|
||||||
|
final String title = '',
|
||||||
|
|
||||||
|
/// What it is a part of: the journal, book or website it appeared in, with
|
||||||
|
/// pages where they matter, as "Nature 591, 12–15"; empty for a work of
|
||||||
|
/// its own, such as a book. The styles set a part's title apart from the
|
||||||
|
/// whole's by this.
|
||||||
|
final String container = '',
|
||||||
|
|
||||||
|
/// Who published it, as "Wiley-Blackwell".
|
||||||
|
final String publisher = '',
|
||||||
|
|
||||||
|
/// The book's ISBN, as written.
|
||||||
|
final String isbn = '',
|
||||||
|
|
||||||
|
/// Where to find it on the web.
|
||||||
|
final String url = '',
|
||||||
|
|
||||||
|
/// The citation as a file brought it, for what couldn't be split into the
|
||||||
|
/// parts above; shown as it is while those are empty.
|
||||||
|
final String raw = '',
|
||||||
|
}) with Equatable {
|
||||||
|
/// The source in [json], as [toJson] writes it.
|
||||||
|
///
|
||||||
|
/// Each part is text; a number reads as its text (a file written by hand
|
||||||
|
/// may give the year as one). A [FormatException] for anything else.
|
||||||
|
factory fromJson(Object? json) {
|
||||||
|
if (json is! Map<String, Object?>) {
|
||||||
|
throw const FormatException('A source is an object.');
|
||||||
|
}
|
||||||
|
String part(String key) => switch (json[key]) {
|
||||||
|
null => '',
|
||||||
|
final String text => text,
|
||||||
|
final num number => '$number',
|
||||||
|
_ => throw FormatException('A source with a malformed $key.'),
|
||||||
|
};
|
||||||
|
return Source(
|
||||||
|
authors: part('authors'),
|
||||||
|
year: part('year'),
|
||||||
|
title: part('title'),
|
||||||
|
container: part('container'),
|
||||||
|
publisher: part('publisher'),
|
||||||
|
isbn: part('isbn'),
|
||||||
|
url: part('url'),
|
||||||
|
raw: part('raw'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether nothing of it is written.
|
||||||
|
bool get isEmpty =>
|
||||||
|
authors.isEmpty &&
|
||||||
|
year.isEmpty &&
|
||||||
|
title.isEmpty &&
|
||||||
|
container.isEmpty &&
|
||||||
|
publisher.isEmpty &&
|
||||||
|
isbn.isEmpty &&
|
||||||
|
url.isEmpty &&
|
||||||
|
raw.isEmpty;
|
||||||
|
|
||||||
|
/// This source without spaces around its parts, nor around each author.
|
||||||
|
Source trimmed() => Source(
|
||||||
|
authors: authors
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.where((line) => line.isNotEmpty)
|
||||||
|
.join('\n'),
|
||||||
|
year: year.trim(),
|
||||||
|
title: title.trim(),
|
||||||
|
container: container.trim(),
|
||||||
|
publisher: publisher.trim(),
|
||||||
|
isbn: isbn.trim(),
|
||||||
|
url: url.trim(),
|
||||||
|
raw: raw.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// This source as JSON, each part left out when empty.
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
if (authors.isNotEmpty) 'authors': authors,
|
||||||
|
if (year.isNotEmpty) 'year': year,
|
||||||
|
if (title.isNotEmpty) 'title': title,
|
||||||
|
if (container.isNotEmpty) 'container': container,
|
||||||
|
if (publisher.isNotEmpty) 'publisher': publisher,
|
||||||
|
if (isbn.isNotEmpty) 'isbn': isbn,
|
||||||
|
if (url.isNotEmpty) 'url': url,
|
||||||
|
if (raw.isNotEmpty) 'raw': raw,
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
authors,
|
||||||
|
year,
|
||||||
|
title,
|
||||||
|
container,
|
||||||
|
publisher,
|
||||||
|
isbn,
|
||||||
|
url,
|
||||||
|
raw,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,243 @@
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/citation/widget/source_sheet.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/citation/widget/source_view.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// Shows [sources] in a sheet titled [title], [intro] above them. With
|
||||||
|
/// [onChanged] the person can change them: the sheet opens for that when
|
||||||
|
/// there are none yet, and otherwise by its edit button, and it reports every
|
||||||
|
/// change as it happens.
|
||||||
|
Future<void> showSourcesSheet(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
required List<Source> sources,
|
||||||
|
String? intro,
|
||||||
|
ValueChanged<List<Source>>? onChanged,
|
||||||
|
}) => showAdaptiveSheet<void>(
|
||||||
|
context,
|
||||||
|
builder: (context) => _SourcesSheet(
|
||||||
|
title: title,
|
||||||
|
intro: intro,
|
||||||
|
sources: sources,
|
||||||
|
onChanged: onChanged,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
class const _SourcesSheet({
|
||||||
|
required final String title,
|
||||||
|
required final String? intro,
|
||||||
|
required final List<Source> sources,
|
||||||
|
required final ValueChanged<List<Source>>? onChanged,
|
||||||
|
}) extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<_SourcesSheet> createState() => _SourcesSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SourcesSheetState() extends State<_SourcesSheet> {
|
||||||
|
late var _sources = widget.sources;
|
||||||
|
late var _editing = widget.onChanged != null && widget.sources.isEmpty;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final onChanged = widget.onChanged;
|
||||||
|
final intro = widget.intro;
|
||||||
|
return SheetScaffold(
|
||||||
|
title: widget.title,
|
||||||
|
// Removing offers an Undo, which has to show on the sheet.
|
||||||
|
hasSnackBars: onChanged != null,
|
||||||
|
action: onChanged == null
|
||||||
|
? null
|
||||||
|
: IconButton(
|
||||||
|
tooltip: _editing ? l10n.sourcesEditDone : l10n.sourcesEdit,
|
||||||
|
isSelected: _editing,
|
||||||
|
icon: const Icon(LucideIcons.pencil),
|
||||||
|
selectedIcon: const Icon(LucideIcons.check),
|
||||||
|
onPressed: () => setState(() => _editing = !_editing),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (intro != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: Text(
|
||||||
|
intro,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SourceList(
|
||||||
|
sources: _sources,
|
||||||
|
onChanged: onChanged == null || !_editing
|
||||||
|
? null
|
||||||
|
: (sources) {
|
||||||
|
setState(() => _sources = sources);
|
||||||
|
onChanged(sources);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [sources] as cards, in the order of a reference list: alphabetical, as
|
||||||
|
/// each style lists them, whatever order they were added in. With
|
||||||
|
/// [onChanged], each can be changed and removed (with an Undo), and sources
|
||||||
|
/// added.
|
||||||
|
class const SourceList({
|
||||||
|
/// The sources, in the order they were added.
|
||||||
|
required final List<Source> sources,
|
||||||
|
|
||||||
|
/// Takes the sources changed; null where they can't be.
|
||||||
|
final ValueChanged<List<Source>>? onChanged,
|
||||||
|
super.key,
|
||||||
|
}) extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<SourceList> createState() => _SourceListState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SourceListState() extends State<SourceList> {
|
||||||
|
/// The sources as they are now, also while a change is on its way to
|
||||||
|
/// [SourceList.sources]: an Undo puts a source back among these.
|
||||||
|
late var _sources = widget.sources;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(SourceList oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (!identical(widget.sources, oldWidget.sources)) {
|
||||||
|
_sources = widget.sources;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _change(List<Source> sources) {
|
||||||
|
setState(() => _sources = sources);
|
||||||
|
widget.onChanged?.call(sources);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _add() async {
|
||||||
|
if (await showSourceSheet(context) case final source? when mounted) {
|
||||||
|
_change([..._sources, source]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _edit(Source source) async {
|
||||||
|
if (await showSourceSheet(context, source: source) case final edited?
|
||||||
|
when mounted) {
|
||||||
|
final at = _sources.indexOf(source);
|
||||||
|
if (at >= 0) {
|
||||||
|
_change([..._sources]..[at] = edited);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _remove(Source source) {
|
||||||
|
final at = _sources.indexOf(source);
|
||||||
|
if (at < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_change([..._sources]..removeAt(at));
|
||||||
|
showUndoSnackBar(
|
||||||
|
context,
|
||||||
|
message: AppLocalizations.of(context).sourceRemoved,
|
||||||
|
onUndo: () {
|
||||||
|
if (mounted && widget.onChanged != null) {
|
||||||
|
_change([..._sources]..insert(min(at, _sources.length), source));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final style = citationStyleOf(context);
|
||||||
|
final editable = widget.onChanged != null;
|
||||||
|
String sortKey(Source source) => switch (formatCitation(source, style)) {
|
||||||
|
[] => source.raw,
|
||||||
|
final runs => runs.map((run) => run.text).join(),
|
||||||
|
}.toLowerCase();
|
||||||
|
final sorted = [..._sources]
|
||||||
|
..sort((a, b) => sortKey(a).compareTo(sortKey(b)));
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (sorted.isEmpty)
|
||||||
|
Text(
|
||||||
|
l10n.sourcesNone,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final (index, source) in sorted.indexed)
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(top: index == 0 ? 0 : 8),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
16,
|
||||||
|
editable ? 4 : 16,
|
||||||
|
editable ? 4 : 16,
|
||||||
|
editable ? 4 : 16,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
vertical: editable ? 12 : 0,
|
||||||
|
),
|
||||||
|
child: SourceView(source: source),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (editable) ...[
|
||||||
|
IconButton(
|
||||||
|
tooltip: l10n.sourceEdit,
|
||||||
|
icon: const Icon(LucideIcons.pencil, size: 20),
|
||||||
|
onPressed: () => _edit(source),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: l10n.sourceRemove,
|
||||||
|
icon: const Icon(LucideIcons.x, size: 20),
|
||||||
|
onPressed: () => _remove(source),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (editable)
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12),
|
||||||
|
child: FilledButton.tonalIcon(
|
||||||
|
icon: const Icon(LucideIcons.plus),
|
||||||
|
label: Text(l10n.sourceAdd),
|
||||||
|
onPressed: _add,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,203 @@
|
||||||
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// Asks for a source's parts: a new one, or [source] changed. Completes with
|
||||||
|
/// the source as written, or null when the person closes the sheet, which
|
||||||
|
/// asks first when something was typed.
|
||||||
|
Future<Source?> showSourceSheet(BuildContext context, {Source? source}) =>
|
||||||
|
showAdaptiveSheet<Source>(
|
||||||
|
context,
|
||||||
|
swipeable: false,
|
||||||
|
builder: (context) => _SourceSheet(initial: source),
|
||||||
|
);
|
||||||
|
|
||||||
|
class const _SourceSheet({required final Source? initial})
|
||||||
|
extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<_SourceSheet> createState() => _SourceSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SourceSheetState() extends State<_SourceSheet> {
|
||||||
|
late final _authors = _controller(widget.initial?.authors);
|
||||||
|
late final _year = _controller(widget.initial?.year);
|
||||||
|
late final _title = _controller(widget.initial?.title);
|
||||||
|
late final _container = _controller(widget.initial?.container);
|
||||||
|
late final _publisher = _controller(widget.initial?.publisher);
|
||||||
|
late final _isbn = _controller(widget.initial?.isbn);
|
||||||
|
late final _url = _controller(widget.initial?.url);
|
||||||
|
|
||||||
|
TextEditingController _controller(String? text) =>
|
||||||
|
// The Add or Save button follows what is typed.
|
||||||
|
TextEditingController(text: text)..addListener(() => setState(() {}));
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (final controller in [
|
||||||
|
_authors,
|
||||||
|
_year,
|
||||||
|
_title,
|
||||||
|
_container,
|
||||||
|
_publisher,
|
||||||
|
_isbn,
|
||||||
|
_url,
|
||||||
|
]) {
|
||||||
|
controller.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The source as the sheet holds it. The citation it was imported as goes
|
||||||
|
/// once there is something to cite instead.
|
||||||
|
Source get _source {
|
||||||
|
final source = Source(
|
||||||
|
authors: _authors.text,
|
||||||
|
year: _year.text,
|
||||||
|
title: _title.text,
|
||||||
|
container: _container.text,
|
||||||
|
publisher: _publisher.text,
|
||||||
|
isbn: _isbn.text,
|
||||||
|
url: _url.text,
|
||||||
|
).trimmed();
|
||||||
|
final cited =
|
||||||
|
source.authors.isNotEmpty ||
|
||||||
|
source.title.isNotEmpty ||
|
||||||
|
source.container.isNotEmpty ||
|
||||||
|
source.publisher.isNotEmpty;
|
||||||
|
return cited
|
||||||
|
? source
|
||||||
|
: Source(
|
||||||
|
authors: source.authors,
|
||||||
|
year: source.year,
|
||||||
|
title: source.title,
|
||||||
|
container: source.container,
|
||||||
|
publisher: source.publisher,
|
||||||
|
isbn: source.isbn,
|
||||||
|
url: source.url,
|
||||||
|
raw: widget.initial?.raw.trim() ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _hasChanges =>
|
||||||
|
_source != (widget.initial?.trimmed() ?? const Source());
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final adding = widget.initial == null;
|
||||||
|
final raw = widget.initial?.raw.trim() ?? '';
|
||||||
|
Widget field(
|
||||||
|
TextEditingController controller,
|
||||||
|
String label,
|
||||||
|
String hint, {
|
||||||
|
String? helper,
|
||||||
|
TextInputType? keyboardType,
|
||||||
|
TextCapitalization capitalization = TextCapitalization.sentences,
|
||||||
|
bool multiline = false,
|
||||||
|
}) => Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 16),
|
||||||
|
child: TextField(
|
||||||
|
controller: controller,
|
||||||
|
keyboardType: multiline ? TextInputType.multiline : keyboardType,
|
||||||
|
minLines: 1,
|
||||||
|
maxLines: multiline ? 4 : 1,
|
||||||
|
textCapitalization: capitalization,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
hintText: hint,
|
||||||
|
helperText: helper,
|
||||||
|
helperMaxLines: 2,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return PopScope<Source>(
|
||||||
|
canPop: !_hasChanges,
|
||||||
|
onPopInvokedWithResult: (didPop, _) async {
|
||||||
|
if (didPop) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
if (await confirmDestructiveAction(
|
||||||
|
context,
|
||||||
|
title: l10n.sourceDiscardTitle,
|
||||||
|
message: l10n.sourceDiscardMessage,
|
||||||
|
confirmLabel: l10n.sourceDiscardConfirm,
|
||||||
|
)) {
|
||||||
|
navigator.pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: SheetScaffold(
|
||||||
|
title: adding ? l10n.sourceAdd : l10n.sourceEdit,
|
||||||
|
// A long form whose helper lines shouldn't make it jump.
|
||||||
|
heightFactor: 0.85,
|
||||||
|
footer: FilledButton(
|
||||||
|
onPressed: _source.isEmpty || !_hasChanges
|
||||||
|
? null
|
||||||
|
: () => Navigator.of(context).pop(_source),
|
||||||
|
child: Text(adding ? l10n.sourceSheetAdd : l10n.commonSave),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (raw.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 16),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: SelectableText(
|
||||||
|
l10n.sourceImportedAs(raw),
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
field(
|
||||||
|
_authors,
|
||||||
|
l10n.sourceAuthors,
|
||||||
|
l10n.sourceAuthorsHint,
|
||||||
|
capitalization: TextCapitalization.words,
|
||||||
|
multiline: true,
|
||||||
|
),
|
||||||
|
field(
|
||||||
|
_year,
|
||||||
|
l10n.sourceYear,
|
||||||
|
l10n.sourceYearHint,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
capitalization: TextCapitalization.none,
|
||||||
|
),
|
||||||
|
field(_title, l10n.sourceTitle, l10n.sourceTitleHint),
|
||||||
|
field(
|
||||||
|
_container,
|
||||||
|
l10n.sourceContainer,
|
||||||
|
l10n.sourceContainerHint,
|
||||||
|
helper: l10n.sourceContainerHelper,
|
||||||
|
),
|
||||||
|
field(_publisher, l10n.sourcePublisher, l10n.sourcePublisherHint),
|
||||||
|
field(
|
||||||
|
_isbn,
|
||||||
|
l10n.sourceIsbn,
|
||||||
|
l10n.sourceIsbnHint,
|
||||||
|
capitalization: TextCapitalization.none,
|
||||||
|
),
|
||||||
|
field(
|
||||||
|
_url,
|
||||||
|
l10n.sourceUrl,
|
||||||
|
l10n.sourceUrlHint,
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
capitalization: TextCapitalization.none,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
/// The style this device cites sources in, from its settings.
|
||||||
|
CitationStyle citationStyleOf(BuildContext context) =>
|
||||||
|
context.select<DeviceSettingsBloc, CitationStyle>(
|
||||||
|
(bloc) => bloc.state.citationStyle,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// [source] cited in [style] (by default the device's), selectable to copy,
|
||||||
|
/// with its ISBN and its link on lines of their own below. A source with
|
||||||
|
/// nothing to cite shows the citation it was imported as, or says it is
|
||||||
|
/// empty.
|
||||||
|
class const SourceView({
|
||||||
|
/// The source.
|
||||||
|
required final Source source,
|
||||||
|
|
||||||
|
/// The style; null for the device's.
|
||||||
|
final CitationStyle? style,
|
||||||
|
super.key,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final runs = formatCitation(source, style ?? citationStyleOf(context));
|
||||||
|
final raw = source.raw.trim();
|
||||||
|
final isbn = source.isbn.trim();
|
||||||
|
final url = source.url.trim();
|
||||||
|
final muted = theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (runs.isNotEmpty)
|
||||||
|
SelectableText.rich(
|
||||||
|
TextSpan(
|
||||||
|
children: [
|
||||||
|
for (final run in runs)
|
||||||
|
TextSpan(
|
||||||
|
text: run.text,
|
||||||
|
style: run.italic
|
||||||
|
? const TextStyle(fontStyle: FontStyle.italic)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
)
|
||||||
|
else if (raw.isNotEmpty)
|
||||||
|
SelectableText(raw, style: theme.textTheme.bodyMedium)
|
||||||
|
else if (isbn.isEmpty && url.isEmpty)
|
||||||
|
Text(
|
||||||
|
l10n.sourceEmpty,
|
||||||
|
style: muted?.copyWith(fontStyle: FontStyle.italic),
|
||||||
|
),
|
||||||
|
if (isbn.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: SelectableText(l10n.sourceIsbnLine(isbn), style: muted),
|
||||||
|
),
|
||||||
|
if (url.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 4,
|
||||||
|
children: [
|
||||||
|
Text(l10n.sourceAvailableAt, style: muted),
|
||||||
|
_Link(url: url, style: muted),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [url], opened in the browser when it is a web address; anything else
|
||||||
|
/// shows as written.
|
||||||
|
class const _Link({required final String url, required final TextStyle? style})
|
||||||
|
extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final uri = Uri.tryParse(url);
|
||||||
|
if (uri == null || !(uri.isScheme('https') || uri.isScheme('http'))) {
|
||||||
|
return SelectableText(url, style: style);
|
||||||
|
}
|
||||||
|
final primary = Theme.of(context).colorScheme.primary;
|
||||||
|
return InkWell(
|
||||||
|
onTap: () => launchUrl(uri, mode: LaunchMode.externalApplication),
|
||||||
|
child: Text(
|
||||||
|
url,
|
||||||
|
style: style?.copyWith(
|
||||||
|
color: primary,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
decorationColor: primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import 'dart:io';
|
||||||
|
|
||||||
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/citation/model/citation.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/device_settings/data/device_settings_store.dart';
|
import 'package:halkyon_learn_flutter/src/core/device_settings/data/device_settings_store.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
|
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
|
||||||
|
|
||||||
|
|
@ -19,6 +20,9 @@ class DeviceSettingsBloc(
|
||||||
on<DeviceSettingsEventLanguageChosen>(
|
on<DeviceSettingsEventLanguageChosen>(
|
||||||
(event, emit) => _apply(emit, state.withLanguage(event.language)),
|
(event, emit) => _apply(emit, state.withLanguage(event.language)),
|
||||||
);
|
);
|
||||||
|
on<DeviceSettingsEventCitationStyleChosen>(
|
||||||
|
(event, emit) => _apply(emit, state.withCitationStyle(event.style)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _apply(
|
Future<void> _apply(
|
||||||
|
|
@ -53,3 +57,11 @@ final class const DeviceSettingsEventLanguageChosen(final String? language)
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [language];
|
List<Object?> get props => [language];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The person chose how sources are cited.
|
||||||
|
final class const DeviceSettingsEventCitationStyleChosen(
|
||||||
|
final CitationStyle style,
|
||||||
|
) extends DeviceSettingsEvent {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [style];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'dart:convert';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
|
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
|
||||||
|
|
||||||
/// Where a device keeps its own settings.
|
/// Where a device keeps its own settings.
|
||||||
|
|
@ -42,6 +43,9 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
|
||||||
final String tag => tag,
|
final String tag => tag,
|
||||||
_ => null,
|
_ => null,
|
||||||
},
|
},
|
||||||
|
citationStyle:
|
||||||
|
CitationStyle.values.asNameMap()[json['citationStyle']] ??
|
||||||
|
CitationStyle.apa,
|
||||||
);
|
);
|
||||||
} on FormatException catch (error) {
|
} on FormatException catch (error) {
|
||||||
log('The device settings are unreadable', name: 'Settings', error: error);
|
log('The device settings are unreadable', name: 'Settings', error: error);
|
||||||
|
|
@ -57,6 +61,7 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
|
||||||
jsonEncode({
|
jsonEncode({
|
||||||
'appearance': settings.appearance.name,
|
'appearance': settings.appearance.name,
|
||||||
'language': ?settings.language,
|
'language': ?settings.language,
|
||||||
|
'citationStyle': settings.citationStyle.name,
|
||||||
}),
|
}),
|
||||||
flush: true,
|
flush: true,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
|
||||||
|
|
||||||
/// How the app looks: as the device does, or always light or dark.
|
/// How the app looks: as the device does, or always light or dark.
|
||||||
enum Appearance() {
|
enum Appearance() {
|
||||||
|
|
@ -8,24 +9,41 @@ enum Appearance() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The settings of one device, which stay on it (decided 2026-10-07): how the
|
/// The settings of one device, which stay on it (decided 2026-10-07): how the
|
||||||
/// app looks and the language it speaks. Unlike the person's settings, they
|
/// app looks, the language it speaks and the style it cites sources in.
|
||||||
/// don't follow anyone to other devices, and they apply before anyone is
|
/// Unlike the person's settings, they don't follow anyone to other devices,
|
||||||
/// signed in.
|
/// and they apply before anyone is signed in.
|
||||||
final class const DeviceSettings({
|
final class const DeviceSettings({
|
||||||
/// Light, dark, or as the device.
|
/// Light, dark, or as the device.
|
||||||
final Appearance appearance = Appearance.device,
|
final Appearance appearance = Appearance.device,
|
||||||
|
|
||||||
/// The language's BCP 47 tag, as `en-GB` or `de`; null for the device's.
|
/// The language's BCP 47 tag, as `en-GB` or `de`; null for the device's.
|
||||||
final String? language,
|
final String? language,
|
||||||
|
|
||||||
|
/// How sources and bibliographies are cited.
|
||||||
|
final CitationStyle citationStyle = CitationStyle.apa,
|
||||||
}) with Equatable {
|
}) with Equatable {
|
||||||
/// These settings with [appearance] instead.
|
/// These settings with [appearance] instead.
|
||||||
DeviceSettings withAppearance(Appearance appearance) =>
|
DeviceSettings withAppearance(Appearance appearance) => DeviceSettings(
|
||||||
DeviceSettings(appearance: appearance, language: language);
|
appearance: appearance,
|
||||||
|
language: language,
|
||||||
|
citationStyle: citationStyle,
|
||||||
|
);
|
||||||
|
|
||||||
/// These settings with [language] instead; null for the device's.
|
/// These settings with [language] instead; null for the device's.
|
||||||
DeviceSettings withLanguage(String? language) =>
|
DeviceSettings withLanguage(String? language) => DeviceSettings(
|
||||||
DeviceSettings(appearance: appearance, language: language);
|
appearance: appearance,
|
||||||
|
language: language,
|
||||||
|
citationStyle: citationStyle,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// These settings with [citationStyle] instead.
|
||||||
|
DeviceSettings withCitationStyle(CitationStyle citationStyle) =>
|
||||||
|
DeviceSettings(
|
||||||
|
appearance: appearance,
|
||||||
|
language: language,
|
||||||
|
citationStyle: citationStyle,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [appearance, language];
|
List<Object?> get props => [appearance, language, citationStyle];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/answer.dart';
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/answer.dart';
|
||||||
|
|
@ -18,10 +19,13 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_conte
|
||||||
// - fill in the blank: {"passage": "… ___ …", "blanks": ["…"]}
|
// - fill in the blank: {"passage": "… ___ …", "blanks": ["…"]}
|
||||||
//
|
//
|
||||||
// An info step has none. A source is {"authors", "year", "title", "url"}, each
|
// An info step has none. A source is {"authors", "year", "title", "url"}, each
|
||||||
// left out when empty but the title.
|
// left out when empty but the title. Schema 2 added a source's "container"
|
||||||
|
// (what it is a part of), "publisher", "isbn" and "raw" (a citation as a file
|
||||||
|
// brought it), and lets every part of it be left out (see Source.toJson);
|
||||||
|
// schema 1 reads as schema 2 without them.
|
||||||
|
|
||||||
/// The schema this app writes.
|
/// The schema this app writes.
|
||||||
const stepSchemaVersion = 1;
|
const stepSchemaVersion = 2;
|
||||||
|
|
||||||
/// Encodes a step's content into a revision's payload.
|
/// Encodes a step's content into a revision's payload.
|
||||||
ByteData encodeStepPayload(StepContent content) =>
|
ByteData encodeStepPayload(StepContent content) =>
|
||||||
|
|
@ -34,15 +38,7 @@ ByteData encodeStepPayload(StepContent content) =>
|
||||||
},
|
},
|
||||||
'rightFeedback': content.rightFeedback.toJson(),
|
'rightFeedback': content.rightFeedback.toJson(),
|
||||||
'wrongFeedback': content.wrongFeedback.toJson(),
|
'wrongFeedback': content.wrongFeedback.toJson(),
|
||||||
'sources': [
|
'sources': [for (final source in content.sources) source.toJson()],
|
||||||
for (final source in content.sources)
|
|
||||||
{
|
|
||||||
if (source.authors.isNotEmpty) 'authors': source.authors,
|
|
||||||
if (source.year.isNotEmpty) 'year': source.year,
|
|
||||||
'title': source.title,
|
|
||||||
if (source.url.isNotEmpty) 'url': source.url,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Decodes a step revision's payload, its exercise as one of [kind]'s.
|
/// Decodes a step revision's payload, its exercise as one of [kind]'s.
|
||||||
|
|
@ -71,7 +67,7 @@ StepContent decodeStepPayload(ByteData payload, StepKind kind) {
|
||||||
},
|
},
|
||||||
rightFeedback: RichContent.fromJson(rightFeedback),
|
rightFeedback: RichContent.fromJson(rightFeedback),
|
||||||
wrongFeedback: RichContent.fromJson(wrongFeedback),
|
wrongFeedback: RichContent.fromJson(wrongFeedback),
|
||||||
sources: [for (final source in sources) _source(source)],
|
sources: [for (final source in sources) Source.fromJson(source)],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw const FormatException('Not a learning path step payload.');
|
throw const FormatException('Not a learning path step payload.');
|
||||||
|
|
@ -188,22 +184,6 @@ ExerciseAnswer decodeAnswer(StepKind kind, String text) =>
|
||||||
_ => throw FormatException('Not an answer to a $kind step.'),
|
_ => throw FormatException('Not an answer to a $kind step.'),
|
||||||
};
|
};
|
||||||
|
|
||||||
StepSource _source(Object? json) => switch (json) {
|
|
||||||
{'title': final String title} => StepSource(
|
|
||||||
title: title,
|
|
||||||
authors: _optional(json, 'authors'),
|
|
||||||
year: _optional(json, 'year'),
|
|
||||||
url: _optional(json, 'url'),
|
|
||||||
),
|
|
||||||
_ => throw const FormatException('A malformed source.'),
|
|
||||||
};
|
|
||||||
|
|
||||||
String _optional(Map<Object?, Object?> json, String key) => switch (json[key]) {
|
|
||||||
null => '',
|
|
||||||
final String value => value,
|
|
||||||
_ => throw FormatException('A source with a malformed $key.'),
|
|
||||||
};
|
|
||||||
|
|
||||||
List<String> _strings(List<Object?> json) => [
|
List<String> _strings(List<Object?> json) => [
|
||||||
for (final value in json)
|
for (final value in json)
|
||||||
switch (value) {
|
switch (value) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
|
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||||
|
|
@ -27,6 +28,10 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_conte
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
//
|
//
|
||||||
|
// Any step may cite "sources": each an object of text parts, as Source.toJson
|
||||||
|
// writes them ("citation" counts as "container", as files of before name
|
||||||
|
// it), or text, kept as the citation it is.
|
||||||
|
//
|
||||||
// Blocks: {"type": "paragraph" | "heading" (with "level"), "text": "…"} or
|
// Blocks: {"type": "paragraph" | "heading" (with "level"), "text": "…"} or
|
||||||
// with "runs": [{"text", "bold", "italic", "link", "math"}]; "bulleted_list"
|
// with "runs": [{"text", "bold", "italic", "link", "math"}]; "bulleted_list"
|
||||||
// and "numbered_list" with "items" (text or {"runs"}); "math" ("formula")
|
// and "numbered_list" with "items" (text or {"runs"}); "math" ("formula")
|
||||||
|
|
@ -63,6 +68,7 @@ enum PathImportProblem() {
|
||||||
tooFew,
|
tooFew,
|
||||||
malformedNumber,
|
malformedNumber,
|
||||||
titleNotText,
|
titleNotText,
|
||||||
|
malformedSource,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A file in the import format that couldn't be read: the [problem], the
|
/// A file in the import format that couldn't be read: the [problem], the
|
||||||
|
|
@ -273,10 +279,30 @@ ParsedPath parsePathImport(String source) {
|
||||||
exercise: exercise,
|
exercise: exercise,
|
||||||
rightFeedback: blocks.read(step['correct_feedback'], where),
|
rightFeedback: blocks.read(step['correct_feedback'], where),
|
||||||
wrongFeedback: blocks.read(step['incorrect_feedback'], where),
|
wrongFeedback: blocks.read(step['incorrect_feedback'], where),
|
||||||
|
sources: [
|
||||||
|
for (final json in _list(step['sources'], where))
|
||||||
|
if (_source(json, where) case final source when !source.isEmpty)
|
||||||
|
source,
|
||||||
|
],
|
||||||
).normalized,
|
).normalized,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Source _source(Object? json, int where) {
|
||||||
|
try {
|
||||||
|
return switch (json) {
|
||||||
|
final String text => Source(raw: text),
|
||||||
|
final Map<String, Object?> source => Source.fromJson({
|
||||||
|
'container': ?source['citation'],
|
||||||
|
...source,
|
||||||
|
}),
|
||||||
|
_ => throw const FormatException('A source is an object or text.'),
|
||||||
|
}.trimmed();
|
||||||
|
} on FormatException {
|
||||||
|
throw PathImportException(PathImportProblem.malformedSource, step: where);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Turns the format's blocks into rich text, counting what it leaves out.
|
/// Turns the format's blocks into rich text, counting what it leaves out.
|
||||||
class _Blocks() {
|
class _Blocks() {
|
||||||
/// How many blocks were left out.
|
/// How many blocks were left out.
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,9 @@
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/revision_merge.dart';
|
import 'package:halkyon_learn_flutter/src/core/content/revision_merge.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
|
||||||
|
|
||||||
/// A work a step draws on, as a citation shows it.
|
|
||||||
final class const StepSource({
|
|
||||||
/// Who wrote it, as they are cited, e.g. "Tae Kim".
|
|
||||||
final String authors = '',
|
|
||||||
|
|
||||||
/// When it appeared, e.g. "2014"; empty when unknown.
|
|
||||||
final String year = '',
|
|
||||||
|
|
||||||
/// Its title.
|
|
||||||
required final String title,
|
|
||||||
|
|
||||||
/// Where to find it; empty without.
|
|
||||||
final String url = '',
|
|
||||||
}) with Equatable {
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [authors, year, title, url];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The parts of a step that merge separately when devices edited it while
|
/// The parts of a step that merge separately when devices edited it while
|
||||||
/// offline (ADR 0003).
|
/// offline (ADR 0003).
|
||||||
enum StepField() {
|
enum StepField() {
|
||||||
|
|
@ -61,7 +44,7 @@ final class const StepContent({
|
||||||
required final RichContent wrongFeedback,
|
required final RichContent wrongFeedback,
|
||||||
|
|
||||||
/// The works the step draws on.
|
/// The works the step draws on.
|
||||||
final List<StepSource> sources = const [],
|
final List<Source> sources = const [],
|
||||||
}) with Equatable implements MergeableContent<StepField, StepContent> {
|
}) with Equatable implements MergeableContent<StepField, StepContent> {
|
||||||
/// A new step of [kind], with nothing written yet.
|
/// A new step of [kind], with nothing written yet.
|
||||||
factory empty(StepKind kind) => StepContent(
|
factory empty(StepKind kind) => StepContent(
|
||||||
|
|
@ -92,7 +75,7 @@ final class const StepContent({
|
||||||
wrongFeedback: field == StepField.wrongFeedback
|
wrongFeedback: field == StepField.wrongFeedback
|
||||||
? value! as RichContent
|
? value! as RichContent
|
||||||
: wrongFeedback,
|
: wrongFeedback,
|
||||||
sources: field == StepField.sources ? value! as List<StepSource> : sources,
|
sources: field == StepField.sources ? value! as List<Source> : sources,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// This content as it is stored: the title without surrounding spaces.
|
/// This content as it is stored: the title without surrounding spaces.
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ extension PathImportTexts on AppLocalizations {
|
||||||
PathImportProblem.tooFew => pathImportTooFew(detail),
|
PathImportProblem.tooFew => pathImportTooFew(detail),
|
||||||
PathImportProblem.malformedNumber => pathImportMalformedNumber,
|
PathImportProblem.malformedNumber => pathImportMalformedNumber,
|
||||||
PathImportProblem.titleNotText => pathImportTitleNotText,
|
PathImportProblem.titleNotText => pathImportTitleNotText,
|
||||||
|
PathImportProblem.malformedSource => pathImportMalformedSource,
|
||||||
};
|
};
|
||||||
final sentence = switch (error.step) {
|
final sentence = switch (error.step) {
|
||||||
final step? => pathImportAtStep(step, problem),
|
final step? => pathImportAtStep(step, problem),
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,15 @@
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/citation/widget/source_list.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
|
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
|
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.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/core/theme/theme_extras.dart';
|
import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_runner_bloc.dart';
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_runner_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path_step.dart';
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path_step.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart';
|
|
||||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/exercise_views.dart';
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/exercise_views.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';
|
||||||
|
|
@ -128,17 +127,22 @@ class const _Step({
|
||||||
RichContentView(content: content.body),
|
RichContentView(content: content.body),
|
||||||
],
|
],
|
||||||
if (content.sources.isNotEmpty)
|
if (content.sources.isNotEmpty)
|
||||||
Align(
|
Padding(
|
||||||
alignment: Alignment.centerLeft,
|
padding: const EdgeInsets.only(top: 12),
|
||||||
child: Padding(
|
child: OutlinedButton.icon(
|
||||||
padding: const EdgeInsets.only(top: 8),
|
style: OutlinedButton.styleFrom(
|
||||||
child: ActionChip(
|
side: BorderSide(
|
||||||
avatar: const Icon(LucideIcons.bookOpen, size: 16),
|
color: theme.colorScheme.outlineVariant,
|
||||||
label: Text(
|
|
||||||
l10n.stepSourcesCount(content.sources.length),
|
|
||||||
),
|
),
|
||||||
onPressed: () =>
|
),
|
||||||
_showSources(context, content.sources),
|
icon: const Icon(LucideIcons.bookOpen, size: 18),
|
||||||
|
label: Text(
|
||||||
|
l10n.sourcesCount(content.sources.length),
|
||||||
|
),
|
||||||
|
onPressed: () => showSourcesSheet(
|
||||||
|
context,
|
||||||
|
title: l10n.sources,
|
||||||
|
sources: content.sources,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -182,37 +186,8 @@ class const _Step({
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showSources(BuildContext context, List<StepSource> sources) =>
|
|
||||||
showAdaptiveSheet<void>(
|
|
||||||
context,
|
|
||||||
builder: (context) => SheetScaffold(
|
|
||||||
title: AppLocalizations.of(context).stepSources,
|
|
||||||
body: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
for (final source in sources)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
|
||||||
child: SelectableText(citationOf(source)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [source] as a citation: "Tae Kim (2014). A Guide to Japanese Grammar.
|
|
||||||
/// https://…", leaving out what it hasn't got.
|
|
||||||
String citationOf(StepSource source) => [
|
|
||||||
[
|
|
||||||
if (source.authors.isNotEmpty) source.authors,
|
|
||||||
if (source.year.isNotEmpty) '(${source.year})',
|
|
||||||
].join(' '),
|
|
||||||
source.title,
|
|
||||||
source.url,
|
|
||||||
].where((part) => part.isNotEmpty).join('. ');
|
|
||||||
|
|
||||||
/// How a check came out, and what the author has to say to it.
|
/// How a check came out, and what the author has to say to it.
|
||||||
class const _Feedback({
|
class const _Feedback({
|
||||||
required final bool right,
|
required final bool right,
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/citation/widget/source_list.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/citation/widget/source_view.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
|
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
|
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_text_screen.dart';
|
import 'package:halkyon_learn_flutter/src/core/editor/rich_text_screen.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
|
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/step_editor_bloc.dart';
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/step_editor_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart';
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_runner_screen.dart';
|
|
||||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/step_kind_labels.dart';
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/step_kind_labels.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';
|
||||||
|
|
@ -493,110 +494,24 @@ class const _FeedbackTile({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The works the step draws on: each as a citation, removable, and a way
|
/// The works the step draws on, each as cited in the device's style, to be
|
||||||
/// to add one.
|
/// changed and removed, and a way to add one.
|
||||||
class const _Sources({
|
class const _Sources({
|
||||||
required final List<StepSource> sources,
|
required final List<Source> sources,
|
||||||
required final ValueChanged<List<StepSource>>? onChanged,
|
required final ValueChanged<List<Source>>? onChanged,
|
||||||
}) extends StatelessWidget {
|
}) extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) => Column(
|
||||||
final l10n = AppLocalizations.of(context);
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
final theme = Theme.of(context);
|
children: [
|
||||||
final onChanged = this.onChanged;
|
Text(
|
||||||
return Column(
|
AppLocalizations.of(context).sources,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
children: [
|
|
||||||
Text(l10n.stepSources, style: theme.textTheme.titleSmall),
|
|
||||||
for (final (index, source) in sources.indexed)
|
|
||||||
ListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: Text(citationOf(source)),
|
|
||||||
trailing: onChanged == null
|
|
||||||
? null
|
|
||||||
: IconButton(
|
|
||||||
tooltip: l10n.stepRemoveSource,
|
|
||||||
icon: const Icon(LucideIcons.x),
|
|
||||||
onPressed: () => onChanged([...sources]..removeAt(index)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (onChanged != null)
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: TextButton.icon(
|
|
||||||
icon: const Icon(LucideIcons.plus),
|
|
||||||
label: Text(l10n.stepAddSource),
|
|
||||||
onPressed: () async {
|
|
||||||
final source = await showAdaptiveSheet<StepSource>(
|
|
||||||
context,
|
|
||||||
builder: (context) => const _SourceSheet(),
|
|
||||||
);
|
|
||||||
if (source != null) {
|
|
||||||
onChanged([...sources, source]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class const _SourceSheet() extends StatefulWidget {
|
|
||||||
@override
|
|
||||||
State<_SourceSheet> createState() => _SourceSheetState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SourceSheetState() extends State<_SourceSheet> {
|
|
||||||
final _authors = TextEditingController();
|
|
||||||
final _year = TextEditingController();
|
|
||||||
final _title = TextEditingController();
|
|
||||||
final _url = TextEditingController();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
for (final controller in [_authors, _year, _title, _url]) {
|
|
||||||
controller.dispose();
|
|
||||||
}
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final l10n = AppLocalizations.of(context);
|
|
||||||
Widget field(TextEditingController controller, String label) => Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
|
||||||
child: TextField(
|
|
||||||
controller: controller,
|
|
||||||
decoration: InputDecoration(labelText: label),
|
|
||||||
onChanged: (_) => setState(() {}),
|
|
||||||
),
|
),
|
||||||
);
|
const SizedBox(height: 8),
|
||||||
return SheetScaffold(
|
SourceList(sources: sources, onChanged: onChanged),
|
||||||
title: l10n.stepAddSource,
|
],
|
||||||
body: Column(
|
);
|
||||||
children: [
|
|
||||||
field(_authors, l10n.stepSourceAuthors),
|
|
||||||
field(_year, l10n.stepSourceYear),
|
|
||||||
field(_title, l10n.stepSourceTitle),
|
|
||||||
field(_url, l10n.stepSourceUrl),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
action: FilledButton(
|
|
||||||
onPressed: _title.text.trim().isEmpty
|
|
||||||
? null
|
|
||||||
: () => Navigator.of(context).pop(
|
|
||||||
StepSource(
|
|
||||||
authors: _authors.text.trim(),
|
|
||||||
year: _year.text.trim(),
|
|
||||||
title: _title.text.trim(),
|
|
||||||
url: _url.text.trim(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Text(l10n.stepAddSource),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The fields devices changed differently, each with every version and a
|
/// The fields devices changed differently, each with every version and a
|
||||||
|
|
@ -703,8 +618,15 @@ class const _Version({required final Object? version}) extends StatelessWidget {
|
||||||
FillInTheBlank(:final passage, :final blanks) => Text(
|
FillInTheBlank(:final passage, :final blanks) => Text(
|
||||||
'$passage\n${blanks.join(', ')}',
|
'$passage\n${blanks.join(', ')}',
|
||||||
),
|
),
|
||||||
final List<StepSource> sources => Text(
|
final List<Source> sources => Column(
|
||||||
sources.map(citationOf).join('\n'),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
for (final source in sources)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
|
child: SourceView(source: source),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
_ => Text('${version ?? ''}'),
|
_ => Text('${version ?? ''}'),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue