diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e08e8ba..9cd1fd0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,3 +38,32 @@ jobs: - name: Build Android debug APK run: ./gradlew :composeApp:assembleDebug + + ios: + if: github.event.pull_request.draft == false + runs-on: macos-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + # The Compile Kotlin Framework build phase shells out to + # ./gradlew :shared:embedAndSignAppleFrameworkForXcode, so the JDK is needed here too. + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + # Builds WornShareExtension as well, via the app's Embed Foundation Extensions phase. + - name: Build iOS app + run: | + xcodebuild build \ + -project iosApp/iosApp.xcodeproj \ + -scheme iosApp \ + -destination 'generic/platform=iOS Simulator' \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/components/WornBottomBar.kt b/composeApp/src/main/kotlin/com/github/worn/ui/components/WornBottomBar.kt index 7cc1597..c9a5af6 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/components/WornBottomBar.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/components/WornBottomBar.kt @@ -66,8 +66,8 @@ fun WornBottomBar( modifier = modifier .fillMaxWidth() .padding( - start = if (isCompact) 21.dp else 32.dp, - end = if (isCompact) 21.dp else 32.dp, + start = if (isCompact) 10.dp else 32.dp, + end = if (isCompact) 10.dp else 32.dp, top = 12.dp, bottom = 21.dp, ), diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig index 72e4500..4aa5852 100644 --- a/iosApp/Configuration/Config.xcconfig +++ b/iosApp/Configuration/Config.xcconfig @@ -9,4 +9,8 @@ MARKETING_VERSION=1.0 // FoundationModels (Apple Intelligence) only exists from iOS 26, but the app deploys back to // 18.2. Weak linking lets it launch on older systems, where OnDeviceAiService reports the // feature as unavailable via its #available guard. -OTHER_LDFLAGS=$(inherited) -weak_framework FoundationModels \ No newline at end of file +// +// -lsqlite3 is required by SQLDelight's NativeSqliteDriver: `Shared` is a static framework +// (isStatic = true in shared/build.gradle.kts), so its sqliter dependency's sqlite3_* symbols +// are left for whoever links the framework to resolve. +OTHER_LDFLAGS=$(inherited) -weak_framework FoundationModels -lsqlite3 \ No newline at end of file diff --git a/iosApp/WornShareExtension/SETUP.md b/iosApp/WornShareExtension/SETUP.md index 87deb74..a1e3008 100644 --- a/iosApp/WornShareExtension/SETUP.md +++ b/iosApp/WornShareExtension/SETUP.md @@ -1,76 +1,79 @@ -# WornShareExtension — Xcode setup +# WornShareExtension -The Swift sources, `Info.plist`, and entitlements in this folder are complete. The target itself -must be created in Xcode, because `iosApp.xcodeproj/project.pbxproj` uses the synchronized-folder -layout (`objectVersion = 77`) and hand-editing it is error-prone. +Shares a photo from any app into Worn's Try It screen. -Do this once, on a machine with Xcode. +The target is committed to `iosApp.xcodeproj` — there is no manual Xcode setup left to do. Open +the project and it builds alongside the app. -## 1. Create the target +## How the handoff works -**File → New → Target… → iOS → Share Extension** +The extension deliberately does **not** link the `Shared` framework. `shared/build.gradle.kts` +sets `isStatic = true`, so linking it would copy the whole Kotlin binary into a process that runs +under a tight memory cap. The entire handoff is one file in an App Group: -- Product Name: `WornShareExtension` -- Embed in Application: `Worn` -- Language: Swift -- When prompted to activate the new scheme: **Cancel** (keep the `iosApp` scheme). +1. `ShareViewController` writes the image to `group.com.github.worn/pending_share.jpg`. +2. It then walks the responder chain to reach `UIApplication` and open `worn://tryit`. +3. `RootView.receiveSharedPhoto()` calls `SharedPhotoInbox.consume()`, which reads the file and + deletes it, and switches to the Try It tab. -Xcode generates its own `ShareViewController.swift`, `MainInterface.storyboard`, and `Info.plist` -under a new group. **Delete all three** (Move to Trash), then drag this `WornShareExtension/` -folder into the project and assign it to the `WornShareExtension` target. +Step 2 is a workaround: `NSExtensionContext.open(_:)` does not launch the host app from a share +extension on current iOS. It is widely shipped but is not blessed API and has occasionally drawn +App Review attention. If you would rather not ship it, delete `openHostApp()` and its call — the +extension still parks the file, and `RootView`'s `scenePhase` observer picks it up the next time +the user opens Worn themselves. -## 2. Build settings for the extension target +## Target configuration + +Set in `project.pbxproj`, and worth preserving if the target is ever recreated: | Setting | Value | |---|---| +| `PRODUCT_BUNDLE_IDENTIFIER` | `$(inherited).ShareExtension` — resolves to `com.github.worn.Worn$(TEAM_ID).ShareExtension`, and must stay a child of the app id or embedded-binary validation fails | | `INFOPLIST_FILE` | `WornShareExtension/Info.plist` | -| `GENERATE_INFOPLIST_FILE` | `NO` | -| `PRODUCT_BUNDLE_IDENTIFIER` | `$(inherited).ShareExtension` — must be a child of the app id | -| `IPHONEOS_DEPLOYMENT_TARGET` | `18.2` — match the app | +| `GENERATE_INFOPLIST_FILE` | `YES` — the checked-in plist only carries `NSExtension` and the display name; Xcode synthesises `CFBundleIdentifier` and friends, same as the app target | | `CODE_SIGN_ENTITLEMENTS` | `WornShareExtension/WornShareExtension.entitlements` | +| `IPHONEOS_DEPLOYMENT_TARGET` | `18.2` — matches the app | -The app id comes from `iosApp/Configuration/Config.xcconfig` -(`com.github.worn.Worn$(TEAM_ID)`), so make sure the extension target also picks up that xcconfig. +Both targets carry `group.com.github.worn`; the app's entitlement lives in +`iosApp/iosApp.entitlements`. Without it on **both** sides the extension writes to a container +the app cannot read and the share silently does nothing. -If Xcode copied the **Compile Kotlin Framework** run-script build phase onto the new target, -delete it. The extension does not link the `Shared` framework on purpose: it is a static framework -(`shared/build.gradle.kts`, `isStatic = true`), so linking would duplicate the whole binary into a -process that runs under a tight memory cap. Parking one file in the App Group is the entire handoff. +The extension has no `Compile Kotlin Framework` build phase. Do not add one. -## 3. App Groups capability — required on BOTH targets +## Verifying -**Signing & Capabilities → + Capability → App Groups**, then add `group.com.github.worn` to: +The app side is deterministic and does not need the share sheet: -- the `Worn` app target — also point its `CODE_SIGN_ENTITLEMENTS` at `iosApp/iosApp.entitlements` -- the `WornShareExtension` target +```shell +xcrun simctl boot "iPhone 16" +xcodebuild -project iosApp/iosApp.xcodeproj -target iosApp -sdk iphonesimulator -arch arm64 \ + -configuration Debug CODE_SIGN_IDENTITY=- CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM= build +xcrun simctl install booted iosApp/build/Debug-iphonesimulator/Worn.app -Without the group on both sides, the extension writes to a container the app cannot read and the -share silently does nothing. +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 +``` -## 4. Verify +Worn should open on **Try It** with the photo in the upload zone, and `pending_share.jpg` should +be gone — `consume()` deletes it either way, so an unreadable file cannot re-trigger forever. -Share extensions do **not** appear in the Simulator's Photos share sheet, so this needs a real -device. +The extension binary should stay tiny, which is how you know it is not pulling in `Shared`: -1. Run the `Worn` scheme on a device. -2. Photos → pick an image → Share → Worn. -3. The app should open on **Try It** with the photo already in the upload zone. +```shell +du -k iosApp/build/Debug-iphonesimulator/Worn.app/PlugIns/WornShareExtension.appex/WornShareExtension +# ~128 KB, against ~28 MB for the app binary +``` -Then walk the credential matrix in Settings, sharing an image after each change: +Then exercise the real share sheet — Photos → pick an image → Share → Worn — and walk the +credential matrix in Settings, sharing an image after each change: | Claude key | YouCam creds | Expected | |---|---|---| -| ✅ | ❌ | Lands on Try It, no dialog, *Analyze* button visible | -| ❌ | ✅ | Lands on Try It, scrolled to the try-on section, no dialog | -| ✅ | ✅ | Chooser dialog; each choice scrolls to its section, both stay visible | +| ✅ | ❌ | Try It, no dialog, *Analyze* visible | +| ❌ | ✅ | Try It, scrolled to the try-on section, no dialog | +| ✅ | ✅ | Chooser dialog; each choice scrolls to its section | | ❌ | ❌ | Locked state with *Open Settings* | -## Known caveat: launching the app from the extension - -`NSExtensionContext.open(_:)` does not launch the host app from a share extension on current iOS. -`ShareViewController.openHostApp()` therefore walks the responder chain to reach `UIApplication`. -This is widely shipped but is not blessed API and has occasionally drawn App Review attention. - -If you would rather not ship it, delete `openHostApp()` and its call. The extension still writes -the handoff file, and `iOSApp.receiveSharedPhoto()` already picks it up on the next foreground via -the `scenePhase` observer — correct behaviour, but the user has to open Worn themselves. +Try the simulator first. If Worn does not appear in the share sheet, or `openHostApp()` does not +bring the app forward, repeat on a real device before concluding anything is broken. diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index c7f3380..fb97d61 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -6,8 +6,37 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + C1F0A20000000000000000E2 /* WornShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C1F0A20000000000000000E1 /* WornShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + C1F0A20000000000000000E6 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E28CFCEF65247A001A747C49 /* Project object */; + proxyType = 1; + remoteGlobalIDString = C1F0A20000000000000000E8; + remoteInfo = WornShareExtension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + C1F0A20000000000000000E5 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + C1F0A20000000000000000E2 /* WornShareExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 375939204A54EA4B83855EEE /* Worn.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Worn.app; sourceTree = BUILT_PRODUCTS_DIR; }; + C1F0A20000000000000000E1 /* WornShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WornShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -18,6 +47,14 @@ ); target = 65C06C6A4E3A64F19C5435A6 /* iosApp */; }; + C1F0A20000000000000000E3 /* Exceptions for "WornShareExtension" folder in "WornShareExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + WornShareExtension.entitlements, + ); + target = C1F0A20000000000000000E8 /* WornShareExtension */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -34,6 +71,14 @@ path = Configuration; sourceTree = ""; }; + C1F0A20000000000000000E4 /* WornShareExtension */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + C1F0A20000000000000000E3 /* Exceptions for "WornShareExtension" folder in "WornShareExtension" target */, + ); + path = WornShareExtension; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -44,6 +89,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C1F0A20000000000000000EA /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -52,6 +104,7 @@ children = ( 54D6C596FC23177E2BA67FF5 /* Configuration */, 6FC0AF4C5CC7C6AD4ACC4053 /* iosApp */, + C1F0A20000000000000000E4 /* WornShareExtension */, 77FB26EA8ED0D9F4C68596E7 /* Products */, ); sourceTree = ""; @@ -60,6 +113,7 @@ isa = PBXGroup; children = ( 375939204A54EA4B83855EEE /* Worn.app */, + C1F0A20000000000000000E1 /* WornShareExtension.appex */, ); name = Products; sourceTree = ""; @@ -75,10 +129,12 @@ 0AE85E8F7B90AE20C30ED99D /* Sources */, 0C984CE3CF6BF656B65BE7C8 /* Frameworks */, 6229B8DC5CC055CC5DD0FDB4 /* Resources */, + C1F0A20000000000000000E5 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + C1F0A20000000000000000E7 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 6FC0AF4C5CC7C6AD4ACC4053 /* iosApp */, @@ -90,6 +146,28 @@ productReference = 375939204A54EA4B83855EEE /* Worn.app */; productType = "com.apple.product-type.application"; }; + C1F0A20000000000000000E8 /* WornShareExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = C1F0A20000000000000000EC /* Build configuration list for PBXNativeTarget "WornShareExtension" */; + buildPhases = ( + C1F0A20000000000000000E9 /* Sources */, + C1F0A20000000000000000EA /* Frameworks */, + C1F0A20000000000000000EB /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + C1F0A20000000000000000E4 /* WornShareExtension */, + ); + name = WornShareExtension; + packageProductDependencies = ( + ); + productName = WornShareExtension; + productReference = C1F0A20000000000000000E1 /* WornShareExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -103,6 +181,9 @@ 65C06C6A4E3A64F19C5435A6 = { CreatedOnToolsVersion = 16.2; }; + C1F0A20000000000000000E8 = { + CreatedOnToolsVersion = 26.5; + }; }; }; buildConfigurationList = DDD68ADE7DFF3706650F6F99 /* Build configuration list for PBXProject "iosApp" */; @@ -121,6 +202,7 @@ projectRoot = ""; targets = ( 65C06C6A4E3A64F19C5435A6 /* iosApp */, + C1F0A20000000000000000E8 /* WornShareExtension */, ); }; /* End PBXProject section */ @@ -133,6 +215,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C1F0A20000000000000000EB /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -157,6 +246,14 @@ }; /* End PBXShellScriptBuildPhase section */ +/* Begin PBXTargetDependency section */ + C1F0A20000000000000000E7 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C1F0A20000000000000000E8 /* WornShareExtension */; + targetProxy = C1F0A20000000000000000E6 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXSourcesBuildPhase section */ 0AE85E8F7B90AE20C30ED99D /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -165,6 +262,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C1F0A20000000000000000E9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ @@ -297,6 +401,7 @@ ARCHS = arm64; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = iosApp/iosApp.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; @@ -325,6 +430,7 @@ ARCHS = arm64; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = iosApp/iosApp.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; @@ -347,6 +453,54 @@ }; name = Release; }; + C1F0A20000000000000000ED /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = arm64; + CODE_SIGN_ENTITLEMENTS = WornShareExtension/WornShareExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = "${TEAM_ID}"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WornShareExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "$(inherited).ShareExtension"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + C1F0A20000000000000000EE /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = arm64; + CODE_SIGN_ENTITLEMENTS = WornShareExtension/WornShareExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = "${TEAM_ID}"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WornShareExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "$(inherited).ShareExtension"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -368,6 +522,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + C1F0A20000000000000000EC /* Build configuration list for PBXNativeTarget "WornShareExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C1F0A20000000000000000ED /* Debug */, + C1F0A20000000000000000EE /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = E28CFCEF65247A001A747C49 /* Project object */; diff --git a/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme new file mode 100644 index 0000000..9e74507 --- /dev/null +++ b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iosApp/iosApp/Components/AiLockedSheet.swift b/iosApp/iosApp/Components/AiLockedSheet.swift index 7c7b2a8..82f1bcf 100644 --- a/iosApp/iosApp/Components/AiLockedSheet.swift +++ b/iosApp/iosApp/Components/AiLockedSheet.swift @@ -64,7 +64,6 @@ struct AiLockedSheet: View { AiLockedSheet(onDismiss: {}) } -#Preview("iPad Portrait") { +#Preview("iPad Portrait", traits: .portrait) { AiLockedSheet(onDismiss: {}) - .previewDevice(PreviewDevice(rawValue: "iPad Pro (11-inch)")) } diff --git a/iosApp/iosApp/Components/CategoryFilterChips.swift b/iosApp/iosApp/Components/CategoryFilterChips.swift index 42348f6..2732542 100644 --- a/iosApp/iosApp/Components/CategoryFilterChips.swift +++ b/iosApp/iosApp/Components/CategoryFilterChips.swift @@ -2,10 +2,10 @@ import SwiftUI import Shared struct CategoryFilterChips: View { - let activeCategory: Category? - let onCategorySelected: (Category?) -> Void + let activeCategory: Shared.Category? + let onCategorySelected: (Shared.Category?) -> Void - private var allChips: [(category: Category?, label: String)] { + private var allChips: [(category: Shared.Category?, label: String)] { [ (nil, String(localized: "filter_all")), (.top, String(localized: "category_tops")), @@ -30,3 +30,21 @@ struct CategoryFilterChips: View { } } } + +#Preview("iPhone") { + VStack(alignment: .leading, spacing: 16) { + CategoryFilterChips(activeCategory: nil, onCategorySelected: { _ in }) + CategoryFilterChips(activeCategory: .top, onCategorySelected: { _ in }) + } + .padding(24) + .background(WornColors.bgPage) +} + +#Preview("iPad Portrait", traits: .portrait) { + VStack(alignment: .leading, spacing: 16) { + CategoryFilterChips(activeCategory: nil, onCategorySelected: { _ in }) + CategoryFilterChips(activeCategory: .shoes, onCategorySelected: { _ in }) + } + .padding(32) + .background(WornColors.bgPage) +} diff --git a/iosApp/iosApp/Components/ClothingCard.swift b/iosApp/iosApp/Components/ClothingCard.swift index a9968a9..475f47b 100644 --- a/iosApp/iosApp/Components/ClothingCard.swift +++ b/iosApp/iosApp/Components/ClothingCard.swift @@ -75,7 +75,7 @@ struct ClothingCard: View { } } - private func dotColor(for category: Category) -> Color { + private func dotColor(for category: Shared.Category) -> Color { switch category { case .top: return WornColors.categoryDotTop case .bottom: return WornColors.categoryDotBottom @@ -86,7 +86,7 @@ struct ClothingCard: View { } } - private func displayLabel(for category: Category) -> String { + private func displayLabel(for category: Shared.Category) -> String { switch category { case .top: return String(localized: "category_tops") case .bottom: return String(localized: "category_bottoms") @@ -97,3 +97,27 @@ struct ClothingCard: View { } } } + +private let previewCardItem = ClothingItem( + id: "1", name: "Black T-Shirt", category: .top, colors: ["black"], seasons: [], + tags: [], description: nil, subcategory: .tShirt, fit: .regular, material: .cotton, + photoPath: "", createdAt: 0 +) + +#Preview("iPhone") { + HStack(spacing: 12) { + ClothingCard(item: previewCardItem) + ClothingCard(item: previewCardItem, isSelected: true, isSelectionMode: true) + } + .padding(24) + .background(WornColors.bgPage) +} + +#Preview("iPad Portrait", traits: .portrait) { + HStack(spacing: 16) { + ClothingCard(item: previewCardItem, photoHeight: 200) + ClothingCard(item: previewCardItem, photoHeight: 200, isSelected: true, isSelectionMode: true) + } + .padding(32) + .background(WornColors.bgPage) +} diff --git a/iosApp/iosApp/Components/CropEditorView.swift b/iosApp/iosApp/Components/CropEditorView.swift index d32bfc3..640d828 100644 --- a/iosApp/iosApp/Components/CropEditorView.swift +++ b/iosApp/iosApp/Components/CropEditorView.swift @@ -261,7 +261,6 @@ private extension Color { CropEditorView(imageData: previewImageData(), onCropped: { _ in }, onCancel: {}) } -#Preview("iPad") { +#Preview("iPad Portrait", traits: .portrait) { CropEditorView(imageData: previewImageData(), onCropped: { _ in }, onCancel: {}) - .previewDevice(PreviewDevice(rawValue: "iPad Pro (11-inch)")) } diff --git a/iosApp/iosApp/Components/CropPhotoButton.swift b/iosApp/iosApp/Components/CropPhotoButton.swift index c8c10c0..3498ccb 100644 --- a/iosApp/iosApp/Components/CropPhotoButton.swift +++ b/iosApp/iosApp/Components/CropPhotoButton.swift @@ -26,7 +26,6 @@ struct CropPhotoButton: View { CropPhotoButton(action: {}) } -#Preview("iPad") { +#Preview("iPad Portrait", traits: .portrait) { CropPhotoButton(action: {}) - .previewDevice(PreviewDevice(rawValue: "iPad Pro (11-inch)")) } diff --git a/iosApp/iosApp/Components/DeleteConfirmationDialog.swift b/iosApp/iosApp/Components/DeleteConfirmationDialog.swift index a9590fb..142f32d 100644 --- a/iosApp/iosApp/Components/DeleteConfirmationDialog.swift +++ b/iosApp/iosApp/Components/DeleteConfirmationDialog.swift @@ -45,7 +45,7 @@ extension View { ) } -#Preview("iPad") { +#Preview("iPad Portrait", traits: .portrait) { Color.clear .deleteConfirmationAlert( title: "Delete 3 items?", @@ -53,5 +53,4 @@ extension View { isPresented: .constant(true), onConfirm: {} ) - .previewDevice(PreviewDevice(rawValue: "iPad Pro (11-inch)")) } diff --git a/iosApp/iosApp/Components/EmptyStateView.swift b/iosApp/iosApp/Components/EmptyStateView.swift index 9235205..86cdd00 100644 --- a/iosApp/iosApp/Components/EmptyStateView.swift +++ b/iosApp/iosApp/Components/EmptyStateView.swift @@ -78,7 +78,7 @@ extension EmptyStateView where Action == EmptyView { ) } -#Preview("iPad", traits: .landscapeLeft) { +#Preview("iPad Portrait", traits: .portrait) { EmptyStateView( icon: { Image(systemName: "tshirt") diff --git a/iosApp/iosApp/Components/ErrorContentView.swift b/iosApp/iosApp/Components/ErrorContentView.swift index e543a04..c86d33b 100644 --- a/iosApp/iosApp/Components/ErrorContentView.swift +++ b/iosApp/iosApp/Components/ErrorContentView.swift @@ -46,7 +46,7 @@ struct ErrorContentView: View { .padding(.vertical, 60) } -#Preview("iPad", traits: .landscapeLeft) { +#Preview("iPad Portrait", traits: .portrait) { ErrorContentView( message: "Something went wrong. Please try again.", onRetry: {}, diff --git a/iosApp/iosApp/Components/PropertyRow.swift b/iosApp/iosApp/Components/PropertyRow.swift index 2883439..e568030 100644 --- a/iosApp/iosApp/Components/PropertyRow.swift +++ b/iosApp/iosApp/Components/PropertyRow.swift @@ -26,7 +26,7 @@ struct PropertyRow: View { .padding() } -#Preview("iPad", traits: .landscapeLeft) { +#Preview("iPad Portrait", traits: .portrait) { VStack(spacing: 12) { PropertyRow(label: "Season", value: "Summer") PropertyRow(label: "Fit", value: "Regular") diff --git a/iosApp/iosApp/Components/SelectionHeader.swift b/iosApp/iosApp/Components/SelectionHeader.swift index 4b2a24a..e521864 100644 --- a/iosApp/iosApp/Components/SelectionHeader.swift +++ b/iosApp/iosApp/Components/SelectionHeader.swift @@ -41,7 +41,7 @@ struct SelectionHeader: View { .padding() } -#Preview("iPad", traits: .landscapeLeft) { +#Preview("iPad Portrait", traits: .portrait) { SelectionHeader(count: 5) .padding() } diff --git a/iosApp/iosApp/Components/SelectionIndicator.swift b/iosApp/iosApp/Components/SelectionIndicator.swift index 7bfafdf..9928e5b 100644 --- a/iosApp/iosApp/Components/SelectionIndicator.swift +++ b/iosApp/iosApp/Components/SelectionIndicator.swift @@ -36,7 +36,7 @@ struct SelectionIndicator: View { .padding() } -#Preview("iPad", traits: .landscapeLeft) { +#Preview("iPad Portrait", traits: .portrait) { HStack(spacing: 16) { SelectionIndicator(isSelected: false) SelectionIndicator(isSelected: true) diff --git a/iosApp/iosApp/Components/WornBottomBar.swift b/iosApp/iosApp/Components/WornBottomBar.swift index 1f2d891..bc1d487 100644 --- a/iosApp/iosApp/Components/WornBottomBar.swift +++ b/iosApp/iosApp/Components/WornBottomBar.swift @@ -52,6 +52,10 @@ struct WornBottomBar: View { TabItem(tab: tab, isActive: tab == activeTab) { onTabSelected(tab) } + // Equal slots. Without this the HStack sizes each item to its label, so a + // long translation ("COMBINAÇÕES" in pt-BR) steals width from its neighbours + // and spills over the active tab's pill. + .frame(maxWidth: .infinity) .accessibilityIdentifier(tab.testTag) } } @@ -68,12 +72,28 @@ struct WornBottomBar: View { if !isCompact { Spacer() } } - .padding(.horizontal, isCompact ? 21 : 32) + .padding(.horizontal, isCompact ? 10 : 32) .padding(.top, 12) .padding(.bottom, 21) } } +#Preview("iPhone") { + VStack { + Spacer() + WornBottomBar(activeTab: .wardrobe, onTabSelected: { _ in }, isCompact: true) + } + .background(WornColors.bgPage) +} + +#Preview("iPad Portrait", traits: .portrait) { + VStack { + Spacer() + WornBottomBar(activeTab: .gaps, onTabSelected: { _ in }, isCompact: false) + } + .background(WornColors.bgPage) +} + private struct TabItem: View { let tab: WornTab let isActive: Bool @@ -87,6 +107,13 @@ private struct TabItem: View { Text(tab.label) .font(.system(size: 10, weight: .semibold)) .tracking(0.5) + // Labels wrap on a 5-tab compact bar, which clips the descender. Shrink + // instead, down far enough for the longest translation to fit its slot, and + // truncate rather than overflow if even that is not enough. + .lineLimit(1) + .minimumScaleFactor(0.6) + .truncationMode(.tail) + .padding(.horizontal, 2) } .foregroundColor(isActive ? WornColors.textOnColor : WornColors.textSecondary) .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/iosApp/iosApp/Components/WornChip.swift b/iosApp/iosApp/Components/WornChip.swift index f6d9c49..fc92e1a 100644 --- a/iosApp/iosApp/Components/WornChip.swift +++ b/iosApp/iosApp/Components/WornChip.swift @@ -31,7 +31,7 @@ struct WornChip: View { .padding() } -#Preview("iPad", traits: .landscapeLeft) { +#Preview("iPad Portrait", traits: .portrait) { HStack(spacing: 8) { WornChip(label: "Summer", isActive: false, onTap: {}) WornChip(label: "Winter", isActive: true, onTap: {}) diff --git a/iosApp/iosApp/Components/WornGradientButton.swift b/iosApp/iosApp/Components/WornGradientButton.swift index dec247e..6dfed2a 100644 --- a/iosApp/iosApp/Components/WornGradientButton.swift +++ b/iosApp/iosApp/Components/WornGradientButton.swift @@ -86,7 +86,7 @@ private extension Color { .padding() } -#Preview("iPad", traits: .landscapeLeft) { +#Preview("iPad Portrait", traits: .portrait) { VStack(spacing: 16) { WornGradientButton(text: "Save to Wardrobe", action: {}) } diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift deleted file mode 100644 index a206b8a..0000000 --- a/iosApp/iosApp/ContentView.swift +++ /dev/null @@ -1,33 +0,0 @@ -import SwiftUI -import Shared - -struct ContentView: View { - @State private var showContent = false - var body: some View { - VStack { - Button("Click me!") { - withAnimation { - showContent = !showContent - } - } - - if showContent { - VStack(spacing: 16) { - Image(systemName: "swift") - .font(.system(size: 200)) - .foregroundColor(.accentColor) - Text("SwiftUI: \(Greeting().greet())") - } - .transition(.move(edge: .top).combined(with: .opacity)) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .padding() - } -} - -struct ContentView_Previews: PreviewProvider { - static var previews: some View { - ContentView() - } -} diff --git a/iosApp/iosApp/Screens/AddItemSheet.swift b/iosApp/iosApp/Screens/AddItemSheet.swift index a881b24..46a2d8f 100644 --- a/iosApp/iosApp/Screens/AddItemSheet.swift +++ b/iosApp/iosApp/Screens/AddItemSheet.swift @@ -6,7 +6,7 @@ struct AddItemSheet: View { let isSaving: Bool let isAiAvailable: Bool var existingItem: ClothingItem? - let onSave: (Data, String, Category, [String], [Season], Subcategory?, Fit?, Material?) -> Void + let onSave: (Data, String, Shared.Category, [String], [Season], Subcategory?, Fit?, Shared.Material?) -> Void let onDismiss: () -> Void @State private var selectedPhotoItem: PhotosPickerItem? @@ -17,12 +17,12 @@ struct AddItemSheet: View { @State private var isProcessingBg = false @State private var showBgError = false @State private var name = "" - @State private var selectedCategory: Category? + @State private var selectedCategory: Shared.Category? @State private var selectedColors: Set = [] @State private var selectedSeasons: Set = [] @State private var selectedSubcategory: Subcategory? @State private var selectedFit: Fit? - @State private var selectedMaterial: Material? + @State private var selectedMaterial: Shared.Material? @State private var showSourceChooser = false @State private var showPhotoPicker = false @State private var cover: PhotoCover? @@ -411,7 +411,7 @@ struct AddItemSheet: View { .accessibilityIdentifier("add_item_save_button") } - private var categoryOptions: [(Category, String)] { + private var categoryOptions: [(Shared.Category, String)] { [ (.top, String(localized: "category_tops")), (.bottom, String(localized: "category_bottoms")), (.outerwear, String(localized: "category_outerwear")), (.shoes, String(localized: "category_shoes")), (.accessory, String(localized: "category_accessories")), @@ -422,7 +422,7 @@ struct AddItemSheet: View { [(.spring, String(localized: "season_spring")), (.summer, String(localized: "season_summer")), (.fall, String(localized: "season_fall")), (.winter, String(localized: "season_winter"))] } - private func iconName(for category: Category) -> String { + private func iconName(for category: Shared.Category) -> String { switch category { case .top: return "tshirt" case .bottom: return "ruler" @@ -433,7 +433,7 @@ struct AddItemSheet: View { } } - private func displayName(for category: Category) -> String { + private func displayName(for category: Shared.Category) -> String { switch category { case .top: return String(localized: "category_tops") case .bottom: return String(localized: "category_bottoms") @@ -537,7 +537,7 @@ struct AddItemSheet: View { // MARK: - Material - private var materialOptions: [(Material, String)] { + private var materialOptions: [(Shared.Material, String)] { [ (.cotton, String(localized: "material_cotton")), (.linen, String(localized: "material_linen")), (.denim, String(localized: "material_denim")), (.wool, String(localized: "material_wool")), @@ -568,7 +568,6 @@ struct AddItemSheet: View { AddItemSheet(isSaving: false, isAiAvailable: false, onSave: { _, _, _, _, _, _, _, _ in }, onDismiss: {}) } -#Preview("iPad Portrait") { +#Preview("iPad Portrait", traits: .portrait) { AddItemSheet(isSaving: false, isAiAvailable: false, onSave: { _, _, _, _, _, _, _, _ in }, onDismiss: {}) - .previewDevice(PreviewDevice(rawValue: "iPad Pro (11-inch)")) } diff --git a/iosApp/iosApp/Screens/CreateOutfitSheet.swift b/iosApp/iosApp/Screens/CreateOutfitSheet.swift index bc306c8..e1bf11f 100644 --- a/iosApp/iosApp/Screens/CreateOutfitSheet.swift +++ b/iosApp/iosApp/Screens/CreateOutfitSheet.swift @@ -4,10 +4,10 @@ import Shared struct CreateOutfitSheet: View { let clothingItems: [ClothingItem] let selectedItemIds: Set - let activeCategory: Category? + let activeCategory: Shared.Category? let isSaving: Bool var existingOutfit: Outfit? - let onCategorySelected: (Category?) -> Void + let onCategorySelected: (Shared.Category?) -> Void let onToggleItem: (String) -> Void let onSave: (String) -> Void let onDismiss: () -> Void @@ -178,12 +178,12 @@ private struct SelectableItemCell: View { } private let previewItems: [ClothingItem] = [ - ClothingItem(id: "1", name: "Black T-Shirt", category: .top, colors: ["black"], seasons: [], tags: [], description: nil, photoPath: "", createdAt: 0), - ClothingItem(id: "2", name: "Navy Jeans", category: .bottom, colors: ["navy"], seasons: [], tags: [], description: nil, photoPath: "", createdAt: 0), - ClothingItem(id: "3", name: "White Sneakers", category: .shoes, colors: ["white"], seasons: [], tags: [], description: nil, photoPath: "", createdAt: 0), - ClothingItem(id: "4", name: "Grey Hoodie", category: .top, colors: ["grey"], seasons: [], tags: [], description: nil, photoPath: "", createdAt: 0), - ClothingItem(id: "5", name: "Olive Jacket", category: .outerwear, colors: ["olive"], seasons: [], tags: [], description: nil, photoPath: "", createdAt: 0), - ClothingItem(id: "6", name: "Chinos", category: .bottom, colors: ["khaki"], seasons: [], tags: [], description: nil, photoPath: "", createdAt: 0), + ClothingItem(id: "1", name: "Black T-Shirt", category: .top, colors: ["black"], seasons: [], tags: [], description: nil, subcategory: nil, fit: nil, material: nil, photoPath: "", createdAt: 0), + ClothingItem(id: "2", name: "Navy Jeans", category: .bottom, colors: ["navy"], seasons: [], tags: [], description: nil, subcategory: nil, fit: nil, material: nil, photoPath: "", createdAt: 0), + ClothingItem(id: "3", name: "White Sneakers", category: .shoes, colors: ["white"], seasons: [], tags: [], description: nil, subcategory: nil, fit: nil, material: nil, photoPath: "", createdAt: 0), + ClothingItem(id: "4", name: "Grey Hoodie", category: .top, colors: ["grey"], seasons: [], tags: [], description: nil, subcategory: nil, fit: nil, material: nil, photoPath: "", createdAt: 0), + ClothingItem(id: "5", name: "Olive Jacket", category: .outerwear, colors: ["olive"], seasons: [], tags: [], description: nil, subcategory: nil, fit: nil, material: nil, photoPath: "", createdAt: 0), + ClothingItem(id: "6", name: "Chinos", category: .bottom, colors: ["khaki"], seasons: [], tags: [], description: nil, subcategory: nil, fit: nil, material: nil, photoPath: "", createdAt: 0), ] #Preview("iPhone") { @@ -199,7 +199,7 @@ private let previewItems: [ClothingItem] = [ ) } -#Preview("iPad Portrait") { +#Preview("iPad Portrait", traits: .portrait) { CreateOutfitSheet( clothingItems: previewItems, selectedItemIds: Set(["1", "2"]), @@ -210,5 +210,4 @@ private let previewItems: [ClothingItem] = [ onSave: { _ in }, onDismiss: {} ) - .previewDevice(PreviewDevice(rawValue: "iPad Pro (11-inch)")) } diff --git a/iosApp/iosApp/Screens/GapsScreen.swift b/iosApp/iosApp/Screens/GapsScreen.swift index 8ae69a8..e467c8d 100644 --- a/iosApp/iosApp/Screens/GapsScreen.swift +++ b/iosApp/iosApp/Screens/GapsScreen.swift @@ -3,7 +3,8 @@ import Shared struct GapsScreen: View { @StateObject private var viewModel = GapsViewModelWrapper() - let onTabSelected: (Tab) -> Void + @Environment(\.horizontalSizeClass) private var sizeClass + let onTabSelected: (WornTab) -> Void @State private var selectedGap: GapRecommendation? @State private var showAiLockedSheet = false @@ -11,40 +12,13 @@ struct GapsScreen: View { @State private var addItemPreFill: GapRecommendation? var body: some View { - VStack(spacing: 0) { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - Text(String(localized: "gaps_title")) - .font(.system(size: 28, weight: .semibold)) - .foregroundColor(WornColors.textPrimary) - .padding(.top, 24) - Text(String(localized: "gaps_subtitle")) - .font(.system(size: 14)) - .foregroundColor(WornColors.textSecondary) - .padding(.top, 4) - .padding(.bottom, 20) - - if viewModel.state.isLoading { - loadingContent - } else if let error = viewModel.state.error { - ErrorContentView( - message: error as String, - onRetry: { viewModel.loadGaps() } - ) - .padding(.vertical, 60) - } else if viewModel.state.recommendations.isEmpty { - completeContent - } else { - gapsContent - } - - Spacer().frame(height: 95) - } - .padding(.horizontal, 24) - } - .background(WornColors.bgPage) - } - .accessibilityIdentifier("gaps_screen") + GapsContent( + state: viewModel.state, + isCompact: sizeClass == .compact, + onRetry: { viewModel.loadGaps() }, + onGapClick: { selectedGap = $0 }, + onBannerClick: { showAiLockedSheet = true } + ) .sheet(item: $selectedGap) { gap in GapDetailSheet( recommendation: gap, @@ -80,6 +54,58 @@ struct GapsScreen: View { } } } +} + +/// Pure, state-driven half of the Gaps screen. +/// +/// Split out for the same reason as `WardrobeContent` and `OutfitsContent`: the stateful wrapper +/// resolves its ViewModel from Koin, so a preview of it only renders once `initKoin()` has run. +/// This view takes the state directly and previews on its own. +struct GapsContent: View { + let state: GapsState + var isCompact: Bool = true + var onRetry: () -> Void = {} + var onGapClick: (GapRecommendation) -> Void = { _ in } + var onBannerClick: () -> Void = {} + + private var contentPadding: CGFloat { isCompact ? 24 : 32 } + + var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Text(String(localized: "gaps_title")) + .font(.system(size: 28, weight: .semibold)) + .foregroundColor(WornColors.textPrimary) + .padding(.top, 24) + Text(String(localized: "gaps_subtitle")) + .font(.system(size: 14)) + .foregroundColor(WornColors.textSecondary) + .padding(.top, 4) + .padding(.bottom, 20) + + if state.isLoading { + loadingContent + } else if let error = state.error { + ErrorContentView( + message: error as String, + onRetry: onRetry + ) + .padding(.vertical, 60) + } else if state.recommendations.isEmpty { + completeContent + } else { + gapsContent + } + + Spacer().frame(height: 95) + } + .padding(.horizontal, contentPadding) + } + .background(WornColors.bgPage) + } + .accessibilityIdentifier("gaps_screen") + } // MARK: - Loading @@ -130,8 +156,8 @@ struct GapsScreen: View { gapsBanner .padding(.bottom, 20) - let grouped = Dictionary(grouping: viewModel.state.recommendations as! [GapRecommendation]) { $0.category } - let orderedKeys = (viewModel.state.recommendations as! [GapRecommendation]).map { $0.category } + let grouped = Dictionary(grouping: state.recommendations as! [GapRecommendation]) { $0.category } + let orderedKeys = (state.recommendations as! [GapRecommendation]).map { $0.category } .reduce(into: [String]()) { if !$0.contains($1) { $0.append($1) } } ForEach(orderedKeys, id: \.self) { category in @@ -152,14 +178,14 @@ struct GapsScreen: View { private var gapsBanner: some View { Button { - if !viewModel.state.isAiMode { showAiLockedSheet = true } + if !state.isAiMode { onBannerClick() } } label: { HStack { VStack(alignment: .leading, spacing: 2) { - Text(viewModel.state.isAiMode ? String(localized: "gaps_banner_ai_title") : String(localized: "gaps_banner_common_title")) + Text(state.isAiMode ? String(localized: "gaps_banner_ai_title") : String(localized: "gaps_banner_common_title")) .font(.system(size: 16, weight: .semibold)) .foregroundColor(.white) - Text(viewModel.state.isAiMode + Text(state.isAiMode ? String(localized: "gaps_banner_ai_subtitle") : String(localized: "gaps_banner_common_subtitle")) .font(.system(size: 13)) @@ -171,7 +197,7 @@ struct GapsScreen: View { .foregroundColor(.white.opacity(0.7)) } .padding(16) - .background(viewModel.state.isAiMode ? WornColors.accentGreen : WornColors.accentGreenDark) + .background(state.isAiMode ? WornColors.accentGreen : WornColors.accentGreenDark) .clipShape(RoundedRectangle(cornerRadius: 16)) } .buttonStyle(.plain) @@ -187,7 +213,7 @@ struct GapsScreen: View { private func gapCard(recommendation: GapRecommendation) -> some View { Button { - selectedGap = recommendation + onGapClick(recommendation) } label: { HStack(spacing: 12) { categoryIcon(for: recommendation.mappedCategory) @@ -195,7 +221,7 @@ struct GapsScreen: View { Text(recommendation.itemName) .font(.system(size: 15, weight: .medium)) .foregroundColor(WornColors.textPrimary) - Text(viewModel.state.isAiMode + Text(state.isAiMode ? String(format: String(localized: "gaps_pairing_ai"), recommendation.pairingCount) : String(localized: "gaps_pairing_common")) .font(.system(size: 12)) @@ -213,7 +239,7 @@ struct GapsScreen: View { .buttonStyle(.plain) } - private func categoryIcon(for category: Category) -> some View { + private func categoryIcon(for category: Shared.Category) -> some View { RoundedRectangle(cornerRadius: 10) .fill(dotColor(for: category)) .frame(width: 36, height: 36) @@ -224,7 +250,7 @@ struct GapsScreen: View { ) } - private func dotColor(for category: Category) -> Color { + private func dotColor(for category: Shared.Category) -> Color { switch category { case .top: return WornColors.categoryDotTop case .bottom: return WornColors.categoryDotBottom @@ -235,7 +261,7 @@ struct GapsScreen: View { } } - private func iconName(for category: Category) -> String { + private func iconName(for category: Shared.Category) -> String { switch category { case .top: return "tshirt" case .bottom: return "ruler" @@ -385,7 +411,7 @@ private struct GapDetailSheet: View { } } - private func dotColor(for category: Category) -> Color { + private func dotColor(for category: Shared.Category) -> Color { switch category { case .top: return WornColors.categoryDotTop case .bottom: return WornColors.categoryDotBottom @@ -396,7 +422,7 @@ private struct GapDetailSheet: View { } } - private func iconName(for category: Category) -> String { + private func iconName(for category: Shared.Category) -> String { switch category { case .top: return "tshirt" case .bottom: return "ruler" @@ -407,7 +433,7 @@ private struct GapDetailSheet: View { } } - private func displayLabel(for category: Category) -> String { + private func displayLabel(for category: Shared.Category) -> String { switch category { case .top: return String(localized: "category_tops") case .bottom: return String(localized: "category_bottoms") @@ -444,11 +470,45 @@ extension GapRecommendation { } } +private let previewGaps: [GapRecommendation] = [ + GapRecommendation( + itemName: "White Oxford Shirt", category: "Tops", pairingCount: 6, + subcategory: .dressShirt, colors: ["white"], seasons: [.spring, .fall], + fit: .regular, material: .cotton, mappedCategory: .top + ), + GapRecommendation( + itemName: "Chelsea Boots", category: "Shoes", pairingCount: 4, + subcategory: .bootsChelsea, colors: ["brown"], seasons: [.fall, .winter], + fit: nil, material: .leather, mappedCategory: .shoes + ), +] + #Preview("iPhone") { - GapsScreen(onTabSelected: { _ in }) + GapsContent( + state: GapsState( + recommendations: previewGaps, isLoading: false, + isAiAvailable: true, isAiMode: true, error: nil + ), + isCompact: true + ) +} + +#Preview("iPhone - Complete") { + GapsContent( + state: GapsState( + recommendations: [], isLoading: false, + isAiAvailable: false, isAiMode: false, error: nil + ), + isCompact: true + ) } -#Preview("iPad") { - GapsScreen(onTabSelected: { _ in }) - .previewDevice(PreviewDevice(rawValue: "iPad Pro (11-inch)")) +#Preview("iPad Portrait", traits: .portrait) { + GapsContent( + state: GapsState( + recommendations: previewGaps, isLoading: false, + isAiAvailable: true, isAiMode: true, error: nil + ), + isCompact: false + ) } diff --git a/iosApp/iosApp/Screens/ItemDetailSheet.swift b/iosApp/iosApp/Screens/ItemDetailSheet.swift index bd6f03c..8fc2e1e 100644 --- a/iosApp/iosApp/Screens/ItemDetailSheet.swift +++ b/iosApp/iosApp/Screens/ItemDetailSheet.swift @@ -37,12 +37,12 @@ struct ItemDetailSheet: View { } .background(WornColors.bgElevated) .accessibilityIdentifier("item_detail_sheet") - .alert(String(localized: "item_detail_delete_dialog_title"), isPresented: $showDeleteAlert) { - Button(String(localized: "common_cancel"), role: .cancel) {} - Button(String(localized: "common_delete"), role: .destructive) { onDelete(item.id) } - } message: { - Text(String(format: String(localized: "item_detail_delete_dialog_message"), item.name)) - } + .deleteConfirmationAlert( + title: String(localized: "item_detail_delete_dialog_title"), + message: String(format: String(localized: "item_detail_delete_dialog_message"), item.name), + isPresented: $showDeleteAlert, + onConfirm: { onDelete(item.id) } + ) } private var photoArea: some View { @@ -178,7 +178,7 @@ struct ItemDetailSheet: View { } } - private func dotColor(for category: Category) -> Color { + private func dotColor(for category: Shared.Category) -> Color { switch category { case .top: return WornColors.categoryDotTop case .bottom: return WornColors.categoryDotBottom @@ -189,7 +189,7 @@ struct ItemDetailSheet: View { } } - private func displayLabel(for category: Category) -> String { + private func displayLabel(for category: Shared.Category) -> String { switch category { case .top: return String(localized: "category_tops") case .bottom: return String(localized: "category_bottoms") @@ -212,7 +212,7 @@ struct ItemDetailSheet: View { private func fitDisplayName(_ fit: Fit) -> String { switch fit { - case .slimFit: return String(localized: "fit_slim_fit") + case .slimFit: return String(localized: "fit_slim") case .regular: return String(localized: "fit_regular") case .relaxed: return String(localized: "fit_relaxed") case .oversized: return String(localized: "fit_oversized") @@ -225,7 +225,7 @@ struct ItemDetailSheet: View { return String(localized: String.LocalizationValue(key)) } - private func materialDisplayName(_ material: Material) -> String { + private func materialDisplayName(_ material: Shared.Material) -> String { let key = "material_\(material.name.lowercased())" return String(localized: String.LocalizationValue(key)) } @@ -260,10 +260,9 @@ private let previewItem = ClothingItem( ) } -#Preview("iPad") { +#Preview("iPad Portrait", traits: .portrait) { ItemDetailSheet( item: previewItem, isCompact: false, onEdit: { _ in }, onDelete: { _ in } ) - .previewDevice(PreviewDevice(rawValue: "iPad Pro (11-inch)")) } diff --git a/iosApp/iosApp/Screens/OutfitDetailSheet.swift b/iosApp/iosApp/Screens/OutfitDetailSheet.swift index d7a33cf..5745f25 100644 --- a/iosApp/iosApp/Screens/OutfitDetailSheet.swift +++ b/iosApp/iosApp/Screens/OutfitDetailSheet.swift @@ -96,12 +96,12 @@ struct OutfitDetailSheet: View { } .background(WornColors.bgElevated) .accessibilityIdentifier("outfit_detail_sheet") - .alert(String(localized: "outfit_detail_delete_dialog_title"), isPresented: $showDeleteAlert) { - Button(String(localized: "common_cancel"), role: .cancel) {} - Button(String(localized: "common_delete"), role: .destructive) { onDelete(outfit.id) } - } message: { - Text(String(format: String(localized: "outfit_detail_delete_dialog_message"), outfit.name)) - } + .deleteConfirmationAlert( + title: String(localized: "outfit_detail_delete_dialog_title"), + message: String(format: String(localized: "outfit_detail_delete_dialog_message"), outfit.name), + isPresented: $showDeleteAlert, + onConfirm: { onDelete(outfit.id) } + ) } private func outfitItemCard(item: ClothingItem) -> some View { @@ -164,10 +164,10 @@ struct OutfitDetailSheet: View { } private let previewItems: [ClothingItem] = [ - ClothingItem(id: "i1", name: "Black T-Shirt", category: .top, colors: ["Black"], seasons: [], tags: [], description: nil, photoPath: "", createdAt: 0), - ClothingItem(id: "i2", name: "Navy Jeans", category: .bottom, colors: ["Navy"], seasons: [], tags: [], description: nil, photoPath: "", createdAt: 0), - ClothingItem(id: "i3", name: "White Sneakers", category: .shoes, colors: ["White"], seasons: [], tags: [], description: nil, photoPath: "", createdAt: 0), - ClothingItem(id: "i4", name: "Olive Jacket", category: .outerwear, colors: ["Olive"], seasons: [], tags: [], description: nil, photoPath: "", createdAt: 0), + ClothingItem(id: "i1", name: "Black T-Shirt", category: .top, colors: ["Black"], seasons: [], tags: [], description: nil, subcategory: nil, fit: nil, material: nil, photoPath: "", createdAt: 0), + ClothingItem(id: "i2", name: "Navy Jeans", category: .bottom, colors: ["Navy"], seasons: [], tags: [], description: nil, subcategory: nil, fit: nil, material: nil, photoPath: "", createdAt: 0), + ClothingItem(id: "i3", name: "White Sneakers", category: .shoes, colors: ["White"], seasons: [], tags: [], description: nil, subcategory: nil, fit: nil, material: nil, photoPath: "", createdAt: 0), + ClothingItem(id: "i4", name: "Olive Jacket", category: .outerwear, colors: ["Olive"], seasons: [], tags: [], description: nil, subcategory: nil, fit: nil, material: nil, photoPath: "", createdAt: 0), ] private let previewOutfit = Outfit(id: "1", name: "Weekend Casual", itemIds: ["i1", "i2", "i3", "i4"], createdAt: 1_710_460_800_000) @@ -179,10 +179,9 @@ private let previewOutfit = Outfit(id: "1", name: "Weekend Casual", itemIds: ["i ) } -#Preview("iPad") { +#Preview("iPad Portrait", traits: .portrait) { OutfitDetailSheet( outfit: previewOutfit, clothingItems: previewItems, isCompact: false, onEdit: { _ in }, onDelete: { _ in } ) - .previewDevice(PreviewDevice(rawValue: "iPad Pro (11-inch)")) } diff --git a/iosApp/iosApp/Screens/OutfitsScreen.swift b/iosApp/iosApp/Screens/OutfitsScreen.swift index 809fd58..0f3603d 100644 --- a/iosApp/iosApp/Screens/OutfitsScreen.swift +++ b/iosApp/iosApp/Screens/OutfitsScreen.swift @@ -98,15 +98,12 @@ struct OutfitsContent: View { } .background(WornColors.bgPage) .accessibilityIdentifier("outfits_screen") - .alert( - String(format: String(localized: "delete_outfits_title"), state.selectedIds.count), - isPresented: $showDeleteDialog - ) { - Button(String(localized: "common_cancel"), role: .cancel) {} - Button(String(localized: "common_delete"), role: .destructive) { onDeleteSelected() } - } message: { - Text(String(localized: "outfits_delete_dialog_message")) - } + .deleteConfirmationAlert( + title: String(format: String(localized: "delete_outfits_title"), state.selectedIds.count), + message: String(localized: "outfits_delete_dialog_message"), + isPresented: $showDeleteDialog, + onConfirm: onDeleteSelected + ) } private var isEmpty: Bool { !state.isLoading && state.outfits.isEmpty } @@ -244,7 +241,7 @@ private let outfitBadgeColors: [Color] = [ private struct OutfitCardView: View { let outfit: Outfit - var itemCategories: [String: Category] = [:] + var itemCategories: [String: Shared.Category] = [:] var isSelected: Bool = false var isSelectionMode: Bool = false @@ -290,7 +287,7 @@ private struct OutfitCardView: View { } } - private func itemThumbnail(for category: Category?) -> some View { + private func itemThumbnail(for category: Shared.Category?) -> some View { ZStack { RoundedRectangle(cornerRadius: 10) .fill(WornColors.bgElevated) @@ -301,7 +298,7 @@ private struct OutfitCardView: View { } } - private func iconName(for category: Category?) -> String { + private func iconName(for category: Shared.Category?) -> String { switch category { case .top: return "tshirt" case .bottom: return "ruler" @@ -356,37 +353,35 @@ private let previewOutfits: [Outfit] = [ #Preview("iPhone") { OutfitsContent( - state: OutfitState(outfits: previewOutfits, isLoading: false, isDeleting: false, selectedIds: Set(), error: nil, itemCategories: [:], clothingItems: [], selectedItemIds: Set(), activeItemCategory: nil, isSaving: false, isLoadingItems: false), + state: OutfitState(outfits: previewOutfits, isLoading: false, isDeleting: false, selectedIds: Set(), error: nil, itemCategories: [:], allClothingItems: [], clothingItems: [], selectedItemIds: Set(), activeItemCategory: nil, isSaving: false, isLoadingItems: false), isCompact: true ) } #Preview("iPhone - Selection") { OutfitsContent( - state: OutfitState(outfits: previewOutfits, isLoading: false, isDeleting: false, selectedIds: Set(["1", "3"]), error: nil, itemCategories: [:], clothingItems: [], selectedItemIds: Set(), activeItemCategory: nil, isSaving: false, isLoadingItems: false), + state: OutfitState(outfits: previewOutfits, isLoading: false, isDeleting: false, selectedIds: Set(["1", "3"]), error: nil, itemCategories: [:], allClothingItems: [], clothingItems: [], selectedItemIds: Set(), activeItemCategory: nil, isSaving: false, isLoadingItems: false), isCompact: true ) } #Preview("iPhone - Empty") { OutfitsContent( - state: OutfitState(outfits: [], isLoading: false, isDeleting: false, selectedIds: Set(), error: nil, itemCategories: [:], clothingItems: [], selectedItemIds: Set(), activeItemCategory: nil, isSaving: false, isLoadingItems: false), + state: OutfitState(outfits: [], isLoading: false, isDeleting: false, selectedIds: Set(), error: nil, itemCategories: [:], allClothingItems: [], clothingItems: [], selectedItemIds: Set(), activeItemCategory: nil, isSaving: false, isLoadingItems: false), isCompact: true ) } -#Preview("iPad Portrait") { +#Preview("iPad Portrait", traits: .portrait) { OutfitsContent( - state: OutfitState(outfits: previewOutfits, isLoading: false, isDeleting: false, selectedIds: Set(), error: nil, itemCategories: [:], clothingItems: [], selectedItemIds: Set(), activeItemCategory: nil, isSaving: false, isLoadingItems: false), + state: OutfitState(outfits: previewOutfits, isLoading: false, isDeleting: false, selectedIds: Set(), error: nil, itemCategories: [:], allClothingItems: [], clothingItems: [], selectedItemIds: Set(), activeItemCategory: nil, isSaving: false, isLoadingItems: false), isCompact: false ) - .previewDevice(PreviewDevice(rawValue: "iPad Pro (11-inch)")) } -#Preview("iPad - Empty") { +#Preview("iPad Portrait - Empty", traits: .portrait) { OutfitsContent( - state: OutfitState(outfits: [], isLoading: false, isDeleting: false, selectedIds: Set(), error: nil, itemCategories: [:], clothingItems: [], selectedItemIds: Set(), activeItemCategory: nil, isSaving: false, isLoadingItems: false), + state: OutfitState(outfits: [], isLoading: false, isDeleting: false, selectedIds: Set(), error: nil, itemCategories: [:], allClothingItems: [], clothingItems: [], selectedItemIds: Set(), activeItemCategory: nil, isSaving: false, isLoadingItems: false), isCompact: false ) - .previewDevice(PreviewDevice(rawValue: "iPad Pro (11-inch)")) } diff --git a/iosApp/iosApp/Screens/SettingsScreen.swift b/iosApp/iosApp/Screens/SettingsScreen.swift index c24e1f6..012cc59 100644 --- a/iosApp/iosApp/Screens/SettingsScreen.swift +++ b/iosApp/iosApp/Screens/SettingsScreen.swift @@ -3,12 +3,65 @@ import Shared struct SettingsScreen: View { @StateObject private var viewModel = SettingsViewModelWrapper() - let onTabSelected: (Tab) -> Void + @Environment(\.horizontalSizeClass) private var sizeClass + let onTabSelected: (WornTab) -> Void @State private var showProfileSheet = false @State private var showApiKeySheet = false @State private var showYouCamSheet = false + var body: some View { + SettingsContent( + state: viewModel.state, + isCompact: sizeClass == .compact, + onProfileClick: { showProfileSheet = true }, + onApiKeyClick: { showApiKeySheet = true }, + onYouCamClick: { showYouCamSheet = true }, + onSetOnDeviceAi: { viewModel.setOnDeviceAi($0) } + ) + .sheet(isPresented: $showProfileSheet) { + ProfileSheet(viewModel: viewModel) + .presentationDetents([.large]) + } + .sheet(isPresented: $showApiKeySheet) { + ApiKeySheet( + hasApiKey: viewModel.state.hasApiKey, + onSave: { viewModel.saveApiKey($0) }, + onClear: { viewModel.clearApiKey() } + ) + .presentationDetents([.medium]) + } + .sheet(isPresented: $showYouCamSheet) { + YouCamCredentialsSheet( + hasCredentials: viewModel.state.hasYouCamKey, + verifying: viewModel.state.verifyingYouCam, + errorMessage: viewModel.state.youCamError, + onSave: { viewModel.saveYouCamCredentials(clientId: $0, clientSecret: $1) }, + onClear: { viewModel.clearYouCamCredentials() } + ) + .presentationDetents([.medium, .large]) + } + .onChange(of: viewModel.state.hasYouCamKey) { _, has in + if has { showYouCamSheet = false } + } + } +} + +/// Pure, state-driven half of the Settings screen. +/// +/// Split out for the same reason as `WardrobeContent` and `OutfitsContent`: the stateful wrapper +/// resolves its ViewModel from Koin, so a preview of it only renders once `initKoin()` has run. +/// This view takes the state directly and previews on its own. +struct SettingsContent: View { + let state: SettingsState + var isCompact: Bool = true + var onProfileClick: () -> Void = {} + var onApiKeyClick: () -> Void = {} + var onYouCamClick: () -> Void = {} + var onSetOnDeviceAi: (Bool) -> Void = { _ in } + + private var contentPadding: CGFloat { isCompact ? 24 : 32 } + var body: some View { VStack(spacing: 0) { ScrollView { @@ -25,7 +78,7 @@ struct SettingsScreen: View { iconName: "person.fill", title: String(localized: "settings_your_profile"), subtitle: profileSummary, - action: { showProfileSheet = true } + action: onProfileClick ) .padding(.top, 10) .accessibilityIdentifier("settings_profile_card") @@ -40,10 +93,10 @@ struct SettingsScreen: View { title: String(localized: "settings_on_device_ai_title"), subtitle: onDeviceAiSubtitle, isOn: Binding( - get: { viewModel.state.onDeviceAiEnabled }, - set: { viewModel.setOnDeviceAi($0) } + get: { state.onDeviceAiEnabled }, + set: onSetOnDeviceAi ), - enabled: viewModel.state.onDeviceAiAvailability.isUsable + enabled: OnDeviceAiAvailabilityKt.isUsable(state.onDeviceAiAvailability) ) .padding(.top, 10) .accessibilityIdentifier("settings_on_device_ai_toggle") @@ -52,8 +105,8 @@ struct SettingsScreen: View { iconColor: WornColors.accentIndigo, iconName: "sparkles", title: String(localized: "settings_api_key_title"), - subtitle: viewModel.state.hasApiKey ? String(localized: "settings_api_key_connected") : String(localized: "settings_api_key_required"), - action: { showApiKeySheet = true } + subtitle: state.hasApiKey ? String(localized: "settings_api_key_connected") : String(localized: "settings_api_key_required"), + action: onApiKeyClick ) .padding(.top, 10) .accessibilityIdentifier("settings_api_key_card") @@ -62,8 +115,8 @@ struct SettingsScreen: View { iconColor: WornColors.accentIndigo, iconName: "tshirt", title: String(localized: "settings_youcam_title"), - subtitle: viewModel.state.hasYouCamKey ? String(localized: "settings_youcam_connected") : String(localized: "settings_youcam_required"), - action: { showYouCamSheet = true } + subtitle: state.hasYouCamKey ? String(localized: "settings_youcam_connected") : String(localized: "settings_youcam_required"), + action: onYouCamClick ) .padding(.top, 10) .accessibilityIdentifier("settings_youcam_card") @@ -78,45 +131,20 @@ struct SettingsScreen: View { Spacer().frame(height: 95) } - .padding(.horizontal, 24) + .padding(.horizontal, contentPadding) } .background(WornColors.bgPage) } .accessibilityIdentifier("settings_screen") - .sheet(isPresented: $showProfileSheet) { - ProfileSheet(viewModel: viewModel) - .presentationDetents([.large]) - } - .sheet(isPresented: $showApiKeySheet) { - ApiKeySheet( - hasApiKey: viewModel.state.hasApiKey, - onSave: { viewModel.saveApiKey($0) }, - onClear: { viewModel.clearApiKey() } - ) - .presentationDetents([.medium]) - } - .sheet(isPresented: $showYouCamSheet) { - YouCamCredentialsSheet( - hasCredentials: viewModel.state.hasYouCamKey, - verifying: viewModel.state.verifyingYouCam, - errorMessage: viewModel.state.youCamError, - onSave: { viewModel.saveYouCamCredentials(clientId: $0, clientSecret: $1) }, - onClear: { viewModel.clearYouCamCredentials() } - ) - .presentationDetents([.medium, .large]) - } - .onChange(of: viewModel.state.hasYouCamKey) { _, has in - if has { showYouCamSheet = false } - } } private var onDeviceAiSubtitle: String { - switch viewModel.state.onDeviceAiAvailability { - case is OnDeviceAiAvailability.Available: + switch state.onDeviceAiAvailability { + case is OnDeviceAiAvailabilityAvailable: return String(localized: "settings_on_device_ai_available") - case is OnDeviceAiAvailability.Downloadable: + case is OnDeviceAiAvailabilityDownloadable: return String(localized: "settings_on_device_ai_downloading") - case let unavailable as OnDeviceAiAvailability.Unavailable: + case let unavailable as OnDeviceAiAvailabilityUnavailable: switch unavailable.reason { case .unsupportedDevice: return String(localized: "settings_on_device_ai_unsupported_device") case .unsupportedOs: return String(localized: "settings_on_device_ai_unsupported_os") @@ -129,7 +157,7 @@ struct SettingsScreen: View { } private var profileSummary: String { - let profile = viewModel.state.userProfile + let profile = state.userProfile let parts: [String] = [ (profile.bodyType as? BodyType)?.displayName, (profile.styleProfile as? StyleProfile)?.displayName, @@ -374,8 +402,8 @@ private struct ProfileSheet: View { private var bodyTypeOptions: [(BodyType, String)] { [(.slim, String(localized: "body_type_slim")), (.athletic, String(localized: "body_type_athletic")), (.average, String(localized: "body_type_average")), - (.stocky, String(localized: "body_type_stocky")), (.short_, String(localized: "body_type_short")), (.tallAndSlim, String(localized: "body_type_tall_slim")), - (.tallAndFit, String(localized: "body_type_tall_and_fit")), (.bigAndTall, String(localized: "body_type_big_tall"))] + (.stocky, String(localized: "body_type_stocky")), (.short_, String(localized: "body_type_short")), (.tallAndSlim, String(localized: "body_type_tall_and_slim")), + (.tallAndFit, String(localized: "body_type_tall_and_fit")), (.bigAndTall, String(localized: "body_type_big_and_tall"))] } private var styleOptions: [(StyleProfile, String)] { [(.classic, String(localized: "style_classic")), (.casual, String(localized: "style_casual")), (.streetwear, String(localized: "style_streetwear")), @@ -639,9 +667,9 @@ private extension BodyType { case .average: return String(localized: "body_type_average") case .stocky: return String(localized: "body_type_stocky") case .short_: return String(localized: "body_type_short") - case .tallAndSlim: return String(localized: "body_type_tall_slim") + case .tallAndSlim: return String(localized: "body_type_tall_and_slim") case .tallAndFit: return String(localized: "body_type_tall_and_fit") - case .bigAndTall: return String(localized: "body_type_big_tall") + case .bigAndTall: return String(localized: "body_type_big_and_tall") default: return "" } } @@ -705,11 +733,44 @@ struct FlowLayout: Layout { } } +private func previewSettingsState( + availability: OnDeviceAiAvailability +) -> SettingsState { + SettingsState( + userProfile: UserProfile( + bodyType: .athletic, styleProfile: .smartCasual, ageRange: .age2635, + climate: .temperate, lifestyles: [.workOffice] + ), + isLoading: false, + hasApiKey: true, + hasYouCamKey: false, + verifyingYouCam: false, + youCamError: nil, + onDeviceAiEnabled: false, + onDeviceAiAvailability: availability, + error: nil + ) +} + #Preview("iPhone") { - SettingsScreen(onTabSelected: { _ in }) + SettingsContent( + state: previewSettingsState(availability: OnDeviceAiAvailabilityAvailable()), + isCompact: true + ) } -#Preview("iPad") { - SettingsScreen(onTabSelected: { _ in }) - .previewDevice(PreviewDevice(rawValue: "iPad Pro (11-inch)")) +#Preview("iPhone - AI unavailable") { + SettingsContent( + state: previewSettingsState( + availability: OnDeviceAiAvailabilityUnavailable(reason: .unsupportedDevice) + ), + isCompact: true + ) +} + +#Preview("iPad Portrait", traits: .portrait) { + SettingsContent( + state: previewSettingsState(availability: OnDeviceAiAvailabilityAvailable()), + isCompact: false + ) } diff --git a/iosApp/iosApp/Screens/TryItScreen.swift b/iosApp/iosApp/Screens/TryItScreen.swift index 1e405bc..f486123 100644 --- a/iosApp/iosApp/Screens/TryItScreen.swift +++ b/iosApp/iosApp/Screens/TryItScreen.swift @@ -220,15 +220,8 @@ struct TryItScreen: View { } if let error = viewModel.state.error, !viewModel.state.isLoading { - ErrorContentView( - message: error as String, - onRetry: { - guard let data = photoData else { return } - viewModel.analyzePhoto(imageData: data) - }, - retryButtonColor: WornColors.accentIndigo - ) - .padding(.vertical, 20) + errorContent(message: error as String) + .padding(.vertical, 20) } if let result = viewModel.state.result as? TryItResult { @@ -370,6 +363,19 @@ struct TryItScreen: View { .accessibilityIdentifier("try_it_analyze_button") } + /// Analysis error, shared by the phone and tablet layouts. Retrying re-sends the garment photo + /// that produced the error, so it is a no-op once the photo has been cleared. + private func errorContent(message: String) -> some View { + ErrorContentView( + message: message, + onRetry: { + guard let data = photoData else { return } + viewModel.analyzePhoto(imageData: data) + }, + retryButtonColor: WornColors.accentIndigo + ) + } + private var loadingIndicator: some View { HStack { Spacer() @@ -764,7 +770,6 @@ struct TryItScreen: View { TryItScreen(onTabSelected: { _ in }) } -#Preview("iPad") { +#Preview("iPad Portrait", traits: .portrait) { TryItScreen(onTabSelected: { _ in }) - .previewDevice(PreviewDevice(rawValue: "iPad Pro (11-inch)")) } diff --git a/iosApp/iosApp/Screens/WardrobeScreen.swift b/iosApp/iosApp/Screens/WardrobeScreen.swift index 21b3eee..6277487 100644 --- a/iosApp/iosApp/Screens/WardrobeScreen.swift +++ b/iosApp/iosApp/Screens/WardrobeScreen.swift @@ -78,7 +78,7 @@ struct WardrobeScreen: View { struct WardrobeContent: View { let state: WardrobeState var isCompact: Bool = true - var onCategorySelected: (Category?) -> Void = { _ in } + var onCategorySelected: (Shared.Category?) -> Void = { _ in } var onAddItemClick: () -> Void = {} var onToggleSelection: (String) -> Void = { _ in } var onClearSelection: () -> Void = {} @@ -108,12 +108,12 @@ struct WardrobeContent: View { } .background(WornColors.bgPage) .accessibilityIdentifier("wardrobe_screen") - .alert(String(format: String(localized: "delete_items_title"), state.selectedIds.count), isPresented: $showDeleteDialog) { - Button(String(localized: "common_cancel"), role: .cancel) {} - Button(String(localized: "common_delete"), role: .destructive) { onDeleteSelected() } - } message: { - Text(String(localized: "wardrobe_delete_dialog_message")) - } + .deleteConfirmationAlert( + title: String(format: String(localized: "delete_items_title"), state.selectedIds.count), + message: String(localized: "wardrobe_delete_dialog_message"), + isPresented: $showDeleteDialog, + onConfirm: onDeleteSelected + ) } private var isWardrobeEmpty: Bool { !state.isLoading && state.totalItemCount == 0 } @@ -281,62 +281,59 @@ struct WardrobeContent: View { } private let previewItems: [ClothingItem] = [ - ClothingItem(id: "1", name: "Black T-Shirt", category: .top, colors: ["black"], seasons: [], tags: [], description: nil, photoPath: "", createdAt: 0), - ClothingItem(id: "2", name: "Navy Jeans", category: .bottom, colors: ["navy"], seasons: [], tags: [], description: nil, photoPath: "", createdAt: 0), - ClothingItem(id: "3", name: "White Sneakers", category: .shoes, colors: ["white"], seasons: [], tags: [], description: nil, photoPath: "", createdAt: 0), - ClothingItem(id: "4", name: "Olive Jacket", category: .outerwear, colors: ["olive"], seasons: [], tags: [], description: nil, photoPath: "", createdAt: 0), - ClothingItem(id: "5", name: "Grey Hoodie", category: .top, colors: ["grey"], seasons: [], tags: [], description: nil, photoPath: "", createdAt: 0), - ClothingItem(id: "6", name: "Chinos", category: .bottom, colors: ["khaki"], seasons: [], tags: [], description: nil, photoPath: "", createdAt: 0), + ClothingItem(id: "1", name: "Black T-Shirt", category: .top, colors: ["black"], seasons: [], tags: [], description: nil, subcategory: nil, fit: nil, material: nil, photoPath: "", createdAt: 0), + ClothingItem(id: "2", name: "Navy Jeans", category: .bottom, colors: ["navy"], seasons: [], tags: [], description: nil, subcategory: nil, fit: nil, material: nil, photoPath: "", createdAt: 0), + ClothingItem(id: "3", name: "White Sneakers", category: .shoes, colors: ["white"], seasons: [], tags: [], description: nil, subcategory: nil, fit: nil, material: nil, photoPath: "", createdAt: 0), + ClothingItem(id: "4", name: "Olive Jacket", category: .outerwear, colors: ["olive"], seasons: [], tags: [], description: nil, subcategory: nil, fit: nil, material: nil, photoPath: "", createdAt: 0), + ClothingItem(id: "5", name: "Grey Hoodie", category: .top, colors: ["grey"], seasons: [], tags: [], description: nil, subcategory: nil, fit: nil, material: nil, photoPath: "", createdAt: 0), + ClothingItem(id: "6", name: "Chinos", category: .bottom, colors: ["khaki"], seasons: [], tags: [], description: nil, subcategory: nil, fit: nil, material: nil, photoPath: "", createdAt: 0), ] #Preview("iPhone") { WardrobeContent( - state: WardrobeState(items: previewItems, isLoading: false, isSaving: false, isDeleting: false, selectedIds: Set(), activeCategory: nil, error: nil, totalItemCount: Int32(previewItems.count)), + state: WardrobeState(items: previewItems, isLoading: false, isSaving: false, isDeleting: false, selectedIds: Set(), activeCategory: nil, isAiAvailable: false, error: nil, totalItemCount: Int32(previewItems.count)), isCompact: true ) } #Preview("iPhone - Selection") { WardrobeContent( - state: WardrobeState(items: previewItems, isLoading: false, isSaving: false, isDeleting: false, selectedIds: Set(["1", "3"]), activeCategory: nil, error: nil, totalItemCount: Int32(previewItems.count)), + state: WardrobeState(items: previewItems, isLoading: false, isSaving: false, isDeleting: false, selectedIds: Set(["1", "3"]), activeCategory: nil, isAiAvailable: false, error: nil, totalItemCount: Int32(previewItems.count)), isCompact: true ) } #Preview("iPhone - Empty") { WardrobeContent( - state: WardrobeState(items: [], isLoading: false, isSaving: false, isDeleting: false, selectedIds: Set(), activeCategory: nil, error: nil, totalItemCount: 0), + state: WardrobeState(items: [], isLoading: false, isSaving: false, isDeleting: false, selectedIds: Set(), activeCategory: nil, isAiAvailable: false, error: nil, totalItemCount: 0), isCompact: true ) } -#Preview("iPad Portrait") { +#Preview("iPad Portrait", traits: .portrait) { WardrobeContent( - state: WardrobeState(items: previewItems, isLoading: false, isSaving: false, isDeleting: false, selectedIds: Set(), activeCategory: nil, error: nil, totalItemCount: Int32(previewItems.count)), + state: WardrobeState(items: previewItems, isLoading: false, isSaving: false, isDeleting: false, selectedIds: Set(), activeCategory: nil, isAiAvailable: false, error: nil, totalItemCount: Int32(previewItems.count)), isCompact: false ) - .previewDevice(PreviewDevice(rawValue: "iPad Pro (11-inch)")) } -#Preview("iPad - Empty") { +#Preview("iPad Portrait - Empty", traits: .portrait) { WardrobeContent( - state: WardrobeState(items: [], isLoading: false, isSaving: false, isDeleting: false, selectedIds: Set(), activeCategory: nil, error: nil, totalItemCount: 0), + state: WardrobeState(items: [], isLoading: false, isSaving: false, isDeleting: false, selectedIds: Set(), activeCategory: nil, isAiAvailable: false, error: nil, totalItemCount: 0), isCompact: false ) - .previewDevice(PreviewDevice(rawValue: "iPad Pro (11-inch)")) } #Preview("iPhone - Empty Category") { WardrobeContent( - state: WardrobeState(items: [], isLoading: false, isSaving: false, isDeleting: false, selectedIds: Set(), activeCategory: .top, error: nil, totalItemCount: Int32(previewItems.count)), + state: WardrobeState(items: [], isLoading: false, isSaving: false, isDeleting: false, selectedIds: Set(), activeCategory: .top, isAiAvailable: false, error: nil, totalItemCount: Int32(previewItems.count)), isCompact: true ) } -#Preview("iPad - Empty Category") { +#Preview("iPad Portrait - Empty Category", traits: .portrait) { WardrobeContent( - state: WardrobeState(items: [], isLoading: false, isSaving: false, isDeleting: false, selectedIds: Set(), activeCategory: .top, error: nil, totalItemCount: Int32(previewItems.count)), + state: WardrobeState(items: [], isLoading: false, isSaving: false, isDeleting: false, selectedIds: Set(), activeCategory: .top, isAiAvailable: false, error: nil, totalItemCount: Int32(previewItems.count)), isCompact: false ) - .previewDevice(PreviewDevice(rawValue: "iPad Pro (11-inch)")) } diff --git a/iosApp/iosApp/Services/BackgroundRemoverService.swift b/iosApp/iosApp/Services/BackgroundRemoverService.swift index 27c83b5..d13dfaf 100644 --- a/iosApp/iosApp/Services/BackgroundRemoverService.swift +++ b/iosApp/iosApp/Services/BackgroundRemoverService.swift @@ -7,29 +7,8 @@ import Shared enum BackgroundRemoverService { static func removeBackground(_ data: Data) async throws -> Data { - let remover = KoinHelper.shared.koin.get(objCClass: BackgroundRemover.self) as! BackgroundRemover + let remover = KoinHelper.shared.backgroundRemover let output = try await remover.removeBackground(bytes: data.toKotlinByteArray()) return output.toData() } } - -private extension Data { - func toKotlinByteArray() -> KotlinByteArray { - let bytes = [UInt8](self) - let array = KotlinByteArray(size: Int32(bytes.count)) - for (index, byte) in bytes.enumerated() { - array.set(index: Int32(index), value: Int8(bitPattern: byte)) - } - return array - } -} - -private extension KotlinByteArray { - func toData() -> Data { - var data = Data(count: Int(size)) - for index in 0.. KotlinByteArray { + let bytes = [UInt8](self) + let array = KotlinByteArray(size: Int32(bytes.count)) + for (index, byte) in bytes.enumerated() { + array.set(index: Int32(index), value: Int8(bitPattern: byte)) + } + return array + } +} + +extension KotlinByteArray { + func toData() -> Data { + var data = Data(count: Int(size)) + for index in 0.. Data { - var data = Data(count: Int(size)) - for index in 0..(flow: vm.state) + self.state = adapter.currentValue cancellable = adapter.subscribe { [weak self] newState in - guard let newState = newState as? GapsState else { return } DispatchQueue.main.async { withAnimation(.easeInOut(duration: 0.3)) { self?.state = newState @@ -26,7 +25,7 @@ class GapsViewModelWrapper: ObservableObject { } func loadGaps() { - viewModel.onIntent(intent: GapsIntent.LoadGaps()) + viewModel.onIntent(intent: GapsIntentLoadGaps()) } deinit { diff --git a/iosApp/iosApp/ViewModels/OutfitViewModelWrapper.swift b/iosApp/iosApp/ViewModels/OutfitViewModelWrapper.swift index 1248a60..aa8987e 100644 --- a/iosApp/iosApp/ViewModels/OutfitViewModelWrapper.swift +++ b/iosApp/iosApp/ViewModels/OutfitViewModelWrapper.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftUI import Shared @MainActor @@ -11,14 +12,12 @@ class OutfitViewModelWrapper: ObservableObject { @Published var outfitCreated = false init() { - let koin = KoinHelper.shared.koin - let vm = koin.get(objCClass: OutfitViewModel.self) as! OutfitViewModel + let vm = KoinHelper.shared.outfitViewModel self.viewModel = vm - self.state = vm.state.value - let stateAdapter = FlowAdapter(flow: vm.state) + let stateAdapter = FlowAdapter(flow: vm.state) + self.state = stateAdapter.currentValue stateCancellable = stateAdapter.subscribe { [weak self] newState in - guard let newState = newState as? OutfitState else { return } DispatchQueue.main.async { withAnimation(.easeInOut(duration: 0.3)) { self?.state = newState @@ -26,48 +25,48 @@ class OutfitViewModelWrapper: ObservableObject { } } - let effectsAdapter = FlowAdapter(flow: vm.effects) + let effectsAdapter = EffectAdapter(flow: vm.effects) effectsCancellable = effectsAdapter.subscribe { [weak self] effect in guard let effect = effect as? OutfitEffect else { return } DispatchQueue.main.async { - if effect is OutfitEffect.OutfitCreated { + if effect is OutfitEffectOutfitCreated { self?.outfitCreated = true } } } } - func filterItemsByCategory(_ category: Category?) { - let intent = OutfitIntent.FilterItemsByCategory(category: category) + func filterItemsByCategory(_ category: Shared.Category?) { + let intent = OutfitIntentFilterItemsByCategory(category: category) viewModel.onIntent(intent: intent) } func toggleItemSelection(_ itemId: String) { - viewModel.onIntent(intent: OutfitIntent.ToggleItemSelection(itemId: itemId)) + viewModel.onIntent(intent: OutfitIntentToggleItemSelection(itemId: itemId)) } func toggleSelection(_ outfitId: String) { - viewModel.onIntent(intent: OutfitIntent.ToggleSelection(outfitId: outfitId)) + viewModel.onIntent(intent: OutfitIntentToggleSelection(outfitId: outfitId)) } func clearSelection() { - viewModel.onIntent(intent: OutfitIntent.ClearSelection()) + viewModel.onIntent(intent: OutfitIntentClearSelection()) } func deleteSelected() { - viewModel.onIntent(intent: OutfitIntent.DeleteSelected()) + viewModel.onIntent(intent: OutfitIntentDeleteSelected()) } func createOutfit(name: String) { - viewModel.onIntent(intent: OutfitIntent.CreateOutfit(name: name)) + viewModel.onIntent(intent: OutfitIntentCreateOutfit(name: name)) } func deleteOutfit(_ outfitId: String) { - viewModel.onIntent(intent: OutfitIntent.DeleteOutfit(outfitId: outfitId)) + viewModel.onIntent(intent: OutfitIntentDeleteOutfit(outfitId: outfitId)) } func updateOutfit(_ outfit: Outfit) { - viewModel.onIntent(intent: OutfitIntent.UpdateOutfit(outfit: outfit)) + viewModel.onIntent(intent: OutfitIntentUpdateOutfit(outfit: outfit)) } deinit { diff --git a/iosApp/iosApp/ViewModels/SettingsViewModelWrapper.swift b/iosApp/iosApp/ViewModels/SettingsViewModelWrapper.swift index 365ce69..16c1861 100644 --- a/iosApp/iosApp/ViewModels/SettingsViewModelWrapper.swift +++ b/iosApp/iosApp/ViewModels/SettingsViewModelWrapper.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftUI import Shared @MainActor @@ -9,14 +10,12 @@ class SettingsViewModelWrapper: ObservableObject { @Published var state: SettingsState init() { - let koin = KoinHelper.shared.koin - let vm = koin.get(objCClass: SettingsViewModel.self) as! SettingsViewModel + let vm = KoinHelper.shared.settingsViewModel self.viewModel = vm - self.state = vm.state.value - let adapter = FlowAdapter(flow: vm.state) + let adapter = FlowAdapter(flow: vm.state) + self.state = adapter.currentValue cancellable = adapter.subscribe { [weak self] newState in - guard let newState = newState as? SettingsState else { return } DispatchQueue.main.async { withAnimation(.easeInOut(duration: 0.3)) { self?.state = newState @@ -26,45 +25,45 @@ class SettingsViewModelWrapper: ObservableObject { } func selectBodyType(_ bodyType: BodyType?) { - viewModel.onIntent(intent: SettingsIntent.SelectBodyType(bodyType: bodyType)) + viewModel.onIntent(intent: SettingsIntentSelectBodyType(bodyType: bodyType)) } func selectStyleProfile(_ styleProfile: StyleProfile?) { - viewModel.onIntent(intent: SettingsIntent.SelectStyleProfile(styleProfile: styleProfile)) + viewModel.onIntent(intent: SettingsIntentSelectStyleProfile(styleProfile: styleProfile)) } func selectAgeRange(_ ageRange: AgeRange?) { - viewModel.onIntent(intent: SettingsIntent.SelectAgeRange(ageRange: ageRange)) + viewModel.onIntent(intent: SettingsIntentSelectAgeRange(ageRange: ageRange)) } func selectClimate(_ climate: Climate?) { - viewModel.onIntent(intent: SettingsIntent.SelectClimate(climate: climate)) + viewModel.onIntent(intent: SettingsIntentSelectClimate(climate: climate)) } func toggleLifestyle(_ lifestyle: Lifestyle) { - viewModel.onIntent(intent: SettingsIntent.ToggleLifestyle(lifestyle: lifestyle)) + viewModel.onIntent(intent: SettingsIntentToggleLifestyle(lifestyle: lifestyle)) } func saveApiKey(_ key: String) { - viewModel.onIntent(intent: SettingsIntent.SaveApiKey(key: key)) + viewModel.onIntent(intent: SettingsIntentSaveApiKey(key: key)) } func clearApiKey() { - viewModel.onIntent(intent: SettingsIntent.ClearApiKey()) + viewModel.onIntent(intent: SettingsIntentClearApiKey()) } func saveYouCamCredentials(clientId: String, clientSecret: String) { viewModel.onIntent( - intent: SettingsIntent.SaveYouCamCredentials(clientId: clientId, clientSecret: clientSecret) + intent: SettingsIntentSaveYouCamCredentials(clientId: clientId, clientSecret: clientSecret) ) } func clearYouCamCredentials() { - viewModel.onIntent(intent: SettingsIntent.ClearYouCamCredentials()) + viewModel.onIntent(intent: SettingsIntentClearYouCamCredentials()) } func setOnDeviceAi(_ enabled: Bool) { - viewModel.onIntent(intent: SettingsIntent.SetOnDeviceAi(enabled: enabled)) + viewModel.onIntent(intent: SettingsIntentSetOnDeviceAi(enabled: enabled)) } deinit { diff --git a/iosApp/iosApp/ViewModels/TryItViewModelWrapper.swift b/iosApp/iosApp/ViewModels/TryItViewModelWrapper.swift index 874365b..ad37fa0 100644 --- a/iosApp/iosApp/ViewModels/TryItViewModelWrapper.swift +++ b/iosApp/iosApp/ViewModels/TryItViewModelWrapper.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftUI import Shared @MainActor @@ -9,14 +10,12 @@ class TryItViewModelWrapper: ObservableObject { @Published var state: TryItState init() { - let koin = KoinHelper.shared.koin - let vm = koin.get(objCClass: TryItViewModel.self) as! TryItViewModel + let vm = KoinHelper.shared.tryItViewModel self.viewModel = vm - self.state = vm.state.value - let adapter = FlowAdapter(flow: vm.state) + let adapter = FlowAdapter(flow: vm.state) + self.state = adapter.currentValue cancellable = adapter.subscribe { [weak self] newState in - guard let newState = newState as? TryItState else { return } DispatchQueue.main.async { withAnimation(.easeInOut(duration: 0.3)) { self?.state = newState @@ -26,40 +25,40 @@ class TryItViewModelWrapper: ObservableObject { } func analyzePhoto(imageData: Data) { - viewModel.onIntent(intent: TryItIntent.AnalyzePhoto(imageBytes: imageData.toKotlinByteArray())) + viewModel.onIntent(intent: TryItIntentAnalyzePhoto(imageBytes: imageData.toKotlinByteArray())) } func reset() { - viewModel.onIntent(intent: TryItIntent.Reset()) + viewModel.onIntent(intent: TryItIntentReset()) } func selectCategory(_ category: GarmentCategory) { - viewModel.onIntent(intent: TryItIntent.SelectCategory(category: category)) + viewModel.onIntent(intent: TryItIntentSelectCategory(category: category)) } func generateTryOn(imageData: Data) { - viewModel.onIntent(intent: TryItIntent.GenerateTryOn(garmentBytes: imageData.toKotlinByteArray())) + viewModel.onIntent(intent: TryItIntentGenerateTryOn(garmentBytes: imageData.toKotlinByteArray())) } func resetTryOn() { - viewModel.onIntent(intent: TryItIntent.ResetTryOn()) + viewModel.onIntent(intent: TryItIntentResetTryOn()) } func setPersonPhoto(imageData: Data) { - viewModel.onIntent(intent: TryItIntent.SetPersonPhoto(imageBytes: imageData.toKotlinByteArray())) + viewModel.onIntent(intent: TryItIntentSetPersonPhoto(imageBytes: imageData.toKotlinByteArray())) } /// Signals that a photo arrived from the share sheet; the bytes stay on the Swift side. func receiveSharedPhoto() { - viewModel.onIntent(intent: TryItIntent.ReceiveSharedPhoto()) + viewModel.onIntent(intent: TryItIntentReceiveSharedPhoto()) } func chooseFeature(_ feature: TryItFeature) { - viewModel.onIntent(intent: TryItIntent.ChooseFeature(feature: feature)) + viewModel.onIntent(intent: TryItIntentChooseFeature(feature: feature)) } func clearFeatureFocus() { - viewModel.onIntent(intent: TryItIntent.ClearFeatureFocus()) + viewModel.onIntent(intent: TryItIntentClearFeatureFocus()) } /// The saved person photo, if any, as displayable `Data`. @@ -76,26 +75,3 @@ class TryItViewModelWrapper: ObservableObject { cancellable?.cancel() } } - -extension Data { - /// Bridges Swift `Data` to a Kotlin `KotlinByteArray` for shared-module calls. - func toKotlinByteArray() -> KotlinByteArray { - let bytes = [UInt8](self) - let array = KotlinByteArray(size: Int32(bytes.count)) - for (index, byte) in bytes.enumerated() { - array.set(index: Int32(index), value: Int8(bitPattern: byte)) - } - return array - } -} - -extension KotlinByteArray { - /// Bridges a Kotlin `KotlinByteArray` back to Swift `Data`. - func toData() -> Data { - var data = Data(count: Int(size)) - for index in 0..(flow: vm.state) + self.state = adapter.currentValue cancellable = adapter.subscribe { [weak self] newState in - guard let newState = newState as? WardrobeState else { return } DispatchQueue.main.async { withAnimation(.easeInOut(duration: 0.3)) { self?.state = newState @@ -29,21 +28,21 @@ class WardrobeViewModelWrapper: ObservableObject { viewModel.onIntent(intent: intent) } - func filterByCategory(_ category: Category?) { - let intent = WardrobeIntent.FilterByCategory(category: category) + func filterByCategory(_ category: Shared.Category?) { + let intent = WardrobeIntentFilterByCategory(category: category) viewModel.onIntent(intent: intent) } func addItem( - imageData: Data, name: String, category: Category, colors: [String], seasons: [Season], - subcategory: Subcategory? = nil, fit: Fit? = nil, material: Material? = nil + imageData: Data, name: String, category: Shared.Category, colors: [String], seasons: [Season], + subcategory: Subcategory? = nil, fit: Fit? = nil, material: Shared.Material? = nil ) { let bytes = [UInt8](imageData) let kotlinBytes = KotlinByteArray(size: Int32(bytes.count)) for (index, byte) in bytes.enumerated() { kotlinBytes.set(index: Int32(index), value: Int8(bitPattern: byte)) } - let intent = WardrobeIntent.AddItem( + let intent = WardrobeIntentAddItem( imageBytes: kotlinBytes, name: name, category: category, @@ -57,23 +56,23 @@ class WardrobeViewModelWrapper: ObservableObject { } func toggleSelection(_ itemId: String) { - viewModel.onIntent(intent: WardrobeIntent.ToggleSelection(itemId: itemId)) + viewModel.onIntent(intent: WardrobeIntentToggleSelection(itemId: itemId)) } func clearSelection() { - viewModel.onIntent(intent: WardrobeIntent.ClearSelection()) + viewModel.onIntent(intent: WardrobeIntentClearSelection()) } func deleteSelected() { - viewModel.onIntent(intent: WardrobeIntent.DeleteSelected()) + viewModel.onIntent(intent: WardrobeIntentDeleteSelected()) } func deleteItem(_ itemId: String) { - viewModel.onIntent(intent: WardrobeIntent.DeleteItem(itemId: itemId)) + viewModel.onIntent(intent: WardrobeIntentDeleteItem(itemId: itemId)) } func updateItem(_ item: ClothingItem) { - viewModel.onIntent(intent: WardrobeIntent.UpdateItem(item: item)) + viewModel.onIntent(intent: WardrobeIntentUpdateItem(item: item)) } deinit { diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 177e2cf..88a60dd 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -4,15 +4,9 @@ import Shared @main struct iOSApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate - @Environment(\.scenePhase) private var scenePhase - @StateObject private var quickActions = QuickActionInbox.shared - @State private var activeTab: WornTab = .wardrobe - @State private var sharedPhoto: SharedPhoto? - @State private var openAddSheet: ShortcutCommand? - @State private var handledShortcutId: UUID? init() { - KoinHelperKt.initKoin() + KoinHelperKt.doInitKoin() // FoundationModels is Swift-only, so the shared engine reaches Apple Intelligence through // this bridge. Registered before any screen can resolve the engine from Koin. OnDeviceAiBridgeRegistry.shared.bridge = OnDeviceAiService() @@ -21,44 +15,71 @@ struct iOSApp: App { var body: some Scene { WindowGroup { - VStack(spacing: 0) { - TabView(selection: $activeTab) { - WardrobeScreen( - onTabSelected: selectTab, - openAddSheet: openAddSheet, - onAddSheetOpened: { openAddSheet = nil } - ) - .tag(WornTab.wardrobe) - OutfitsScreen(onTabSelected: selectTab) - .tag(WornTab.outfits) - GapsScreen(onTabSelected: selectTab) - .tag(WornTab.gaps) - TryItScreen(onTabSelected: selectTab, sharedPhoto: sharedPhoto) - .tag(WornTab.tryIt) - SettingsScreen(onTabSelected: selectTab) - .tag(WornTab.settings) - } - .tabViewStyle(.page(indexDisplayMode: .never)) + RootView() + } + } +} - WornBottomBar(activeTab: activeTab, onTabSelected: selectTab) - } - .onOpenURL { _ in receiveSharedPhoto() } - .onChange(of: scenePhase) { _, phase in - // Covers the case where the extension wrote the file but could not launch us. - if phase == .active { receiveSharedPhoto() } - } - // onReceive, not onChange: on a cold launch the scene delegate fills the inbox before - // this view installs its observers, and @Published replays its current value to a new - // subscriber whereas onChange would only see later ones. - // - // The tap is marked handled here rather than cleared on the inbox, because that replay - // can land mid-update and SwiftUI forbids publishing changes from there. Every tap - // carries a fresh id, so a repeat of the same action still gets through. - .onReceive(quickActions.$pending) { command in - guard let command, command.id != handledShortcutId else { return } - handledShortcutId = command.id - perform(command) +/// Tab host and entry-point router. +/// +/// This is a `View` rather than the body of `iOSApp` because `@Environment` on an `App` is not +/// filled in from the view hierarchy: `horizontalSizeClass` read there is always nil, which would +/// leave the bottom bar permanently in its expanded layout. +struct RootView: View { + @Environment(\.scenePhase) private var scenePhase + @Environment(\.horizontalSizeClass) private var sizeClass + @StateObject private var quickActions = QuickActionInbox.shared + @State private var activeTab: WornTab = .wardrobe + @State private var sharedPhoto: SharedPhoto? + @State private var openAddSheet: ShortcutCommand? + @State private var handledShortcutId: UUID? + + var body: some View { + VStack(spacing: 0) { + TabView(selection: $activeTab) { + WardrobeScreen( + onTabSelected: selectTab, + openAddSheet: openAddSheet, + onAddSheetOpened: { openAddSheet = nil } + ) + .tag(WornTab.wardrobe) + OutfitsScreen(onTabSelected: selectTab) + .tag(WornTab.outfits) + GapsScreen(onTabSelected: selectTab) + .tag(WornTab.gaps) + TryItScreen(onTabSelected: selectTab, sharedPhoto: sharedPhoto) + .tag(WornTab.tryIt) + SettingsScreen(onTabSelected: selectTab) + .tag(WornTab.settings) } + .tabViewStyle(.page(indexDisplayMode: .never)) + + WornBottomBar( + activeTab: activeTab, + onTabSelected: selectTab, + isCompact: sizeClass == .compact + ) + } + // The screens paint bgPage inside their own bounds, but nothing filled the status bar + // and home indicator areas, so they fell through to the window's black. Android gets + // this from enableEdgeToEdge() plus the theme background. + .background(WornColors.bgPage.ignoresSafeArea()) + .onOpenURL { _ in receiveSharedPhoto() } + .onChange(of: scenePhase) { _, phase in + // Covers the case where the extension wrote the file but could not launch us. + if phase == .active { receiveSharedPhoto() } + } + // onReceive, not onChange: on a cold launch the scene delegate fills the inbox before + // this view installs its observers, and @Published replays its current value to a new + // subscriber whereas onChange would only see later ones. + // + // The tap is marked handled here rather than cleared on the inbox, because that replay + // can land mid-update and SwiftUI forbids publishing changes from there. Every tap + // carries a fresh id, so a repeat of the same action still gets through. + .onReceive(quickActions.$pending) { command in + guard let command, command.id != handledShortcutId else { return } + handledShortcutId = command.id + perform(command) } } diff --git a/shared/src/androidMain/kotlin/com/github/worn/Platform.android.kt b/shared/src/androidMain/kotlin/com/github/worn/Platform.android.kt deleted file mode 100644 index 4c19a9f..0000000 --- a/shared/src/androidMain/kotlin/com/github/worn/Platform.android.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.github.worn - -import android.os.Build - -class AndroidPlatform : Platform { - override val name: String = "Android ${Build.VERSION.SDK_INT}" -} - -actual fun getPlatform(): Platform = AndroidPlatform() diff --git a/shared/src/commonMain/kotlin/com/github/worn/Greeting.kt b/shared/src/commonMain/kotlin/com/github/worn/Greeting.kt deleted file mode 100644 index c285386..0000000 --- a/shared/src/commonMain/kotlin/com/github/worn/Greeting.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.github.worn - -class Greeting { - private val platform = getPlatform() - - fun greet(): String { - return "Hello, ${platform.name}!" - } -} diff --git a/shared/src/commonMain/kotlin/com/github/worn/Platform.kt b/shared/src/commonMain/kotlin/com/github/worn/Platform.kt deleted file mode 100644 index 1536ba3..0000000 --- a/shared/src/commonMain/kotlin/com/github/worn/Platform.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.github.worn - -interface Platform { - val name: String -} - -expect fun getPlatform(): Platform diff --git a/shared/src/iosMain/kotlin/com/github/worn/Platform.ios.kt b/shared/src/iosMain/kotlin/com/github/worn/Platform.ios.kt deleted file mode 100644 index 0148b03..0000000 --- a/shared/src/iosMain/kotlin/com/github/worn/Platform.ios.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.github.worn - -import platform.UIKit.UIDevice - -class IOSPlatform: Platform { - override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion -} - -actual fun getPlatform(): Platform = IOSPlatform() diff --git a/shared/src/iosMain/kotlin/com/github/worn/di/KoinHelper.kt b/shared/src/iosMain/kotlin/com/github/worn/di/KoinHelper.kt index 82fda1e..c32169b 100644 --- a/shared/src/iosMain/kotlin/com/github/worn/di/KoinHelper.kt +++ b/shared/src/iosMain/kotlin/com/github/worn/di/KoinHelper.kt @@ -1,5 +1,13 @@ package com.github.worn.di +import com.github.worn.data.source.image.BackgroundRemover +import com.github.worn.presentation.viewmodel.GapsViewModel +import com.github.worn.presentation.viewmodel.OutfitViewModel +import com.github.worn.presentation.viewmodel.SettingsViewModel +import com.github.worn.presentation.viewmodel.TryItViewModel +import com.github.worn.presentation.viewmodel.WardrobeViewModel +import org.koin.core.component.KoinComponent +import org.koin.core.component.get import org.koin.core.context.startKoin fun initKoin() { @@ -7,3 +15,20 @@ fun initKoin() { modules(sharedModule, iosModule) } } + +/** + * Typed entry point into Koin for Swift. + * + * Koin's own types (`Koin`, `Scope`, `KoinComponent`) come from a dependency that is not part of + * the `Shared` framework's exported API, so they never reach the generated Objective-C header and + * Swift cannot call `koin.get(...)` directly. Exposing one property per dependency keeps every + * type in the signature a shared type, which does get exported. + */ +object KoinHelper : KoinComponent { + val wardrobeViewModel: WardrobeViewModel get() = get() + val outfitViewModel: OutfitViewModel get() = get() + val gapsViewModel: GapsViewModel get() = get() + val tryItViewModel: TryItViewModel get() = get() + val settingsViewModel: SettingsViewModel get() = get() + val backgroundRemover: BackgroundRemover get() = get() +} diff --git a/shared/src/iosMain/kotlin/com/github/worn/util/FlowAdapter.kt b/shared/src/iosMain/kotlin/com/github/worn/util/FlowAdapter.kt index 0d743de..fa255b3 100644 --- a/shared/src/iosMain/kotlin/com/github/worn/util/FlowAdapter.kt +++ b/shared/src/iosMain/kotlin/com/github/worn/util/FlowAdapter.kt @@ -3,6 +3,7 @@ package com.github.worn.util import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -12,6 +13,14 @@ class Cancellable(private val job: Job) { } } +/** + * Bridges a [StateFlow] of MVI state to Swift. + * + * Kotlin/Native exports this as an Objective-C lightweight generic, so Swift keeps the element + * type by naming it explicitly — `FlowAdapter(flow: vm.state)`. It cannot be + * inferred from the argument, because `StateFlow` itself is exported as a plain non-generic + * protocol whose `value` is `Any?`. + */ class FlowAdapter(private val flow: StateFlow) { val currentValue: T get() = flow.value @@ -22,3 +31,21 @@ class FlowAdapter(private val flow: StateFlow) { return Cancellable(job) } } + +/** + * Bridges a one-shot effect [Flow] to Swift. + * + * Effects are a plain `Flow`, not a `StateFlow`, so they cannot go through [FlowAdapter]. The + * element type stays `Any` rather than a generic parameter because the effect types are sealed + * *interfaces*, which reach Swift as protocols and are awkward to name as a generic argument. + * `Flow` is covariant, so any `Flow` satisfies this constructor and Swift + * recovers the concrete case with an `as?` cast. + */ +class EffectAdapter(private val flow: Flow) { + fun subscribe(onEach: (Any) -> Unit): Cancellable { + val job = CoroutineScope(Dispatchers.Main).launch { + flow.collect { onEach(it) } + } + return Cancellable(job) + } +}