Building Flutter Apps (2/5) — QuietLeaf Technical Notes: Flutter Text Reader Architecture

Riverpod State Management, Hive Local Storage, 1,848 Tests, Multi-Format Rendering Design


ํ•ต์‹ฌ ์š”์•ฝ

  • DDD 3-Layer + Riverpod: Domain (pure business logic) → Data (10 Hive Boxes) → Presentation (UI). StateNotifier for composite reading state; FutureProvider for async file loading.
  • Multi-format: TXT (encoding fallback chain), EPUB (ZIP + HTML), PDF (page rendering), Image (pinch zoom). Separate providers per format.
  • Stabilization results: 24 categorized issues resolved across 3 rounds. Key axes: O(n)→O(1) lookups, controller leak elimination, async mounted checks, theme system consistency.
  • Tests: 82 files / 1,848 cases. AdMob timer conflict with pumpAndSettle resolved via pump(Duration) workaround.

What This Post Covers

This post documents the design decision points that recur when building a local text reader app with Flutter: encoding detection, format-specific rendering strategy, reading position restore, theme consistency, and the irreversibility of Hive schemas. For each topic, the focus is on what alternatives exist, the rationale for each choice, and what limitations remain. The app is QuietLeaf.


1. Tech Stack Selection

Item Choice Rationale
Framework Flutter 3.41 + Dart 3.11 Cross-platform, single codebase
State management Riverpod 3.3 StateNotifier for composite state, FutureProvider for async, testability
Local storage Hive CE 2.19 Key-value store, Box-level concern separation, low entry barrier
Routing go_router 17.1 Declarative routing, deep link support
PDF pdfrx 2.2 Page rendering
EPUB archive 4.0 + flutter_html ZIP parsing + HTML-style rendering
TTS flutter_tts 4.2 Text-to-speech, speed/pitch control
Ads google_mobile_ads 7.0 Rewarded ads (1 view → 16-hour ad removal)

Riverpod vs BLoC — Selection Criteria

The state space of a text reader is dominated by continuously updated values: scroll position, current page, progress, brightness, font size, theme. BLoC's event-state pattern requires declaring an event type for each update, causing boilerplate accumulation in this shape. StateNotifier modifies state through method calls, expressing the same functionality in roughly half the code.

FutureProvider handles the three states (loading / complete / error) at the framework level for async file loading scenarios like EPUB and PDF, eliminating the need to build a separate loader UI.

Conversely, BLoC is more appropriate for domains where event history tracking matters (e.g., game input logs). The selection criterion is whether state is a continuous value or an event stream.

Hive vs SQLite — Selection Criteria

At a solo-developer scale, Hive is usable without schema declarations, offers fast key-value access, and allows entity type separation at the Box level. 10 Box configuration:

documents, reading_states, bookmarks, collections,
app_settings, reading_sessions, monetization_state,
search_history, collection_items, highlights

Limitation: typeId and HiveField numbers cannot be rearranged once finalized. AppSettings has expanded to 45 fields, and intermediate numbers cannot be reused. If relational query requirements emerge later, SQLite migration costs apply. For domains where relational queries are core, SQLite is the better starting point.


2. DDD 3-Layer Architecture

Domain — Pure Business Logic

// Use Case example — no Flutter/Hive imports, pure Dart
class AddDocumentUseCase {
  final DocumentRepository repository;
  Future<Document> execute(String path) { ... }
}

The Domain layer contains no Flutter or Hive imports. It hosts AddDocumentUseCase, RestoreReadingStateUseCase, and SaveReadingStateUseCase. Repository implementation changes do not require Domain modifications.

Data — Persistence

Contains Hive services and repositories. Responsible for Box initialization, adapter registration, and CRUD operations.

Model definitions: - Document (typeId: 0) - ReadingState (typeId: 1) - Bookmark (typeId: 2) - AppSettings (typeId: 3, 45 fields) - Collection (typeId: 4) - MonetizationState (typeId: 6) - ReadingSession (typeId: 7)

Presentation — UI

5 main screens: - LibraryScreen — 5 tabs (directory browser, recent, bookmarks, search, highlights) - ReaderScreen — branching TXT/EPUB/PDF/image renderer - SettingsScreen — 45 setting fields - StatisticsScreen — daily/weekly reading statistics charts - ThemeBuilderScreen — custom theme seed color configuration


3. Multi-Format Rendering Strategy

TXT — Encoding Fallback Chain

Korean TXT files mix UTF-8, EUC-KR, and CP949; Japanese includes Shift-JIS; Chinese includes Big5. Automatic encoding detection cannot be solved with a single algorithm, so the approach is a fallback chain.

UTF-8 → EUC-KR → Shift-JIS → Big5 → Latin-1 (fallback)

Each step attempts decoding; if invalid byte sequences are detected, the next encoding is tried. Latin-1 accepts all single-byte values and therefore always succeeds. The goal is best-effort interpretation + crash prevention — displaying garbled text is acceptable; crashing is not.

Large files (10MB+) are read using 512KB chunk streaming. Loading the entire file into memory at once causes OOM on low-end devices.

EPUB — ZIP + HTML

The EPUB container structure places OPF/NCX/XHTML inside a ZIP archive. archive extracts the archive; flutter_html renders XHTML. Supports table of contents parsing, chapter navigation, and heading/bold/italic/blockquote styles.

PDF — Page-Level Rendering

Based on pdfrx. Includes page-level navigation, current page tracking, and progress display.

Image — Pinch Zoom

Supports JPG, PNG, WEBP, GIF, BMP. Pinch zoom (0.5x–5x) and panning. Handles manga and scanned-document use cases.

Each format uses a dedicated provider (epubContentProvider, pdfContentProvider). Reading state shares a common interface, but content loading logic differs per format, so providers are not shared.


4. Reading Experience Design

Position Restore — 500ms Debounce

Current position is saved to Hive 500ms after scrolling stops. Progress (0.0–1.0) is recorded simultaneously. Without debouncing, disk I/O fires on every scroll frame; 500ms is the tradeoff between perceived latency and I/O cost.

Theme System — 10 Presets + Custom

Light, Dark, Sepia, Paper, Night, Green, Cream, Mint, Lavender, Ocean. An additional custom seed mode leverages Material 3 dynamic color seeding.

Rule: no hardcoded colors. All colors are sourced from Theme.of(context).colorScheme. Violating this rule causes regressions where parts of the UI retain previous colors during theme transitions. The stabilization process identified and fixed 14 violations of this rule.

Highlights, Bookmarks, Search

Text highlighting in 5 colors with note attachment. Bookmarks support position jumping. In-document search is combined with a minimap.

TTS

Based on flutter_tts. Highlights the currently read paragraph in the UI; provides speed and pitch controls.

Accessibility

Semantics, textScaler, high-contrast mode, volume key navigation (opt-in), immersive mode (status bar and navigation bar hidden).


5. Monetization — Rewarded Ad Design

Google AdMob rewarded ads. 1 view → 16-hour ad removal, stackable up to 3 views (48 hours).

Banner ads are excluded because they fragment the reading flow. Rewarded ads surface only at the user's chosen moment and deliver an ad-free session as the reward, making them compatible with the reading experience. Ad network load failures fall back to ad-free continuation — UX must not be contingent on network quality.


6. Patterns Identified During Stabilization Rounds

3 stabilization rounds before release identified 24 issues. Pattern-level classification is more valuable than tracking individual bugs.

  • Lookup complexity: box.values iteration (O(n)) → box.get(id) direct access (O(1)). Eliminated points where latency scaled linearly with collection size.
  • Controller leaks: TextEditingController not disposed in 6 dialog locations. Dialogs wrapped in StatefulWidget with dispose() releasing the controller.
  • Async mounted check: missing mounted check before setState calls following async callbacks. Fixed via full codebase scan.
  • Theme consistency: 14 hardcoded color occurrences → replaced with Theme.colorScheme.
  • Backup restore order: original order was "clear → parse"; parse failure could cause data loss. Reordered to "parse → validate → clear" to preserve rollback capability.

These patterns are accumulated in tasks/lessons.md and used to catch the same mistakes early in subsequent sessions.


7. Claude Code-Based Development Pipeline

This project was developed via pair programming with Claude Code. Role separation:

Agent Model Role
orchestrator Opus Judgment, architecture design, simple implementation
executor Sonnet Complex code writing, refactoring
quality Sonnet Read-only review, code quality verification

Skills are triggered by keyword: brainstorming, code-review, deep-interview, flutter-hive, flutter-reader, flutter-test, git-commit, project-doctor, self-audit, testing, ux-ui-design, verification, writing-plans.

The highest-impact area was the stabilization rounds. Patterned issues (undisposed controllers, O(n) lookups, missing mounted checks) are caught faster through rule-based scanning than individual code review.


8. Play Store Deployment Considerations

Current Status

  • Internal testing track: published
  • Closed testing: under review (v2.0.0+22, 63.1MB AAB)
  • 14-day closed testing period required
  • Production release pending

MANAGE_EXTERNAL_STORAGE Permission

The core feature is directory browsing. SAF (Storage Access Framework) or MediaStore alone is insufficient for TXT file navigation. Without this permission, the app's core use case is structurally inoperable — submitted as justified cause during review.

Localization

Korean, English, Japanese, Chinese. 267+ keys managed via ARB.


9. Test Coverage

82 files / 1,848 cases.

Coverage scope: - Hive persistence (CRUD per model) - Riverpod provider state transitions - Use cases (document add, reading state restore, backup) - Settings cascade (45 fields) - Encoding detection - File browsing - TTS paragraph tracking

Known issue: AdMob rewarded ads contain an internal timer that does not satisfy pumpAndSettle's termination condition. Widget tests enter an infinite wait state; AdMob-dependent paths use pump(Duration) for explicit time increments.


10. Metrics Summary

Item Value
App version v2.0.0+22
AAB size 63.1MB
Supported formats TXT, EPUB, PDF, Image (5 types)
Themes 10 presets + custom
Hive Boxes 10
Settings fields 45
Tests 1,848 (82 files)
Stabilization fixes 24 (3 rounds)
Learned patterns 28
Localization keys 267 × 4 languages
Skills 14

Applicability and Open Questions

Applicability - For local-first Flutter apps, the Riverpod + Hive combination is well-suited to composite state and non-relational persistence. - The encoding fallback chain is portable to any reader or viewer handling multilingual text. - Stabilization round pattern categories (controller leaks, complexity, async mounted, theme consistency) apply broadly across Flutter apps.

Open Questions - At what point does Hive's typeId immutability exceed the cost of SQLite migration in long-term maintenance? How can early signals of relational query demand be detected? - By what metric should the rewarded ad value-time exchange (1 view ↔ 16 hours) be tuned to balance retention and revenue? - Can the theme consistency rule (Theme.colorScheme only) be enforced via static analysis automation?

Series overview: Series index

๋Œ“๊ธ€

์ด ๋ธ”๋กœ๊ทธ์˜ ์ธ๊ธฐ ๊ฒŒ์‹œ๋ฌผ

Agent Memory Engine (2/10) — Building an AI Agent Memory System with SQLite Alone

"ML Foundations (9/9) — PyTorch vs TensorFlow, and the Road to Local LLMs"

"RAG Core Study (14/26) — Evaluation Sets with RAGAS & DeepEval"

"ML Foundations (8/9) — Deep Learning Architectures: CNN, RNN, Attention"

"ML Foundations (7/9) — Deep Learning Training: Optimizers, Regularization, Initialization"

OpenClaw to Hermes Migration (2/13) — What to Preserve, Partially Port, or Discard

AI Agents I Built (5/7) — Building an Automated Blogger API Publishing System