From f2d10abadf53146309ffa57140e84f7fee48bfbc Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 28 Jul 2026 12:17:11 -0300 Subject: [PATCH 01/13] fix: make the iOS app compile and link The Swift layer had never been built against the shared module, so it drifted into an API that Kotlin/Native does not produce. CI runs on ubuntu-latest and only ever compiled Kotlin, so nothing caught it. 136 compile errors and a link failure; the app now builds for the simulator. Kotlin/Native interop: - Sealed interfaces export as flat Objective-C classes, not nested types, so WardrobeIntent.ClearSelection is WardrobeIntentClearSelection. Same for the other four Intent/Effect hierarchies and OnDeviceAiAvailability. - Functions named init* get a `do` prefix to avoid clashing with ObjC init, so initKoin() is doInitKoin(). - isUsable is an extension property, which exports as a static function on OnDeviceAiAvailabilityKt rather than a member. - Koin's own types are not part of the framework's exported API and never reach the generated header, so `koin.get(objCClass:)` could not work. Added a typed KoinHelper object in iosMain exposing one property per dependency. - StateFlow exports as a non-generic protocol whose value is Any?, so Swift cannot infer FlowAdapter's element type. Named it explicitly at each call site and seeded state from currentValue. Effects are a plain Flow, which FlowAdapter cannot accept, so added EffectAdapter alongside it. Name collisions and drift: - Category collides with objc_category from objc/runtime.h and Material with SwiftUI.Material; both are now qualified as Shared.*. - Kotlin default arguments do not reach Objective-C, so the preview constructors must pass every parameter: ClothingItem gained subcategory/fit/ material, WardrobeState isAiAvailable, OutfitState allClothingItems. - WornColors declared Identifiable conformance for ClothingItem and Outfit without importing Shared; the five ViewModel wrappers used withAnimation without importing SwiftUI. - toKotlinByteArray/toData were declared in three files; consolidated into Services/ByteArrayBridge.swift. - TryItScreen's tablet layout called an errorContent helper that was never written; extracted it from the phone layout so both share one definition. Linking: - Shared is a static framework, so SQLDelight's sqliter dependency leaves its sqlite3_* symbols for the app to resolve. Added -lsqlite3. Behaviour change: - OnDeviceAiService built its image prompt from Attachment and ImageAttachmentContent, neither of which exists. FoundationModels is text-only as of iOS 26.5, so on-device photo analysis is not possible on iOS; generate() now reports that clearly when given an image. Gap recommendations are text-only and still run on device. Android is unaffected, since Gemini Nano does accept an ImagePart. Co-Authored-By: Claude Opus 5 (1M context) --- iosApp/Configuration/Config.xcconfig | 6 ++- .../Components/CategoryFilterChips.swift | 6 +-- iosApp/iosApp/Components/ClothingCard.swift | 4 +- iosApp/iosApp/Screens/AddItemSheet.swift | 14 +++--- iosApp/iosApp/Screens/CreateOutfitSheet.swift | 16 +++--- iosApp/iosApp/Screens/GapsScreen.swift | 14 +++--- iosApp/iosApp/Screens/ItemDetailSheet.swift | 6 +-- iosApp/iosApp/Screens/OutfitDetailSheet.swift | 8 +-- iosApp/iosApp/Screens/OutfitsScreen.swift | 16 +++--- iosApp/iosApp/Screens/SettingsScreen.swift | 10 ++-- iosApp/iosApp/Screens/TryItScreen.swift | 24 +++++---- iosApp/iosApp/Screens/WardrobeScreen.swift | 32 ++++++------ .../Services/BackgroundRemoverService.swift | 23 +-------- iosApp/iosApp/Services/ByteArrayBridge.swift | 27 ++++++++++ .../iosApp/Services/OnDeviceAiService.swift | 32 ++++-------- iosApp/iosApp/Theme/WornColors.swift | 1 + .../ViewModels/GapsViewModelWrapper.swift | 11 ++-- .../ViewModels/OutfitViewModelWrapper.swift | 31 ++++++------ .../ViewModels/SettingsViewModelWrapper.swift | 29 ++++++----- .../ViewModels/TryItViewModelWrapper.swift | 50 +++++-------------- .../ViewModels/WardrobeViewModelWrapper.swift | 29 ++++++----- iosApp/iosApp/iOSApp.swift | 2 +- .../kotlin/com/github/worn/di/KoinHelper.kt | 25 ++++++++++ .../com/github/worn/util/FlowAdapter.kt | 27 ++++++++++ 24 files changed, 235 insertions(+), 208 deletions(-) create mode 100644 iosApp/iosApp/Services/ByteArrayBridge.swift 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/iosApp/Components/CategoryFilterChips.swift b/iosApp/iosApp/Components/CategoryFilterChips.swift index 42348f6..ecd62a7 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")), diff --git a/iosApp/iosApp/Components/ClothingCard.swift b/iosApp/iosApp/Components/ClothingCard.swift index a9968a9..aecb540 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") diff --git a/iosApp/iosApp/Screens/AddItemSheet.swift b/iosApp/iosApp/Screens/AddItemSheet.swift index a881b24..4687265 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")), diff --git a/iosApp/iosApp/Screens/CreateOutfitSheet.swift b/iosApp/iosApp/Screens/CreateOutfitSheet.swift index bc306c8..e387c14 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") { diff --git a/iosApp/iosApp/Screens/GapsScreen.swift b/iosApp/iosApp/Screens/GapsScreen.swift index 8ae69a8..c397aa6 100644 --- a/iosApp/iosApp/Screens/GapsScreen.swift +++ b/iosApp/iosApp/Screens/GapsScreen.swift @@ -3,7 +3,7 @@ import Shared struct GapsScreen: View { @StateObject private var viewModel = GapsViewModelWrapper() - let onTabSelected: (Tab) -> Void + let onTabSelected: (WornTab) -> Void @State private var selectedGap: GapRecommendation? @State private var showAiLockedSheet = false @@ -213,7 +213,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 +224,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 +235,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 +385,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 +396,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 +407,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") diff --git a/iosApp/iosApp/Screens/ItemDetailSheet.swift b/iosApp/iosApp/Screens/ItemDetailSheet.swift index bd6f03c..e0f811b 100644 --- a/iosApp/iosApp/Screens/ItemDetailSheet.swift +++ b/iosApp/iosApp/Screens/ItemDetailSheet.swift @@ -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") @@ -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)) } diff --git a/iosApp/iosApp/Screens/OutfitDetailSheet.swift b/iosApp/iosApp/Screens/OutfitDetailSheet.swift index d7a33cf..fb4724d 100644 --- a/iosApp/iosApp/Screens/OutfitDetailSheet.swift +++ b/iosApp/iosApp/Screens/OutfitDetailSheet.swift @@ -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) diff --git a/iosApp/iosApp/Screens/OutfitsScreen.swift b/iosApp/iosApp/Screens/OutfitsScreen.swift index 809fd58..2e22312 100644 --- a/iosApp/iosApp/Screens/OutfitsScreen.swift +++ b/iosApp/iosApp/Screens/OutfitsScreen.swift @@ -244,7 +244,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 +290,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 +301,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,28 +356,28 @@ 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") { 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)")) @@ -385,7 +385,7 @@ private let previewOutfits: [Outfit] = [ #Preview("iPad - 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: false ) .previewDevice(PreviewDevice(rawValue: "iPad Pro (11-inch)")) diff --git a/iosApp/iosApp/Screens/SettingsScreen.swift b/iosApp/iosApp/Screens/SettingsScreen.swift index c24e1f6..c3757af 100644 --- a/iosApp/iosApp/Screens/SettingsScreen.swift +++ b/iosApp/iosApp/Screens/SettingsScreen.swift @@ -3,7 +3,7 @@ import Shared struct SettingsScreen: View { @StateObject private var viewModel = SettingsViewModelWrapper() - let onTabSelected: (Tab) -> Void + let onTabSelected: (WornTab) -> Void @State private var showProfileSheet = false @State private var showApiKeySheet = false @@ -43,7 +43,7 @@ struct SettingsScreen: View { get: { viewModel.state.onDeviceAiEnabled }, set: { viewModel.setOnDeviceAi($0) } ), - enabled: viewModel.state.onDeviceAiAvailability.isUsable + enabled: OnDeviceAiAvailabilityKt.isUsable(viewModel.state.onDeviceAiAvailability) ) .padding(.top, 10) .accessibilityIdentifier("settings_on_device_ai_toggle") @@ -112,11 +112,11 @@ struct SettingsScreen: View { private var onDeviceAiSubtitle: String { switch viewModel.state.onDeviceAiAvailability { - case is OnDeviceAiAvailability.Available: + 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") diff --git a/iosApp/iosApp/Screens/TryItScreen.swift b/iosApp/iosApp/Screens/TryItScreen.swift index 1e405bc..e2ea7ae 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() diff --git a/iosApp/iosApp/Screens/WardrobeScreen.swift b/iosApp/iosApp/Screens/WardrobeScreen.swift index 21b3eee..3ab6ea9 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 = {} @@ -281,38 +281,38 @@ 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") { 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)")) @@ -320,22 +320,22 @@ private let previewItems: [ClothingItem] = [ #Preview("iPad - 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: false ) .previewDevice(PreviewDevice(rawValue: "iPad Pro (11-inch)")) } -#Preview("iPhone - Empty Category") { +#Preview("iPhone - Empty Shared.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 - Empty Shared.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: 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..24ec12a 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -12,7 +12,7 @@ struct iOSApp: App { @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() 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) + } +} From da8cd10c37f25803ed5f8d167fc8f1fc04b7c4e2 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 28 Jul 2026 12:19:24 -0300 Subject: [PATCH 02/13] fix: point three iOS strings at keys that exist String(localized:) falls back to rendering the raw key when it is missing, so the profile chips showed "body_type_tall_slim" and the item detail sheet showed "fit_slim_fit" instead of their translations, in both en and pt-BR. The definitions are correct and match Android's strings.xml; only the Swift references were wrong. body_type_tall_slim -> body_type_tall_and_slim body_type_big_tall -> body_type_big_and_tall fit_slim_fit -> fit_slim Audited the rest: every key referenced from Swift now resolves, including the 31 subcategory_* keys that localizedSubcategoryName builds at runtime from the Kotlin enum name. Co-Authored-By: Claude Opus 5 (1M context) --- iosApp/iosApp/Screens/ItemDetailSheet.swift | 2 +- iosApp/iosApp/Screens/SettingsScreen.swift | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/iosApp/iosApp/Screens/ItemDetailSheet.swift b/iosApp/iosApp/Screens/ItemDetailSheet.swift index e0f811b..65c83a3 100644 --- a/iosApp/iosApp/Screens/ItemDetailSheet.swift +++ b/iosApp/iosApp/Screens/ItemDetailSheet.swift @@ -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") diff --git a/iosApp/iosApp/Screens/SettingsScreen.swift b/iosApp/iosApp/Screens/SettingsScreen.swift index c3757af..50f99dc 100644 --- a/iosApp/iosApp/Screens/SettingsScreen.swift +++ b/iosApp/iosApp/Screens/SettingsScreen.swift @@ -374,8 +374,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 +639,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 "" } } From 361f98ee07d52a7433a979c1a0739775ba69655f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 28 Jul 2026 12:25:58 -0300 Subject: [PATCH 03/13] fix: give the iPad bottom bar its centred layout WornBottomBar already had an isCompact parameter driving a 480pt centred pill with wider side padding, mirroring Android's WornBottomBar.kt, but iOSApp never passed it. The parameter defaults to true, so iPad rendered the full-width phone bar. Co-Authored-By: Claude Opus 5 (1M context) --- iosApp/iosApp/iOSApp.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 24ec12a..86579f6 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -5,6 +5,7 @@ import Shared struct iOSApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @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? @@ -40,7 +41,11 @@ struct iOSApp: App { } .tabViewStyle(.page(indexDisplayMode: .never)) - WornBottomBar(activeTab: activeTab, onTabSelected: selectTab) + WornBottomBar( + activeTab: activeTab, + onTabSelected: selectTab, + isCompact: sizeClass == .compact + ) } .onOpenURL { _ in receiveSharedPhoto() } .onChange(of: scenePhase) { _, phase in From e10ac14d05ed544cb620d8099cc333f65375a9bb Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 28 Jul 2026 13:19:52 -0300 Subject: [PATCH 04/13] feat: adapt the Gaps and Settings screens to iPad Android splits every screen into a stateful wrapper plus a pure *Content composable taking isCompact, and on these two the effect is a 24dp/32dp content padding (GapsScreen.kt:142, SettingsScreen.kt:174). iOS already followed that shape in WardrobeScreen and OutfitsScreen, but Gaps and Settings were monolithic and hardcoded 24pt, so they stayed at phone padding on iPad. Extracted GapsContent and SettingsContent, leaving sheet presentation and ViewModel wiring in the stateful wrappers. This also fixes their previews. Both previously instantiated the stateful screen, whose @StateObject resolves a ViewModel from Koin, so the Xcode canvas only rendered if initKoin() had already run. The new previews take state directly and render standalone, with iPad in portrait per the repo convention. Co-Authored-By: Claude Opus 5 (1M context) --- iosApp/iosApp/Screens/GapsScreen.swift | 152 ++++++++++++++------- iosApp/iosApp/Screens/SettingsScreen.swift | 141 +++++++++++++------ 2 files changed, 207 insertions(+), 86 deletions(-) diff --git a/iosApp/iosApp/Screens/GapsScreen.swift b/iosApp/iosApp/Screens/GapsScreen.swift index c397aa6..e467c8d 100644 --- a/iosApp/iosApp/Screens/GapsScreen.swift +++ b/iosApp/iosApp/Screens/GapsScreen.swift @@ -3,6 +3,7 @@ import Shared struct GapsScreen: View { @StateObject private var viewModel = GapsViewModelWrapper() + @Environment(\.horizontalSizeClass) private var sizeClass let onTabSelected: (WornTab) -> Void @State private var selectedGap: GapRecommendation? @@ -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)) @@ -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/SettingsScreen.swift b/iosApp/iosApp/Screens/SettingsScreen.swift index 50f99dc..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() + @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: OnDeviceAiAvailabilityKt.isUsable(viewModel.state.onDeviceAiAvailability) + 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,40 +131,15 @@ 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 { + switch state.onDeviceAiAvailability { case is OnDeviceAiAvailabilityAvailable: return String(localized: "settings_on_device_ai_available") case is OnDeviceAiAvailabilityDownloadable: @@ -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, @@ -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 + ) } From bc179edacf9574188073e216cd7445e6468c068c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 28 Jul 2026 13:27:15 -0300 Subject: [PATCH 05/13] style: preview iPad in portrait, adopt the delete alert, drop template code Previews: CLAUDE.md asks for iPhone and iPad *portrait* previews, but seven component previews used traits: .landscapeLeft, and the rest set .previewDevice, a PreviewProvider-era modifier the #Preview macro ignores. Both now use traits: .portrait. Added the missing iPhone/iPad pairs to ClothingCard, CategoryFilterChips and WornBottomBar (covering both isCompact values). CameraView and PhotoCover are skipped: one wraps UIImagePickerController, the other is a plain enum. DeleteConfirmationDialog defined a deleteConfirmationAlert modifier that nothing used, while four screens inlined their own .alert. Adopted it at all four call sites, matching Android's shared DeleteConfirmationDialog.kt. This also gives the buttons the delete_dialog_cancel and delete_dialog_confirm identifiers, which the Android journeys already drive. Removed the untouched KMP template: ContentView.swift was still compiled into the app but never referenced, and was the only consumer of Greeting.kt and, transitively, Platform.kt and both actuals. Co-Authored-By: Claude Opus 5 (1M context) --- iosApp/iosApp/Components/AiLockedSheet.swift | 3 +- .../Components/CategoryFilterChips.swift | 18 ++++++++++ iosApp/iosApp/Components/ClothingCard.swift | 24 ++++++++++++++ iosApp/iosApp/Components/CropEditorView.swift | 3 +- .../iosApp/Components/CropPhotoButton.swift | 3 +- .../Components/DeleteConfirmationDialog.swift | 3 +- iosApp/iosApp/Components/EmptyStateView.swift | 2 +- .../iosApp/Components/ErrorContentView.swift | 2 +- iosApp/iosApp/Components/PropertyRow.swift | 2 +- .../iosApp/Components/SelectionHeader.swift | 2 +- .../Components/SelectionIndicator.swift | 2 +- iosApp/iosApp/Components/WornBottomBar.swift | 16 +++++++++ iosApp/iosApp/Components/WornChip.swift | 2 +- .../Components/WornGradientButton.swift | 2 +- iosApp/iosApp/ContentView.swift | 33 ------------------- iosApp/iosApp/Screens/AddItemSheet.swift | 3 +- iosApp/iosApp/Screens/CreateOutfitSheet.swift | 3 +- iosApp/iosApp/Screens/ItemDetailSheet.swift | 15 ++++----- iosApp/iosApp/Screens/OutfitDetailSheet.swift | 15 ++++----- iosApp/iosApp/Screens/OutfitsScreen.swift | 21 +++++------- iosApp/iosApp/Screens/TryItScreen.swift | 3 +- iosApp/iosApp/Screens/WardrobeScreen.swift | 23 ++++++------- .../com/github/worn/Platform.android.kt | 9 ----- .../kotlin/com/github/worn/Greeting.kt | 9 ----- .../kotlin/com/github/worn/Platform.kt | 7 ---- .../kotlin/com/github/worn/Platform.ios.kt | 9 ----- 26 files changed, 104 insertions(+), 130 deletions(-) delete mode 100644 iosApp/iosApp/ContentView.swift delete mode 100644 shared/src/androidMain/kotlin/com/github/worn/Platform.android.kt delete mode 100644 shared/src/commonMain/kotlin/com/github/worn/Greeting.kt delete mode 100644 shared/src/commonMain/kotlin/com/github/worn/Platform.kt delete mode 100644 shared/src/iosMain/kotlin/com/github/worn/Platform.ios.kt 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 ecd62a7..2732542 100644 --- a/iosApp/iosApp/Components/CategoryFilterChips.swift +++ b/iosApp/iosApp/Components/CategoryFilterChips.swift @@ -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 aecb540..475f47b 100644 --- a/iosApp/iosApp/Components/ClothingCard.swift +++ b/iosApp/iosApp/Components/ClothingCard.swift @@ -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..eb0f2e9 100644 --- a/iosApp/iosApp/Components/WornBottomBar.swift +++ b/iosApp/iosApp/Components/WornBottomBar.swift @@ -74,6 +74,22 @@ struct WornBottomBar: View { } } +#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 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 4687265..46a2d8f 100644 --- a/iosApp/iosApp/Screens/AddItemSheet.swift +++ b/iosApp/iosApp/Screens/AddItemSheet.swift @@ -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 e387c14..e1bf11f 100644 --- a/iosApp/iosApp/Screens/CreateOutfitSheet.swift +++ b/iosApp/iosApp/Screens/CreateOutfitSheet.swift @@ -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/ItemDetailSheet.swift b/iosApp/iosApp/Screens/ItemDetailSheet.swift index 65c83a3..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 { @@ -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 fb4724d..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 { @@ -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 2e22312..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 } @@ -375,18 +372,16 @@ private let previewOutfits: [Outfit] = [ ) } -#Preview("iPad Portrait") { +#Preview("iPad Portrait", traits: .portrait) { OutfitsContent( 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: [:], 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/TryItScreen.swift b/iosApp/iosApp/Screens/TryItScreen.swift index e2ea7ae..f486123 100644 --- a/iosApp/iosApp/Screens/TryItScreen.swift +++ b/iosApp/iosApp/Screens/TryItScreen.swift @@ -770,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 3ab6ea9..6277487 100644 --- a/iosApp/iosApp/Screens/WardrobeScreen.swift +++ b/iosApp/iosApp/Screens/WardrobeScreen.swift @@ -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 } @@ -310,33 +310,30 @@ private let previewItems: [ClothingItem] = [ ) } -#Preview("iPad Portrait") { +#Preview("iPad Portrait", traits: .portrait) { WardrobeContent( 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, isAiAvailable: false, error: nil, totalItemCount: 0), isCompact: false ) - .previewDevice(PreviewDevice(rawValue: "iPad Pro (11-inch)")) } -#Preview("iPhone - Empty Shared.Category") { +#Preview("iPhone - Empty Category") { WardrobeContent( 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 Shared.Category") { +#Preview("iPad Portrait - Empty Category", traits: .portrait) { WardrobeContent( 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/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() From 36921c95e927426652cf116e2510fb5380878c28 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 28 Jul 2026 13:33:05 -0300 Subject: [PATCH 06/13] fix: apply the App Group entitlement to the app target iosApp/iosApp.entitlements declares group.com.github.worn, but CODE_SIGN_ENTITLEMENTS was set on no target, so it was never applied and FileManager.containerURL(forSecurityApplicationGroupIdentifier:) always returned nil. SharedPhotoInbox.consume() could therefore never find a handoff file, which would have made share-to-Worn silently do nothing even once the extension target exists. Verified on the iPhone 16 simulator: get_app_container now resolves group.com.github.worn, and planting a JPEG as pending_share.jpg in that container makes the app open on Try It and delete the file, which is exactly what WornShareExtension does at runtime. Co-Authored-By: Claude Opus 5 (1M context) --- iosApp/iosApp.xcodeproj/project.pbxproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index c7f3380..5476b2b 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -297,6 +297,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 +326,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\""; From c5ea20375a1260c35c9a0ad62a90655544b3e1a4 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 28 Jul 2026 13:33:05 -0300 Subject: [PATCH 07/13] fix: stop the Wardrobe tab label wrapping in the bottom bar "WARDROBE" is the longest of the five labels and wrapped to a second line on a compact bar, which clipped the descender. Constrain it to one line and let it shrink slightly instead. Co-Authored-By: Claude Opus 5 (1M context) --- iosApp/iosApp/Components/WornBottomBar.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/iosApp/iosApp/Components/WornBottomBar.swift b/iosApp/iosApp/Components/WornBottomBar.swift index eb0f2e9..faf8080 100644 --- a/iosApp/iosApp/Components/WornBottomBar.swift +++ b/iosApp/iosApp/Components/WornBottomBar.swift @@ -103,6 +103,11 @@ private struct TabItem: View { Text(tab.label) .font(.system(size: 10, weight: .semibold)) .tracking(0.5) + // "WARDROBE" is the longest label and wraps on a 5-tab compact bar, which + // clips the descender. Shrink instead of wrapping, and never take two lines. + .lineLimit(1) + .minimumScaleFactor(0.75) + .fixedSize(horizontal: false, vertical: true) } .foregroundColor(isActive ? WornColors.textOnColor : WornColors.textSecondary) .frame(maxWidth: .infinity, maxHeight: .infinity) From 4f3475f73b48a9520494944a5e129470fab0bfeb Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 28 Jul 2026 13:34:20 -0300 Subject: [PATCH 08/13] fix: paint the app background behind the safe areas Each screen sets bgPage within its own bounds, but the root container had no background, so the status bar and home indicator areas fell through to the window's black. Android gets this from enableEdgeToEdge() plus the theme background; iOS needs the colour explicitly extended past the safe area. Co-Authored-By: Claude Opus 5 (1M context) --- iosApp/iosApp/iOSApp.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 86579f6..b61dc72 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -47,6 +47,10 @@ struct iOSApp: App { 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. From d709398c1d1c2a9dabeebbdddbbe9dc0714b4851 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 28 Jul 2026 13:38:22 -0300 Subject: [PATCH 09/13] fix: read the size class from a view, not the App @Environment on an App conformer is not filled in from the view hierarchy, so horizontalSizeClass read there was always nil and `sizeClass == .compact` was permanently false. The bottom bar therefore stayed in its expanded layout on iPhone too: capped at 480pt with 32pt side padding. Moved the tab host and its state into a RootView, where the environment resolves. Verified on both simulators: iPhone 16 now spans the full width, iPad Pro 11-inch keeps the centred 480pt pill. Co-Authored-By: Claude Opus 5 (1M context) --- iosApp/iosApp/iOSApp.swift | 110 ++++++++++++++++++++----------------- 1 file changed, 61 insertions(+), 49 deletions(-) diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index b61dc72..88a60dd 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -4,13 +4,6 @@ import Shared @main struct iOSApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate - @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? init() { KoinHelperKt.doInitKoin() @@ -22,52 +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, +/// 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, - isCompact: sizeClass == .compact + 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) } - // 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) - } + .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) } } From e8b92265b23efdaf5531fdba13d50294a5857a86 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 28 Jul 2026 13:38:22 -0300 Subject: [PATCH 10/13] style: widen the compact bottom bar Drop the compact side padding from 21 to 10 so the bar has more room and the tab labels sit comfortably inside their pills. Applied to both platforms to keep the layouts in step; the expanded (tablet) padding is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .../kotlin/com/github/worn/ui/components/WornBottomBar.kt | 4 ++-- iosApp/iosApp/Components/WornBottomBar.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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/iosApp/Components/WornBottomBar.swift b/iosApp/iosApp/Components/WornBottomBar.swift index faf8080..28719c8 100644 --- a/iosApp/iosApp/Components/WornBottomBar.swift +++ b/iosApp/iosApp/Components/WornBottomBar.swift @@ -68,7 +68,7 @@ struct WornBottomBar: View { if !isCompact { Spacer() } } - .padding(.horizontal, isCompact ? 21 : 32) + .padding(.horizontal, isCompact ? 10 : 32) .padding(.top, 12) .padding(.bottom, 21) } From 7179630c6eb83244475438a50e5ba35a667eb17e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 28 Jul 2026 13:45:05 -0300 Subject: [PATCH 11/13] feat: wire up the WornShareExtension target The Swift sources, Info.plist and entitlements were already complete, but the project had exactly one target, so the extension was never built and share-to-Worn could not work at all. Added the app-extension target, its synchronized group, an Embed Foundation Extensions phase on the app, and the dependency plumbing. Two things SETUP.md was emphatic about, both preserved: - No Compile Kotlin Framework phase on the extension. Shared is a static framework, so linking it would duplicate the whole Kotlin binary into a memory-capped process. Verified: the appex binary is 128 KB against the app's 28 MB, and otool shows no Shared linkage. - openHostApp()'s responder-chain walk is kept, since NSExtensionContext.open does not launch the host app from a share extension. GENERATE_INFOPLIST_FILE is YES rather than NO as SETUP.md suggested. With NO, the checked-in plist supplies no CFBundleIdentifier and the build fails embedded-binary validation; YES matches the app target, which also merges a partial plist. Bundle ids now resolve to com.github.worn.Worn and com.github.worn.Worn.ShareExtension. Rewrote SETUP.md to describe the committed target rather than the manual steps. Verified end to end on the iPhone 16 simulator: planting a JPEG as pending_share.jpg in the App Group container opens Worn on Try It and removes the file. Driving the real share sheet still needs manual interaction. Co-Authored-By: Claude Opus 5 (1M context) --- iosApp/WornShareExtension/SETUP.md | 101 +++++++-------- iosApp/iosApp.xcodeproj/project.pbxproj | 161 ++++++++++++++++++++++++ 2 files changed, 213 insertions(+), 49 deletions(-) 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 5476b2b..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 */ @@ -349,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 */ @@ -370,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 */; From 8aaad08ed534da8c2e411dce761d1d56d9398f83 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 28 Jul 2026 13:47:46 -0300 Subject: [PATCH 12/13] ci: build the iOS app on every PR CI ran only on ubuntu-latest, so the Swift side was never compiled. That is how 136 compile errors, a missing -lsqlite3 and three broken localization keys all reached main unnoticed. Also commits a shared xcscheme. Xcode autocreates schemes into xcuserdata, which is gitignored, so `xcodebuild -scheme iosApp` had nothing to resolve on a fresh checkout. .gitignore already whitelists xcodeproj/xcshareddata. Building the iosApp scheme also builds and embeds WornShareExtension. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 29 +++++++ .../xcshareddata/xcschemes/iosApp.xcscheme | 77 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme 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/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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From a6693e93082298751837201ea3a02b0b9267b3ac Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 28 Jul 2026 13:53:25 -0300 Subject: [PATCH 13/13] fix: keep long tab labels inside their own slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each TabItem was sized to its label, so the widest translation stole width from its neighbours. In pt-BR "COMBINAÇÕES" spilled over the adjacent tab's pill. Give every tab an equal slot and let the label shrink further, truncating only if it still will not fit — Android already ellipsises for the same reason. Found by running the app with -AppleLanguages "(pt-BR)"; the English labels were short enough to hide it. Co-Authored-By: Claude Opus 5 (1M context) --- iosApp/iosApp/Components/WornBottomBar.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/iosApp/iosApp/Components/WornBottomBar.swift b/iosApp/iosApp/Components/WornBottomBar.swift index 28719c8..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) } } @@ -103,11 +107,13 @@ private struct TabItem: View { Text(tab.label) .font(.system(size: 10, weight: .semibold)) .tracking(0.5) - // "WARDROBE" is the longest label and wraps on a 5-tab compact bar, which - // clips the descender. Shrink instead of wrapping, and never take two lines. + // 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.75) - .fixedSize(horizontal: false, vertical: true) + .minimumScaleFactor(0.6) + .truncationMode(.tail) + .padding(.horizontal, 2) } .foregroundColor(isActive ? WornColors.textOnColor : WornColors.textSecondary) .frame(maxWidth: .infinity, maxHeight: .infinity)