fix: make the iOS app build, and close the parity gaps with Android - #35
Merged
Conversation
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>
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.
Summary
The iOS app did not compile.
TabinGapsScreen/SettingsScreenwas 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.ymlran only onubuntu-latestand never compiled Swift. Three brokenString(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/actualpairs have real iOS actuals,iosModulebinds the same 11 slots asandroidModule, and the on-device AI feature from 4922618 is fully wired. No stubs orTODOs inshared/src/iosMain.One behaviour change worth reviewing
OnDeviceAiServicebuilt its image prompt fromAttachmentandImageAttachmentContent. Neither exists — the iOS 26.5.swiftinterfaceshows FoundationModels is text-only (the onlyAttachmentsymbols 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 anImagePart.Changes
Kotlin/Native interop (the bulk of the 136 errors)
WardrobeIntent.ClearSelectionisWardrobeIntentClearSelection— 36 sites across five Intent/Effect hierarchies plusOnDeviceAiAvailabilityinit*functions get adoprefix to avoid clashing with ObjCinit:initKoin()isdoInitKoin()isUsableis an extension property, exported as a static onOnDeviceAiAvailabilityKt, not a memberkoin.get(objCClass:)could never work — added a typedobject KoinHelperiniosMainexposing one property per dependencyStateFlowexports as a non-generic protocol whosevalueisAny?, soFlowAdapter's element type is named explicitly at each call site; effects are a plainFlow, whichFlowAdaptercannot take, so addedEffectAdapteralongside itName collisions and drift
Categorycollides withobjc_categoryfromobjc/runtime.handMaterialwithSwiftUI.Material; both qualified asShared.*ClothingItem.subcategory/fit/material,WardrobeState.isAiAvailable,OutfitState.allClothingItems)WornColorsdeclaredIdentifiableconformance without importingShared; the five ViewModel wrappers usedwithAnimationwithout importingSwiftUItoKotlinByteArray/toDatawere declared in three files — consolidated intoServices/ByteArrayBridge.swiftTryItScreen's tablet layout called anerrorContenthelper that was never written; extracted from the phone layout so both share one definitionLinking
Sharedis a static framework, so SQLDelight's sqliter dependency leaves itssqlite3_*symbols to the app — added-lsqlite3Parity with Android
GapsContentandSettingsContentso both screens adapt to iPad (24pt/32pt content padding, matchingGapsScreen.kt:142andSettingsScreen.kt:174), and so their previews render without Koin@Environment(\.horizontalSizeClass)read from anAppstruct is never populated, soisCompactwas permanently false and the bottom bar stayed expanded on iPhone too — moved the tab host into aRootViewenableEdgeToEdge())String(localized:)calls referencing keys that do not exist, which rendered as raw key text in both localesdeleteConfirmationAlertmodifier at all four inline.alertsites, matching Android's sharedDeleteConfirmationDialog.ktand giving the buttons thedelete_dialog_cancel/delete_dialog_confirmidentifiers the journeys drivetraits: .portraitper CLAUDE.md, replacing.landscapeLeftand the#Preview-ignored.previewDevice; added the missing pairs toClothingCard,CategoryFilterChipsandWornBottomBarShare extension
CODE_SIGN_ENTITLEMENTSwas set on no target, sogroup.com.github.wornwas never applied andSharedPhotoInbox.consume()always read a nil containerWornShareExtensiontarget, 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 frameworkGENERATE_INFOPLIST_FILEisYES, notNOas SETUP.md suggested: withNOthe checked-in plist supplies noCFBundleIdentifierand the build fails embedded-binary validationCI and cleanup
macos-latestjob building theiosAppscheme, which also builds and embeds the extensionxcuserdata, soxcodebuild -scheme iosApphad nothing to resolve on a fresh checkoutContentView.swiftwas still compiled into the app but never referenced, and was the only consumer ofGreeting.ktand transitivelyPlatform.ktand both actualsTest plan
Run on iPhone 16 and iPad Pro 11-inch in portrait:
body_type_tall_slim; a slim-fit item's detail sheet shows "Slim", notfit_slim_fitRe-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:
Worn should open on Try It with the photo loaded, and
pending_share.jpgshould be gone.Xcode canvas:
GapsScreen.swiftandSettingsScreen.swiftpreviews must render withoutinitKoin()having run.Verified
detekt,:shared:allTestsand:composeApp:assembleDebugpass. 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
simctlcannot drive, and are left for review on a device:openHostApp()bringing the app forward. The credential matrix is documented iniosApp/WornShareExtension/SETUP.mdAvailable/Downloadable/Unavailable(reason:)TryItScreenstill instantiates its stateful screen in previews, so its canvas needs Koin — left as a follow-up to keep this diff bounded.Checklist
./gradlew detektpasses🤖 Generated with Claude Code