Most mobile apps are built online-first and retrofitted with offline support as an afterthought. The offline code is the error path — a spinner that eventually shows “No internet connection.” Users learn not to open the app on the tube.
When I designed Tuniri, offline was not a feature to add — it was the constraint the entire architecture had to satisfy. A learning app for children aged 2–6 should work in airplane mode from the first tap. No spinners, no “content not available offline,” no degraded mode. It just works.
Why offline-first is harder than it sounds
The hard part is not making the app load without internet. That is just asset bundling. The hard parts are:
- Data — where does learning progress live, and how do you read and write it without a server?
- State — how do you keep the app feeling consistent when the source of truth is local?
- Sync — if there is no sync, you never have to resolve conflicts. But you also never leave the device.
For Tuniri, the answer to point 3 was: no sync. Progress, settings, and all learning data live on-device forever. That decision collapsed a large category of complexity.
The data layer: SQLite with no server dependency
Flutter ships with excellent SQLite support via sqflite. A local SQLite database handles all reads and writes — mastery tracking, pet companion state, session history, parent dashboard data. Everything.
The schema is simple:
- One row per child profile
- Progress stored as a per-skill mastery score (0–3 scale, enough granularity to drive adaptive difficulty)
- Session logs for the parent dashboard
No API calls. No auth tokens. No “sync in background” jobs that drain battery or fail silently.
Encryption without a server
The privacy promise is that no one — not the app developer, not a data broker, not a hacker with physical access to the device — can read a child’s learning data.
For the first part of that promise, the architecture does the work: no server means no breach surface. For the second part — physical device access — we encrypt the local SQLite database at rest with AES-256.
Flutter’s flutter_secure_storage and sqflite_sqlcipher make this straightforward. The encryption key is generated at first launch and stored in the platform keychain (Keychain on iOS, Android Keystore on Android). The database file is meaningless without it.
The important design choice: encryption is on by default for all profiles, with no way to opt out. Not a setting. Not something a parent can forget to enable.
State management: keep it local
With no server, the state management question becomes simpler. There is no server state to synchronise with. The app’s state is a view on top of the local SQLite database.
Tuniri uses Riverpod as the state management layer. Each feature (learning session, pet companion, parent dashboard) has its own repository class that reads and writes to SQLite via sqflite. The UI subscribes to providers backed by those repositories.
When a child completes an activity, the write goes to SQLite and the provider notifies its subscribers. The UI updates. No round trips, no latency, no failure modes.
What I would do differently
The current app bundles all challenge content — stories, phonics audio, activity logic — inside the app binary. This keeps the offline guarantee simple: everything is there from the first install. But it means every content update requires a new app release, and the binary grows with every content addition.
The next architectural step is extracting content into a one-time downloadable content pack. The install is smaller, the content can update independently, and the app stays offline once the initial download is done. The tricky part is not the download — it is the UX of the first launch, where you have to tell parents “you need to download this once” without undermining the “works offline” story.
That balance is the interesting design problem for the next version.