feat(citation): sources are books and articles, websites or YouTube videos

A source was a book's or an article's parts, however a website or a
video had to be squeezed into them. Adding one to a bibliography or a
step now asks what it is, and its form asks for what cites it: a book
or an article its parts as before; a website the page's link, title,
the site's name, authors and year; a YouTube video its link (checked to
be one), title, channel and year. Websites and videos need their link,
marked as required. A video is cited as APA cites one ("[Video]", a
part of YouTube) and plays in the app where it can, else on YouTube.

The kind is kept in the source's JSON, left out for a book or an
article; a kind this app doesn't know reads as a work. The studyset's
payload goes to schema 6 and a step's to schema 4 for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-12 23:55:39 +02:00
commit 61bf7b2fa0
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
17 changed files with 606 additions and 89 deletions

View file

@ -150,7 +150,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **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) with its `position` among the studyset's files in a column of its own (decided 2026-10-08; the files' default order, Custom order, which Reorder changes; PDFs from before it stand at `a0`, in the order they were added) 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. Beside the pages, cards are written in the composer's own editor (`CardDraftEditor`, with a `CardComposerBloc` of the panel's; decided 2026-10-12), every kind with its tags, hints and explanation, tied to the page shown and tagged with its chapter, and saved by Create, as the composer saves a card only once it is complete; the screenshot's button ends the format bar there (`InsertableBlock.inFormatBar`).
- **Dictionaries** (decided 2026-10-08): reference data the server holds for everyone, owned by no one, never synced (`dictionary_entry`, `dictionary_source`, `lib/src/dictionary/`). `halkyon_learn_curator` (AGPL, no Serverpod, so the server never links its code) turns each source's download into one format, a dictionary file (its README is the contract), with an adapter per source; a dictionary is one source in one meaning language (`jmdict` in `en`), of words or of characters (`kind`: JMdict, KANJIDIC2 with the kana), and the endpoints take it by those two, so a search for words never brings kanji; everything specific to a language (kana, romaji, meanings without "to") goes into each entry's `keys` and `looseKeys`, so the server's search knows no language: exact key, exact loose key, key starting with the query, loose key starting with it, each by the source's `rank` (`DictionaryEndpoint.search`, LIKE on barred keys, no extension needed). The server loads the files in its `dictionaries` folder at start and hourly (`DictionaryReloadFutureCall`), those whose SHA-256 changed, each source replaced in one transaction, one load at a time (`dictionary_loader.dart`). EDRDG's licence asks a server showing its dictionaries to update them at least monthly (decided 2026-10-09: instances keep current by themselves, weekly): the `dictionaries` service of `deploy/compose.yaml` runs the curator in a container of its own (`halkyon_learn_curator/Dockerfile`, user ID 10001; the AGPL tool never enters the server's image or process), `curate update --every 7` downloading EDRDG's files of the day (`JMdict_e_examp.gz`, `kanjidic2.xml.gz`; the JLPT lists are static, in the curator's `data/`), converting them and renaming each over its old file in the shared volume, which the server mounts read-only. A card made from an entry copies what it needs and keeps `(source, sourceId)` as a soft reference only (`CardContent.entry`, card schema 6), so loading a newer version never touches cards. **Corrections reach cards by offer** (decided 2026-10-09): every entry has a `version` (16 hex digits of the SHA-256 of what cards take of it, made by the loader), a card keeps the one it took (`entryVersion`, card schema 8), and `DictionaryEndpoint.changed` returns, of the entries named with the versions cards keep, those that are another now. The card browser says how many cards are behind (`DictionaryCorrections`, `DictionaryUpdates`) and takes the corrections at a tap: `CardRepository.takeDictionaryUpdate` writes the entry as it is now over the revision in which the card last took it, as a revision of its own beside whatever came after, so the revisions merge per side as two devices' edits do (ADR 0003): a side only the dictionary changed takes the correction, a side the person changed too is a conflict settled in the composer. A card made before versions is never offered one. Every file carries its credits, which the app shows (About, and at the foot of the dictionary). In the app, a studyset's dictionary follows from its front language (`StudysetDictionary`, through `StudysetLanguageScope.of`): no attaching or detaching, and nothing dictionary-shaped shows without one (Add material › From the dictionary; Look up beside a vocabulary card's front). A word's example sentences (Tatoeba, through JMdict's examples edition) become its card's hints, up to three, the word in bold; Look up gives them to a card without hints of its own. `DictionaryAdder` makes the cards, each entry once per studyset, a word tagged `JLPT-NX` and with its word class by the first sense (`Noun`, `Verb`, `na-Adjective`); Look up in the composer gives a card the same tags (`DictionaryItem.tagNames`), and an entry looked up again on that card swaps the tags the one before brought (`isDictionaryTagName`). Where the instance also holds the language's characters, the dictionary screen has a tab for them (Kanji & kana: the kana, the JLPT levels and the school grades to add whole, each under its name), a character becoming a character card tagged `JLPT-NX`, `Grade N` or `Secondary school`, and `Hiragana` or `Katakana`; a character card's Look up searches the characters. Beside them a Radicals tab lists the 214 Kangxi radicals by their strokes (`feature/kanji/model/radical.dart`: the ideograph each of Unicode's Kangxi radicals stands for, the forms Japanese writes it as inside kanji, Unicode's English name), each listing the kanji KANJIDIC2 files under it (its classical radical, the label `radical:N`, which the curator writes), to add or see; that tab shares the characters' bloc, so what it adds shows as held in a search too. The curator stamps its files with how it converted them (`.conversion`, `conversion` in its `update.dart`): raise it whenever it writes something else from the same download, and instances convert again as soon as they run the new curator rather than up to a week later.
- **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`.
- **Citations** (`core/citation/`): a `Source` is of a kind (`SourceKind`, decided 2026-10-12: a book or an article, a website, a YouTube video, each asked for by its own form, from the Add menu of a list of sources; a video is cited with "[Video]" in APA, as a part of YouTube, and plays in the app where it can; studyset schema 6, step schema 4) and 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`.
- **What a card carries beside its type's fields** (card schema 5): up to six `hints` and an `explanation` (rich text, any type), a `report` that something on it is wrong (category, note, when; studied as before, its badge in the list), and a word's `senses` (glosses, label, JMdict's grammar codes, whose English short forms and meanings are data in `feature/card/model/jmdict_tags.dart`; with senses a word's meanings are their glosses, and `uk` on its first sense shows its reading from the start; wherever its meanings show, in a session, the card browser and the dictionary's lists, each sense's grammar notes follow its glosses small, grey and slanted, their meaning on hover: `VocabularySensesView`, `vocabularySensesSpan` for a list's row). Each merges as a field of its own. Deleting a card goes through `CardDeletions` (a `DelayedDeletions`, as notes and studysets), so the list and the session offer Undo. A study session's Undo takes back its steps last first (ratings, burials and deletions alike, across its rounds), so it holds the cards it deletes (`hold`) until it ends, when they go after the usual time to undo; a card deleted in a session that never ended (the app killed) stays. Vocabulary can be quizzed as multiple choice (`quizVocabulary`, `quizAspect` in the study options, settings schema 5; `vocabularyQuiz` draws the wrong answers from the other words studied). **A character card** (`CardType.character`, card schema 7, decided 2026-10-09: one type for kanji and kana) holds the character on its front, its `meanings` (a kana's romaji is its meaning, so kana are studied and written as kanji are) and a kanji's `onReadings` and `kunReadings` as dictionaries write them (katakana; hiragana with the okurigana after a dot); its JLPT level and school grade are tags, its strokes the app's own. **How characters are written** (`feature/kanji/`, decided 2026-10-09: bundled, so writing works offline) is KanjiVG's, in `assets/kanjivg/` (CC BY-SA 3.0, its notice in the licence list): the curator's `curate kanjivg` writes one gzipped JSON file per 256 code points (2.7 MB in all), which `StrokeRepository` (app-wide in `Dependencies`, as the maps) reads when a character of it is first asked for; the strokes are SVG path data in a 109 × 109 square, read with `path_parsing` (pure Dart, so `CharacterStrokes` flattens them into points without `dart:ui`). `StrokeOrderView` traces them in order; `showCharacterSheet` shows a character with its stroke order, meanings, readings and components, from a studied character's answer and from the dictionary's characters. A studied character's answer also lists the words written with it (`example_word.dart`: up to five vocabulary cards of the studysets the session studies, whatever tags narrow it, read with its cards, so offline), each opening its card. **Writing by hand** (decided 2026-10-09, after Kanji Dojo's practice): the study option Write by hand (`StudyOptions.writeByHand`, settings schema 7) has character cards and words written in the session, mode `write` on a schedule of its own (words so written are neither quizzed nor typed). `WritingCanvas` takes raw pointers and claims every touch on it at once (an `EagerGestureRecognizer`), so a stroke never scrolls or swipes to rate; each stroke is checked as it ends (`checkStroke`, `kanji/model/stroke_check.dart`: both resampled to 22 points, shape compared once centred, position, length and direction, against what a loose hand strays by; a stroke closer to one still to come is out of order), a right one drawn as KanjiVG draws it, and the hand's offset from the middle forgiven in the strokes after. Mistakes are strokes that didn't count and hints asked for; two misses in a row show the next stroke faintly. `WritingBody` writes a word character by character, filling in those without a stroke order, and once written (or given up on, Show it) outlines the rating the mistakes suggest (`suggestedRating`: none Good, one or two OK, more Bad; given up, Bad). `stroke_check_test.dart` holds the checker to real strokes, moved, wavering, larger and smaller, reversed and out of order: change its weights only with that test passing.
- **Imported cards** say where they came from in their first revision's payload (`origin`, card schema 3: `anki`, or `text` for flashcards pasted or read from CSV/TSV); 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.

View file

@ -206,6 +206,19 @@
"sourceDiscardTitle": "Änderungen verwerfen?",
"sourceDiscardMessage": "Was du für diese Quelle eingegeben hast, geht verloren.",
"sourceDiscardConfirm": "Verwerfen",
"sourceKind": "Art der Quelle",
"sourceKindWork": "Buch oder Artikel",
"sourceKindWebsite": "Website",
"sourceKindVideo": "YouTube-Video",
"sourceWebsiteName": "Website",
"sourceWebsiteNameHint": "Ihr Name, z. B. Wikipedia",
"sourcePageTitleHint": "Titel der Seite",
"sourceChannel": "Kanal",
"sourceChannelHint": "Wer es auf YouTube veröffentlicht hat",
"sourceVideoTitleHint": "Titel des Videos",
"sourceVideoLink": "Link zum Video",
"sourceVideoLinkInvalid": "Kein Link zu einem YouTube-Video",
"sourcePlay": "Video abspielen",
"stepConflict": "Dieser Schritt wurde auf einem anderen Gerät anders geändert. Behalte eine Version, um weiterzuschreiben.",
"stepFieldTitle": "Titel",
"stepFieldBody": "Text",

View file

@ -240,6 +240,19 @@
"sourceDiscardTitle": "Discard changes?",
"sourceDiscardMessage": "What you typed for this source will be lost.",
"sourceDiscardConfirm": "Discard",
"sourceKind": "Kind of source",
"sourceKindWork": "Book or article",
"sourceKindWebsite": "Website",
"sourceKindVideo": "YouTube video",
"sourceWebsiteName": "Website",
"sourceWebsiteNameHint": "Its name, e.g. Wikipedia",
"sourcePageTitleHint": "Title of the page",
"sourceChannel": "Channel",
"sourceChannelHint": "Who published it on YouTube",
"sourceVideoTitleHint": "Title of the video",
"sourceVideoLink": "Link to the video",
"sourceVideoLinkInvalid": "Not a link to a YouTube video",
"sourcePlay": "Play video",
"stepConflict": "This step was changed differently on another device. Keep one version to go on writing.",
"stepFieldTitle": "Title",
"stepFieldBody": "Text",

View file

@ -1282,6 +1282,84 @@ abstract class AppLocalizations {
/// **'Discard'**
String get sourceDiscardConfirm;
/// No description provided for @sourceKind.
///
/// In en, this message translates to:
/// **'Kind of source'**
String get sourceKind;
/// No description provided for @sourceKindWork.
///
/// In en, this message translates to:
/// **'Book or article'**
String get sourceKindWork;
/// No description provided for @sourceKindWebsite.
///
/// In en, this message translates to:
/// **'Website'**
String get sourceKindWebsite;
/// No description provided for @sourceKindVideo.
///
/// In en, this message translates to:
/// **'YouTube video'**
String get sourceKindVideo;
/// No description provided for @sourceWebsiteName.
///
/// In en, this message translates to:
/// **'Website'**
String get sourceWebsiteName;
/// No description provided for @sourceWebsiteNameHint.
///
/// In en, this message translates to:
/// **'Its name, e.g. Wikipedia'**
String get sourceWebsiteNameHint;
/// No description provided for @sourcePageTitleHint.
///
/// In en, this message translates to:
/// **'Title of the page'**
String get sourcePageTitleHint;
/// No description provided for @sourceChannel.
///
/// In en, this message translates to:
/// **'Channel'**
String get sourceChannel;
/// No description provided for @sourceChannelHint.
///
/// In en, this message translates to:
/// **'Who published it on YouTube'**
String get sourceChannelHint;
/// No description provided for @sourceVideoTitleHint.
///
/// In en, this message translates to:
/// **'Title of the video'**
String get sourceVideoTitleHint;
/// No description provided for @sourceVideoLink.
///
/// In en, this message translates to:
/// **'Link to the video'**
String get sourceVideoLink;
/// No description provided for @sourceVideoLinkInvalid.
///
/// In en, this message translates to:
/// **'Not a link to a YouTube video'**
String get sourceVideoLinkInvalid;
/// No description provided for @sourcePlay.
///
/// In en, this message translates to:
/// **'Play video'**
String get sourcePlay;
/// No description provided for @stepConflict.
///
/// In en, this message translates to:

View file

@ -788,6 +788,45 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get sourceDiscardConfirm => 'Verwerfen';
@override
String get sourceKind => 'Art der Quelle';
@override
String get sourceKindWork => 'Buch oder Artikel';
@override
String get sourceKindWebsite => 'Website';
@override
String get sourceKindVideo => 'YouTube-Video';
@override
String get sourceWebsiteName => 'Website';
@override
String get sourceWebsiteNameHint => 'Ihr Name, z. B. Wikipedia';
@override
String get sourcePageTitleHint => 'Titel der Seite';
@override
String get sourceChannel => 'Kanal';
@override
String get sourceChannelHint => 'Wer es auf YouTube veröffentlicht hat';
@override
String get sourceVideoTitleHint => 'Titel des Videos';
@override
String get sourceVideoLink => 'Link zum Video';
@override
String get sourceVideoLinkInvalid => 'Kein Link zu einem YouTube-Video';
@override
String get sourcePlay => 'Video abspielen';
@override
String get stepConflict =>
'Dieser Schritt wurde auf einem anderen Gerät anders geändert. Behalte eine Version, um weiterzuschreiben.';

View file

@ -780,6 +780,45 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get sourceDiscardConfirm => 'Discard';
@override
String get sourceKind => 'Kind of source';
@override
String get sourceKindWork => 'Book or article';
@override
String get sourceKindWebsite => 'Website';
@override
String get sourceKindVideo => 'YouTube video';
@override
String get sourceWebsiteName => 'Website';
@override
String get sourceWebsiteNameHint => 'Its name, e.g. Wikipedia';
@override
String get sourcePageTitleHint => 'Title of the page';
@override
String get sourceChannel => 'Channel';
@override
String get sourceChannelHint => 'Who published it on YouTube';
@override
String get sourceVideoTitleHint => 'Title of the video';
@override
String get sourceVideoLink => 'Link to the video';
@override
String get sourceVideoLinkInvalid => 'Not a link to a YouTube video';
@override
String get sourcePlay => 'Play video';
@override
String get stepConflict =>
'This step was changed differently on another device. Keep one version to go on writing.';

View file

@ -49,8 +49,18 @@ List<CitationRun> formatCitation(Source source, CitationStyle style) {
CitationStyle.mla => _mlaAuthors(_names(names)),
CitationStyle.chicago => _chicagoAuthors(_names(names)),
};
final title = source.title.trim();
final container = source.container.trim();
final video = source.kind == SourceKind.video;
// A video is cited as APA cites one, "[Video]" after its title, and as a
// part of YouTube where nothing else is named.
final title = switch (source.title.trim()) {
final title when video && title.isNotEmpty && style == CitationStyle.apa =>
'$title [Video]',
final title => title,
};
final container = switch (source.container.trim()) {
'' when video => 'YouTube',
final container => container,
};
final publisher = source.publisher.trim();
final isPart = container.isNotEmpty;
final year = switch (source.year.trim()) {

View file

@ -1,9 +1,26 @@
import 'package:equatable/equatable.dart';
/// What kind of work a source is, which its form asks for and its citation
/// says (decided 2026-10-12).
enum SourceKind() {
/// A book, an article, a chapter: the default.
work,
/// A page on the web, [Source.container] the website's name.
website,
/// A YouTube video, its [Source.authors] the channel; played in the app
/// where it can be.
video,
}
/// 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({
/// What kind of work it is.
final SourceKind kind = SourceKind.work,
/// Who wrote it, one full name per line (or separated by `;`), as
/// "Jonathan Gregory Pevsner"; each style abbreviates and orders them
/// itself.
@ -49,6 +66,13 @@ final class const Source({
_ => throw FormatException('A source with a malformed $key.'),
};
return Source(
// A kind this app doesn't know yet reads as a work: its parts still
// cite it.
kind:
SourceKind.values
.where((kind) => kind.name == json['kind'])
.firstOrNull ??
SourceKind.work,
authors: part('authors'),
year: part('year'),
title: part('title'),
@ -73,6 +97,7 @@ final class const Source({
/// This source without spaces around its parts, nor around each author.
Source trimmed() => Source(
kind: kind,
authors: authors
.split('\n')
.map((line) => line.trim())
@ -87,8 +112,10 @@ final class const Source({
raw: raw.trim(),
);
/// This source as JSON, each part left out when empty.
/// This source as JSON, each part left out when empty, and its kind
/// when it isn't a work.
Map<String, Object?> toJson() => {
if (kind != SourceKind.work) 'kind': kind.name,
if (authors.isNotEmpty) 'authors': authors,
if (year.isNotEmpty) 'year': year,
if (title.isNotEmpty) 'title': title,
@ -101,6 +128,7 @@ final class const Source({
@override
List<Object?> get props => [
kind,
authors,
year,
title,

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:math';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
@ -127,8 +128,9 @@ class _SourceListState() extends State<SourceList> {
widget.onChanged?.call(sources);
}
Future<void> _add() async {
if (await showSourceSheet(context) case final source? when mounted) {
Future<void> _add(SourceKind kind) async {
if (await showSourceSheet(context, kind: kind) case final source?
when mounted) {
_change([..._sources, source]);
}
}
@ -230,10 +232,38 @@ class _SourceListState() extends State<SourceList> {
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(top: 12),
child: FilledButton.tonalIcon(
// A book or an article, a website or a YouTube video, each
// asked for as it is cited.
child: MenuAnchor(
menuChildren: [
for (final (kind, icon, name) in [
(
SourceKind.work,
LucideIcons.bookOpen,
l10n.sourceKindWork,
),
(
SourceKind.website,
LucideIcons.globe,
l10n.sourceKindWebsite,
),
(
SourceKind.video,
LucideIcons.circlePlay,
l10n.sourceKindVideo,
),
])
MenuItemButton(
leadingIcon: Icon(icon),
onPressed: () => unawaited(_add(kind)),
child: Text(name),
),
],
builder: (context, menu, _) => FilledButton.tonalIcon(
icon: const Icon(LucideIcons.plus),
label: Text(l10n.sourceAdd),
onPressed: _add,
onPressed: () => menu.isOpen ? menu.close() : menu.open(),
),
),
),
),

View file

@ -1,27 +1,39 @@
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/video/youtube_videos.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:halkyon_learn_flutter/src/core/widget/field_label.dart';
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
import 'package:lucide_icons_flutter/lucide_icons.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>(
/// Asks for a source's parts: a new one, of [kind] to begin with, or
/// [source] changed. Its kind can be chosen, each asking for what cites it:
/// a book or an article its parts, a website the page's link, title and
/// the site's name, a YouTube video its link, title and channel. 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,
SourceKind kind = SourceKind.work,
}) => showAdaptiveSheet<Source>(
context,
swipeable: false,
builder: (context) => _SourceSheet(initial: source),
builder: (context) => _SourceSheet(initial: source, kind: kind),
);
class const _SourceSheet({required final Source? initial})
extends StatefulWidget {
class const _SourceSheet({
required final Source? initial,
required final SourceKind kind,
}) extends StatefulWidget {
@override
State<_SourceSheet> createState() => _SourceSheetState();
}
class _SourceSheetState() extends State<_SourceSheet> {
late var _kind = widget.initial?.kind ?? widget.kind;
late final _authors = _controller(widget.initial?.authors);
late final _year = _controller(widget.initial?.year);
late final _title = _controller(widget.initial?.title);
@ -50,16 +62,19 @@ class _SourceSheetState() extends State<_SourceSheet> {
super.dispose();
}
/// The source as the sheet holds it. The citation it was imported as goes
/// once there is something to cite instead.
/// The source as the sheet holds it, of the parts its kind asks for. The
/// citation it was imported as goes once there is something to cite
/// instead.
Source get _source {
final work = _kind == SourceKind.work;
final source = Source(
kind: _kind,
authors: _authors.text,
year: _year.text,
title: _title.text,
container: _container.text,
publisher: _publisher.text,
isbn: _isbn.text,
container: _kind == SourceKind.video ? '' : _container.text,
publisher: work ? _publisher.text : '',
isbn: work ? _isbn.text : '',
url: _url.text,
).trimmed();
final cited =
@ -70,6 +85,7 @@ class _SourceSheetState() extends State<_SourceSheet> {
return cited
? source
: Source(
kind: source.kind,
authors: source.authors,
year: source.year,
title: source.title,
@ -84,6 +100,15 @@ class _SourceSheetState() extends State<_SourceSheet> {
bool get _hasChanges =>
_source != (widget.initial?.trimmed() ?? const Source());
/// Whether the link a website or a video is cited by is missing.
bool get _linkMissing => _kind != SourceKind.work && _url.text.trim().isEmpty;
/// Whether a video's link is not one of a YouTube video.
bool get _notAVideo =>
_kind == SourceKind.video &&
!_linkMissing &&
parseYoutubeLink(_url.text.trim()) == null;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
@ -95,10 +120,21 @@ class _SourceSheetState() extends State<_SourceSheet> {
String label,
String hint, {
String? helper,
String? error,
TextInputType? keyboardType,
TextCapitalization capitalization = TextCapitalization.sentences,
bool multiline = false,
}) => Padding(
bool? missing,
}) {
final decoration = InputDecoration(
labelText: label,
hintText: hint,
helperText: helper,
helperMaxLines: 2,
errorText: error,
border: const OutlineInputBorder(),
);
return Padding(
padding: const EdgeInsets.only(top: 16),
child: TextField(
maxLength: TextLimits.reference,
@ -108,15 +144,29 @@ class _SourceSheetState() extends State<_SourceSheet> {
minLines: 1,
maxLines: multiline ? 4 : 1,
textCapitalization: capitalization,
decoration: InputDecoration(
labelText: label,
hintText: hint,
helperText: helper,
helperMaxLines: 2,
border: const OutlineInputBorder(),
),
decoration: missing == null
? decoration
: requiredDecoration(context, decoration, missing: missing),
),
);
}
Widget link(String label) => field(
_url,
label,
_kind == SourceKind.video ? 'https://youtu.be/…' : l10n.sourceUrlHint,
keyboardType: TextInputType.url,
capitalization: TextCapitalization.none,
missing: _linkMissing,
error: _notAVideo ? l10n.sourceVideoLinkInvalid : null,
);
Widget year() => field(
_year,
l10n.sourceYear,
l10n.sourceYearHint,
keyboardType: TextInputType.number,
capitalization: TextCapitalization.none,
);
return PopScope<Source>(
canPop: !_hasChanges,
onPopInvokedWithResult: (didPop, _) async {
@ -138,7 +188,8 @@ class _SourceSheetState() extends State<_SourceSheet> {
// A long form whose helper lines shouldn't make it jump.
heightFactor: 0.85,
footer: FilledButton(
onPressed: _source.isEmpty || !_hasChanges
onPressed:
_source.isEmpty || !_hasChanges || _linkMissing || _notAVideo
? null
: () => Navigator.of(context).pop(_source),
child: Text(adding ? l10n.sourceSheetAdd : l10n.commonSave),
@ -163,6 +214,44 @@ class _SourceSheetState() extends State<_SourceSheet> {
),
),
),
Padding(
padding: const EdgeInsets.only(top: 16),
child: Semantics(
label: l10n.sourceKind,
child: SegmentedButton<SourceKind>(
showSelectedIcon: false,
segments: [
for (final (kind, icon, name) in [
(
SourceKind.work,
LucideIcons.bookOpen,
l10n.sourceKindWork,
),
(
SourceKind.website,
LucideIcons.globe,
l10n.sourceKindWebsite,
),
(
SourceKind.video,
LucideIcons.circlePlay,
l10n.sourceKindVideo,
),
])
ButtonSegment(
value: kind,
icon: Icon(icon),
label: Text(name, textAlign: TextAlign.center),
),
],
selected: {_kind},
onSelectionChanged: (kinds) =>
setState(() => _kind = kinds.single),
),
),
),
...switch (_kind) {
SourceKind.work => [
field(
_authors,
l10n.sourceAuthors,
@ -170,13 +259,7 @@ class _SourceSheetState() extends State<_SourceSheet> {
capitalization: TextCapitalization.words,
multiline: true,
),
field(
_year,
l10n.sourceYear,
l10n.sourceYearHint,
keyboardType: TextInputType.number,
capitalization: TextCapitalization.none,
),
year(),
field(_title, l10n.sourceTitle, l10n.sourceTitleHint),
field(
_container,
@ -184,7 +267,11 @@ class _SourceSheetState() extends State<_SourceSheet> {
l10n.sourceContainerHint,
helper: l10n.sourceContainerHelper,
),
field(_publisher, l10n.sourcePublisher, l10n.sourcePublisherHint),
field(
_publisher,
l10n.sourcePublisher,
l10n.sourcePublisherHint,
),
field(
_isbn,
l10n.sourceIsbn,
@ -199,6 +286,36 @@ class _SourceSheetState() extends State<_SourceSheet> {
capitalization: TextCapitalization.none,
),
],
SourceKind.website => [
link(l10n.sourceUrl),
field(_title, l10n.sourceTitle, l10n.sourcePageTitleHint),
field(
_container,
l10n.sourceWebsiteName,
l10n.sourceWebsiteNameHint,
),
field(
_authors,
l10n.sourceAuthors,
l10n.sourceAuthorsHint,
capitalization: TextCapitalization.words,
multiline: true,
),
year(),
],
SourceKind.video => [
link(l10n.sourceVideoLink),
field(_title, l10n.sourceTitle, l10n.sourceVideoTitleHint),
field(
_authors,
l10n.sourceChannel,
l10n.sourceChannelHint,
capitalization: TextCapitalization.words,
),
year(),
],
},
],
),
),
);

View file

@ -1,8 +1,13 @@
import 'dart:async';
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/dependencies/dependencies_scope.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
import 'package:url_launcher/url_launcher.dart';
@ -13,9 +18,9 @@ CitationStyle citationStyleOf(BuildContext context) =>
);
/// [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.
/// with its ISBN and its link on lines of their own below, and a video's
/// Play. 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,
@ -77,11 +82,49 @@ class const SourceView({
],
),
),
if (source.kind == SourceKind.video)
if (parseYoutubeLink(url) case final video?)
Padding(
padding: const EdgeInsets.only(top: 4),
child: TextButton.icon(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
visualDensity: VisualDensity.compact,
),
icon: const Icon(LucideIcons.circlePlay, size: 18),
label: Text(l10n.sourcePlay),
onPressed: () => unawaited(playSourceVideo(context, video)),
),
),
],
);
}
}
/// Plays [video] in the app, in a dialog, where it can be; else opens it
/// outside, saying so when nothing could.
Future<void> playSourceVideo(BuildContext context, YoutubeVideo video) async {
final videos = DependenciesScope.of(context).videos;
if (videos.playInApp) {
await showDialog<void>(
context: context,
builder: (dialogContext) => Dialog(
clipBehavior: Clip.antiAlias,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 800),
child: AspectRatio(aspectRatio: 16 / 9, child: videos.player(video)),
),
),
);
return;
}
final messenger = ScaffoldMessenger.maybeOf(context);
final message = AppLocalizations.of(context).youtubeNotOpened;
if (!await videos.open(video)) {
messenger?.showSnackBar(SnackBar(content: Text(message)));
}
}
/// [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})

View file

@ -25,10 +25,11 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_conte
// brought it), and lets every part of it be left out (see Source.toJson);
// schema 1 reads as schema 2 without them. Schema 3 added where a copy's
// step came from (`published`, see published_ref.dart), left out for a step
// of its own.
// of its own. Schema 4 gave a source its kind (`kind`: `website` or
// `video`, left out for a book or an article).
/// The schema this app writes.
const stepSchemaVersion = 3;
const stepSchemaVersion = 4;
/// Encodes a step's content into a revision's payload.
ByteData encodeStepPayload(StepContent content) =>

View file

@ -18,10 +18,11 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_conten
// Source.toJson writes them), left out when empty. Schema 5 added where a
// copy's studyset came from (`published`, see published_ref.dart, with the
// published studyset's `location` on the copy's top studyset), left out for a
// studyset of its own.
// studyset of its own. Schema 6 gave a source of the bibliography its kind
// (`kind`: `website` or `video`, left out for a book or an article).
/// The schema this app writes.
const studysetSchemaVersion = 5;
const studysetSchemaVersion = 6;
/// Encodes a studyset's content into its payload.
ByteData encodeStudysetPayload(StudysetContent content) => encodePayload(

View file

@ -333,4 +333,54 @@ void main() {
expect(const Source(title: ' ').trimmed().isEmpty, isTrue);
});
});
group('kinds of source', () {
const video = Source(
kind: SourceKind.video,
authors: 'Tofugu',
year: '2020',
title: 'Learn hiragana',
url: 'https://youtu.be/dQw4w9WgXcQ',
);
test('a video is cited as one, a part of YouTube', () {
expect(
cite(video, CitationStyle.apa),
'Tofugu. (2020). Learn hiragana [Video]. *YouTube*.',
);
expect(
cite(video, CitationStyle.mla),
'Tofugu. “Learn hiragana.” *YouTube*, 2020.',
);
});
test('a website is cited as a page of it', () {
expect(
cite(
const Source(
kind: SourceKind.website,
title: 'Hiragana',
container: 'Wikipedia',
url: 'https://en.wikipedia.org/wiki/Hiragana',
),
CitationStyle.apa,
),
'Hiragana. (n.d.). *Wikipedia*.',
);
});
test('keeps its kind in JSON, a work leaving it out, and reads a kind '
"it doesn't know as a work", () {
expect(video.toJson()['kind'], 'video');
expect(Source.fromJson(video.toJson()), video);
expect(
const Source(title: 'A book').toJson().keys,
isNot(contains('kind')),
);
expect(
Source.fromJson({'kind': 'podcast', 'title': 'Episode 1'}).kind,
SourceKind.work,
);
});
});
}

View file

@ -658,6 +658,8 @@ void main() {
);
await tester.tap(find.text('Add a source'));
await tester.pumpAndSettle();
await tester.tap(find.text('Book or article'));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextField, 'Published in'),
'Nature',

View file

@ -67,17 +67,17 @@ void main() {
),
);
expect(studysetSchemaVersion, 5);
expect(studysetSchemaVersion, 6);
expect(json(payload)['schemaVersion'], studysetSchemaVersion);
});
test('without study options or a bibliography writes the fifth schema '
test('without study options or a bibliography writes the sixth schema '
'all the same', () {
final payload = encodeStudysetPayload(
StudysetContent(title: 'Japanese', description: RichContent.empty()),
);
expect(json(payload)['schemaVersion'], 5);
expect(json(payload)['schemaVersion'], 6);
expect(json(payload).keys, isNot(contains('study')));
expect(json(payload).keys, isNot(contains('bibliography')));
});

View file

@ -328,7 +328,7 @@ void main() {
expect(find.text('All topics'), findsOneWidget);
});
testWidgets("a long press on a topic opens its options, every "
testWidgets('a long press on a topic opens its options, every '
"studyset's", (tester) async {
stored('japanese', 'Japanese');
stored('kana', 'Kana', parent: 'japanese');
@ -1056,6 +1056,8 @@ void main() {
expect(find.text('No sources cited yet.'), findsOneWidget);
await tester.tap(find.text('Add a source'));
await tester.pumpAndSettle();
await tester.tap(find.text('Book or article'));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextField, 'Authors'),
'Tae Kim',
@ -1083,6 +1085,57 @@ void main() {
]);
});
testWidgets('a YouTube video is cited by its link, title and channel, '
'as one, and plays', (tester) async {
stored('japanese', 'Japanese');
await openFromLibrary(tester, 'Japanese');
await open(tester);
await tester.tap(find.text('Add a source'));
await tester.pumpAndSettle();
await tester.tap(find.text('YouTube video'));
await tester.pumpAndSettle();
expect(find.text('Required'), findsOneWidget);
expect(find.widgetWithText(TextField, 'Publisher'), findsNothing);
await tester.enterText(
find.widgetWithText(TextField, 'Link to the video'),
'https://example.org/kana',
);
await tester.pump();
expect(find.text('Not a link to a YouTube video'), findsOneWidget);
await tester.enterText(
find.widgetWithText(TextField, 'Link to the video'),
'https://youtu.be/dQw4w9WgXcQ',
);
await tester.enterText(
find.widgetWithText(TextField, 'Title').last,
'Learn hiragana',
);
await tester.enterText(
find.widgetWithText(TextField, 'Channel'),
'Tofugu',
);
await tester.enterText(find.widgetWithText(TextField, 'Year'), '2020');
await tester.tap(find.widgetWithText(FilledButton, 'Add'));
await tester.pumpAndSettle();
expect(
find.text('Tofugu. (2020). Learn hiragana [Video]. YouTube.'),
findsOneWidget,
);
expect(find.text('Play video'), findsOneWidget);
final (_, payload) = gateway.writes.last;
expect(decodeStudysetPayload(payload).bibliography, const [
Source(
kind: SourceKind.video,
authors: 'Tofugu',
year: '2020',
title: 'Learn hiragana',
url: 'https://youtu.be/dQw4w9WgXcQ',
),
]);
});
testWidgets('opens to be read, in the order of a reference list, and '
'changes behind its edit button', (tester) async {
stored(