Found on the Pixel 9a with v0.2.1:
- Do Not Disturb while focusing: switching it on opens Android's list
of apps with Do Not Disturb access; once the person allowed it there
and came back, the switch was still off, saying the app may not, and
had to be switched on again. The profile's sheet now looks again when
the app comes back, and the switch goes on by itself once allowed.
- The timer's notification, posted while Android was still asking for
leave to notify at the first start, showed nowhere until the timer
next changed. It is now posted again as soon as notifications are
allowed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
From the snap, nothing showed in the top bar: AppArmor refused the
item's bus name (dbus_bind org.freedesktop.StatusNotifierItem-<pid>-1),
which snapd's desktop plug doesn't grant, contrary to what the research
found. The item is now laid out as libayatana-appindicator lays itself
out, within what snapd v2.77's desktop-legacy plug allows: at
/org/ayatana/NotificationItem/halkyon_learn, the only path from which
the label's signal (XAyatanaNewLabel) may come, and registered with the
watcher by that path under the connection's own name, binding no name
at all. Checked against GNOME's watcher here: it registers as the
system's own indicators do.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Asked for on 2026-10-07: the rest day moves from below the week into the
week's header, between the day shown and Today and the arrows. From
medium windows on it stays a chip; on a phone, where the date, a chip
and the arrows don't fit in a row, it is a snowflake toggle with "Rest
day" as its tooltip.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Found on the laptop: the outlines at a sheet's left (a text field, the
round buttons) were shaved by half a pixel, and the scrollbar lay over
the fields at the right. The sheet's room at its sides was around its
scroll view, so the body's outlines lay on the scroll view's edge: a
scroll view cuts what it holds there, an outline's outer, antialiased
half pixel included, which shows wherever the edge falls between two
pixels, as with a dialog centred in a window of odd width; and its
scrollbar runs along that edge. The room now lies inside the scroll
view, and the title and the footer keep their own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Found on the laptop: the Pomodoro profile's last switches (Do Not
Disturb among them) lay below the visible part of the sheet, and
nothing said so: the body scrolled right up to the pinned buttons.
Every sheet (SheetScaffold) now has, as Material 3 has scrolling
dialogs, a line above the footer while some of the body lies below and
one under the title while some lies above, and a scrollbar beside the
body, always shown on the desktop in place of the one Material shows
only while scrolling.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Found testing on the laptop: deleting the chosen profile left the
choice pointing at it, and a timer running on it went on with the
deleted profile's lengths and name. Deleting it now chooses the first
profile left, and the timer goes on with that one, its phase keeping
its end.
The top bar's suggestion didn't show with the app started from
VSCodium's terminal, which sets XDG_CURRENT_DESKTOP to Unity for what
it starts. The app now asks the bus whether GNOME's Shell is there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Decided 2026-10-07: where GNOME's top bar shows no icons (the
AppIndicator extension missing or off), the timer shows in the app
alone, and no notification stands in for it. The Pomodoro card says
that the timer can count down in the top bar, with a button that asks
GNOME Shell to switch the extension on where it is installed or to
install it (GNOME's own dialog), or opens its page on
extensions.gnome.org where Shell can't be asked; until the person says
not to suggest it again. An app can't install or switch on a Shell
extension by itself. The alert at a phase's end stays.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Decided 2026-10-07: the codecs write the newest schema every time, as
before, rather than the oldest one that holds what is there. An older
app loses sight of a studyset or of the settings once a newer one has
written them, until it is updated too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
A review of the timer found:
- Stop in the notification with the app swiped away took the timer
away there, but the app showed it again at its next start. The
service now leaves a mark the app takes when it starts, and the timer
it kept is forgotten.
- Pause, Resume and Skip did nothing with the app gone: since Android
12 a service may not start an activity from a notification's button.
The notification is posted again with buttons that open the app.
- A timer kept while the app was closed was shown as it was before it
caught up, so its ended phase alerted a second time. It is now caught
up before it first shows.
- Dart's timers sleep with the device, so a phase that ended while the
phone or laptop slept went unnoticed until the app came back. While a
phase runs the timer looks every 15 seconds, and on Android the
alarm's receiver wakes it too.
- On Linux, changes that came at once overlapped on the bus (an
indicator registered twice, notifications left over, a ticking label
never stopped); they now go one at a time. The indicator registers
again when GNOME's watcher restarts, and with a watcher that only
turns up later.
- Changing the running profile could put the timer outside its cycle
("Focus 6 of 4", the Live Update's bar past its end); where it is
stays within the new cycle, and a phase waiting at its full length
takes the new length. A countdown past its end shows 00:00.
- Writes of the kept timer go one at a time, and a closed timer arms no
more timers.
- The alert no longer renames the timer's notification channel, and a
German hint said the alert came at the device's end.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
A review found three edges of the study options:
- Saving a studyset's study options without changing anything wrote
the defaults as its own, so it stopped following the person's new
cards a day and was written in the newer schema older apps skip. It
now keeps no options of its own.
- The library's badges read the person's new cards a day only when
something else changed; a change to the setting now refreshes them.
- A person's random order shuffled the whole round, undoing the
studyset's Reviews first or New first. The reviews and the new cards
are now each shuffled, the order between them kept.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The importer said what was wrong with a file in English sentences built
in the data layer, the last English left in a German app. It now names
the problem, the step it is in and what the problem names (a field, a
type, a number), and the studyset screen puts it into words: "Step 2:
unknown "type": quiz." or "Schritt 2: unbekannter „type“: quiz.". The
format's own field names stay as they are in the file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Home has a Pomodoro timer (decided 2026-10-07, after Goodtime): the
profile it runs, chosen there while no timer is set, the time left of
the phase, and Start, Pause or Resume, Skip and Stop. While studying, a
small countdown sits in the study screen's header, and a tap pauses it
or goes on. A phase that ends leads to the next, which starts by itself
where the profile says so and otherwise waits, paused at its full
length.
Profiles follow the person to every device, in their settings (schema
4, still written as schema 3 while the profiles are as they come, so
older apps go on reading the settings): Classic, 25 minutes of focus,
5 of break and 15 after four, and 52/17 without long breaks, built in;
both can be changed, more added and deleted, from Home's card or from
Settings. A profile can start breaks and focus by themselves, and keep
the device quiet while focusing. The running timer is the device's own,
kept in the person's folder: closed and opened again, the app catches
up with the phases that ran out meanwhile.
Android: the app's own foreground service (Kotlin, specialUse, as
Goodtime does) shows an ongoing notification counting the phase down,
with Pause or Resume, Skip and Stop; on Android 16 it is a Live Update,
its progress bar the profile's cycle, each phase a segment, focus in
cobalt and breaks in copper. An exact alarm (USE_EXACT_ALARM) alerts
with sound and vibration when the phase ends, also with the app
closed, and Do Not Disturb goes on while focusing once the person
allowed it, the filter before given back afterwards. No notification
plugin: flutter_local_notifications has Live Updates only in a
prerelease that needs compileSdk 37; androidx.core 1.18, which the
build resolved already, has every API needed.
Linux, over the session bus (package:dbus, already in the build): where
the desktop shows icons in its top bar (GNOME with the AppIndicator
extension, which Ubuntu has built in; KDE) the timer counts down there,
the buttons in the icon's menu, as a StatusNotifierItem written in Dart
on the names and paths the snap's desktop plug allows; elsewhere a
notification with the buttons says until when the phase runs. An alert
with sound ends each phase while the app runs, and GNOME's banners stay
off while focusing, through gsettings. xdg-desktop-portal's Background
status, the planned fallback, serves Flatpaks only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
A studyset's options in the library now offer, as on its own screen,
Edit, Move to…, Study options, Import from Anki, Archive and Delete.
Moving goes through the same sheet and says where the studyset went;
importing reads an Anki deck into it as Add material does.
Study options say how a studyset is studied, on every device: how many
new cards it starts a day (by default as the person's study settings
say), how many reviews it brings a day (no limit by default; cards in
their learning steps come anyway), and whether reviews or new cards
come first, or the new ones spread evenly among the reviews. They live
in the studyset's payload, schema 3; a studyset without them is still
written in schema 2, so that older apps, which skip a payload in a
newer schema whole, keep seeing every studyset nobody gave options.
Editing a studyset keeps its options, and Reset to defaults takes them
away.
The study queue takes each studyset's limits for its own cards and the
order of the studyset studied; a card's schedule now knows when it was
last reviewed, to count the reviews of the day. Sessions, the round's
end and the library's badges all count the same way, the badges now
with the person's own number of new cards a day too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Settings offers English (UK), English (US) and Deutsch after the
device's language, each named in its own language. app_en.arb, the
template, is now British English (colour, centre) and the fallback for
any language the app doesn't speak; app_en_US.arb overrides the five
messages American English spells differently, and app_en_GB.arb is
empty, there so that Material's texts and dates are British too. The
German translation says "du" and keeps one set of terms throughout
(Lernset, Karteikarte, Lernpfad; Anki's own Stapel and Unterstapel in
the Anki import). arb_files_test checks that German has every message
of the template and that every translation keeps its placeholders.
resolveAppLocale picks the first language the device prefers that the
app speaks, in its country's variant where there is one; other English
is British, and a country alone never picks a language (Spanish in the
United States isn't American English), which Flutter's own resolution
would do.
Both MaterialApps take material_ui's GlobalMaterialLocalizations
delegates: gen-l10n's list brings flutter_localizations', which serve
only package:flutter/material.dart, so material_ui's widgets found no
Material texts in German and threw.
The editor's own text blocks show our placeholders while empty (list
item, to-do, quote, heading, and the desktop's / hint): the editor's
own follow a locale of their own, process-wide, in few languages. The
last texts written into widgets (the person's data failing to open,
the cards failing to read, the About dialog) moved into the arb files.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Settings gets a card for how this device shows the app: its appearance
(as the device, light or dark) and its language (the device's, or one
the app speaks, each named in its own language), side by side from
medium windows on, as two outlined dropdowns. Both stay on the device
rather than following the person: they are kept in
<app support>/device_settings.json (written to a temporary file and
renamed into place), read before the app shows, so its first frame
already looks as chosen, and a file that can't be read falls back to
what the device does. DeviceSettingsBloc sits above SessionGate's keyed
subtree, since the settings outlive every person, and both the plain
app and the person's app take their theme mode and locale from it.
At the bottom of Settings, the release and its build, read once at
start together with the build the server is asked about.
Home has a Settings cog beside the streak, with the sync trouble dot as
on Profile. The library's create button reads "Create studyset", and
Delete is red in every menu that offers it: the studyset's card, screen
and rail, and the composer's draft.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The launcher showed Flutter's logo: the project template's icons were
never replaced. tool/app_icons.py now makes every icon from the
kingfisher (assets/icon/halkyon_icon.png), through flutter_launcher_icons
v0.14.4 configured in the app's pubspec: Android's adaptive icon (the
kingfisher inset 16 % on white, for each launcher to cut to its shape)
and the square one before Android 8; iOS's, without transparency;
macOS's, the rounded tile on Apple's grid (824 of 1024 pixels); and
Windows', the rounded tile, its .ico with every size from 16 to 256
pixels (the tool writes only 256). It keeps the iOS project file as it
was, which the tool would break (flutter_launcher_icons issue 634), and
writes the snap's icon as tool/snap_icon.py did, which it replaces.
The Linux runner gives its window the bundled icon too, for desktops
that don't take it from the desktop file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
fdroid finds a launcher icon only under res/<folder>/<name>.png, and
release APKs shorten the resources' paths (res/9w.png; fdroidserver
issue 885), so the app showed no icon in the F-Droid app.
tool/fdroid_repo.sh now gives it the snap's icon in the metadata
(metadata/app.halkyon.learn/en-US/icon.png), as the repository's own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
A fast-forward merge brings the commit a branch's push checked to main
unchanged, and CI ran every check a second time before deploying it. The
check job now marks every commit that passed in the runner's home
(checked/, kept 90 days), and a commit marked already skips straight to
its deploy; the sandbox check still runs every time. Only pushes run CI,
so only those who may push can leave a mark.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
It asked Forgejo for the token's user, which needs the scope read:user;
the token the script asks for only has write:repository, so the check
would have refused a token that works. It now reads the repository
itself, which that scope covers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
tool/release.sh no longer takes a version: it works it out from the
commits since the last release, following semantic versioning (feat
raises the minor number, fix or perf the patch number, a breaking change
the major number from 1.0 on and the minor one before; other types
release nothing), or takes major, minor or patch as chosen. It shows the
version and its commits and asks first, checks that CI is green on the
newest commit with code, writes the version and a raised build number
into the pubspec in a signed release commit with a signed tag, and
pushes only once the APK and the snap passed their checks. The release
notes list the features and fixes. A run that stopped after its release
commit and tag carries on with them.
Channels for now: the release on code.halkyon.app (APK, snap, checksums)
and the F-Droid repository; the Snap Store upload is off until
snap_store_channel names a channel.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The repository is Halkyon's, for all of its apps, so its address is the
brand's: https://halkyon.app/fdroid/repo. learn.halkyon.app stays Halkyon
Learn's own address, for its website later. Moved before anyone but the
maintainer had added it; the address is permanent once people have.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Releases also publish the APK in the project's own F-Droid repository,
https://learn.halkyon.app/fdroid/repo. tool/fdroid_repo.sh adds the APK,
signs the indexes with the repository's own key and uploads them to
ramiel as the user fdroid, whose key can only write into the folder
Caddy serves (rrsync), the APKs first and the indexes last.
tool/release.sh checks that key and its password before building and
publishes after the Forgejo release.
- tool/release/fdroid/ holds the repository's settings (Halkyon, the
snap's icon) and the app's listing (the snap's text), with the
anti-features TetheredNet (learn.halkyon.eu only, for now) and
NonFreeNet (YouTube, the server's SSPL) declared with their reasons,
in F-Droid's own definitions and with its icons (fdroiddata, AGPL v3).
- The index key's certificate is pinned: a new key would make the F-Droid
app refuse the repository.
- fdroidserver v2.4.5, since Ubuntu's v2.2.1 knows neither TetheredNet
nor reasons.
README and AGENTS.md describe it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The editor scrolls a new selection into view by keeping a box around it,
220 px by default, inside its viewport. A card's field is shorter than
that (180 px in the PDF reader, 200 in the composer), so once its content
could scroll, as with a screenshot taller than the field, the box stuck
out at both ends: the editor scrolled up and down for ever, drew no
frame again, and took ever more memory. RichContentEditor now keeps the
box at most half its own height (autoScrollEdgeOffset).
Found clicking a screenshot in a card in the PDF reader, on Linux.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The README opens with what the app aims at: creating, sharing and
collaborating on study material, FSRS for retention, study material
organised with its external references, offline-first, and a transparent
stack anyone can host.
"Setting up" lists what developers need: Flutter as CI pins it, with
Flutter's own guide per platform; the Serverpod CLI; the Linux packages,
with what each plugin needs them for; Developer Mode on Windows, and why;
no Docker for development; Python for the scripts in tool/.
"Releases" says where releases are published, how to check a download
(the signed checksums, the APK certificate's fingerprint), how versions
are numbered, and how a maintainer makes one with tool/release.sh.
AGENTS.md: Windows needs Developer Mode for Flutter's plugin links too,
not only for pdfrx's build.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
snap/snapcraft.yaml builds the Linux app as a strictly confined Snap on
core24 with the GNOME extension (GTK, WebKitGTK, GStreamer and libsecret
come from its runtime; files and the browser go through the desktop's
portals), connecting to learn.halkyon.eu, the project's instance. It
builds what is committed, with Flutter pinned as in CI.
- The session: libsecret's keyring over D-Bus needs the
password-manager-service plug, which the Store never connects by
itself, so unless it is connected the app keeps the session in an
encrypted file whose key comes from the Secret portal
(snap/local/secret-backend).
- network-manager-observe lets Serverpod hear that the network went and
end the sync's connection at once; until the Store grants it, it too
is connected by hand.
- The listing points to the project, not the instance: the website
learn.halkyon.app, contact and issues support@halkyon.app.
- The icon is the app's with rounded corners (tool/snap_icon.py), for
the Store and the dock alike.
- The app's version is 0.1.0, build 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
tool/release.sh builds a release on the maintainer's laptop: one APK for
every phone, from a checkout of the signed tag and signed with the app's
own key, and the snap, both connecting to learn.halkyon.eu, the project's
instance; their checksums signed with GPG; the release on code.halkyon.app
through Forgejo's API, and the snap in the Store's edge channel.
Before building it checks that main is clean and pushed, that the tag is
signed by a key deploys accept, the version and a raised build number,
the pinned Flutter, and that the keystore holds the key whose certificate
tool/release/android-certificate.sha256 names. After building it checks
the APK's signer, package, version, that it isn't debuggable and that it
names the server, and the snap's version. The key's password and the
Forgejo token are asked for at each run and never stored.
Release builds take the app's key from the environment and otherwise fall
back to the debug key, so `flutter run --release` works anywhere. The
native libraries go into the APK compressed: an APK for three
architectures was 110 MB with them stored as they are, 48 MB now. The
list of dependencies Gradle adds for Google Play is left out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Inserting a sound, a YouTube video or a study button through the / menu
in a card's field failed (found with the Snap build). Two causes:
A card field closes its editor when the focus leaves it, except to a
dialog. The / menu takes the focus too, so the editor was gone while
its menu was open, and the block chosen went to an editor that no
longer existed. The field now stays open while the editor's own popup
holds the focus (the editor package's keepEditorFocusNotifier, which
its editors heed for themselves, read through
RichContentEditor.popupHasFocus).
The editor package keeps the menu, and the layer closing it on a tap,
above everything until the chosen item's handler returns, so a block
asking in a dialog (formula, video, study button) had the menu over
that dialog. Our items now close the menu first; the handler is given
the menu for that.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Published apps can't be made to update, so the app now notices when it
no longer matches the server: the server names the oldest app build it
works with (instance.info's minimumAppBuild), asked before the first
sync of each launch, and the sync engine's refusal of differing synced
tables counts the same. Either way the app says "update the app; if it
is up to date, the server needs an update" in a banner on Home and in
the new Sync card in Settings, and what the person does meanwhile stays
on the device.
The Sync card shows where sync stands since the app started (syncing,
everything synced, sync failing, with the last completion and failure)
and offers Sync now. While the last sync failed, a dot sits on the
Profile tab and the Settings cog. One sync that goes through clears it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
A Storage Box answers only inside Hetzner's network unless External
Reachability is on, for the box and for the sub-account. FTP and SFTP on
port 22 can't be turned off, so the sub-account turns off Samba and
WebDAV only. A host key of another type is checked against Hetzner's
list too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
learn.halkyon.eu dumps its database every hour (people's files are in it
too) and restic stores the dump encrypted in a Hetzner Storage Box,
keeping 24 hourly, 7 daily, 4 weekly and 3 monthly snapshots. The box's
own snapshots, removable only in Hetzner's Console, protect the backups
from a taken-over server. A systemd timer runs it as deploy; once a day
it also prunes and checks the repository. Failures go to the journal.
Hourly, because a restore costs people who started after the backup
what their device held: the app no longer finds its session and starts
anew. deploy/backups.md describes the setup, the restore and what a
restore loses; both were rehearsed on the local Compose stack.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The option's text sits in an IgnorePointer, so the read-only editor
drawing it doesn't take taps for selecting text. Tapping the text made
tap() warn that its centre doesn't hit it; the InkWell around it took
the tap anyway. Tap the option's InkWell instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The endpoint's comments now name PDFs and the 20 MB rule; the generator
copies them into the client, which the commit before left behind.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
PDFs live in a studyset's new Files tab: uploaded from Add material
(a file the studyset holds already asks first), each row with its
pages, cards, notes and size, how far it is read and its reading
deadline; renamed, given a deadline (a page or a chapter by a day),
marked as read, started afresh, deleted with a moment to undo.
The reader (/pdfs/:id) opens where reading left off: on wide windows
a rail of tools (search, fit, zoom, fullscreen), a page counter with
the chapter, and a sidebar; on phones a top bar, a page scrubber and
a sheet. The sidebar holds the PDF's flashcards, by page or chapter,
made from selected text or written there, each tied to its page and
tagged with its chapter; and the page's note, an ordinary note tied
to the page. Screenshots of a region become images that know their
page. Cards and screenshots lead back to their page; reading on past
a chapter with cards suggests studying it. Home shows the deadlines
running and to come, and a timeline of them.
Server: pdf_source rows (content in the payload, last writer wins)
and an append-only pdf_reading log, the furthest page derived from
it so devices never conflict. Files up to 50 MB for PDFs (20 MB for
others; a larger file that isn't a PDF is deleted once stored). A
deleted PDF takes its file's asset row along unless another PDF has
the same file. Card payload 4 and note payload 2 keep the page.
The reader draws with pdfrx (PDFium) behind PdfEngine in
Dependencies; widget tests use a fake, and
integration_test/pdf_engine_test.dart checks the real engine on a
device or the desktop.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Blocks beyond the editor's own, each built once for editor and view:
- callout: eight kinds in theme colours, kind, title and "folded at
first" from its header; written in place, left with Enter on its
empty last line; never inside another
- spoiler: written in plain sight, hidden behind Reveal read-only
- formula on its own line, written on its own screen with a preview
and snippet buttons, and formulas within a line (Σ in the format
bar); set by flutter_math_fork behind MathView, LaTeX's numbered
environments read as their aligned forms
- image and sound picked from the device, kept as synced files
- YouTube video from its link, with its start and a caption: a card
until played, nothing fetched before; played in place where a web
view exists (youtube_player_iframe, privacy-enhanced), else opened
in the browser, behind YoutubeVideos in Dependencies
- study button: a studyset, a tag or all cards, with or without its
topics, back first; its StudyFocus rides on the study route and
narrows that session only
- tables, already in the editor, now insertable everywhere
An Insert button opens on the format bar, so blocks can be inserted on
phones too; the desktop's / menu lists the same blocks, translated.
Card faces now dock the format bar while written, so cards get blocks
and formatting on phones as well. The blocks are listed in
EditorBlocks: the core's, plus the features' through
EditorBlocksScope. Blocks of a type the app doesn't know say they need
a newer version and stay as they are, so formatVersion stays 1.
Their types and keys are in content_blocks.dart, which RichContent
reads: a block's text counts as text, and a video or study button
isn't blank. The path import now makes formulas real ones.
integration_test/youtube_player_test.dart plays a real video through
the app's player on a device, which no widget test can: YouTube
refuses embeds that don't say where they are embedded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
An Anki import switches to Flashcards when the cards come into this
studyset (flattened or tagged by subdeck), not when subdecks become
sub-studysets; an imported path switches to Paths. Creating flashcards,
a note or a path switches to its tab as well, seen on coming back.
On opening, the studyset shows the first tab with content of its own:
unfinished paths, then flashcards, then notes; Flashcards when it holds
nothing. Its topics' content doesn't count, since the tabs list only the
studyset's own. A tab tapped or swiped to by the person, or material
added, stays.
Add material is built both in the app bar and in the bottom bar, so a
ContentTabCubit carries the requests to the tab controller, and
importFromAnki now returns the mode it imported with.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
- A Paths tab before Flashcards and Notes: each path with its title and
secondary title, dimmed once completed; Edit, Reset progress (with an
undo) and Delete (with an undo) behind its options. The studyset opens
on its paths while one is unfinished, on its flashcards otherwise.
- "Create a path" in Add material asks for the titles in a sheet and
opens the new path to write; "Import a path (JSON)" reads a file in
the import format, says what came in and how many blocks were left out
(images and animations: the app never fetches from the web).
- The player: one step at a time at reading width, a bar of the steps
above, Back and "Check answers" / "Continue" / "Finish" below. It
opens at the first unfinished step; an exercise passes only once
answered right, as often as it takes; after a check, only the
learner's own picks are marked, with the author's feedback; sources
behind a chip; a finish screen leading to the next path. Words and
items are dragged into place, or tapped into place for phones.
- Writing: the path's screen (titles, description, tags, steps added by
kind, reordered, deleted with an undo) and the step's (title, text
with the format bar, its kind's exercise, feedback for right and
wrong in the whole window, sources). The step saves on what it
started from, as notes do, read-only while devices disagree until a
version is kept.
- The library and the topics count paths; steps done count on the
dashboard, for the streak and the activity.
- The rich-text screen for a studyset's description moves to core, for
the steps' feedback too, and gains the format bar.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Server: five synced tables, only additions (migration
20261002211346701-learning-paths):
- `learning_path`: its studyset, a fractional position, and its own
content in the payload {title, secondaryTitle, description}, the last
writer winning, as a studyset's;
- `learning_path_step`: its path, a position, and its kind ('info',
'singleChoice', 'multipleChoice', 'dragAndDrop', 'fillInTheBlank'),
fixed once created;
- `learning_path_step_revision`: a step's content as append-only
revisions, as a card's: {title, body, exercise, rightFeedback,
wrongFeedback, sources};
- `learning_path_answer`: an append-only log (right, wrong, continued,
reset), as reviews are, so answers from two devices never conflict;
- `learning_path_tag`: one row per assignment, as `card_tag`.
App, `feature/learning_path`: the five exercises and their JSON, grading
that marks only the learner's own wrong picks, progress worked out from
the log (a step is done once answered right since its last fresh start),
`LearningPathRepository` (steps saved on what they started from, merged
per field) and `PathProgressRepository` (answers, resets with Undo, the
times steps were done). Paths share the person's tags; deleting a path
or a step can be undone for a moment, and a studyset's deletion takes
its paths. Tests: models, codecs, repositories, tags, the device
migration guard, and sync tests against the server (four).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The sync engine (serverpod_offline_sync v0.0.8) keeps rebuilding the
projections of every space on every session of a server whose first
reconciliation changed its registry of synced tables
(serverpod_offline_sync#150, open). `serverpod start` migrates in the
running server, so after such a migration the development server is
slow until restarted; deploys reconcile in their maintenance run and
aren't affected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
`flutter pub get --enforce-lockfile` installs what pubspec.lock says or
fails with pub's own message, so CI never resolves something else on
its own.
The second try works around https://github.com/dart-lang/pub/issues/4912
(Dart v3.13): when the runner's cached copy of a git dependency's
repository lacks the locked commit, pub resolves that package without
its dependencies. That failed the CI run of 079179d with Flutter's
"package_graph.json: dependencies for `universal_platform` missing".
With --enforce-lockfile the first run fails at pub instead, having
fetched the commit, and the second goes through; checked locally on a
copy of the pub cache with the editor fork's commit removed. pub's fix
(ebba0dc) is in no Dart release yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
- A Notes tab beside Flashcards lists a studyset's notes in order: the
title ("Untitled note" without one) and when each was last edited
("Edited 5 minutes ago", by the calendar from yesterday on, the date
after a week); Edit and Delete behind its options. Deleting asks,
then offers Undo for a moment (`NoteDeletions`). With none, the tab
says so.
- "Create a note" in Add material opens a new note over the tabs; so
does a note of the list. The note: a title, its tags once it exists,
and its text at reading width; the app bar says how saving stands.
- `NoteEditorBloc` saves after 5 seconds without typing, when the app
goes to the background, and when the note is closed (the bloc saves
as it closes, so leaving never waits, and the write outlives the
screen). Each save builds on the revisions the editor started from;
another device's change takes over a note with nothing unsaved, and
merges with one that has. A note changed differently on two devices
is read-only until the person keeps a version of each field in
conflict. A new note is created by its first save, never when left
blank; a note deleted elsewhere says it no longer exists.
- The editor gains a format bar, docked below it while it has the
focus (`RichContentEditor(formatBar: true)`): alignment, bold,
italic, underline, superscript and subscript. It takes no focus, so
the selection stays. Superscript and subscript are inline marks of
their own, drawn by the font's raised and lowered glyphs in editors
and read-only text alike.
- The library's and the topics' counts include notes (a studyset with
what is inside it, as for cards).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Server: three synced tables, only additions (migration
20261002191122122-notes): `note` (its studyset, a fractional position),
`note_revision` (append-only, as a card's: parents, author, report,
the payload {schemaVersion, title, body}) and `note_tag` (one row per
assignment, never updated, as `card_tag`). Each indexes `spaceId`, and
everything goes with its space, studyset, note or tag. A migration of
synced tables locks app builds from before it out of syncing (ADR 0002,
rule 3): cheap now, before the first release.
App, `feature/note`:
- `NoteContent` is a title and rich text, merged apart (ADR 0003): a
title changed on one device and the body on another both survive, a
body changed on two is a conflict.
- `NoteRepository` creates a note at the end of its studyset, saves an
edit on the revisions it started from (`saveEdit`, as the card
composer now does), says when a note was last edited (its newest
head's UUID v7), counts notes per studyset, and deletes a note's
revisions blanked first (ADR 0002, rule 2).
- Notes share the person's tags: `setTagsOfNote`, and folding tags of
one name or undoing a tag's deletion covers notes too.
- The delayed deletion with its Undo moves to `core/async`
(`DelayedDeletions`): `StudysetDeletions` builds on it and now takes
a studyset's notes too; `NoteDeletions` deletes a single note.
Tests: content, merge, payload, repository, deletions, tags, the device
migration guard, and sync tests against the server (four).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
`mergeRevisionsOf` merges the revisions of any `MergeableContent` field
by field against their newest common ancestor, as the card merge did;
cards keep `mergeRevisions`, now a wrapper naming their fields.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The composer saved a stored card with the card's current heads as its
parents, so whatever had arrived meanwhile was replaced, not merged:
- another device's change to the side the person left alone was
overwritten silently;
- an edit made while saving, saved before the card was read back,
stood beside the first save: a conflict with itself;
- a new card left again before it was read back wasn't written at
all, while it showed as saved.
A draft now keeps the revisions its content came from (`base`): taken
with the stored content while the person hasn't changed it, and its own
revision after each save. A save replaces exactly those
(`CardRepository.saveEdit`), so what another device wrote meanwhile
stays a head of its own and merges per side (ADR 0003): a side only it
changed comes through, a side both changed is a conflict to settle. A
changed draft takes new conflicts only once it is saved.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The flow ran on a Pixel 9a and its emulator (2026-10-02); what it found:
- The composer stayed on the card just written after "New flashcard", and
on the original after "Duplicate": leaving a card changed the state
before the new one was in it, so the screen took the new card for gone.
It now waits for the card it asked for.
- Bottom sheets reached under the status bar with the keyboard open
(useSafeArea).
- The field being written stayed behind the keyboard: the editor scrolls
only within itself, so the card face now keeps it in view when it opens
and when the keyboard rises, as a text field does.
- A tap on read-only rich text put a caret in it and opened the keyboard on
phones, and in the composer never opened the field. RichContentView is
display only now: no selection, taps go to what surrounds it, a sound's
button still plays (decided 2026-10-02).
- The card types broke their names on a phone; they are choice chips that
wrap (decided 2026-10-02).
- The week overview marked days before the person's first activity as
missed; they show nothing now. An import starts the activity too, without
counting as studying (decided 2026-10-02).
- A composer preview cut short gave no sign of it; it fades out over its
last 32 dp, only when cut short (decided 2026-10-02).
- "Isn't on this device yet" showed for any image or sound that failed;
a file that is here but can't be decoded now says so, and offers no retry.
- The signed-in account card was narrower than the anonymous one.
- The composer's title said "Create flashcards" also when opened on a
stored card; it says "Edit flashcards in …" then (decided 2026-10-02).
- The Anki fixtures carry a valid PNG and a silent MP3, so they also serve
to try the import by hand (decided 2026-10-02); regenerated with Anki
26.9.3.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The editor's fork moves to keyboard_height_plugin v0.3.0 (83bdb2a3): v0.1.5
compiled against API 31, while the AndroidX libraries of Flutter v3.47 need
34, so Gradle's AAR metadata check failed. Release builds get the internet
permission, which Flutter's template grants to debug and profile builds
only. Gradle's own build folder in android/ (its problems report) is
ignored. AGENTS.md: the server's test/integration/test_tools/ is generated
too, so test files are formatted one by one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
An anonymous person may keep 100 MB of images and sounds, an identified one
500 MB, and one person more by a file_allowance row the operator adds; all
of them together 15 GB, since every file is in the database and in each dump
the server keeps (decided 2026-10-02). Each comes from the environment
(FILE_LIMIT_*_MB), with these defaults, documented in deploy/.env.example.
The server checks when an upload starts, counting unfinished uploads so they
can't fill the disk unnoticed, and again when it finishes, counting what is
kept, so uploads started side by side can't overshoot; a refusal is a
FileLimitExceededException, and an upload refused as it finishes is deleted.
deletePerson deletes the allowance with the person. Migration: the new
table file_allowance, only added.
The app keeps a refused file waiting on the device and stops that round of
uploads; the Anki import warns when a deck's files may not fit in the room
left (asset.usage()).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>