ProductCase study

Rewriting ZooLog in Swift and Kotlin in a week

On 19 October the ZooLog mobile app was 134,442 lines of Dart. On 26 October it was Swift and Kotlin, opening the old app's database and settings where they lay. What it took, and why we have no better reason than a director's call.

7 days set large on a black background beside two phones showing the new native ZooLog home screen with test data, one in light mode and one in dark mode, above the words: From Flutter to Swift and Kotlin, plan to merge
7 daysfrom the written plan to the cutover merging
134,442lines of Dart replaced, in 377 files
2,701native snapshot images by 27 October, against 740 Flutter golden cases
7defects in the old app found by the port and fixed before cutover

Pull request 413 merged on 26 October. It deleted 257,360 lines, and the Flutter version of ZooLog with them. The native apps had landed beside it the day before, in a pull request of 4,119 files. The plan for all of it was written on the evening of 19 October.

This is how a small studio rewrote a working mobile app for two platforms in seven days without, as far as every test we could run says, losing a single queued log. It is also a post where we have to be straight about the reason.

Why native, honestly

The plan does not argue the case. It opens with a mandate: 100% native on both platforms, Swift 6 and SwiftUI on iOS, Kotlin 2 and Jetpack Compose on Android, no Flutter, no shared cross-platform layer, and no web views wrapping the admin screens. Every keeper screen and every one of the 69 admin screens built natively. The task that started it was raised by Mark.

It was not unopposed. A planning note written on 24 October recommended not doing the cutover yet, and waiting. On the evening of the 25th Mark said “cutover now”, and our decision log records that we took that as the go-ahead, with the earlier advice noted alongside.

We are not going to invent a reason after the fact. What we can describe is what the rewrite took, and what it turned up.

The objection writes itself: you replaced a working app, for no recorded reason, a day after a planning note told you to wait. We would raise it ourselves. Two answers hold up. The first is the stage. ZooLog was young and small, with one live customer at the time of our hosting review, so the number of phones that an upgrade could break was small, and the cost of the rewrite fell on us rather than on users. The second is that the go-ahead did not remove the caution, it moved it into proof. The upgrade had to open the old app’s data byte for byte, the queued logs had to drain, and the first native build went to internal testing only. It is also not free: we now maintain two native codebases where there was one. If the check on real phones fails, or the maintenance burden outruns what two codebases give us, the plan is to say so here.

377Dart files, 134,442 lines
470test files, 84,987 lines
55+navigation routes
13on-device tables to keep byte-compatible

Ten slices and one switch

The work was cut into ten slices. The first nine built complete native apps in their own folders, ios-native and android-native, beside the Flutter app, so each could merge to the main branch while the shipping app kept working. The tenth swapped them in: native folders promoted, Flutter source, tests and packages deleted, build and release pipelines switched, in one change.

Slice 0 (tooling, generated Swift and Kotlin from the shared API contract, empty app shells) was done on the night of the 19th. Slice 1 was the dangerous one: all thirteen local tables, the corruption quarantine, and the 2,074-line sync engine that sends queued welfare logs. It was done by 23:20 the same night.

What made that pace safe was refusing to trust our own reading of the old code. Before porting the sync engine we wrote 101 test vectors describing its behaviour and checked that the Flutter app passed all 101. The native ports then ran the same file: 99 passed on both platforms, and the two left over are written-down, deliberate differences.

Nobody should notice the upgrade

The hard requirement was that a keeper updating from the last Flutter build to the first native one stays signed in and loses nothing, including logs saved with no signal and not yet sent. That meant the native apps had to adopt the old app’s storage exactly as it sat: the same app id, the same SQLite file at schema version 40, the session tokens under the keychain names the Flutter plugin used, and preferences under its flutter. key prefix. On Android the database is not even where you would look: Flutter keeps it in app_flutter/, not databases/.

We tested that against data the real Flutter app had written. We built the Flutter app, used it to save offline logs, photos, a reminder and changed settings, then installed the native build over the top, on an iOS simulator and an Android emulator. On iOS the database dump, write-ahead log included, came out byte-identical after the native app opened it, the app opened signed in, and the queued logs and photos drained into the server when the network came back. Android passed the same checks, with the old secure-storage files byte-identical at first launch.

What we could not prove by 27 October was the same thing on real phones, where the signed keychain and the hardware key store behave differently from a simulator. That check is open, so the first native build went to internal testing only.

The port found bugs in the old app

Porting code line by line is the most thorough review we have done, because you cannot rewrite a behaviour you do not understand. Seven defects turned up in the shipping Flutter app, and we fixed them there on 21 and 23 October, before the cutover, so the app in use got them too.

Three could lose data offline. One unreadable photo row stopped the whole photo queue. An upload the server refused deleted the keeper’s only copy of the file. And the recovery path for a damaged database copied columns by position instead of by name. The other four crashed admin screens or quietly dropped text, including a PDF report that lost any name not written in the Latin alphabet. The upgrade test found one more in Flutter, which we reported rather than fixed: a routine save deleting its queued photo files.

What a snapshot test covers

Flutter had 740 golden test cases. By 27 October the native apps had 1,350 Android snapshot images and 1,351 iOS ones, each a picture of a screen in a known state, checked pixel by pixel on every change. The same screen is drawn several ways, because each way breaks differently.

iOS snapshot of the ZooLog home screen in light mode with a haiku card, reminders and a Today's round card.
The same home screen in dark mode, with dark cards and yellow accents for the due reminders.
The same home screen at the largest accessibility text size. The haiku wraps to four lines and the tip is shortened with an ellipsis.
The same home screen on a small iPhone, with the header and reminder cards fitted to the narrower width.
One screen, four snapshots: light, dark, the largest accessibility text and a small iPhone. All test data.

Light and dark catch a colour that loses contrast in one theme. The largest text size catches truncation and overlap: in the third image the tip is cut short with an ellipsis while the haiku wraps. A snapshot records which of those happened; a person still decides whether it is right. The small iPhone catches anything that assumed a wider screen. What snapshots do not catch is whether a button works. That is what the unit tests, the sync vectors and the live round trip to a local server are for.

Commits to the mobile app per month

October is counted to the 27th. It looks quiet because the native port was built on its own branch and landed as two squash-merged pull requests.

464Jul498Aug331Sep126Oct

Source: ZooLog mobile repository, git history

The first native release tag failed its own verification, on a settings test that behaved differently on the build runners. The second, build 2077, went to internal TestFlight on 26 October. By the end of the 27th the native apps had been tagged seven times.

Update, 7 November 2026

By 3 November the native apps had been tagged 22 times, 21 of them in the eight days after the cutover. For comparison, the Flutter app was tagged 81 times between 20 July and 23 October, up to build 87. The check on real phones is still open.

Where the numbers come from

  1. ZooLog native cutover plan, written 19 October 2026.
  2. ZooLog native port progress log and go or no-go note, 19 to 21 October 2026.
  3. ZooLog native upgrade test results for iOS and Android, 21 October 2026.
  4. ZooLog development root decision log, entries for 25 October 2026, and the cutover go-ahead task.
  5. ZooLog mobile repository: pull requests 400, 401, 411 and 413, release tags, and commit counts per month.
  6. Estate hosting review, 20 October 2026 (ZooLog had one live customer at the time).

Mark Lovelady, Director. Builds most of our software.