RFDs/0012
Cross-platform Flutter frontend
Goal
Build Shōsai as one Dart and Flutter application for desktop, phone, and tablet, with the document engines, the store, and the reading model in Dart rather than behind a native bridge, and define what that application ships first: one reading experience across formats and devices (see The first release). Preserve a credible web path without claiming that native dependencies already run in a browser.
Proposal
Replace the unreleased Iced frontend with Flutter across Linux, macOS, Windows, Android, and iOS. The Iced application is historical reference, not a baseline the Flutter frontend must match: the first release is defined by the outcomes in The first release, and the Iced code is removed in its own change rather than kept until a parity gate.
The application is Dart only (decided 2026-10-21; see Decisions). The
Rust core (crates/shosai-core), the flutter_rust_bridge crate and its
generated Dart are replaced by Dart modules before the first-release reader
work builds on them: the EPUB engine is already Dart (shosai_epub, adopted
2026-09-28), PDF moves to a Dart-owned PDFium binding, storage to a Dart
SQLite binding, and library, bookmarks, reading state, annotations and search
are ported with their tests. Rust stays a behavioural reference until each
slice lands, then is deleted; nothing new is built in it. The replacement is
tracked as milestone M3.0 in the checklist.
| Platform | Frontend | Direction |
|---|---|---|
Linux, macOS, Windows |
Flutter desktop |
Production desktop frontend for the first release |
Android phones and tablets |
Flutter mobile |
Production target after physical-device validation |
iPhone and iPad |
Flutter mobile |
Production target after physical-device validation |
Web |
Flutter Web |
Feasibility target: ordinary Flutter web once the PDF and SQLite bindings run in a browser, or a service adapter |
The application has not been released. Internal Dart APIs and the development SQLite schema may change without compatibility wrappers or dual-read periods. Once a release or synchronization wire format exists, its declared compatibility contract applies normally.
The first release
The first release is a reader that gives the same reading experience regardless of medium: a person reads, marks up, searches, and follows references the same way in an EPUB or a PDF, on a desktop, a tablet, or a phone. The outcomes below are what the Flutter frontend must deliver first. Where the current implementation disagrees with them, the outcomes win. (Folded in from a former proposal numbered 7 in the Shōsai repository on 2026-10-20; the product is unreleased, so the proposal lives here rather than in a superseding RFD.)
Design mock and design system
An interactive mock accompanies this RFD on the project design canvas (Shōsai design canvas, "MVP" row): one working prototype laid out for desktop (1440 × 900), tablet (1180 × 820), and phone (390 × 844), plus every screen drawn in the chosen look. The prototype uses public-domain text from On the Origin of Species (EPUB), Shannon’s A Mathematical Theory of Communication (PDF), and 吾輩は猫である (EPUB, vertical text).
The mock is the reference for layout and interaction. How it looks and speaks
is the design system: tokens, components,
words and reading-surface rules, with a development viewer under
docs/design-system/. The application renders from assets/theme/tokens.json,
which carries the design system’s colors, radii, spacing and sizes under the
same names; its type styles, shadows and the Bookshelf composition move across
as the screens are rebuilt.
1. Every device
The page, gestures, and controls are the same everywhere; only their arrangement adapts to the window:
| Surface | Desktop | Tablet | Phone |
|---|---|---|---|
Places and open books |
Sidebar: Library, Notes, Bookmarks, Search and the Reading now list; a rail of icons and covers while reading |
The same sidebar and rail, narrower |
Bottom bar for the places; open books as cards on the library and in a switcher behind a count button |
Notes |
Panel docked beside the page |
Panel sliding over the page |
Bottom sheet |
Reference peek |
Card above the page |
Card above the page |
Bottom sheet |
Search |
Palette over the window |
Palette over the window |
Full screen |
Done when the same library, open books, notes, and search work on Linux, macOS, Windows, iPad, Android tablets, iPhone, and Android phones.
2. Any medium
EPUB and PDF share one reader. The format appears only as a small badge in the sidebar, library entries, and search results; it never changes which controls exist or where they are.
-
Location. Every position reads as a section label, a percentage, and a page number. EPUB page numbers depend on the current layout; PDF page numbers are fixed.
-
Progress. One progress bar and one location line in the same place.
-
Appearance. One theme set (paper, sepia, night) applies to both formats. A PDF in night theme is shown with inverted luminance; its layout never changes.
-
Size. One control sets text size for EPUB and zoom for PDF, labeled for what it does in the current book.
-
Pages. One setting chooses one or two pages for both formats, defaulting to two.
-
Selection, highlights and notes. One model, as defined by RFD 16.
-
The book keeps its voice. EPUB fonts and writing direction, including vertical Japanese text, are honored. PDF pages keep their appearance.
Done when every reader action in the mock behaves the same for an EPUB and a PDF.
3. Notes and annotations, findable anywhere
Highlights, page notes and book notes are stored and listed the same way, in one notes surface for this book or all books, and one search covers book text and notes across the library from every screen. RFD 16 owns the model and the "done when"; this RFD places the surfaces (outcome 1).
4. Open books
Every open book keeps its current position and appears in the sidebar’s Reading now list (the phone’s cards and switcher). Opening a book that is already open focuses it instead of opening it twice. Open books and their positions are restored when the application starts again. They are per device in the first release (see RFD 11).
Done when books open, switch, close, and come back after relaunch with their positions kept.
5. Jumpable references
Footnotes, cross-references, figure and table links, and contents entries are links in both formats: EPUB hyperlinks and PDF link annotations or internal destinations.
-
Activating a reference opens a peek with the target’s label and a short preview, without leaving the page.
-
Go to jumps to the target.
-
Every jump records where it left from. A Back control returns there. Jumps from search results and notes record a return point too, including jumps into another open book.
Footnotes whose text is already in the peek need no jump.
Done when EPUB links and PDF link annotations both peek, jump, and return.
Interaction rules
-
Format is metadata, not a mode. No control exists for one format only. Where formats need different mechanics, the control keeps its place and meaning.
-
Peek before you jump, and always a way back.
-
Everything you write is findable. Notes are first-class search results.
-
Layout adapts, behavior does not. Panels become sheets, the sidebar becomes a rail and then a bottom bar; outcomes stay the same.
Out of scope for the first release
-
Synchronization between devices (RFD 11). Notes stay on the device but use portable identities so synchronization can follow.
-
Additional formats, including the comic archives the Iced application read (RFD 10).
-
Writing highlights back into PDF files (RFD 16 interoperability phases).
-
A web build, which this RFD keeps as a feasibility target.
-
Collections or shelves beyond a single library list.
Decisions
Recorded from owner review on 2026-10-14 and 2026-10-20.
-
EPUB defaults to paginated reading. Continuous scrolling is an option, one scrolled chapter at a time, but is not the default.
-
Two-page spreads are the default for EPUB and PDF. Readers can switch between one and two pages. Spreads follow the book’s page progression, so a right-to-left book places its first page on the right. Where two pages would not be readable side by side, such as a phone in portrait, the reader shows one page.
-
CBZ is not part of the first release. Revisit once there is a concrete need; it returns as a format package in RFD 10.
-
Open books are per device. Carrying them across devices is a possible follow-up to RFD 11, decided by how much complexity it adds.
-
Phone PDFs show fit-width pages, not a reflowed text view. Pages are re-rendered at the current zoom and display density, so text never shows as an enlarged bitmap. A reflowed view may follow later.
-
EPUB writing direction is honored with no override. The direction comes from the book: the spine’s
page-progression-directionand the content’s CSSwriting-mode(including the-epub-writing-modealias). A book that declares neither reads horizontally, left to right. Readers cannot force a horizontal layout in the first release. -
The look is the Bookshelf (2026-10-20): a sidebar with the places and the open books that collapses to a rail while reading, a bottom bar on phones, a shelf of covers under a Continue reading hero, warm paper chrome, book and screen titles in the editorial serif. The design system records the tokens and components; the Iced tab strip is not carried forward.
-
The application is Dart only (2026-10-21). The Rust core and the Dart/Rust bridge are replaced by Dart before the reader work of M3.2 builds on them, in the order PDF engine, SQLite store and records, then deletion of the crates and the bridge toolchain (M3.0). The Dart document stack evaluation had found a Dart-owned PDFium binding and a Dart SQLite binding viable and left both unselected; this decision selects them. EPUB was already Dart-owned (2026-09-28). Old development databases and persisted anchors need no migration.
Rationale
Iced’s desktop implementation proves important product behavior, but Iced does not officially support Android or iOS. Owning mobile bootstrap, lifecycle, packaging, software keyboards, accessibility, and platform services would turn Shōsai into a mobile GUI framework maintainer.
Flutter contributes more than wrappers around native APIs. It provides one widget, navigation, input, accessibility, localization, state-restoration, and testing model across form factors; adaptive layouts; touch, mouse, keyboard, and stylus input; hot reload; golden and integration tests; and a mature plugin ecosystem. Those capabilities can be reused by every format reader and by sync, annotation, notification, and settings experiences.
Keeping Iced on desktop and Flutter on mobile would preserve working code in the short term but permanently duplicate reader interaction, accessibility, and UI testing. Before release, replacing that duplication is cheaper than maintaining two frontends indefinitely.
The same reasoning applies one layer down. With the EPUB engine in Dart, what the Rust core still owned after the Iced removal was one PDFium wrapper, the SQLite store and the library, bookmark, reading-state, annotation and search records: ordinary application code with no shaping or layout in it. Keeping it in Rust cost a second language and test suite, a generated bridge pinned to a Rust toolchain and a bridge-generator version, a native library to cross-compile and package for five targets, and a WASM project before any web build. Every first-release feature (notes, search, references) would have crossed that bridge. Replacing the core before M3.2 builds on it is cheaper than building the reader twice. The evaluation’s measured findings (a Dart PDFium binding rasterised at least as fast on the tested corpus; SQLite needs eleven tables and no full-text extension) support the move; its named risks (binding version pinning, native asset provisioning, one permission-bit defect) are gates in M3.0, not reasons to keep the bridge.
Architecture
One language, three layers: document engines, an application core, and the Flutter presentation. The layers are Dart packages and modules with explicit ownership, not processes or a bridge; the Elm-style controller boundary in the Flutter architecture contract is unchanged.
Document engines
Each format has one engine that owns parsing, admission and resource limits, canonical text, durable locations, and the geometry the reader paints or hit tests. An engine never produces geometry for pixels it did not lay out.
-
EPUB:
shosai_epub, the app-owned pure-Dart parser and normalizer (archive, OPF, spine, table of contents, bounded CSS subset, canonical text,(spine, scalar)locations, embedded-font and image admission), with Flutter laying out and painting chapters over it. Adopted 2026-09-28; the RFD 9 fidelity, resource-security and stable-location decisions apply to it. -
PDF: a Dart-owned PDFium binding (the evaluation tested
pdfrx, which bundles PDFium per platform). It renders pages at the requested zoom and display density into bounded owned bitmaps, extracts page text, character bounds, links, outlines and internal destinations, and reports encryption and permission state. Page rasters and page-space geometry remain one contract. The binding’s PDFium version and checksums are pinned.
Engines expose coarse asynchronous operations with cancellation and explicit resource release, run their heavy work off the UI isolate where the binding allows, and return owned values, never live handles into a widget.
Application core
A Dart package, independent of Flutter widgets, owns the records and the
policy around them: the library and import (referenced and private managed
copies, bounded archive preflight, cover extraction, deferred cleanup), stable
content identity, reader sessions and reading progress, open books, bookmarks,
annotations with their layered durable anchors and resolution states
(RFD 16),
notes, search over book text and notes, notification intents, and the future
synchronization records (RFD 11). It is the only
writer of the SQLite database,
through a Dart SQLite binding (sqlite3, or drift over it if typed queries
and migration tooling justify the code generation), with ordered migrations
and a single connection owner. Anchor normalization and quote recovery live
here, format-neutral, with the engines supplying canonical text and geometry.
The core’s API is the controller’s effect surface: typed commands, immutable result values, typed errors, bounded buffers, cancellation tokens. It does not expose Flutter types, database rows, viewport pixels as durable locations, or engine-internal handles.
Flutter frontend and platform adapters
Flutter owns application navigation, responsive phone/tablet/desktop layouts, reader gestures and shortcuts, overlays, accessibility semantics, localization, safe areas, orientation, lifecycle, memory-pressure handling, file and document providers, secure storage, deep links, notification delivery, signing, and packaging. Platform plugins are replaceable adapters, not domain dependencies.
Canonical paths, Android content URIs, iOS security-scoped URLs, and browser file handles are device-local locators, never portable book identities. Flutter supplies storage locations and file capabilities; the core never infers a desktop configuration directory.
|
Note
|
History. Until 2026-10-21 the core was Rust ( |
Web boundary
Flutter Web cannot load native PDFium, native SQLite, or the filesystem the way the native hosts do. A web target reuses the same Dart engines and core through either:
-
the bindings' browser builds (PDFium compiled to WebAssembly, SQLite over WebAssembly with IndexedDB or OPFS persistence) plus browser file capabilities; or
-
a remote service implementing the application boundary.
The native release is not blocked on web. No core contract may unnecessarily require a native path, thread, dynamic library, or SQLite connection when a portable value or injected capability is sufficient.
Notifications
The core may emit a typed notification intent containing an event kind, opaque record IDs, timing policy, and privacy-safe fallback text. Flutter adapters own permission prompts, local scheduling, Android channels, Apple/Google push registration, badges, actions, and deep-link routing. Reading reminders are the first candidate and require no server. Remote push is added only for a concrete server-originated event; push delivery is not part of the synchronization protocol and never becomes required for convergence.
Lock-screen content defaults to generic text. Titles, note contents, reading history, endpoint details, and credentials are excluded unless a user explicitly chooses a more descriptive privacy setting.
Feasibility and migration gates
The Flutter gate (M2, passed)
RFD 16 highlighting was the first demanding cross-format Flutter vertical slice, built after its renderer-neutral annotation model and store existed. The Iced Phase 0 spike had proved PDF and EPUB selection endpoints; reproducing the complete feature in Iced before migration would have created disposable UI work. The Flutter slice had to prove, and did (Phase 2):
-
packaged native calls and deterministic resource release;
-
SQLite create, reset/migrate, restart, and process-death recovery;
-
PDF and EPUB opening, bounded rendering, selection hit testing, highlight overlays, persistence, and relayout;
-
mouse, keyboard, and touch selection with one logical interaction contract;
-
responsive desktop, phone, and tablet layouts;
-
automated screen-reader semantics, focus order, text scaling, and non-color indication, plus selection discovery and action navigation with the named screen reader on the first physical mobile target;
-
cancellation, suspend/resume, rotation, memory pressure, and renderer recovery, with representative background/resume and process-death evidence on the first physical mobile target;
-
reproducible debug and release builds with measured bridge latency, rendering latency, drag latency, and resource-cycle behavior on the first desktop and physical mobile targets.
The M2 slice ran on desktop and a physical Android device. One mobile target may ship first, but evidence does not transfer between operating systems: independently validate the other OS before claiming or packaging it as supported. Complete per-release-target accessibility, physical failure, startup, installed-size, representative large-document, navigation/relayout, and native comparison gates remain mandatory for M5. Web feasibility separately proves the chosen browser or service boundary and does not delay native delivery.
The Dart core gate (M3.0)
The replacement of the Rust core is delivered in slices behind the retained reader and library UI, each stacked on the previous one, and the first-release reader work (M3.2) starts on the Dart core, not on the bridge. The slices must prove, with the M2 evidence as the bar:
-
the PDF engine opens the existing fixture corpus and representative real documents, renders at the active zoom and display density within the M0 raster budgets, extracts text and geometry that the RFD 16 PDF selection adapter can consume unchanged, reports links, outlines and destinations for outcome 5, and either enforces document permissions correctly or the product records that it does not enforce them;
-
the SQLite store creates, migrates, restarts, and recovers from process death; one connection owner; no second writer;
-
every record type (library, open books, reading state, bookmarks, annotations and anchors, notes) round-trips through the Dart core with the Rust test expectations ported, including anchor exact, recovered, ambiguous and orphaned states across relayout and changed bytes;
-
the reader and library interaction, lifecycle, restoration and accessibility coverage from M2 and M3.1 stays green through each slice;
-
the native bindings load from the packaged Linux, macOS and Android hosts with pinned, checksummed PDFium and SQLite, and the bundle sizes are recorded against the M0 budgets.
After the Rust crates, the bridge package and the bridge toolchain are gone, build the remaining first-release outcomes in vertical slices.
Migration phases and tracking
Migration is tracked as dependency-ordered milestones rather than as one frontend rewrite. Each milestone has an explicit exit gate in the implementation checklist. A phase is complete only when its exit gate is satisfied; checking individual tasks records progress but does not waive the gate.
| Phase | Outcome | Gate |
|---|---|---|
M0 — Boundary and slice |
Freeze core/frontend ownership, the bridge decision process, target matrix, budgets, and highlighting as the first vertical slice. |
The boundary can be implemented without creating two production feature paths. |
M1 — Rust application core (done, historical) |
Extract renderer-neutral sessions, layout, caches, orchestration, persistence, and coarse bridge contracts from the Iced application. |
Core behavior is independently tested and bridge-facing types contain no UI framework or durable viewport state. |
M2 — Highlighting feasibility (done) |
Build the smallest packaged Flutter application that opens EPUB and PDF and completes selection, persistence, relayout, and accessibility end to end. |
The measured slice passes on desktop and at least one physical mobile platform; otherwise reconsider the rendering boundary or the Flutter decision. |
M3.0 — Dart core |
Replace the Rust core and the bridge with the Dart PDF engine, the Dart SQLite store and ported records, then delete the crates and the bridge toolchain. |
The Dart core gate above passes; no Rust remains in the build, the repository’s checks, or the packaged hosts. |
M3 — Vertical product delivery |
Build the library shell and the PDF and EPUB reader behavior the first release defines, in reviewable vertical slices on the Dart core. |
Flutter delivers every first-release outcome with its "done when" criterion on desktop, tablet, and phone. |
M4 — Platform services |
Add only product-required secure storage, deep links, sharing, background work, local notifications, and eventually justified push notifications. |
Permission, lifecycle, privacy, duplicate-delivery, and stale-routing failures are tested independently of synchronization correctness. |
M5 — Packaging and release |
Package, sign, and smoke-test each Flutter host with its pinned PDFium and SQLite libraries, assets, manifests, and notices. |
Every claimed platform has an independently releasable artifact. |
M6 — Web feasibility |
Evaluate the engines' and store’s browser builds against a remote-service boundary using opening, reading, search, and annotations. |
Record a separate web go/no-go decision without blocking or weakening native delivery. |
M0 to M2 are complete and record the Rust-era boundary. M3.0 precedes M3.2: the reader by format is built on the Dart core, not on the bridge; M3.1 (the library slice) was delivered on the bridge and is re-pointed at the Dart core by M3.0. M4 is demand-driven and need not block M3. M5 follows the first-release outcomes on each platform. M6 is intentionally independent and may run in parallel once the Dart core is stable.
Alternatives
Using Iced everywhere maximizes Rust UI sharing but requires unsupported mobile platform ownership. Keeping Iced desktop plus Flutter mobile avoids an immediate desktop rewrite but creates two long-lived interaction and accessibility stacks. Separate SwiftUI and Android-native frontends maximize platform fidelity but add two UI languages and still need another desktop frontend. Reconsider separate native frontends only if the Flutter gate fails a required criterion.
Keeping the Rust core behind flutter_rust_bridge (the architecture from M0 to
M3.1) preserved tested code and a memory-safe parsing boundary, and it is what
M2 measured. It was rejected on 2026-10-21 because, once EPUB was Dart, the
remaining Rust was application code whose only Rust-specific value was the
PDFium wrapper, and that wrapper exists as a Dart binding; the bridge’s
toolchain, cross-compilation, packaging and web costs then bought nothing. A
hybrid that kept only PDF in Rust was not taken either: it keeps every cost of
the bridge for one module. Rust may return for a specific, measured need (a
format parser with no sound Dart binding) as a leaf native plugin, never as
the application core.
Dependencies and consequences
RFD 9's parser, resource-security, stable-location, and bounded-layout decisions remain valid and now bind the Dart EPUB engine; its Iced implementation is historical evidence rather than the presentation boundary. RFD 10 format capabilities, including the comic archives that leave the first release, are Dart engines behind one Flutter reader shell. RFD 11 supplies portable identity and sync semantics independently of notification delivery and extends the Dart store. RFD 13 packages Flutter hosts with their pinned native libraries (PDFium, SQLite) instead of a Rust library and bridge. RFD 16 supplied the first cross-format interaction and persistence slice; its annotation store and PDF adapter move to Dart with M3.0.
Implementation
The dependency-ordered spike, extraction, migration, web, and packaging work is tracked in RFD 12 implementation checklist.