Draw a Chinese or Japanese character with your finger and look it up. The app recognizes the strokes on the device, then shows the reading, the meaning, and how the same character is written and read in Chinese, Japanese and Korean (Hanja).
The story of how it was built, what was measured and what was not is in the case study. The product spec is in docs/prd.md and the decisions so far are in docs/adr.
Drawing 山 and looking it up. The dictionary card appears first, the AI card shows grey bars while the model answers, then fills in. Recorded on an Android emulator.
Taken from a release build on an Android emulator.
![]() |
![]() |
![]() |
![]() |
![]() |
| Draw, then pick a candidate | Dictionary card first | AI card loads separately | AI card, with a flagged reading | History |
In the fourth screenshot, ものがたり for 物語 is flagged as unverified even though it is a correct reading. The check compares against the dictionary readings of 語, and the sound change from かたり to がたり is a known limit (see Known limits).
Built:
- Drawing canvas with undo, clear, and recognition after a short pause or on demand
- On-device recognition with Google ML Kit Digital Ink (Chinese and Japanese), with a model download state
- Candidates filtered to characters that exist in the dictionary, at most five
- Offline dictionary: a bundled SQLite file built from Unihan by a script in this repo
- Result sheet: reading and meaning from the dictionary, the same character across Chinese, Japanese and Korean, and "Not it?" to switch candidate
- Pronunciation with the voices installed on the device, with a message when there is none
- Clean Architecture in three layers (
features,domain,data), with no Flutter imports indomain - Riverpod with sealed states, and typed failures that each end in a readable message
- AI explanation (components, memory hook, example words) from Gemini through Firebase AI Logic, in its own card labeled "AI-generated". The dictionary card never waits for it
- A validator that marks an example reading the dictionary does not confirm as unverified
- Explanations cached per character, language and prompt version, so each character costs one model call
- History of the last 50 characters, reopened from the cache without a network
- Unit and widget tests, run with
flutter analyzeandflutter testin GitHub Actions - A demo GIF and a case study
| Area | Tools |
|---|---|
| Framework | Flutter, Dart |
| State | flutter_riverpod, equatable |
| Handwriting recognition | google_mlkit_digital_ink_recognition |
| Dictionary | sqflite, built from Unihan with sqlite3 |
| Speech | flutter_tts |
| AI explanation | firebase_ai (Gemini), firebase_core, firebase_app_check |
| Tests | flutter_test, mocktail, fake_async, sqflite_common_ffi |
| CI | GitHub Actions |
There is no backend of my own. Recognition, the dictionary, the cache and the history run on the device. The network is used for the one-time download of each recognition model, and for the AI explanation, which is optional.
flowchart LR
subgraph Device
App[Character Lens]
MLKit[ML Kit Digital Ink<br>on-device models]
DB[(dictionary.db<br>bundled asset)]
TTS[System TTS voices]
Cache[(explanation_cache.db<br>history.db)]
end
Google[Google model servers]
Gemini[Gemini via<br>Firebase AI Logic<br>with App Check]
App --> MLKit
App --> DB
App --> TTS
App --> Cache
MLKit -.->|first use of a language| Google
App -.->|character + dictionary entry| Gemini
From stroke to result:
flowchart LR
A[Strokes] --> B[ML Kit]
B --> C[Keep one-character<br>candidates in the dictionary]
C --> D[Pick a candidate]
D --> E[Dictionary lookup]
E --> F[Result sheet]
Then the AI card, after the dictionary card is already on screen:
flowchart LR
A[Dictionary entry] --> B{Cached?}
B -- yes --> E
B -- no --> C[Gemini, JSON schema,<br>one retry if invalid]
C --> D[Cache]
D --> E[Validator marks<br>unverified readings]
E --> F[AI-generated card]
Strokes stay on the device. Readings and meanings come only from the dictionary. The model gets the recognized character and its dictionary entry, its answer is labeled AI-generated, and the readings in its example words are checked against the dictionary.
flowchart TB
subgraph features
canvas
result
history
end
subgraph domain
UC[Use cases<br>RecognizeCharacters, ResolveCharacter,<br>ExplainCharacter, ListHistory]
IF[Interfaces<br>InkRecognizer, Dictionary, TtsService,<br>Explainer, ExplanationCache, LookupHistory]
EN[Entities and failures]
end
subgraph data
ink[MlKitInkRecognizer]
sql[SqliteDictionary]
tts[FlutterTtsService]
ai[LlmExplainer<br>FirebaseExplanationModel]
cache[SqliteExplanationCache<br>SqliteLookupHistory]
end
canvas --> UC
result --> UC
history --> UC
UC --> IF
data -.->|implements| IF
main[main.dart] -.->|wires| data
- Features hold screens, widgets and Riverpod controllers. They know the domain interfaces, never the concrete classes.
- Domain is plain Dart with no Flutter imports. A use case exists only where there is a rule to apply:
RecognizeCharactersfilters candidates,ResolveCharacterdecides which readings to show for each language,ExplainCharacterreads the cache before the model and runs the validator, andListHistoryjoins saved lookups with the dictionary. - Data implements the interfaces.
main.dartis the only place that names the concrete classes and overrides the providers, and tests override them with fakes. - State is a sealed class per concern:
CanvasState,LookupState,SpeechState,ExplainStateandHistoryState. The result sheet has its own state so closing it leaves the strokes and candidates as they were. See ADR 004. - Failures are typed (
InkFailure,DictionaryFailure,ExplanationFailure), and each one ends in a message the user can read.
Who listens to whom: one state, the lookup of a character, is the hub. The AI card and the history listen to it and never change it.
flowchart LR
Look[Look up button<br>or History tile] --> Lookup[LookupController]
Lookup -.->|listens| Explain[ExplainController]
Lookup -.->|listens| History[HistoryController]
Explain --> UC[ExplainCharacter]
History --> Store[(history.db)]
More in docs/architecture.md.
Decision records: ADR 001 for recognition, ADR 002 for the dictionary, ADR 003 for Gemini through Firebase AI Logic, ADR 004 for state and ADR 005 for the grounding and the validator. docs/data-flow.md follows a drawing from strokes to result and lists every failure path with its test, and docs/ai-usage.md covers the AI parts, their cost and their limits.
lib/
features/ canvas, result, history, home screens, widgets, Riverpod controllers
domain/ ink, dictionary, explainer, tts entities, failures, interfaces, use cases
data/ ink, dictionary, ai, cache, tts implementations of the interfaces
core/ theme
tool/ builds the dictionary from Unihan
assets/db/ the bundled dictionary and its license
docs/ spec, design prototype, decision records
Features depend on the domain, and data implements the domain interfaces. main.dart is the only place that names the concrete classes.
flutter pub get
flutter run
The first time you draw in a language, the app downloads that language's recognition model from Google, so that step needs a connection that can reach Google's servers. After that, recognition and the dictionary work offline.
flutter analyze
flutter test
iOS has not been built or run. The Android checks so far were on an emulator.
The AI explanation needs Firebase settings. They are optional and kept out of the repo: see docs/firebase-setup.md. Without them the app runs as before, and the AI card says it is not set up in this build.
630 tests: domain rules, the dictionary builder, SQLite access, the ML Kit adapter and the Gemini caller with fakes, controllers with fake_async, and widget tests that drive the real screen, including 1.5x text scale on a narrow screen.
Line coverage of the files the tests load, measured with flutter test --coverage on 2026-10-31, before the About sheet and its 6 tests were added:
| Area | Lines covered |
|---|---|
lib/domain |
92% |
lib/data |
90% |
lib/features/canvas |
99% |
lib/features/result |
98% |
lib/features/history |
97% |
| All | 95.9% (1831 of 1909) |
Two gaps behind that number. The adapters that call the real ML Kit plugin (lib/data/ink/ink_plugin.dart) are not run by any test, because the plugin needs a device. lib/main.dart, firebase_bootstrap.dart, firebase_config.dart and firebase_explanation_model.dart are not counted, because no test loads them: they start Firebase or call Gemini, which needs a real project. The prompt, the retry, the parser and the failure mapping around that call are tested with a fake model. Coverage says which lines ran, not that the behavior is right. Some tests were also checked by breaking the code on purpose and confirming they fail.
flutter test --coverage
Readings, meanings and variant forms come only from a SQLite dictionary bundled in the app (assets/db/dictionary.db, 60,183 characters). It is built from the Unicode Han Database (Unihan) 18.0.0, dated 2026-07-31:
- Mandarin, Japanese on and kun, and Korean (Hangul) readings
- English definitions
- simplified and traditional variants
The Unihan files are not in the repo. To rebuild the dictionary, download and unzip Unihan.zip from unicode.org, then run:
dart run tool/build_dictionary.dart <unihan folder> [output path]
Unihan data is Copyright (c) 1991-2026 Unicode, Inc. and is used under the Unicode License v3. The license text is in assets/db/UNICODE-LICENSE.txt. The dictionary keeps selected fields and reorganizes them, so it is not the official Unicode data, and Unicode does not endorse this app.
Strokes never leave the device. The AI request holds only the recognized character, the language and its dictionary entry. There is no account and no analytics. If you run it with your own Firebase project on the Gemini free tier, the pricing page (checked 2026-10-27) says the content sent is used to improve Google products.
- The AI explanation is labeled and its example readings are checked, but the check is loose. "Verified" means the dictionary does not disagree, not that the word's reading is right, and correct readings with sound changes (学校 is がっこう) get flagged. Components, memory hooks and usage notes are not checked at all, and a memory hook can be plausible-sounding folk etymology. See ADR 005.
- AI latency varies a lot. On an emulator the spike had medians of 3.2 to 3.9 s (20 calls), and a second run had a median of 6.2 s with one call at 24 s, past the 15 s timeout (11 calls, a poor network at the time). The "under 4 s" target is not shown to be met, and it was not measured on a physical device. App Check with Play Integrity is not confirmed to be enforced yet.
- No formal evaluation of the AI explanations was run. The checks that were done are small, and they are listed in docs/ai-usage.md.
- Pronunciation comes from the device's voice engine. It reads the character in its own way, which can differ from the reading in the dictionary card. Korean engines often cannot read Hanja.
- Unihan does not link every pair of simplified, traditional and Japanese forms. For some characters a reading is borrowed from one linked variant, and the card says so. Other pairs stay unlinked.
- Recognition quality comes from ML Kit. The decision record has the measurements taken so far, and they are small.
Instrument Sans and Newsreader are bundled under the SIL Open Font License 1.1. The license texts are in assets/fonts.
I wrote the specification, the architecture and the decision records, and reviewed every change. I used Claude Code for implementation and test writing, working in small steps under the rules in CLAUDE.md. Each step ends with flutter analyze and flutter test, and tests are checked by breaking the code on purpose and confirming they fail.
The code is MIT, see LICENSE. The bundled data and fonts keep their own licenses, listed above.
Nicodemus Lin, Senior Flutter Engineer. nicolin.dev · LinkedIn





