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
pumpAndSettleresolved viapump(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 |
| 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.valuesiteration (O(n)) →box.get(id)direct access (O(1)). Eliminated points where latency scaled linearly with collection size. - Controller leaks:
TextEditingControllernot disposed in 6 dialog locations. Dialogs wrapped inStatefulWidgetwithdispose()releasing the controller. - Async mounted check: missing
mountedcheck beforesetStatecalls 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
๋๊ธ
๋๊ธ ์ฐ๊ธฐ