Skip to content

Add a native SwiftUI app for iOS, iPadOS and macOS - #7

Merged
solcott merged 6 commits into
mainfrom
feature/iosApp
Aug 14, 2026
Merged

Add a native SwiftUI app for iOS, iPadOS and macOS#7
solcott merged 6 commits into
mainfrom
feature/iosApp

Conversation

@solcott

@solcott solcott commented Aug 14, 2026

Copy link
Copy Markdown
Owner

Stack 3/4 — base feature/search-text-event (#6).

A fourth front end, and the first that does not share the Compose UI. Same data, same states, same
behaviour; hand-written SwiftUI rather than a port of the Compose design.

:apple — the Kotlin bridge

Packages the shared Kotlin as CountriesKit.xcframework. One module covers iPhone, iPad and Mac,
hence :apple rather than :ios.

Swift cannot call a Circuit presenter — it is a @Composable function with a hidden $composer
parameter. PresenterHolders runs each one under Molecule and exposes a StateFlow, which is why the
module applies the Compose compiler while rendering nothing. Two deliberate deviations from Circuit's
counter sample: no presenterOf { } wrapper (it trips a @ComposableTarget mismatch), and a
cancel() the sample omits but a repeatedly-opened detail screen needs.

PresenterHolder is deliberately not generic — a type parameter would reach Swift as an Obj-C
lightweight generic and force SKIE to express SkieSwiftStateFlow<UiState> for it. The Swift side
is generic, which is fine: Swift generics are real.

The app

NavigationSplitView is the whole navigation container: it collapses to push-and-pop on iPhone and
opens two columns on iPad and Mac. Apple idioms throughout — .searchable, ContentUnavailableView,
pull-to-refresh, Form/LabeledContent.

SwiftUI rather than UIKit because UIKit does not run on macOS, and the usual escape hatch is shut:
Kotlin/Native has no Mac Catalyst target.

Two bugs worth calling out

  • iPadOS 18 portrait launched to a full-width "No Country Selected" with the list unreachable.
    .automatic hides the sidebar there; iPadOS 26 defaults differently, which is exactly why it is
    pinned. The deployment floor is iOS 17, so the old behaviour is in scope.
  • The first XCUITest draft used Canada as its fixture and every test failed — List only realises
    visible rows, so a row far down the list does not exist as an accessibility element. Fixtures are now
    rows one and two.

Testing

Three suites, deliberately different tools: kotlin.test in :apple, Swift Testing for pure
presentation logic, and XCTest for the UI tests — Swift Testing has no UI-testing support.

testListAndDetailAreVisibleTogetherOnIPad was confirmed to fail against the pre-fix column
visibility, so it is known to test what it claims.

Verified on iPhone 17 Pro and iPad mini 18.4 (7 UI tests + 8 unit tests each) plus a macOS build.

Known limitation, documented in AGENTS.md: xcodebuild test for macOS now fails on
Signing for CountriesUITests requires a development team — Xcode builds every testable in the scheme
regardless of SUPPORTED_PLATFORMS or -only-testing, and a macOS UI-test runner cannot be ad-hoc
signed. Three settings combinations were tried and removed rather than left as dead config. Building
and running on macOS is unaffected.

🤖 Generated with Claude Code

Base automatically changed from feature/search-text-event to main August 14, 2026 16:06
solcott and others added 4 commits August 14, 2026 10:06
Packages the shared Kotlin as CountriesKit.xcframework for iOS, iPadOS and macOS.
One module covers all three — hence :apple rather than :ios.

Swift cannot call a Circuit presenter: it is a @composable function with a hidden
$composer parameter. PresenterHolders runs each one under Molecule with
RecompositionMode.Immediate and exposes a StateFlow, which is why the module applies
the Compose compiler plugin while rendering nothing. Two deviations from Circuit's
counter sample, both deliberate: no presenterOf { } wrapper, because launchMolecule
already takes a @composable lambda and wrapping trips a @ComposableTarget mismatch;
and a cancel() the sample omits, which a repeatedly-opened detail screen needs.

The PresenterHolder base is not generic on purpose. A PresenterHolder<UiState> would
reach Swift as an Objective-C lightweight generic and leave SKIE expressing
SkieSwiftStateFlow<UiState> for a type parameter. Subclasses declare `state`
concretely instead; the Swift side is free to be generic, since Swift generics are
real.

SwiftNavigator forwards goTo/pop into Swift closures and mirrors the stack back via
syncFromSwift, so navigation has exactly one writer and needs none of the
feedback-loop guarding BrowserHistory does.

Two build details that fail obscurely without them:

  - linkerOpts("-lsqlite3"). The Apollo plugin adds this only for :network's own
    binaries; this is a different binary in a module that does not apply Apollo, so
    the link otherwise dies on undefined _sqlite3_* symbols from SQLiter.
  - export() is not transitive, and Screen lives in circuit-runtime-screen rather
    than circuit-runtime. Without exporting it by name Swift sees
    Circuit_runtime_screenScreen.

The entry point is CountriesCore, not CountriesKit: a Kotlin class matching the
framework name is silently renamed to CountriesKit_ in Swift.

Verified with ./gradlew 🍎macosArm64Test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One target covers all three. NavigationSplitView is the whole navigation container:
it collapses to push-and-pop on iPhone and opens into two columns on iPad and Mac, so
a single construct gives three correct behaviours and the selected country code *is*
the navigation state.

The design is deliberately not a port of the Compose UI. Same data, same states, same
behaviour, expressed with Apple idioms — .searchable instead of a text field pinned in
the list, ContentUnavailableView instead of ErrorContent, pull-to-refresh instead of a
progress strip, Form/LabeledContent instead of a column of "Label: value" text.

SwiftUI rather than UIKit because UIKit does not run on macOS — that is AppKit — and
the usual escape hatch is shut: Kotlin/Native has no Mac Catalyst target, so a KMP
framework cannot link into a Catalyst app.

columnVisibility and preferredCompactColumn are pinned rather than left .automatic.
On iPadOS 18 in portrait .automatic hides the sidebar outright and the app launches to
a full-width "No Country Selected" with no way to see the list but a toggle button.
iPadOS 26 defaults differently, which is exactly why this is worth pinning — the
deployment floor is iOS 17, so the old behaviour is in scope.

PresenterModel<Holder> is generic where the Kotlin side could not be: Swift generics
are real, so one @observable wrapper drives both screens. It bridges the SKIE
StateFlow into an AsyncSequence and cancels the holder's scope on deinit.

LoadPhase maps the non-generic `status` rather than extending ContentState. Swift
forbids an extension of a generic Objective-C class from touching its type parameters,
so an isLoading/errorOrNull extension on ContentState<T> cannot be written at all.

Xcode builds the Kotlin framework itself through a Run Script phase running
:apple:embedAndSignAppleFrameworkForXcode, which builds only the slice being targeted;
assembleCountriesKitDebugXCFramework is 255 MB across three slices and belongs to
distribution, not an edit-build-run loop. The project uses
PBXFileSystemSynchronizedRootGroup, so new Swift files need no project edit.

The scheme is committed as a shared scheme on purpose: an auto-generated per-user
scheme silently shadowed it once and lost the test action, leaving `xcodebuild test`
reporting that Countries was not configured for testing.

Verified with xcodebuild test on iPhone 17 Pro — 8 tests in 3 suites pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Selecting a country, the search field, the continent filter, back navigation and the
iPad two-column layout had never been exercised. Everything under them is tested, but
nothing had touched the views — and the iPad portrait bug that shipped in the previous
commit's description is exactly the kind a UI test catches.

XCUITest rather than a third-party driver because it needs nothing installed: it is
Apple's own framework and runs under xcodebuild. simctl has no touch injection and
osascript is refused assistive access, so the alternative was idb, whose Homebrew
formula pins a prebuilt binary from August 2022 against CoreSimulator internals.

These are XCTest, not Swift Testing, unlike the unit tests beside them. That
inconsistency is forced: Swift Testing has no UI-testing support, and XCUIApplication
assertions require XCTestCase.

Two things the first draft got wrong, both now pinned by comments in UITestSupport:

  - Fixtures must be at the top of the list. List only realises visible rows, so
    Canada does not exist as an accessibility element until scrolled to, and all five
    tests failed on a row that was merely off screen. They use Andorra and the UAE,
    rows one and two.
  - LabeledContent merges its label and value into one accessibility element, so the
    detail screen's values are not addressable as exact staticTexts. Matched with a
    `label CONTAINS` predicate — and against the calling code rather than the capital,
    since the capital also appears in the list row subtitle and would prove nothing on
    iPad where both columns are visible.

testListAndDetailAreVisibleTogetherOnIPad has been confirmed to fail against the
pre-fix .automatic column visibility, so it is known to test what it claims.
NavigationUITests branches on userInterfaceIdiom with XCTSkipUnless so the suite is
meaningful on both destinations rather than passing vacuously on one.

The tests drive the live GraphQL API, so a cold simulator needs network; reruns are
warm from the Apollo SQLite cache. A hermetic version would need a launch argument
swapping in a stub repository, which means production code changing shape for tests.

Verified on iPhone 17 Pro (7 tests, 2 skipped) and iPad mini iPadOS 18.4 (7 tests,
1 skipped), both alongside the 8 Swift Testing unit tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AGENTS.md gains "The `apple` module" and "Testing the Apple app": why SwiftUI rather
than UIKit, why the presenters travel through Molecule, why PresenterHolder is not
generic, the three build details that fail obscurely, and the two XCUITest traps
(List virtualisation, LabeledContent merging its accessibility element).

Also records the one limitation found while wiring the tests up: `xcodebuild test`
against macOS now fails with "Signing for CountriesUITests requires a development
team". Xcode builds every testable in the scheme regardless of the target's
SUPPORTED_PLATFORMS or of -only-testing, and a macOS UI-test runner cannot be ad-hoc
signed. Three settings combinations were tried and removed rather than left as dead
configuration. Building and running on macOS is unaffected, and nothing is lost from
coverage — the unit tests are pure functions and run on the simulator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
solcott and others added 2 commits August 14, 2026 10:33
Both hardcoded `jvmToolchain(17)` while every other module reads
`Versions.JVM_TOOLCHAIN`, so bumping Java meant editing three places.

:desktop even documented the duplication as unavoidable — "build-logic's
`Versions` is visible to convention plugins only". That was wrong.
`Versions.class` ships in the same build-logic jar as the plugin
descriptors, and both modules already apply `formatting` from that build,
so the object is on their buildscript classpath and a plain import reaches
it. No new convention plugin, and no moving the version into the catalog —
which would have been worse, since precompiled script plugins cannot use
type-safe catalog accessors and the five existing call sites would have
degraded to stringly-typed lookups.

Behaviour is unchanged: Versions.JVM_TOOLCHAIN is 17, and :desktop still
compiles to bytecode major version 61.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AppIcon.appiconset was Xcode's placeholder: eleven size slots, none with a
`filename`, and no images on disk. That builds clean and emits no warning —
it just produces an app with no icon, which is what was showing.

Derived from desktop/icons/icon.icns, already the source of truth for the
other platforms and the only file there with a 1024x1024 representation.
The macOS slots are that art unchanged, straight out of iconutil.

iOS cannot use it. The system applies its own superellipse mask, so the
macOS rounded rect would render inside iOS's rounding with the corners
going black, and App Store validation rejects any icon carrying an alpha
channel. So the iOS image crops the 61px margin away and rescales 902 ->
1024, which leaves the globe at 729px — the same 71.2% of the visible icon
it occupies on macOS — then flattens onto a gradient rebuilt from the two
sampled endpoint colours so the corners fill seamlessly.

Those steps are written down in AGENTS.md, since they are not recoverable
from looking at the output.

Verified in the built bundles, not the build log: iPhone destination emits
AppIcon60x60@2x.png (120x120, alpha fully opaque) and the iPad variant;
macOS emits Contents/Resources/AppIcon.icns. None of the three existed
before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@solcott
solcott merged commit 05b6216 into main Aug 14, 2026
@solcott
solcott deleted the feature/iosApp branch August 14, 2026 17:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant