Skip to content

fix: make the iOS app build, and close the parity gaps with Android - #35

Merged
jvsena42 merged 13 commits into
mainfrom
fix/ios-parity
Jul 28, 2026
Merged

fix: make the iOS app build, and close the parity gaps with Android#35
jvsena42 merged 13 commits into
mainfrom
fix/ios-parity

Conversation

@jvsena42

Copy link
Copy Markdown
Owner

Summary

The iOS app did not compile. Tab in GapsScreen/SettingsScreen was only the first error the compiler reported — fixing it exposed 136 compile errors across 19 of the 39 Swift files, plus a link failure. The Swift layer had been written against a Kotlin/Native API that does not exist and had never been built against the shared module.

It went unnoticed because .github/workflows/ci.yml ran only on ubuntu-latest and never compiled Swift. Three broken String(localized:) keys shipped the same way.

This PR gets iOS building, closes the layout and preview gaps against Android, makes share-to-Worn actually work, and adds a macOS CI job so the next Swift break is caught.

The shared KMP layer was already at parity: all five expect/actual pairs have real iOS actuals, iosModule binds the same 11 slots as androidModule, and the on-device AI feature from 4922618 is fully wired. No stubs or TODOs in shared/src/iosMain.

One behaviour change worth reviewing

OnDeviceAiService built its image prompt from Attachment and ImageAttachmentContent. Neither exists — the iOS 26.5 .swiftinterface shows FoundationModels is text-only (the only Attachment symbols are feedback-logging). On-device photo analysis is therefore not possible on iOS; generate() now reports that clearly when handed an image. Gap recommendations are text-only and still run on device. Android is unaffected, since Gemini Nano does accept an ImagePart.

Changes

Kotlin/Native interop (the bulk of the 136 errors)

  • Sealed interfaces export as flat Objective-C classes, so WardrobeIntent.ClearSelection is WardrobeIntentClearSelection — 36 sites across five Intent/Effect hierarchies plus OnDeviceAiAvailability
  • init* functions get a do prefix to avoid clashing with ObjC init: initKoin() is doInitKoin()
  • isUsable is an extension property, exported as a static on OnDeviceAiAvailabilityKt, not a member
  • Koin's own types are not in the framework's exported API and never reach the generated header, so koin.get(objCClass:) could never work — added a typed object KoinHelper in iosMain exposing one property per dependency
  • StateFlow exports as a non-generic protocol whose value is Any?, so FlowAdapter's element type is named explicitly at each call site; effects are a plain Flow, which FlowAdapter cannot take, so added EffectAdapter alongside it

Name collisions and drift

  • Category collides with objc_category from objc/runtime.h and Material with SwiftUI.Material; both qualified as Shared.*
  • Kotlin default arguments do not reach ObjC, so preview constructors now pass every parameter (ClothingItem.subcategory/fit/material, WardrobeState.isAiAvailable, OutfitState.allClothingItems)
  • WornColors declared Identifiable conformance without importing Shared; the five ViewModel wrappers used withAnimation without importing SwiftUI
  • toKotlinByteArray/toData were declared in three files — consolidated into Services/ByteArrayBridge.swift
  • TryItScreen's tablet layout called an errorContent helper that was never written; extracted from the phone layout so both share one definition

Linking

  • Shared is a static framework, so SQLDelight's sqliter dependency leaves its sqlite3_* symbols to the app — added -lsqlite3

Parity with Android

  • Extracted GapsContent and SettingsContent so both screens adapt to iPad (24pt/32pt content padding, matching GapsScreen.kt:142 and SettingsScreen.kt:174), and so their previews render without Koin
  • @Environment(\.horizontalSizeClass) read from an App struct is never populated, so isCompact was permanently false and the bottom bar stayed expanded on iPhone too — moved the tab host into a RootView
  • Painted the app background behind the safe areas; they were falling through to the window's black (Android gets this from enableEdgeToEdge())
  • Fixed three String(localized:) calls referencing keys that do not exist, which rendered as raw key text in both locales
  • Adopted the unused deleteConfirmationAlert modifier at all four inline .alert sites, matching Android's shared DeleteConfirmationDialog.kt and giving the buttons the delete_dialog_cancel/delete_dialog_confirm identifiers the journeys drive
  • iPad previews now use traits: .portrait per CLAUDE.md, replacing .landscapeLeft and the #Preview-ignored .previewDevice; added the missing pairs to ClothingCard, CategoryFilterChips and WornBottomBar
  • Bottom bar: widened the compact layout (both platforms, per the update-both rule) and gave each tab an equal slot so long translations stay inside it — pt-BR "COMBINAÇÕES" was spilling over the adjacent pill

Share extension

  • CODE_SIGN_ENTITLEMENTS was set on no target, so group.com.github.worn was never applied and SharedPhotoInbox.consume() always read a nil container
  • Added the WornShareExtension target, its synchronized group, an Embed Foundation Extensions phase and the dependency plumbing. No Kotlin framework phase, per SETUP.md — the appex binary is 128 KB against the app's 28 MB, confirming it does not link the static framework
  • GENERATE_INFOPLIST_FILE is YES, not NO as SETUP.md suggested: with NO the checked-in plist supplies no CFBundleIdentifier and the build fails embedded-binary validation
  • Rewrote SETUP.md to describe the committed target instead of manual Xcode steps

CI and cleanup

  • Added a macos-latest job building the iosApp scheme, which also builds and embeds the extension
  • Committed a shared xcscheme; Xcode autocreates schemes into gitignored xcuserdata, so xcodebuild -scheme iosApp had nothing to resolve on a fresh checkout
  • Removed the untouched KMP template: ContentView.swift was still compiled into the app but never referenced, and was the only consumer of Greeting.kt and transitively Platform.kt and both actuals

Test plan

./gradlew detekt :shared:allTests :composeApp:assembleDebug

# the CI-equivalent iOS build — fails on main
xcodebuild build -project iosApp/iosApp.xcodeproj -scheme iosApp \
  -destination 'generic/platform=iOS Simulator' \
  CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO

Run on iPhone 16 and iPad Pro 11-inch in portrait:

  • All five tabs load; background is cream edge to edge, no black safe areas
  • iPhone: bar spans the width. iPad: centred 480pt pill
  • Settings → Your Profile shows "Tall and slim"/"Big and tall", not body_type_tall_slim; a slim-fit item's detail sheet shows "Slim", not fit_slim_fit
  • Gaps and Settings use 32pt side padding on iPad, matching Wardrobe and Outfits

Re-run in pt-BR (xcrun simctl launch <device> com.github.worn.Worn -AppleLanguages "(pt-BR)") — the bottom bar labels must stay inside their own slots.

Share handoff, without needing the share sheet:

GROUP=$(xcrun simctl get_app_container booted com.github.worn.Worn group.com.github.worn)
cp some.jpg "$GROUP/pending_share.jpg"
xcrun simctl launch booted com.github.worn.Worn

Worn should open on Try It with the photo loaded, and pending_share.jpg should be gone.

Xcode canvas: GapsScreen.swift and SettingsScreen.swift previews must render without initKoin() having run.

Verified

detekt, :shared:allTests and :composeApp:assembleDebug pass. iOS builds via the exact CI invocation. Ran on iPhone 16 (iOS 18.6), iPad Pro 11-inch and iPhone 17 Pro (iOS 26.5, exercising the weak-linked FoundationModels) in both en and pt-BR. App Group container resolves and the handoff file is consumed and deleted.

Not verified

Two checks need interactive taps that simctl cannot drive, and are left for review on a device:

  • The real Photos share sheet listing Worn, and openHostApp() bringing the app forward. The credential matrix is documented in iosApp/WornShareExtension/SETUP.md
  • The Settings on-device-AI toggle subtitle across Available/Downloadable/Unavailable(reason:)

TryItScreen still instantiates its stateful screen in previews, so its canvas needs Koin — left as a follow-up to keep this diff bounded.

Checklist

  • ./gradlew detekt passes
  • Tested on Android
  • Tested on iOS
  • Updated documentation (if applicable)

🤖 Generated with Claude Code

jvsena42 and others added 13 commits July 28, 2026 12:17
The Swift layer had never been built against the shared module, so it drifted
into an API that Kotlin/Native does not produce. CI runs on ubuntu-latest and
only ever compiled Kotlin, so nothing caught it. 136 compile errors and a link
failure; the app now builds for the simulator.

Kotlin/Native interop:

- Sealed interfaces export as flat Objective-C classes, not nested types, so
  WardrobeIntent.ClearSelection is WardrobeIntentClearSelection. Same for the
  other four Intent/Effect hierarchies and OnDeviceAiAvailability.
- Functions named init* get a `do` prefix to avoid clashing with ObjC init, so
  initKoin() is doInitKoin().
- isUsable is an extension property, which exports as a static function on
  OnDeviceAiAvailabilityKt rather than a member.
- Koin's own types are not part of the framework's exported API and never reach
  the generated header, so `koin.get(objCClass:)` could not work. Added a typed
  KoinHelper object in iosMain exposing one property per dependency.
- StateFlow exports as a non-generic protocol whose value is Any?, so Swift
  cannot infer FlowAdapter's element type. Named it explicitly at each call
  site and seeded state from currentValue. Effects are a plain Flow, which
  FlowAdapter cannot accept, so added EffectAdapter alongside it.

Name collisions and drift:

- Category collides with objc_category from objc/runtime.h and Material with
  SwiftUI.Material; both are now qualified as Shared.*.
- Kotlin default arguments do not reach Objective-C, so the preview
  constructors must pass every parameter: ClothingItem gained subcategory/fit/
  material, WardrobeState isAiAvailable, OutfitState allClothingItems.
- WornColors declared Identifiable conformance for ClothingItem and Outfit
  without importing Shared; the five ViewModel wrappers used withAnimation
  without importing SwiftUI.
- toKotlinByteArray/toData were declared in three files; consolidated into
  Services/ByteArrayBridge.swift.
- TryItScreen's tablet layout called an errorContent helper that was never
  written; extracted it from the phone layout so both share one definition.

Linking:

- Shared is a static framework, so SQLDelight's sqliter dependency leaves its
  sqlite3_* symbols for the app to resolve. Added -lsqlite3.

Behaviour change:

- OnDeviceAiService built its image prompt from Attachment and
  ImageAttachmentContent, neither of which exists. FoundationModels is
  text-only as of iOS 26.5, so on-device photo analysis is not possible on
  iOS; generate() now reports that clearly when given an image. Gap
  recommendations are text-only and still run on device. Android is
  unaffected, since Gemini Nano does accept an ImagePart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
String(localized:) falls back to rendering the raw key when it is missing, so
the profile chips showed "body_type_tall_slim" and the item detail sheet showed
"fit_slim_fit" instead of their translations, in both en and pt-BR.

The definitions are correct and match Android's strings.xml; only the Swift
references were wrong.

  body_type_tall_slim -> body_type_tall_and_slim
  body_type_big_tall  -> body_type_big_and_tall
  fit_slim_fit        -> fit_slim

Audited the rest: every key referenced from Swift now resolves, including the
31 subcategory_* keys that localizedSubcategoryName builds at runtime from the
Kotlin enum name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WornBottomBar already had an isCompact parameter driving a 480pt centred pill
with wider side padding, mirroring Android's WornBottomBar.kt, but iOSApp never
passed it. The parameter defaults to true, so iPad rendered the full-width
phone bar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Android splits every screen into a stateful wrapper plus a pure *Content
composable taking isCompact, and on these two the effect is a 24dp/32dp content
padding (GapsScreen.kt:142, SettingsScreen.kt:174). iOS already followed that
shape in WardrobeScreen and OutfitsScreen, but Gaps and Settings were
monolithic and hardcoded 24pt, so they stayed at phone padding on iPad.

Extracted GapsContent and SettingsContent, leaving sheet presentation and
ViewModel wiring in the stateful wrappers.

This also fixes their previews. Both previously instantiated the stateful
screen, whose @StateObject resolves a ViewModel from Koin, so the Xcode canvas
only rendered if initKoin() had already run. The new previews take state
directly and render standalone, with iPad in portrait per the repo convention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e code

Previews: CLAUDE.md asks for iPhone and iPad *portrait* previews, but seven
component previews used traits: .landscapeLeft, and the rest set
.previewDevice, a PreviewProvider-era modifier the #Preview macro ignores.
Both now use traits: .portrait. Added the missing iPhone/iPad pairs to
ClothingCard, CategoryFilterChips and WornBottomBar (covering both isCompact
values). CameraView and PhotoCover are skipped: one wraps
UIImagePickerController, the other is a plain enum.

DeleteConfirmationDialog defined a deleteConfirmationAlert modifier that
nothing used, while four screens inlined their own .alert. Adopted it at all
four call sites, matching Android's shared DeleteConfirmationDialog.kt. This
also gives the buttons the delete_dialog_cancel and delete_dialog_confirm
identifiers, which the Android journeys already drive.

Removed the untouched KMP template: ContentView.swift was still compiled into
the app but never referenced, and was the only consumer of Greeting.kt and,
transitively, Platform.kt and both actuals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
iosApp/iosApp.entitlements declares group.com.github.worn, but
CODE_SIGN_ENTITLEMENTS was set on no target, so it was never applied and
FileManager.containerURL(forSecurityApplicationGroupIdentifier:) always
returned nil. SharedPhotoInbox.consume() could therefore never find a handoff
file, which would have made share-to-Worn silently do nothing even once the
extension target exists.

Verified on the iPhone 16 simulator: get_app_container now resolves
group.com.github.worn, and planting a JPEG as pending_share.jpg in that
container makes the app open on Try It and delete the file, which is exactly
what WornShareExtension does at runtime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"WARDROBE" is the longest of the five labels and wrapped to a second line on a
compact bar, which clipped the descender. Constrain it to one line and let it
shrink slightly instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each screen sets bgPage within its own bounds, but the root container had no
background, so the status bar and home indicator areas fell through to the
window's black. Android gets this from enableEdgeToEdge() plus the theme
background; iOS needs the colour explicitly extended past the safe area.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Environment on an App conformer is not filled in from the view hierarchy, so
horizontalSizeClass read there was always nil and `sizeClass == .compact` was
permanently false. The bottom bar therefore stayed in its expanded layout on
iPhone too: capped at 480pt with 32pt side padding.

Moved the tab host and its state into a RootView, where the environment
resolves. Verified on both simulators: iPhone 16 now spans the full width,
iPad Pro 11-inch keeps the centred 480pt pill.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drop the compact side padding from 21 to 10 so the bar has more room and the
tab labels sit comfortably inside their pills. Applied to both platforms to
keep the layouts in step; the expanded (tablet) padding is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Swift sources, Info.plist and entitlements were already complete, but the
project had exactly one target, so the extension was never built and
share-to-Worn could not work at all. Added the app-extension target, its
synchronized group, an Embed Foundation Extensions phase on the app, and the
dependency plumbing.

Two things SETUP.md was emphatic about, both preserved:

- No Compile Kotlin Framework phase on the extension. Shared is a static
  framework, so linking it would duplicate the whole Kotlin binary into a
  memory-capped process. Verified: the appex binary is 128 KB against the app's
  28 MB, and otool shows no Shared linkage.
- openHostApp()'s responder-chain walk is kept, since NSExtensionContext.open
  does not launch the host app from a share extension.

GENERATE_INFOPLIST_FILE is YES rather than NO as SETUP.md suggested. With NO,
the checked-in plist supplies no CFBundleIdentifier and the build fails
embedded-binary validation; YES matches the app target, which also merges a
partial plist. Bundle ids now resolve to com.github.worn.Worn and
com.github.worn.Worn.ShareExtension.

Rewrote SETUP.md to describe the committed target rather than the manual steps.

Verified end to end on the iPhone 16 simulator: planting a JPEG as
pending_share.jpg in the App Group container opens Worn on Try It and removes
the file. Driving the real share sheet still needs manual interaction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI ran only on ubuntu-latest, so the Swift side was never compiled. That is how
136 compile errors, a missing -lsqlite3 and three broken localization keys all
reached main unnoticed.

Also commits a shared xcscheme. Xcode autocreates schemes into xcuserdata,
which is gitignored, so `xcodebuild -scheme iosApp` had nothing to resolve on a
fresh checkout. .gitignore already whitelists xcodeproj/xcshareddata.

Building the iosApp scheme also builds and embeds WornShareExtension.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each TabItem was sized to its label, so the widest translation stole width from
its neighbours. In pt-BR "COMBINAÇÕES" spilled over the adjacent tab's pill.
Give every tab an equal slot and let the label shrink further, truncating only
if it still will not fit — Android already ellipsises for the same reason.

Found by running the app with -AppleLanguages "(pt-BR)"; the English labels
were short enough to hide it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jvsena42
jvsena42 enabled auto-merge July 28, 2026 17:26
@jvsena42
jvsena42 merged commit d2755ed into main Jul 28, 2026
3 of 4 checks passed
@jvsena42
jvsena42 deleted the fix/ios-parity branch July 28, 2026 17:36
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