Add a native SwiftUI app for iOS, iPadOS and macOS - #7
Merged
Conversation
solcott
force-pushed
the
feature/iosApp
branch
from
August 14, 2026 14:45
8fbc5d3 to
6b6e2b4
Compare
solcott
force-pushed
the
feature/iosApp
branch
from
August 14, 2026 15:48
6b6e2b4 to
edf1960
Compare
solcott
force-pushed
the
feature/iosApp
branch
from
August 14, 2026 16:04
edf1960 to
42db754
Compare
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
force-pushed
the
feature/iosApp
branch
from
August 14, 2026 16:06
42db754 to
2e71697
Compare
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
force-pushed
the
feature/iosApp
branch
from
August 14, 2026 17:06
f2aeb71 to
9de9274
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 bridgePackages the shared Kotlin as
CountriesKit.xcframework. One module covers iPhone, iPad and Mac,hence
:applerather than:ios.Swift cannot call a Circuit presenter — it is a
@Composablefunction with a hidden$composerparameter.
PresenterHoldersruns each one under Molecule and exposes aStateFlow, which is why themodule applies the Compose compiler while rendering nothing. Two deliberate deviations from Circuit's
counter sample: no
presenterOf { }wrapper (it trips a@ComposableTargetmismatch), and acancel()the sample omits but a repeatedly-opened detail screen needs.PresenterHolderis deliberately not generic — a type parameter would reach Swift as an Obj-Clightweight generic and force SKIE to express
SkieSwiftStateFlow<UiState>for it. The Swift sideis generic, which is fine: Swift generics are real.
The app
NavigationSplitViewis the whole navigation container: it collapses to push-and-pop on iPhone andopens 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
.automatichides the sidebar there; iPadOS 26 defaults differently, which is exactly why it ispinned. The deployment floor is iOS 17, so the old behaviour is in scope.
Listonly realisesvisible 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.testin:apple, Swift Testing for purepresentation logic, and XCTest for the UI tests — Swift Testing has no UI-testing support.
testListAndDetailAreVisibleTogetherOnIPadwas confirmed to fail against the pre-fix columnvisibility, 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 testfor macOS now fails onSigning for CountriesUITests requires a development team— Xcode builds every testable in the schemeregardless of
SUPPORTED_PLATFORMSor-only-testing, and a macOS UI-test runner cannot be ad-hocsigned. Three settings combinations were tried and removed rather than left as dead config. Building
and running on macOS is unaffected.
🤖 Generated with Claude Code