Fig. 06 · Personal project

SpeakUp

An offline-first reading companion for Android that keeps every book's identity, position and notes safe across devices.

Project
SpeakUp
Role
Designer and developer
Period
07/2026 - present
Context
Personal project
Stack
Flutter · Dart · Riverpod · Drift · Supabase · Google Drive API · foliate-js
Outcomes
  1. Offline-first reader for EPUB, PDF and TXT with a local database as the source of truth
  2. Updated editions detected as versions, with the reading position carried across
  3. Cross-device sync through a transactional outbox with explicit merge rules

Problem

Readers keep books as loose files - downloads, EPUBs, PDFs, papers - spread across devices. Renaming a file, re-importing it or getting an updated edition loses the place, the highlights and the history. SpeakUp is a reading companion for Android that treats every file as a book with a permanent identity, and quietly turns reading into vocabulary and retention without becoming a language-learning app.

Constraints

  • Reading never needs the network. Sync, backup and discovery may; opening and reading a book may not.
  • No silent data loss. Nothing is deleted or overwritten automatically; destructive actions are confirmable and recoverable.
  • Books belong to the reader. Files live on the device and in the reader’s own Google Drive, never on a server SpeakUp owns.
  • Positions must survive change. Page numbers move between renders and editions, so they cannot be the position.

Architecture

The figure follows a book from import, through reading on the device, to sync and backup.

  1. 01

    Reading never needs the network

    The reader engines open EPUB, PDF and TXT straight from the local library. Book content runs in a sandboxed WebView under a strict content policy, and the app chrome stays native Flutter.

  2. 02

    A file becomes a book, not a filename

    On import the intelligence engine fingerprints the file and reads its metadata to give it a permanent identity, then decides whether it is new, a duplicate or an updated edition. It never deletes on its own - uncertain cases ask the reader.

  3. 03

    The reading position survives new editions

    An updated edition of a book in progress becomes a new version of the same book. Position transfers through a recovery chain - EPUB CFI, paragraph anchor, sentence fingerprint, chapter, percentage - and only then asks the reader.

  4. 04

    Every change is queued in the same transaction

    Each local write commits together with an outbox row, so no update is lost. The outbox drains to Supabase when online; reading progress merges furthest-position-wins and everything else last-write-wins on server timestamps.

  5. 05

    Book files stay in the reader's own Drive

    Files back up to the reader's Google Drive with the narrowest scope, in folders keyed by book id rather than title, so a new device restores metadata from Supabase and downloads files on demand.

  6. 06

    Free books, and words worth keeping

    A catalog of public-domain books imports through the same pipeline as the reader's own files. Inside the reader, a tapped word opens a dictionary and can be saved with its sentence, a CEFR level and a spaced-repetition review.

Fig. 06 - SpeakUpScale model
READER'S DEVICEReaderFlutterappReaderenginesIntelligenceengineLocallibrary(Drift)SyncoutboxSupabase(Postgres+ RLS)GoogleDrivePublicbookcatalogs
Fig. 06 · Plan - components, connections and data flows
Read the diagram as text

Components

Reader
Someone reading novels, EPUBs, PDFs and documents on an Android phone or tablet.
Flutter app
Library, reader, vocabulary and stats screens built only from the design system, talking to engines through Riverpod providers.
Reader engines
EPUB in a sandboxed WebView running foliate-js with CFI positions, PDF on PDFium, and native TXT - one dispatcher, one progress model.
Intelligence engine
Plain Dart services for identity, metadata, duplicates, versions, reading recovery, storage and sync behind domain interfaces.
Local library (Drift)
SQLite through Drift with FTS5 search - the source of truth for books, progress, bookmarks, highlights and vocabulary.
Sync outbox
Pending operations written in the same transaction as each local change, drained in the background with retry and backoff.
Supabase (Postgres + RLS) (external, not owned)
Sync replica of metadata, progress, bookmarks, highlights and vocabulary; row-level security on every table and server-assigned timestamps.
Google Drive (external, not owned)
The reader's own Drive, used with the drive.file scope only; book files are backed up in folders keyed by a stable book id.
Public book catalogs (external, not owned)
Gutendex and Open Library, used to discover and import free public-domain books; lending-restricted scans are rejected.

Boundaries

  • Reader's device: Everything needed to read runs on the device; the network is used only for sync, backup and discovery. Contains Flutter app, Reader engines, Intelligence engine, Local library (Drift), Sync outbox.

Flows

  • Reading needs no network: Reader → Flutter app → Reader engines.
  • A file becomes a book: Flutter app → Intelligence engine → Local library (Drift).
  • A change reaches other devices: Flutter app → Local library (Drift) → Sync outbox → Supabase (Postgres + RLS).
  • The file is backed up: Intelligence engine → Google Drive.
  • A free book joins the library: Public book catalogs → Intelligence engine → Local library (Drift).

The code is layered so that domain/ holds pure entities and interfaces, engine/ holds the intelligence services, data/ holds Drift, Supabase and Drive clients, and features/ build screens only from the design system and reach data only through Riverpod providers. Every significant choice is recorded as an architecture decision record - sixteen so far.

Key decisions & trade-offs

The local database is the source of truth

Drift on SQLite (with FTS5 search) owns books, progress, bookmarks, highlights and vocabulary; Supabase is only a sync replica. Making the server the source of truth would have simplified multi-device consistency, but it would have made reading depend on connectivity - the one thing the product cannot allow.

A custom outbox instead of a sync framework

Every local mutation commits in the same transaction as a pending-operation row, and a background service drains it to Supabase with retry and backoff. Reading progress merges furthest-position-wins and asks on a large regression; bookmarks and highlights merge additively with tombstones; everything else is last-write-wins on server timestamps, never device clocks. An off-the-shelf sync layer would have imposed one conflict rule for every kind of data.

A hybrid reader rather than a pure-Flutter one

Flutter owns all the chrome, while a single sandboxed WebView runs foliate-js for EPUB layout, pagination and CFI positions over a small versioned message protocol; PDF uses PDFium. Pixel-quality EPUB layout with CFI is solved in foliate-js and not in pure Flutter, so the trade was a WebView boundary and a strict content policy in exchange for a reliable position model.

Identity before files, and ask instead of guess

Each book gets a stable id from its metadata and a SHA-256 fingerprint, independent of filename and location. Duplicate and edition checks run in a fixed order; high confidence merges with a visible undo, medium confidence asks, low confidence imports separately. Google Drive is used with the drive.file scope only and folders are keyed by book id, which keeps permissions minimal and survives renames.

Results

  • EPUB, PDF and TXT readers with bookmarks, highlights, reading stats, series shelves and Continue Reading.
  • Updated-edition detection that turns a re-imported file into a new version and carries the reading position across.
  • Cross-device sync of progress, bookmarks and vocabulary, and Google Drive backup and restore.
  • A free public-domain book catalog, a tap-a-word dictionary and a vocabulary vault with CEFR levels and spaced-repetition review.

SpeakUp is in active development and verified on a physical Android tablet at every milestone; continuous integration runs analysis, tests and a debug build on every push.

What I’d do next

  • Reading-position recovery tests against a corpus of real books, to hold the 95% restoration target in the specification.
  • Series automation - attaching the next volume and continuing into it.
  • iOS and desktop builds, which the chosen packages already support.