diff --git a/Alfie/Alfie.xcodeproj/project.pbxproj b/Alfie/Alfie.xcodeproj/project.pbxproj index d8af79f..081e249 100644 --- a/Alfie/Alfie.xcodeproj/project.pbxproj +++ b/Alfie/Alfie.xcodeproj/project.pbxproj @@ -8,7 +8,7 @@ /* Begin PBXBuildFile section */ 17036A8A2C1881A7002DD7AB /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = BAEFC2582B84A99000387C00 /* GoogleService-Info.plist */; }; - 490013A72BF608F50028C0FA /* ProductDetailsColorSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 490013A62BF608F50028C0FA /* ProductDetailsColorSheet.swift */; }; + 490013A72BF608F50028C0FA /* ProductDetailsColorAndSizeSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 490013A62BF608F50028C0FA /* ProductDetailsColorAndSizeSheet.swift */; }; 49032CD72B694520002C86F5 /* TabBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49032CD62B694520002C86F5 /* TabBarView.swift */; }; 49032CDA2B6949CD002C86F5 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49032CD92B6949CD002C86F5 /* HomeView.swift */; }; 49032CE32B694BB7002C86F5 /* BagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49032CE22B694BB7002C86F5 /* BagView.swift */; }; @@ -165,7 +165,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 490013A62BF608F50028C0FA /* ProductDetailsColorSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailsColorSheet.swift; sourceTree = ""; }; + 490013A62BF608F50028C0FA /* ProductDetailsColorAndSizeSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailsColorAndSizeSheet.swift; sourceTree = ""; }; 490013A92BF60B730028C0FA /* ProductDetailsColorSheetSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailsColorSheetSnapshotTests.swift; sourceTree = ""; }; 49032CD62B694520002C86F5 /* TabBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarView.swift; sourceTree = ""; }; 49032CD92B6949CD002C86F5 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; @@ -352,7 +352,7 @@ 490013A82BF60A470028C0FA /* ComplementaryViews */ = { isa = PBXGroup; children = ( - 490013A62BF608F50028C0FA /* ProductDetailsColorSheet.swift */, + 490013A62BF608F50028C0FA /* ProductDetailsColorAndSizeSheet.swift */, ); path = ComplementaryViews; sourceTree = ""; @@ -1289,7 +1289,7 @@ 49032CE72B694BC6002C86F5 /* ShopView.swift in Sources */, BAE1A0552BA0B8F800DB5276 /* ServiceProvider.swift in Sources */, BA833AF92B90863D0056D3F5 /* WebViewPreload.swift in Sources */, - 490013A72BF608F50028C0FA /* ProductDetailsColorSheet.swift in Sources */, + 490013A72BF608F50028C0FA /* ProductDetailsColorAndSizeSheet.swift in Sources */, DA9AC7962B768BD700140F91 /* LocalizableProtocol.swift in Sources */, DA5736F32BCD3BD900FA5107 /* ThemedURL.swift in Sources */, A13968C62B91F47E00AB8806 /* BagDependencyContainer.swift in Sources */, diff --git a/Alfie/Alfie/Views/ProductDetails/ComplementaryViews/ProductDetailsColorAndSizeSheet.swift b/Alfie/Alfie/Views/ProductDetails/ComplementaryViews/ProductDetailsColorAndSizeSheet.swift new file mode 100644 index 0000000..b624ffe --- /dev/null +++ b/Alfie/Alfie/Views/ProductDetails/ComplementaryViews/ProductDetailsColorAndSizeSheet.swift @@ -0,0 +1,173 @@ +import Models +import StyleGuide +import SwiftUI +#if DEBUG +import Mocks +#endif + +private enum Constants { + static let sheetCloseIconSize: CGFloat = 16 + static let colorCheckmarkSize: CGFloat = 16 +} + +enum SheetType { + case color + case size +} + +struct ProductDetailsColorAndSizeSheet: View { + @StateObject private var viewModel: ViewModel + @Binding private var isPresented: Bool + @Binding private var searchText: String + + private let type: SheetType + + private var title: String { + // swiftlint:disable vertical_whitespace_between_cases + switch type { + case .color: + LocalizableProductDetails.$color + case .size: + LocalizableProductDetails.$size + } + // swiftlint:enable vertical_whitespace_between_cases + } + + internal init(viewModel: ViewModel, type: SheetType, isPresented: Binding, searchText: Binding = Binding.constant("")) { + self._viewModel = StateObject(wrappedValue: viewModel) + self._isPresented = isPresented + self._searchText = searchText + self.type = type + } + + var body: some View { + VStack { + HStack { + Text(title) + .font(Font(theme.font.paragraph.normal.withSize(18))) + .foregroundStyle(Colors.primary.mono900) + + Spacer() + + Button { + isPresented = false + } label: { + Icon.close.image + .resizable() + .scaledToFit() + .frame(size: Constants.sheetCloseIconSize) + .foregroundStyle(Colors.primary.mono900) + } + } + .padding([.top, .horizontal], Spacing.space200) + + ThemedDivider.horizontalThin + .padding(.bottom, Spacing.space100) + + itemsView + } + .presentationDetents([.medium]) + .presentationDragIndicator(.hidden) + } +} + +private extension ProductDetailsColorAndSizeSheet { + @ViewBuilder var itemsView: some View { + // swiftlint:disable vertical_whitespace_between_cases + switch type { + case .color: + colorItemsView + case .size: + sizeItemsView + } + // swiftlint:enable vertical_whitespace_between_cases + } + + @ViewBuilder var colorItemsView: some View { + ScrollView { + ForEach(viewModel.colorSwatches(filteredBy: searchText)) { item in + VStack { + Button { + viewModel.colorSelectionConfiguration.selectedItem = item + isPresented = false + } label: { + HStack(spacing: Spacing.space200) { + ColorSwatchView( + item: item, + swatchSize: .normal, + isSelected: viewModel.colorSelectionConfiguration.selectedItem == item + ) + + Text.build(theme.font.paragraph.normal(item.name.capitalized)) + + Spacer() + + if viewModel.colorSelectionConfiguration.selectedItem == item { + checkmark + } + } + } + .tint(Colors.primary.black) + + ThemedDivider.horizontalThin + } + } + .padding(.horizontal, Spacing.space200) + .padding(.vertical, Spacing.space100) + } + .searchable( + placeholder: LocalizableProductDetails.$searchColors, + placeholderOnFocus: LocalizableProductDetails.$searchColors, + searchText: $searchText, + theme: .soft, + dismissConfiguration: .init(type: .cancel(title: LocalizableSearch.$cancel)), + verticalSpacing: Spacing.space200 + ) + } + + @ViewBuilder var sizeItemsView: some View { + ScrollView { + ForEach(viewModel.sizingSelectionConfiguration.items) { item in + VStack { + Button { + viewModel.sizingSelectionConfiguration.selectedItem = item + isPresented = false + } label: { + HStack(spacing: Spacing.space200) { + Text.build(theme.font.paragraph.normal(item.name.capitalized)) + + Spacer() + + if viewModel.sizingSelectionConfiguration.selectedItem == item { + checkmark + } + } + } + .tint(Colors.primary.black) + + ThemedDivider.horizontalThin + } + } + .padding(.horizontal, Spacing.space200) + .padding(.vertical, Spacing.space100) + } + } + + @ViewBuilder var checkmark: some View { + Icon.checkmark.image + .resizable() + .scaledToFit() + .frame(size: Constants.colorCheckmarkSize) + } +} + +#if DEBUG +#Preview { + ProductDetailsColorAndSizeSheet( + viewModel: MockProductDetailsViewModel(), + type: .color, + isPresented: .constant(true), + searchText: .constant("") + ) +} +#endif diff --git a/Alfie/Alfie/Views/ProductDetails/ComplementaryViews/ProductDetailsColorSheet.swift b/Alfie/Alfie/Views/ProductDetails/ComplementaryViews/ProductDetailsColorSheet.swift deleted file mode 100644 index 21edf3c..0000000 --- a/Alfie/Alfie/Views/ProductDetails/ComplementaryViews/ProductDetailsColorSheet.swift +++ /dev/null @@ -1,104 +0,0 @@ -import Models -import StyleGuide -import SwiftUI -#if DEBUG -import Mocks -#endif - -private enum Constants { - static let sheetCloseIconSize: CGFloat = 16 - static let colorCheckmarkSize: CGFloat = 16 -} - -struct ProductDetailsColorSheet: View { - @StateObject private var viewModel: ViewModel - @Binding private var isPresented: Bool - @Binding private var searchText: String - - internal init(viewModel: ViewModel, isPresented: Binding, searchText: Binding) { - self._viewModel = StateObject(wrappedValue: viewModel) - self._isPresented = isPresented - self._searchText = searchText - } - - var body: some View { - VStack { - HStack { - Text(LocalizableProductDetails.$color) - .font(Font(theme.font.paragraph.normal.withSize(18))) - .foregroundStyle(Colors.primary.mono900) - - Spacer() - - Button { - isPresented = false - } label: { - Icon.close.image - .resizable() - .scaledToFit() - .frame(size: Constants.sheetCloseIconSize) - .foregroundStyle(Colors.primary.mono900) - } - } - .padding([.top, .horizontal], Spacing.space200) - - ThemedDivider.horizontalThin - .padding(.bottom, Spacing.space100) - - ScrollView { - ForEach(viewModel.colorSwatches(filteredBy: searchText)) { item in - VStack { - Button { - viewModel.colorSelectionConfiguration.selectedItem = item - isPresented = false - } label: { - HStack(spacing: Spacing.space200) { - ColorSwatchView( - item: item, - swatchSize: .normal, - isSelected: viewModel.colorSelectionConfiguration.selectedItem == item - ) - - Text.build(theme.font.paragraph.normal(item.name.capitalized)) - - Spacer() - - if viewModel.colorSelectionConfiguration.selectedItem == item { - Icon.checkmark.image - .resizable() - .scaledToFit() - .frame(size: Constants.colorCheckmarkSize) - } - } - } - .tint(Colors.primary.black) - - ThemedDivider.horizontalThin - } - } - .padding(.horizontal, Spacing.space200) - .padding(.vertical, Spacing.space100) - } - .searchable( - placeholder: LocalizableProductDetails.$searchColors, - placeholderOnFocus: LocalizableProductDetails.$searchColors, - searchText: $searchText, - theme: .soft, - dismissConfiguration: .init(type: .cancel(title: LocalizableSearch.$cancel)), - verticalSpacing: Spacing.space200 - ) - } - .presentationDetents([.medium]) - .presentationDragIndicator(.hidden) - } -} - -#if DEBUG -#Preview { - ProductDetailsColorSheet( - viewModel: MockProductDetailsViewModel(), - isPresented: .constant(true), - searchText: .constant("") - ) -} -#endif diff --git a/Alfie/Alfie/Views/ProductDetails/Localisation/LocalizableProductDetails.swift b/Alfie/Alfie/Views/ProductDetails/Localisation/LocalizableProductDetails.swift index 65190c4..0892125 100644 --- a/Alfie/Alfie/Views/ProductDetails/Localisation/LocalizableProductDetails.swift +++ b/Alfie/Alfie/Views/ProductDetails/Localisation/LocalizableProductDetails.swift @@ -14,6 +14,8 @@ struct LocalizableProductDetails: LocalizableProtocol { @LocalizableResource(.errorButtonBackLabel) static var errorButtonBackLabel @LocalizableResource(.color) static var color @LocalizableResource(.searchColors) static var searchColors + @LocalizableResource(.size) static var size + @LocalizableResource(.oneSize) static var oneSize enum Keys: String, LocalizableKeyProtocol { case complementaryInfoDelivery = "KeyComplementaryInfoDelivery" @@ -29,5 +31,7 @@ struct LocalizableProductDetails: LocalizableProtocol { case errorButtonBackLabel = "KeyErrorButtonBackLabel" case color = "KeyColor" case searchColors = "KeySearchColors" + case size = "KeySize" + case oneSize = "KeyOneSize" } } diff --git a/Alfie/Alfie/Views/ProductDetails/Localisation/LocalizableProductDetails.xcstrings b/Alfie/Alfie/Views/ProductDetails/Localisation/LocalizableProductDetails.xcstrings index e020e7e..d193d73 100644 --- a/Alfie/Alfie/Views/ProductDetails/Localisation/LocalizableProductDetails.xcstrings +++ b/Alfie/Alfie/Views/ProductDetails/Localisation/LocalizableProductDetails.xcstrings @@ -100,6 +100,17 @@ } } }, + "KeyOneSize" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "One Size" + } + } + } + }, "KeyOutOfStock" : { "extractionState" : "manual", "localizations" : { @@ -143,6 +154,17 @@ } } } + }, + "KeySize" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Size" + } + } + } } }, "version" : "1.0" diff --git a/Alfie/Alfie/Views/ProductDetails/ProductDetailsView.swift b/Alfie/Alfie/Views/ProductDetails/ProductDetailsView.swift index 0aace49..aa0a4ba 100644 --- a/Alfie/Alfie/Views/ProductDetails/ProductDetailsView.swift +++ b/Alfie/Alfie/Views/ProductDetails/ProductDetailsView.swift @@ -15,6 +15,7 @@ struct ProductDetailsView: View { @State private var currentMediaIndex = 0 @State private var isMediaFullScreen = false @State private var showColorSheet = false + @State private var showSizeSheet = false @State private var showDetailsSheet = false @State private var shouldAnimateCurrentMediaIndex = true @State private var carouselSize: CGSize = .zero @@ -34,6 +35,18 @@ struct ProductDetailsView: View { viewModel.colorSelectionConfiguration.items.count > 1 } + private var canShowSizePickers: Bool { + viewModel.sizingSelectionConfiguration.items.count > 6 + } + + private var canShowSizeSelector: Bool { + viewModel.sizingSelectionConfiguration.items.count > 1 + } + + private var isOneSize: Bool { + viewModel.sizingSelectionConfiguration.items.count == 1 + } + // TODO: remove showFailureState (created for snapshot purposes) init(viewModel: ViewModel, showFailureState: Bool = false) { _showFailureState = State(initialValue: showFailureState) @@ -140,6 +153,10 @@ struct ProductDetailsView: View { colorSheet .presentationBackgroundInteraction(.enabled) }) + .sheet(isPresented: $showSizeSheet) { + sizeSheet + .presentationBackgroundInteraction(.enabled) + } .fullScreenCover(isPresented: $isMediaFullScreen) { fullscreenMediaCarousel } @@ -242,6 +259,8 @@ extension ProductDetailsView { colorSelector + sizeSelector + descriptionTab .padding(.vertical, Spacing.space200) @@ -311,40 +330,29 @@ extension ProductDetailsView { } private var colorSheet: some View { - ProductDetailsColorSheet(viewModel: viewModel, isPresented: $showColorSheet, searchText: $colorSheetSearchText) + ProductDetailsColorAndSizeSheet( + viewModel: viewModel, + type: .color, + isPresented: $showColorSheet, + searchText: $colorSheetSearchText + ) + } + + private var sizeSheet: some View { + ProductDetailsColorAndSizeSheet(viewModel: viewModel, type: .size, isPresented: $showSizeSheet) } @ViewBuilder private var colorSelector: some View { if viewModel.shouldShow(section: .colorSelector) { if hasSpaceForSizeSelector { - VStack(alignment: .leading) { - HStack { - Text.build(theme.font.small.bold(LocalizableProductDetails.$color + ":")) - .foregroundStyle(Colors.primary.mono900) - Button(action: { - guard canShowColorPickers else { - return - } - showColorSheet = true - }, label: { - HStack { - Text.build( - theme.font.small.normal( - viewModel.colorSelectionConfiguration.selectedItem?.name.capitalized ?? "" - ) - ) - .foregroundStyle(Colors.primary.mono900) - if canShowColorPickers { - Icon.chevronDown.image - .resizable() - .scaledToFit() - .frame(size: Constants.colorChevronSize) - } - } - }) - .allowsHitTesting(canShowColorPickers) - .tint(Colors.primary.mono900) + VStack(alignment: .leading, spacing: Spacing.space150) { + ColorAndSizingSelectorHeaderView( + configuration: viewModel.colorSelectionConfiguration, + isExpandable: canShowColorPickers + ) { + showColorSheet = true } + if canShowColorPickers { ColorSelectorComponentView( configuration: viewModel.colorSelectionConfiguration, @@ -380,6 +388,43 @@ extension ProductDetailsView { } } + @ViewBuilder private var sizeSelector: some View { + if viewModel.shouldShow(section: .sizeSelector) { + VStack(alignment: .leading, spacing: Spacing.space150) { + if canShowSizeSelector { + ColorAndSizingSelectorHeaderView( + configuration: viewModel.sizingSelectionConfiguration, + isExpandable: canShowSizePickers + ) { + showSizeSheet = true + } + + if !canShowSizePickers { + SizingSelectorComponentView( + configuration: viewModel.sizingSelectionConfiguration, + layoutConfiguration: .init(arrangement: .grid(columns: 3, columnWidth: 60)) + ) + } + } else { + singleSizeView + } + } + .shimmering(while: shimmeringBinding(for: .sizeSelector), animateOnStateTransition: false) + } + } + + @ViewBuilder private var singleSizeView: some View { + let sizeText: String = isOneSize + ? (viewModel.sizingSelectionConfiguration.items.first?.name ?? "") + : LocalizableProductDetails.$oneSize + HStack { + Text.build(theme.font.small.bold(LocalizableProductDetails.$size + ":")) + .foregroundStyle(Colors.primary.mono900) + Text.build(theme.font.small.normal(sizeText)) + .foregroundStyle(Colors.primary.mono900) + } + } + @ViewBuilder private var complementaryInfo: some View { if viewModel.shouldShow(section: .complementaryInfo) { VStack(spacing: Spacing.space0) { @@ -501,7 +546,7 @@ private enum Constants { URL.fromString("https://www.alfieproj.com/productimages/thumb/2/2666503_22841458_13891527.jpg"), ], productDescription: "A short-sleeved dress in a slim fit by BOSS Womenswear. Featuring a wrap-over bodice and a tiered skirt, this V-neck dress is crafted in metallic fabric with lining underneath.", // swiftlint:disable:this line_length - colorSelectionConfiguration: .init( + colorSelectionConfiguration: ColorSelectorConfiguration( items: [ .init(name: "", type: .url(URL.fromString("https://www.alfieproj.com/productimages/thumb/3/2479864_22579704_13941430.jpg"))), .init(name: "", type: .url(URL.fromString("https://www.alfieproj.com/productimages/thumb/3/2479864_22005770_9866399.jpg"))), diff --git a/Alfie/Alfie/Views/ProductDetails/ProductDetailsViewModel.swift b/Alfie/Alfie/Views/ProductDetails/ProductDetailsViewModel.swift index d021764..6988b36 100644 --- a/Alfie/Alfie/Views/ProductDetails/ProductDetailsViewModel.swift +++ b/Alfie/Alfie/Views/ProductDetails/ProductDetailsViewModel.swift @@ -6,6 +6,9 @@ import Models import StyleGuide final class ProductDetailsViewModel: ProductDetailsViewModelProtocol { + typealias ColorSelector = ColorSelectorConfiguration + typealias SizingSelector = SizingSelectorConfiguration + private let dependencies: ProductDetailsDependencyContainerProtocol // In case we already have a full or partial product to show while fetching private let baseProduct: Product? @@ -13,6 +16,7 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol { @Published private(set) var state: ViewState = .loading private(set) var colorSelectionConfiguration: ColorSelectorConfiguration = .init(items: []) + private(set) var sizingSelectionConfiguration: SizingSelectorConfiguration = .init(items: []) public let productId: String private var product: Product? { @@ -89,6 +93,7 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol { if let baseProduct { buildColorSelectionConfiguration(product: baseProduct, selectedVariant: baseProduct.defaultVariant) + buildSizingSelectionConfiguration(product: baseProduct, selectedVariant: baseProduct.defaultVariant) } } @@ -103,7 +108,8 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol { case .titleHeader: return state.isLoading && productName.isEmpty case .colorSelector, - .mediaCarousel, // swiftlint:disable:this indentation_width + .sizeSelector, // swiftlint:disable:this indentation_width + .mediaCarousel, .complementaryInfo: return state.isLoading case .productDescription, @@ -116,7 +122,8 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol { // swiftlint:disable vertical_whitespace_between_cases switch section { case .titleHeader, - .colorSelector: // swiftlint:disable:this indentation_width + .colorSelector, // swiftlint:disable:this indentation_width + .sizeSelector: return true case .complementaryInfo: return !complementaryInfoToShow.isEmpty @@ -197,7 +204,11 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol { selectedSwatch = colorSwatches.first { $0.id == selectedVariant.colour?.id } } - colorSelectionConfiguration = .init(selectedTitle: "", items: colorSwatches, selectedItem: selectedSwatch) + colorSelectionConfiguration = .init( + selectedTitle: LocalizableProductDetails.$color + ":", + items: colorSwatches, + selectedItem: selectedSwatch + ) colorSelectionSubscription = colorSelectionConfiguration.$selectedItem .receive(on: DispatchQueue.main) .dropFirst() @@ -240,6 +251,51 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol { return productColors } + private func buildSizingSelectionConfiguration(product: Product, selectedVariant: Product.Variant?) { + let sizingSwatches = buildSizingSwatches(product: product, selectedVariant: selectedVariant) + + var selectedSwatch: SizingSwatch? + if let selectedVariant { + selectedSwatch = sizingSwatches.first { $0.id == selectedVariant.size?.id } + } + + sizingSelectionConfiguration = .init( + selectedTitle: LocalizableProductDetails.$size + ":", + items: sizingSwatches, + selectedItem: selectedSwatch + ) + } + + private func buildSizingSwatches(product: Product, selectedVariant: Product.Variant?) -> [SizingSwatch] { + let sizes = buildVariantSizes(product: product, selectedVariant: selectedVariant) + return sizes.map { size in + let isAvailable = product.variants.contains { $0.size?.id == size.id && $0.stock > 0 } + + // TODO: Handle unavailable state if needed + return SizingSwatch(id: size.id, name: size.value, state: isAvailable ? .available : .outOfStock) + } + } + + private func buildVariantSizes(product: Product, selectedVariant: Product.Variant?) -> [Product.ProductSize] { + let variantsForSelectedColor = product.variants.filter { $0.colour?.id == selectedVariant?.colour?.id } + var productSizes = [Product.ProductSize]() + variantsForSelectedColor.forEach { variant in + guard let size = variant.size, !productSizes.contains(where: { $0.id == size.id }) else { + return + } + productSizes.append( + Product.ProductSize( + id: size.id, + value: size.value, + scale: size.scale, + description: size.description, + sizeGuide: size.sizeGuide + ) + ) + } + return productSizes + } + private func didSelect(colorSwatch: ColorSwatch) { guard let product else { logError("Tried to select color on inexistent product") diff --git a/Alfie/Packages/Mocks/Sources/Core/Features/MockProductDetailsViewModel.swift b/Alfie/Packages/Mocks/Sources/Core/Features/MockProductDetailsViewModel.swift index b6d9894..b110bd0 100644 --- a/Alfie/Packages/Mocks/Sources/Core/Features/MockProductDetailsViewModel.swift +++ b/Alfie/Packages/Mocks/Sources/Core/Features/MockProductDetailsViewModel.swift @@ -2,7 +2,7 @@ import Combine import Foundation import Models -public class MockProductDetailsViewModel: ProductDetailsViewModelProtocol { +public class MockProductDetailsViewModel: ProductDetailsViewModelProtocol { public var state: ViewState = .loading public var productId: String = "" @@ -10,7 +10,8 @@ public class MockProductDetailsViewModel: ProductDetailsViewModelProtocol { public var productHasStock: Bool = true public var productName: String = "" public var productImageUrls: [URL] = [] - public var colorSelectionConfiguration: ColorSelectorConfiguration = .init(items: []) + public var colorSelectionConfiguration: ColorSelector + public var sizingSelectionConfiguration: SizingSelector public var complementaryInfoToShow: [ProductDetailsComplementaryInfoType] = [] public var productDescription: String = "" public var shareConfiguration: ShareConfiguration? @@ -23,7 +24,8 @@ public class MockProductDetailsViewModel: ProductDetailsViewModelProtocol { productName: String = "", productImageUrls: [URL] = [], productDescription: String = "", - colorSelectionConfiguration: ColorSelectorConfiguration = .init(items: []), + colorSelectionConfiguration: ColorSelector = ColorSelectorConfiguration(items: []), + sizingSelectionConfiguration: SizingSelector = SizingSelectorConfiguration(items: []), complementaryInfoToShow: [ProductDetailsComplementaryInfoType] = [], onShouldShowLoadingForSectionCalled: ((ProductDetailsSection) -> Bool)? = nil, onShouldShowSectionCalled: ((ProductDetailsSection) -> Bool)? = nil) { @@ -34,6 +36,7 @@ public class MockProductDetailsViewModel: ProductDetailsViewModelProtocol { self.productImageUrls = productImageUrls self.productDescription = productDescription self.colorSelectionConfiguration = colorSelectionConfiguration + self.sizingSelectionConfiguration = sizingSelectionConfiguration self.complementaryInfoToShow = complementaryInfoToShow self.onShouldShowLoadingForSectionCalled = onShouldShowLoadingForSectionCalled self.onShouldShowSectionCalled = onShouldShowSectionCalled @@ -64,8 +67,8 @@ public class MockProductDetailsViewModel: ProductDetailsViewModelProtocol { onDidTapAddToBagCalled?() } - public var onColorSwatchesFilteredByCalled: ((String) -> [Models.ColorSwatch])? - public func colorSwatches(filteredBy searchTerm: String) -> [Models.ColorSwatch] { + public var onColorSwatchesFilteredByCalled: ((String) -> [ColorSelector.Swatch])? + public func colorSwatches(filteredBy searchTerm: String) -> [ColorSelector.Swatch] { onColorSwatchesFilteredByCalled?(searchTerm) ?? colorSelectionConfiguration.items } } diff --git a/Alfie/Packages/Models/Sources/Features/ProductDetailsViewModelProtocol.swift b/Alfie/Packages/Models/Sources/Features/ProductDetailsViewModelProtocol.swift index bd567da..62cfedc 100644 --- a/Alfie/Packages/Models/Sources/Features/ProductDetailsViewModelProtocol.swift +++ b/Alfie/Packages/Models/Sources/Features/ProductDetailsViewModelProtocol.swift @@ -26,6 +26,7 @@ public enum ProductDetailsViewErrorType: Error, CaseIterable { public enum ProductDetailsSection { case titleHeader case colorSelector + case sizeSelector case mediaCarousel case complementaryInfo case productDescription @@ -43,6 +44,9 @@ public enum ProductDetailsComplementaryInfoType { // MARK: - ProductDetailsViewModelProtocol public protocol ProductDetailsViewModelProtocol: ObservableObject { + associatedtype ColorSelector: ColorSelectorProtocol + associatedtype SizingSelector: SizingSelectorProtocol + var state: ViewState { get } var productId: String { get } @@ -51,7 +55,8 @@ public protocol ProductDetailsViewModelProtocol: ObservableObject { var productHasStock: Bool { get } var productImageUrls: [URL] { get } var productDescription: String { get } - var colorSelectionConfiguration: ColorSelectorConfiguration { get } + var colorSelectionConfiguration: ColorSelector { get } + var sizingSelectionConfiguration: SizingSelector { get } var complementaryInfoToShow: [ProductDetailsComplementaryInfoType] { get } var shareConfiguration: ShareConfiguration? { get } var shouldShowMediaPaginatedControl: Bool { get } @@ -62,5 +67,5 @@ public protocol ProductDetailsViewModelProtocol: ObservableObject { func shouldShowLoading(for section: ProductDetailsSection) -> Bool func complementaryInfoWebFeature(for type: ProductDetailsComplementaryInfoType) -> WebFeature? func didTapAddToBag() - func colorSwatches(filteredBy searchTerm: String) -> [ColorSwatch] + func colorSwatches(filteredBy searchTerm: String) -> [ColorSelector.Swatch] } diff --git a/Alfie/Packages/Models/Sources/UI/ColorAndSizingSwatchProtocol.swift b/Alfie/Packages/Models/Sources/UI/ColorAndSizingSwatchProtocol.swift new file mode 100644 index 0000000..c550860 --- /dev/null +++ b/Alfie/Packages/Models/Sources/UI/ColorAndSizingSwatchProtocol.swift @@ -0,0 +1,4 @@ +public protocol ColorAndSizingSwatchProtocol: Equatable, Identifiable { + var id: String { get } + var name: String { get } +} diff --git a/Alfie/Packages/Models/Sources/UI/ColorSelection/ColorSelectorConfiguration.swift b/Alfie/Packages/Models/Sources/UI/ColorSelection/ColorSelectorConfiguration.swift index 725b84b..35420b4 100644 --- a/Alfie/Packages/Models/Sources/UI/ColorSelection/ColorSelectorConfiguration.swift +++ b/Alfie/Packages/Models/Sources/UI/ColorSelection/ColorSelectorConfiguration.swift @@ -1,8 +1,11 @@ import SwiftUI +public protocol ColorSelectorProtocol: ColorSizingSelectorConfigurationProtocol where Swatch: ColorSwatchProtocol { +} + // MARK: - ColorSelectorConfiguration -public class ColorSelectorConfiguration: ObservableObject { +public class ColorSelectorConfiguration: ColorSelectorProtocol { /// Title to display before the currently selected color name public let selectedTitle: String /// Color items to display as swatches in the banner. Won't be shown if empty or containing a single color diff --git a/Alfie/Packages/Models/Sources/UI/ColorSelection/ColorSwatch.swift b/Alfie/Packages/Models/Sources/UI/ColorSelection/ColorSwatch.swift index 9790664..6ac8dfb 100644 --- a/Alfie/Packages/Models/Sources/UI/ColorSelection/ColorSwatch.swift +++ b/Alfie/Packages/Models/Sources/UI/ColorSelection/ColorSwatch.swift @@ -2,6 +2,13 @@ import SwiftUI // MARK: - SwatchType +public typealias ColorSwatchProtocol = ColorAndSizingSwatchProtocol & ColorProtocol + +public protocol ColorProtocol { + var type: SwatchType { get } + var isDisabled: Bool { get } +} + public enum SwatchType: Equatable { case image(Image) case color(Color) @@ -25,7 +32,7 @@ public enum SwatchType: Equatable { // MARK: - ColorSwatch -public struct ColorSwatch: Equatable, Identifiable { +public struct ColorSwatch: ColorSwatchProtocol { public let id: String public let name: String public let type: SwatchType diff --git a/Alfie/Packages/Models/Sources/UI/ColorSizingSelectorConfigurationProtocol.swift b/Alfie/Packages/Models/Sources/UI/ColorSizingSelectorConfigurationProtocol.swift new file mode 100644 index 0000000..666bd33 --- /dev/null +++ b/Alfie/Packages/Models/Sources/UI/ColorSizingSelectorConfigurationProtocol.swift @@ -0,0 +1,11 @@ +import Combine + +public protocol ColorSizingSelectorConfigurationProtocol: ObservableObject { + associatedtype Swatch: ColorAndSizingSwatchProtocol + /// Title to display before the currently selected item name + var selectedTitle: String { get } + /// Items to display as swatches in the banner. Won't be shown if empty or containing a single item + var items: [Swatch] { get } + /// The currently selected item, if `items` is not empty, then a item with the same name must exist there + var selectedItem: Swatch? { get set } +} diff --git a/Alfie/Packages/Models/Sources/UI/SizeSelection/SizingSelectorConfiguration.swift b/Alfie/Packages/Models/Sources/UI/SizeSelection/SizingSelectorConfiguration.swift new file mode 100644 index 0000000..521c29f --- /dev/null +++ b/Alfie/Packages/Models/Sources/UI/SizeSelection/SizingSelectorConfiguration.swift @@ -0,0 +1,19 @@ +import SwiftUI + +public protocol SizingSelectorProtocol: ColorSizingSelectorConfigurationProtocol where Swatch: SizingSwatchProtocol { +} + +public class SizingSelectorConfiguration: SizingSelectorProtocol { + /// Title to display before the currently selected size name + public let selectedTitle: String + /// Sizing items to display as swatches in the banner. Won't be shown if empty or containing a single size + public let items: [SizingSwatch] + /// The currently selected size, if `items` is not empty, then a size with the same name must exist there + @Published public var selectedItem: SizingSwatch? + + public init(selectedTitle: String = "", items: [SizingSwatch], selectedItem: SizingSwatch? = nil) { + self.selectedTitle = selectedTitle + self.items = items + self.selectedItem = selectedItem + } +} diff --git a/Alfie/Packages/Models/Sources/UI/SizeSelection/SizingSwatch.swift b/Alfie/Packages/Models/Sources/UI/SizeSelection/SizingSwatch.swift new file mode 100644 index 0000000..9538c50 --- /dev/null +++ b/Alfie/Packages/Models/Sources/UI/SizeSelection/SizingSwatch.swift @@ -0,0 +1,43 @@ +import SwiftUI + +public typealias SizingSwatchProtocol = ColorAndSizingSwatchProtocol & SizingProtocol + +public protocol SizingProtocol { + var state: ItemState { get } +} + +public enum ItemState { + case available + case unavailable + case outOfStock +} + +public struct SizingSwatch: SizingSwatchProtocol { + public let id: String + public let name: String + public let state: ItemState + + public init(id: String = UUID().uuidString, name: String, state: ItemState) { + self.id = id + self.name = name + self.state = state + } +} + +public struct SwatchLayoutConfiguration { + public let arrangement: Arrangement + public let hideSelectionTitle: Bool + public let hideOnSingleColor: Bool + + public enum Arrangement { + case horizontal(itemSpacing: CGFloat, scrollable: Bool = true) + case chips(itemHorizontalSpacing: CGFloat, itemVerticalSpacing: CGFloat) + case grid(columns: Int, columnWidth: CGFloat) + } + + public init(arrangement: Arrangement, hideSelectionTitle: Bool = false, hideOnSingleColor: Bool = true) { + self.arrangement = arrangement + self.hideSelectionTitle = hideSelectionTitle + self.hideOnSingleColor = hideOnSingleColor + } +} diff --git a/Alfie/Packages/StyleGuide/Sources/Demo/ColorBanner/ColorBannerDemoView.swift b/Alfie/Packages/StyleGuide/Sources/Demo/ColorBanner/ColorBannerDemoView.swift index d684ab7..e1c0b36 100644 --- a/Alfie/Packages/StyleGuide/Sources/Demo/ColorBanner/ColorBannerDemoView.swift +++ b/Alfie/Packages/StyleGuide/Sources/Demo/ColorBanner/ColorBannerDemoView.swift @@ -46,11 +46,14 @@ struct ColorBannerDemoView: View { Spacer() section(title: "Color Swatches - Scrollable") { ColorSelectorComponentView( - configuration: .init(selectedTitle: Self.selectedTitle, items: Self.items), + configuration: ColorSelectorConfiguration(selectedTitle: Self.selectedTitle, items: Self.items), layoutConfiguration: .init(arrangement: .horizontal(itemSpacing: Spacing.space100)) ) ColorSelectorComponentView( - configuration: .init(selectedTitle: Self.selectedTitle, items: Self.itemsSmall), + configuration: ColorSelectorConfiguration( + selectedTitle: Self.selectedTitle, + items: Self.itemsSmall + ), layoutConfiguration: .init(arrangement: .horizontal(itemSpacing: Spacing.space100)) ) } @@ -59,7 +62,7 @@ struct ColorBannerDemoView: View { section(title: "Color Swatches - Chips") { ColorSelectorComponentView( - configuration: .init(selectedTitle: Self.selectedTitle, items: Self.items), + configuration: ColorSelectorConfiguration(selectedTitle: Self.selectedTitle, items: Self.items), layoutConfiguration: .init( arrangement: .chips( itemHorizontalSpacing: Spacing.space100, itemVerticalSpacing: Spacing.space100 @@ -67,7 +70,10 @@ struct ColorBannerDemoView: View { ) ) ColorSelectorComponentView( - configuration: .init(selectedTitle: Self.selectedTitle, items: Self.itemsSmall), + configuration: ColorSelectorConfiguration( + selectedTitle: Self.selectedTitle, + items: Self.itemsSmall + ), layoutConfiguration: .init( arrangement: .chips( itemHorizontalSpacing: Spacing.space100, itemVerticalSpacing: Spacing.space100 @@ -80,11 +86,14 @@ struct ColorBannerDemoView: View { section(title: "Color Swatches - Grid") { ColorSelectorComponentView( - configuration: .init(selectedTitle: Self.selectedTitle, items: Self.items), + configuration: ColorSelectorConfiguration(selectedTitle: Self.selectedTitle, items: Self.items), layoutConfiguration: .init(arrangement: .grid(columns: 5, columnWidth: 50)) ) ColorSelectorComponentView( - configuration: .init(selectedTitle: Self.selectedTitle, items: Self.itemsSmall), + configuration: ColorSelectorConfiguration( + selectedTitle: Self.selectedTitle, + items: Self.itemsSmall + ), layoutConfiguration: .init(arrangement: .grid(columns: 5, columnWidth: 50)) ) } @@ -93,7 +102,10 @@ struct ColorBannerDemoView: View { section(title: "Image Swatches - Grid") { ColorSelectorComponentView( - configuration: .init(selectedTitle: Self.selectedImageTitle, items: Self.itemsImage), + configuration: ColorSelectorConfiguration( + selectedTitle: Self.selectedImageTitle, + items: Self.itemsImage + ), layoutConfiguration: .init(arrangement: .grid(columns: 5, columnWidth: 50)) ) } diff --git a/Alfie/Packages/StyleGuide/Sources/Demo/SizingBanner/SizingBannerDemoView.swift b/Alfie/Packages/StyleGuide/Sources/Demo/SizingBanner/SizingBannerDemoView.swift index dbddc77..6dbae1d 100644 --- a/Alfie/Packages/StyleGuide/Sources/Demo/SizingBanner/SizingBannerDemoView.swift +++ b/Alfie/Packages/StyleGuide/Sources/Demo/SizingBanner/SizingBannerDemoView.swift @@ -1,3 +1,4 @@ +import Models import SwiftUI struct SizingBannerDemoView: View { @@ -18,7 +19,7 @@ struct SizingBannerDemoView: View { VStack(spacing: Spacing.space250) { section(title: "Sizing Swatches - Scrollable") { SizingSelectorComponentView( - configuration: .init( + configuration: SizingSelectorConfiguration( selectedTitle: Self.selectedTitle, items: Self.items ), @@ -30,7 +31,10 @@ struct SizingBannerDemoView: View { section(title: "Sizing Swatches - Chips") { SizingSelectorComponentView( - configuration: .init(selectedTitle: Self.selectedTitle, items: Self.items), + configuration: SizingSelectorConfiguration( + selectedTitle: Self.selectedTitle, + items: Self.items + ), layoutConfiguration: .init( arrangement: .chips( itemHorizontalSpacing: Spacing.space100, @@ -44,7 +48,10 @@ struct SizingBannerDemoView: View { section(title: "Sizing Swatches - Grid") { SizingSelectorComponentView( - configuration: .init(selectedTitle: Self.selectedTitle, items: Self.items), + configuration: SizingSelectorConfiguration( + selectedTitle: Self.selectedTitle, + items: Self.items + ), layoutConfiguration: .init(arrangement: .grid(columns: 4, columnWidth: 50)) ) } diff --git a/Alfie/Packages/StyleGuide/Sources/Theme/Components/ColorBanner/ColorSelectorComponentView.swift b/Alfie/Packages/StyleGuide/Sources/Theme/Components/ColorBanner/ColorSelectorComponentView.swift index 8d133ed..3ad37e9 100644 --- a/Alfie/Packages/StyleGuide/Sources/Theme/Components/ColorBanner/ColorSelectorComponentView.swift +++ b/Alfie/Packages/StyleGuide/Sources/Theme/Components/ColorBanner/ColorSelectorComponentView.swift @@ -1,17 +1,17 @@ import Models import SwiftUI -public struct ColorSelectorComponentView: View { - @ObservedObject private var configuration: ColorSelectorConfiguration +public struct ColorSelectorComponentView: View { + @ObservedObject private var configuration: Configuration private let layoutConfiguration: SwatchLayoutConfiguration - private let swatchesSize: ColorSwatchView.SwatchSize + private let swatchesSize: ColorSwatchView.SwatchSize private var frameSize: Binding? /// - Parameters: /// - size: ReadOnly public init( - configuration: ColorSelectorConfiguration, - swatchesSize: ColorSwatchView.SwatchSize = .large, + configuration: Configuration, + swatchesSize: ColorSwatchView.SwatchSize = .large, layoutConfiguration: SwatchLayoutConfiguration, frameSize: Binding? = nil ) { @@ -23,24 +23,12 @@ public struct ColorSelectorComponentView: View { public var body: some View { VStack(alignment: .leading, spacing: Spacing.space150) { - if !layoutConfiguration.hideSelectionTitle { - header - } if !layoutConfiguration.hideOnSingleColor || configuration.items.count > 1 { container } } } - private var header: some View { - HStack(spacing: Spacing.space050) { - Text.build(theme.font.paragraph.normal(configuration.selectedTitle)) - .foregroundStyle(Colors.primary.mono400) - Text.build(theme.font.paragraph.normal(configuration.selectedItem?.name ?? "")) - .foregroundStyle(Colors.primary.mono900) - } - } - @ViewBuilder private var container: some View { // swiftlint:disable vertical_whitespace_between_cases switch layoutConfiguration.arrangement { @@ -84,17 +72,21 @@ public struct ColorSelectorComponentView: View { @ViewBuilder private func swatches() -> some View { ForEach(configuration.items) { item in - ColorSwatchView(item: item, swatchSize: swatchesSize, isSelected: configuration.selectedItem == item) - .onTapGesture { - configuration.selectedItem = item - } + ColorSwatchView( + item: ColorSwatch(id: item.id, name: item.name, type: item.type, isDisabled: item.isDisabled), + swatchSize: swatchesSize, + isSelected: configuration.selectedItem == item + ) + .onTapGesture { + configuration.selectedItem = item + } } } } #Preview("Grid") { ColorSelectorComponentView( - configuration: .init( + configuration: ColorSelectorConfiguration( selectedTitle: "Color:", items: [ .init(name: "Black", type: .color(Colors.primary.black)), @@ -111,7 +103,7 @@ public struct ColorSelectorComponentView: View { #Preview("Grid - No title") { ColorSelectorComponentView( - configuration: .init( + configuration: ColorSelectorConfiguration( selectedTitle: "Color:", items: [ .init(name: "Black", type: .color(Colors.primary.black)), @@ -128,7 +120,7 @@ public struct ColorSelectorComponentView: View { #Preview("Chips") { ColorSelectorComponentView( - configuration: .init( + configuration: ColorSelectorConfiguration( selectedTitle: "Color:", items: [ .init(name: "Black", type: .color(Colors.primary.black)), @@ -147,7 +139,7 @@ public struct ColorSelectorComponentView: View { #Preview("Scrollable Single Row") { ColorSelectorComponentView( - configuration: .init( + configuration: ColorSelectorConfiguration( selectedTitle: "Color:", items: [ .init(name: "Black", type: .color(Colors.primary.black)), diff --git a/Alfie/Packages/StyleGuide/Sources/Theme/Components/ColorBanner/ColorSwatchView.swift b/Alfie/Packages/StyleGuide/Sources/Theme/Components/ColorBanner/ColorSwatchView.swift index 4b1152e..394046f 100644 --- a/Alfie/Packages/StyleGuide/Sources/Theme/Components/ColorBanner/ColorSwatchView.swift +++ b/Alfie/Packages/StyleGuide/Sources/Theme/Components/ColorBanner/ColorSwatchView.swift @@ -4,7 +4,7 @@ import SwiftUI // MARK: - ColorSwatchView -public struct ColorSwatchView: View { +public struct ColorSwatchView: View { public enum SwatchSize { /// 24pts case small @@ -14,25 +14,16 @@ public struct ColorSwatchView: View { case large } - private let item: ColorSwatch + private let item: Swatch private let isSelected: Bool private let swatchSize: SwatchSize - public init(item: ColorSwatch, swatchSize: SwatchSize, isSelected: Bool) { + public init(item: Swatch, swatchSize: SwatchSize, isSelected: Bool) { self.item = item self.swatchSize = swatchSize self.isSelected = isSelected } - private enum Constants { - static let borderInset: CGFloat = 3 - static let borderLineWidth: CGFloat = 1 - static let swatchSmallSize: CGFloat = 24 - static let swatchNormalSize: CGFloat = 32 - static let swatchLargeSize: CGFloat = 44 - static let disabledOpacity: CGFloat = 0.75 - } - public var body: some View { ZStack { RoundedRectangle(cornerRadius: size / 2) @@ -99,15 +90,28 @@ public struct ColorSwatchView: View { } } +private enum Constants { + static let borderInset: CGFloat = 3 + static let borderLineWidth: CGFloat = 1 + static let swatchSmallSize: CGFloat = 24 + static let swatchNormalSize: CGFloat = 32 + static let swatchLargeSize: CGFloat = 44 + static let disabledOpacity: CGFloat = 0.75 +} + @available(iOS 17, *) #Preview(traits: .sizeThatFitsLayout) { VStack { - ColorSwatchView(item: .init(name: "Default", type: .color(.red)), swatchSize: .normal, isSelected: false) + ColorSwatchView(item: ColorSwatch(name: "Default", type: .color(.red)), swatchSize: .normal, isSelected: false) - ColorSwatchView(item: .init(name: "Selected", type: .color(.green)), swatchSize: .normal, isSelected: true) + ColorSwatchView( + item: ColorSwatch(name: "Selected", type: .color(.green)), + swatchSize: .normal, + isSelected: true + ) ColorSwatchView( - item: .init(name: "Selected", type: .color(.green), isDisabled: true), + item: ColorSwatch(name: "Selected", type: .color(.green), isDisabled: true), swatchSize: .normal, isSelected: true ) diff --git a/Alfie/Packages/StyleGuide/Sources/Theme/Components/SizingBanner/ColorAndSizingSelectorHeaderView.swift b/Alfie/Packages/StyleGuide/Sources/Theme/Components/SizingBanner/ColorAndSizingSelectorHeaderView.swift new file mode 100644 index 0000000..009785f --- /dev/null +++ b/Alfie/Packages/StyleGuide/Sources/Theme/Components/SizingBanner/ColorAndSizingSelectorHeaderView.swift @@ -0,0 +1,46 @@ +import Models +import SwiftUI + +public struct ColorAndSizingSelectorHeaderView: View { + var action: (() -> Void)? + + @ObservedObject private var configuration: Configuration + private let isExpandable: Bool + + public init(configuration: Configuration, isExpandable: Bool, action: (() -> Void)? = nil) { + self.configuration = configuration + self.isExpandable = isExpandable + self.action = action + } + + public var body: some View { + HStack { + Button(action: { + guard isExpandable else { + return + } + action?() + }, label: { + HStack(spacing: Spacing.space050) { + Text.build(theme.font.paragraph.normal(configuration.selectedTitle)) + .foregroundStyle(Colors.primary.mono400) + Text.build(theme.font.paragraph.normal(configuration.selectedItem?.name ?? "")) + .foregroundStyle(Colors.primary.mono900) + + if isExpandable { + Icon.chevronDown.image + .resizable() + .scaledToFit() + .frame(size: Constants.chevronSize) + } + } + }) + .allowsHitTesting(isExpandable) + .tint(Colors.primary.mono900) + } + } +} + +private enum Constants { + static let chevronSize: CGFloat = 16 +} diff --git a/Alfie/Packages/StyleGuide/Sources/Theme/Components/SizingBanner/SizingSelectorComponentView.swift b/Alfie/Packages/StyleGuide/Sources/Theme/Components/SizingBanner/SizingSelectorComponentView.swift index 04411d2..2b46316 100644 --- a/Alfie/Packages/StyleGuide/Sources/Theme/Components/SizingBanner/SizingSelectorComponentView.swift +++ b/Alfie/Packages/StyleGuide/Sources/Theme/Components/SizingBanner/SizingSelectorComponentView.swift @@ -1,34 +1,16 @@ +import Models import SwiftUI -public struct SizingSelectorComponentView: View { - @ObservedObject private var configuration: SizingSelectorConfiguration +public struct SizingSelectorComponentView: View { + @ObservedObject private var configuration: Configuration private let layoutConfiguration: SwatchLayoutConfiguration - public init(configuration: SizingSelectorConfiguration, layoutConfiguration: SwatchLayoutConfiguration) { + public init(configuration: Configuration, layoutConfiguration: SwatchLayoutConfiguration) { self.configuration = configuration self.layoutConfiguration = layoutConfiguration } public var body: some View { - VStack(alignment: .leading, spacing: Spacing.space150) { - header - - if configuration.items.count > 1 { - container - } - } - } - - private var header: some View { - HStack(spacing: Spacing.space050) { - Text.build(theme.font.paragraph.normal(configuration.selectedTitle)) - .foregroundStyle(Colors.primary.mono400) - Text.build(theme.font.paragraph.normal(configuration.selectedItem?.name ?? "")) - .foregroundStyle(Colors.primary.mono900) - } - } - - @ViewBuilder private var container: some View { // swiftlint:disable vertical_whitespace_between_cases switch layoutConfiguration.arrangement { case .horizontal(let itemSpacing, let scrollable): @@ -42,9 +24,7 @@ public struct SizingSelectorComponentView: View { } private func gridSwatches(columns: Int, columnWidth: CGFloat) -> some View { - LazyVGrid( - columns: Array(repeating: GridItem(.flexible(minimum: columnWidth), alignment: .topLeading), count: columns) - ) { + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: columns)) { swatches() } } @@ -74,14 +54,15 @@ public struct SizingSelectorComponentView: View { .onTapGesture { configuration.selectedItem = item } - }.disabled(item.state != .available) + } + .disabled(item.state != .available) } } } #Preview("Grid") { SizingSelectorComponentView( - configuration: .init( + configuration: SizingSelectorConfiguration( selectedTitle: "Size:", items: [ .init(name: "XS", state: .available), @@ -100,7 +81,7 @@ public struct SizingSelectorComponentView: View { #Preview("Chips") { SizingSelectorComponentView( - configuration: .init( + configuration: SizingSelectorConfiguration( selectedTitle: "Size:", items: [ .init(name: "XS", state: .available), @@ -121,7 +102,7 @@ public struct SizingSelectorComponentView: View { #Preview("Scrollable Single Row") { SizingSelectorComponentView( - configuration: .init( + configuration: SizingSelectorConfiguration( selectedTitle: "Size:", items: [ .init(name: "XS", state: .available), diff --git a/Alfie/Packages/StyleGuide/Sources/Theme/Components/SizingBanner/SizingSwatch.swift b/Alfie/Packages/StyleGuide/Sources/Theme/Components/SizingBanner/SizingSwatch.swift deleted file mode 100644 index 5ca6231..0000000 --- a/Alfie/Packages/StyleGuide/Sources/Theme/Components/SizingBanner/SizingSwatch.swift +++ /dev/null @@ -1,51 +0,0 @@ -import SwiftUI - -public struct SizingSwatch: Equatable, Identifiable { - public let id: UUID = .init() - public let name: String - public let state: ItemState - - public enum ItemState { - case available - case unavailable - case outOfStock - } - - public init(name: String, state: ItemState) { - self.name = name - self.state = state - } -} - -public struct SwatchLayoutConfiguration { - public let arrangement: Arrangement - public let hideSelectionTitle: Bool - public let hideOnSingleColor: Bool - - public enum Arrangement { - case horizontal(itemSpacing: CGFloat, scrollable: Bool = true) - case chips(itemHorizontalSpacing: CGFloat, itemVerticalSpacing: CGFloat) - case grid(columns: Int, columnWidth: CGFloat) - } - - public init(arrangement: Arrangement, hideSelectionTitle: Bool = false, hideOnSingleColor: Bool = true) { - self.arrangement = arrangement - self.hideSelectionTitle = hideSelectionTitle - self.hideOnSingleColor = hideOnSingleColor - } -} - -public class SizingSelectorConfiguration: ObservableObject { - /// Title to display before the currently selected size name - public let selectedTitle: String - /// Sizing items to display as swatches in the banner. Won't be shown if empty or containing a single size - public let items: [SizingSwatch] - /// The currently selected size, if `items` is not empty, then a size with the same name must exist there - @Published public var selectedItem: SizingSwatch? - - public init(selectedTitle: String, items: [SizingSwatch], selectedItem: SizingSwatch? = nil) { - self.selectedTitle = selectedTitle - self.items = items - self.selectedItem = selectedItem - } -} diff --git a/Alfie/Packages/StyleGuide/Sources/Theme/Components/SizingBanner/SizingSwatchView.swift b/Alfie/Packages/StyleGuide/Sources/Theme/Components/SizingBanner/SizingSwatchView.swift index 5a94b97..87a8e55 100644 --- a/Alfie/Packages/StyleGuide/Sources/Theme/Components/SizingBanner/SizingSwatchView.swift +++ b/Alfie/Packages/StyleGuide/Sources/Theme/Components/SizingBanner/SizingSwatchView.swift @@ -1,44 +1,37 @@ +import Models import SwiftUI -public struct SizingSwatchView: View { - private let item: SizingSwatch +public struct SizingSwatchView: View { + private let item: Swatch private let isSelected: Bool - public init(item: SizingSwatch, isSelected: Bool) { + public init(item: Swatch, isSelected: Bool) { self.item = item self.isSelected = isSelected } - private enum Constants { - static let disabledStateColor: Color = Colors.primary.mono400 - static let insetVertical: CGFloat = Spacing.space100 - static let insetHorizontal: CGFloat = Spacing.space300 - static let borderLineWidth: CGFloat = 1 - } - public var body: some View { - VStack { - Text.build(theme.font.paragraph.normal(item.name)) - .lineLimit(1) - .padding(.vertical, Constants.insetVertical) - .padding(.horizontal, Constants.insetHorizontal) - .foregroundStyle(textColor) - .background( - ZStack { - RoundedRectangle(cornerRadius: CornerRadius.xs) - .fill(isSelected ? Colors.primary.black : .clear) + Text.build(theme.font.paragraph.normal(item.name)) + .frame(maxWidth: .infinity) + .lineLimit(1) + .padding(.vertical, Constants.insetVertical) + .padding(.horizontal, Constants.insetHorizontal) + .foregroundStyle(textColor) + .background( + ZStack { + RoundedRectangle(cornerRadius: CornerRadius.xs) + .fill(isSelected ? Colors.primary.black : .clear) - RoundedRectangle(cornerRadius: CornerRadius.xs) - .inset(by: Constants.borderLineWidth) - .stroke( - item.state == .available ? Colors.primary.black : Constants.disabledStateColor, - lineWidth: Constants.borderLineWidth - ) + RoundedRectangle(cornerRadius: CornerRadius.xs) + .inset(by: Constants.borderLineWidth) + .stroke( + item.state == .available ? Colors.primary.black : Constants.disabledStateColor, + lineWidth: Constants.borderLineWidth + ) - outOfStockSlashView - } - ) - } + outOfStockSlashView + } + ) } private var textColor: Color { @@ -57,15 +50,22 @@ public struct SizingSwatchView: View { } } +private enum Constants { + static let disabledStateColor: Color = Colors.primary.mono400 + static let insetVertical: CGFloat = Spacing.space100 + static let insetHorizontal: CGFloat = Spacing.space300 + static let borderLineWidth: CGFloat = 1 +} + @available(iOS 17, *) #Preview(traits: .sizeThatFitsLayout) { VStack { - SizingSwatchView(item: .init(name: "Default", state: .available), isSelected: false) + SizingSwatchView(item: SizingSwatch(name: "Default", state: .available), isSelected: false) - SizingSwatchView(item: .init(name: "Selected", state: .available), isSelected: true) + SizingSwatchView(item: SizingSwatch(name: "Selected", state: .available), isSelected: true) - SizingSwatchView(item: .init(name: "Unavailable", state: .unavailable), isSelected: false) + SizingSwatchView(item: SizingSwatch(name: "Unavailable", state: .unavailable), isSelected: false) - SizingSwatchView(item: .init(name: "Out of Stock", state: .outOfStock), isSelected: false) + SizingSwatchView(item: SizingSwatch(name: "Out of Stock", state: .outOfStock), isSelected: false) } }