diff --git a/Modules/SharedUI/Sources/SharedUI/Colors.swift b/Modules/SharedUI/Sources/SharedUI/Colors.swift index 81e9e9a..c353982 100644 --- a/Modules/SharedUI/Sources/SharedUI/Colors.swift +++ b/Modules/SharedUI/Sources/SharedUI/Colors.swift @@ -30,4 +30,8 @@ public extension Color { static var palletOrange: Color { Color(.palletOrange) } + + static var lightGray: Color { + Color(.lightGray) + } } diff --git a/Modules/SharedUI/Sources/SharedUI/Images.swift b/Modules/SharedUI/Sources/SharedUI/Images.swift index bcfe6f5..4873187 100644 --- a/Modules/SharedUI/Sources/SharedUI/Images.swift +++ b/Modules/SharedUI/Sources/SharedUI/Images.swift @@ -2,6 +2,6 @@ import SwiftUI public extension Image { static var logoRounded: Image { - Image(.logoRounded) + Image("LogoRounded") } } diff --git a/Modules/SharedUI/Sources/SharedUI/Resources/Colors.xcassets/lightGray.colorset/Contents.json b/Modules/SharedUI/Sources/SharedUI/Resources/Colors.xcassets/lightGray.colorset/Contents.json new file mode 100644 index 0000000..055eeee --- /dev/null +++ b/Modules/SharedUI/Sources/SharedUI/Resources/Colors.xcassets/lightGray.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xD6", + "green" : "0xD1", + "red" : "0xD1" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Modules/SharedUI/Sources/SharedUI/Resources/Images.xcassets/Contents.json b/Modules/SharedUI/Sources/SharedUI/Resources/Images.xcassets/Contents.json deleted file mode 100644 index 73c0059..0000000 --- a/Modules/SharedUI/Sources/SharedUI/Resources/Images.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/WaiterRobot/Features/Order/Search/ProducSearchTabBarHeader.swift b/Modules/SharedUI/Sources/SharedUI/TabBarHeader.swift similarity index 80% rename from WaiterRobot/Features/Order/Search/ProducSearchTabBarHeader.swift rename to Modules/SharedUI/Sources/SharedUI/TabBarHeader.swift index cc76630..ced4f6d 100644 --- a/WaiterRobot/Features/Order/Search/ProducSearchTabBarHeader.swift +++ b/Modules/SharedUI/Sources/SharedUI/TabBarHeader.swift @@ -1,11 +1,16 @@ import SwiftUI -struct ProducSearchTabBarHeader: View { +public struct TabBarHeader: View { @Namespace var namespace @Binding var currentTab: Int var tabBarOptions: [String] - var body: some View { + public init(currentTab: Binding, tabBarOptions: [String]) { + _currentTab = currentTab + self.tabBarOptions = tabBarOptions + } + + public var body: some View { VStack(spacing: 0) { ScrollView(.horizontal) { HStack { @@ -48,8 +53,11 @@ struct ProducSearchTabBarHeader: View { } } +@available(iOS 17.0, *) #Preview { - ProducSearchTabBarHeader( - currentTab: .constant(4), tabBarOptions: ["All", "Food", "Drinks", "more", "One more"] + @Previewable @State var currentTab = 3 + TabBarHeader( + currentTab: $currentTab, + tabBarOptions: ["All", "Food", "Drinks", "more", "One more"] ) } diff --git a/Modules/WRCore/Package.swift b/Modules/WRCore/Package.swift index 1392efc..e870c64 100644 --- a/Modules/WRCore/Package.swift +++ b/Modules/WRCore/Package.swift @@ -15,7 +15,7 @@ let package = Package( ], dependencies: [ .package(path: "../SharedUI"), - .package(url: "https://github.com/DatepollSystems/WaiterRobot-Shared-Android.git", from: "1.6.1"), + .package(url: "https://github.com/DatepollSystems/WaiterRobot-Shared-Android.git", from: "1.7.3"), ], targets: [ .target( diff --git a/Modules/WRCore/Sources/WRCore/Alert.swift b/Modules/WRCore/Sources/WRCore/Alert.swift new file mode 100644 index 0000000..01a2306 --- /dev/null +++ b/Modules/WRCore/Sources/WRCore/Alert.swift @@ -0,0 +1,21 @@ +import shared +import SwiftUI + +public extension Alert { + init(_ dialog: DialogState) { + if let secondaryButton = dialog.secondaryButton { + self.init( + title: Text(dialog.title.localized()), + message: Text(dialog.text.localized()), + primaryButton: .default(Text(dialog.primaryButton.text.localized()), action: dialog.primaryButton.action), + secondaryButton: .cancel(Text(secondaryButton.text.localized()), action: secondaryButton.action) + ) + } else { + self.init( + title: Text(dialog.title.localized()), + message: Text(dialog.text.localized()), + dismissButton: .default(Text(dialog.primaryButton.text.localized()), action: dialog.primaryButton.action) + ) + } + } +} diff --git a/Modules/WRCore/Sources/WRCore/ErrorBar.swift b/Modules/WRCore/Sources/WRCore/ErrorBar.swift new file mode 100644 index 0000000..a70dae5 --- /dev/null +++ b/Modules/WRCore/Sources/WRCore/ErrorBar.swift @@ -0,0 +1,47 @@ +import shared +import SharedUI +import SwiftUI + +public struct ErrorBar: View { + let message: StringDesc + let initialLines: Int + let retryAction: (() -> Void)? + + @State private var expanded = false + + public init(message: StringDesc, initialLines: Int = 2, retryAction: (() -> Void)? = nil) { + self.message = message + self.initialLines = initialLines + self.retryAction = retryAction + } + + public var body: some View { + HStack(alignment: .center) { + Text(message()) + .lineLimit(expanded ? nil : initialLines) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + + if retryAction != nil { + Spacer().frame(width: 16) + Button(action: { + retryAction?() + }) { + Text(localize.exceptions_retry()) + .bold() + .multilineTextAlignment(.center) + .lineLimit(expanded ? nil : initialLines) + } + } + } + .padding(.leading, 16) + .padding(.top, 8) + .padding(.trailing, retryAction == nil ? 16 : 8) + .padding(.bottom, 8) + .background(Color.red) + .onTapGesture { + expanded.toggle() + } + .animation(.default, value: expanded) + } +} diff --git a/Modules/WRCore/Sources/WRCore/Globals.swift b/Modules/WRCore/Sources/WRCore/Globals.swift index 85f1812..bd23b46 100644 --- a/Modules/WRCore/Sources/WRCore/Globals.swift +++ b/Modules/WRCore/Sources/WRCore/Globals.swift @@ -4,8 +4,7 @@ import SwiftUI import UIKit public var koin: IosKoinComponent { IosKoinComponent.shared } - -public var localize: shared.L.Companion { shared.L.Companion.shared } +public var localize: shared.MR.strings { shared.MR.strings() } public enum WRCore { /// Setup of frameworks and all the other related stuff which is needed everywhere in the app @@ -29,7 +28,6 @@ public enum WRCore { let logger = koin.logger(tag: "AppDelegate") - KMMResourcesLocalizationKt.localizationBundle = Bundle(for: shared.L.self) logger.d { "initialized localization bundle" } print("finished app setup") } @@ -53,3 +51,32 @@ public extension EnvironmentValues { #endif } } + +public extension StringResource { + func callAsFunction() -> String { + desc().localized() + } + + func callAsFunction(_ args: String...) -> String { + format(args: args).localized() + } +} + +public extension StringDesc { + func callAsFunction() -> String { + localized() + } +} + +public extension Skie.Shared.Resource.__Sealed { + var data: T? { + switch self { + case let .loading(resource): + resource.data + case let .error(resource): + resource.data + case let .success(resource): + resource.data + } + } +} diff --git a/Modules/WRCore/Sources/WRCore/KotlinArrayWrapper.swift b/Modules/WRCore/Sources/WRCore/KotlinArrayWrapper.swift index 53840b4..952c771 100644 --- a/Modules/WRCore/Sources/WRCore/KotlinArrayWrapper.swift +++ b/Modules/WRCore/Sources/WRCore/KotlinArrayWrapper.swift @@ -2,6 +2,13 @@ import Foundation import shared public extension Array where Element: AnyObject { + init?(_ kotlinArray: KotlinArray?) { + guard let array = kotlinArray else { + return nil + } + self.init(array) + } + init(_ kotlinArray: KotlinArray) { self.init() let iterator = kotlinArray.iterator() diff --git a/Modules/WRCore/Sources/WRCore/Mock.swift b/Modules/WRCore/Sources/WRCore/Mock.swift index 1bae963..45407f6 100644 --- a/Modules/WRCore/Sources/WRCore/Mock.swift +++ b/Modules/WRCore/Sources/WRCore/Mock.swift @@ -2,39 +2,76 @@ import Foundation import shared public enum Mock { - public static func tableGroups() -> [TableGroup] { - [ - tableGroup(with: 1, name: "Hof"), - tableGroup(with: 2, name: "Terasse"), - tableGroup(with: 3, name: "Zimmer A"), - ] + public static func groupedTables(groups: Int = 1) -> [GroupedTables] { + let colors = ["ffaaee", "ffeeaa", "eeaaff", nil] + return (1 ... groups).map { groupId in + let tableCount = groupId % 3 == 0 ? 4 : 3 + let groupName = "Table Group \(groupId)" + + return GroupedTables( + id: Int64(groupId), + name: groupName, + eventId: 1, + color: colors[groupId % colors.count], + tables: (1 ... tableCount).map { + table(with: groupId * 10 + $0, hasOrders: $0 % 2 == 0, groupName: groupName) + } + ) + } } - public static func tableGroup(with id: Int64, name: String = "Hof") -> TableGroup { - TableGroup( - id: id, - name: name, - eventId: 1, - position: Int32(id), - color: "", - hidden: false, - tables: [ - table(with: 1), - table(with: 2, hasOrders: true), - table(with: 3), - table(with: 4), - table(with: 5), - table(with: 6), - ] - ) + public static func tableGroups(groups: Int = 1) -> [TableGroup] { + groupedTables(groups: groups).map { + TableGroup( + id: $0.id, + name: $0.name, + color: $0.color, + hidden: false + ) + } } - public static func table(with id: Int64, hasOrders: Bool = false) -> shared.Table { + public static func table(with id: Int, hasOrders: Bool = false, groupName: String = "Hof") -> shared.Table { shared.Table( - id: id, + id: Int64(id), number: Int32(id), - groupName: "Hof", + groupName: groupName, hasOrders: hasOrders ) } + + public static func product(with id: Int, soldOut: Bool = false, color: String? = nil, allergens: Set = []) -> Product { + Product( + id: Int64(id), + name: "Product \(id)", + price: Money(cents: Int32(id * 10)), + soldOut: soldOut, + color: color, + allergens: allergens.enumerated().map { index, shortName in + Allergen(id: Int64(index), name: shortName.description, shortName: shortName.description) + }.filter { $0.shortName.isEmpty == false }, + position: Int32(id) + ) + } + + public static func productGroups(groups: Int = 1) -> [GroupedProducts] { + let colors = ["ffaaee", "ffeeaa", "eeaaff", nil].shuffled() + let allergenList = "ABCDEFG " + return (1 ... groups).map { groupId in + let productCount = groupId % 3 == 0 ? 4 : 3 + let groupName = "Product Group \(groupId)" + return GroupedProducts( + id: Int64(groupId), + name: groupName, + position: Int32(groupId), + color: colors[groupId % colors.count], + products: (1 ... productCount).map { + let allergens = (0 ... ($0 % 3)).map { _ in + allergenList.randomElement()! + } + return product(with: groupId * 10 + $0, soldOut: $0 % 5 == 2, allergens: Set(allergens)) + } + ) + } + } } diff --git a/Modules/WRCore/Sources/WRCore/Navigation.swift b/Modules/WRCore/Sources/WRCore/Navigation.swift index c0072ce..3ae2c0f 100644 --- a/Modules/WRCore/Sources/WRCore/Navigation.swift +++ b/Modules/WRCore/Sources/WRCore/Navigation.swift @@ -36,7 +36,7 @@ extension UIPilot { public extension View { func customBackNavigation( - title: String = localize.navigation.back(), + title: String = localize.navigation_back(), icon: String? = "chevron.left", action: @escaping () -> Void ) -> some View { @@ -73,7 +73,7 @@ public extension View { logger.d { "Got sideEffect: \(sideEffect)" } switch onEnum(of: sideEffect as! NavOrViewModelEffect) { case let .navEffect(navEffect): - await navigator.navigate(navEffect.action) + navigator.navigate(navEffect.action) case let .vMEffect(effect): if handler?(effect.effect) != true { logger.w { "Side effect \(effect.effect) was not handled." } diff --git a/Modules/WRCore/Sources/WRCore/ObservableViewModel.swift b/Modules/WRCore/Sources/WRCore/ObservableViewModel.swift index 8e43e28..a658886 100644 --- a/Modules/WRCore/Sources/WRCore/ObservableViewModel.swift +++ b/Modules/WRCore/Sources/WRCore/ObservableViewModel.swift @@ -36,6 +36,12 @@ public class ObservableTableListViewModel: ObservableViewModel { + public init() { + super.init(viewModel: koin.tableGroupFilterVM()) + } +} + public class ObservableTableDetailViewModel: ObservableViewModel { public init(table: Table) { super.init(viewModel: koin.tableDetailVM(table: table)) @@ -60,6 +66,12 @@ public class ObservableOrderViewModel: ObservableViewModel { + public init() { + super.init(viewModel: koin.productListVM()) + } +} + public class ObservableLoginScannerViewModel: ObservableViewModel { public init() { super.init(viewModel: koin.loginScannerVM()) diff --git a/README.md b/README.md index 899d38c..760d2c6 100644 --- a/README.md +++ b/README.md @@ -20,16 +20,14 @@ The KMM module is integrated as a Swift-Package (shared). This project uses XcodeGen for generating the Xcode project. -1. Xcodegen +1. Gems Run in your terminal: ```bash -swift run xcodegen +bundle install ``` -> This command must also be run after switching branches and it's advisable to also run it after a `git pull` - 2. Git pre-commit hook To have unified formatting, we use SwiftFormat. The pre-commit hook can be installed if the code should be formatted automatically before every commit. Execute following command in your terminal: diff --git a/WaiterRobot/Resources/Images.xcassets/LogoRounded.imageset/Contents.json b/Targets/Lava/Images.xcassets/LogoRounded.imageset/Contents.json similarity index 74% rename from WaiterRobot/Resources/Images.xcassets/LogoRounded.imageset/Contents.json rename to Targets/Lava/Images.xcassets/LogoRounded.imageset/Contents.json index e4164e5..c7d86da 100644 --- a/WaiterRobot/Resources/Images.xcassets/LogoRounded.imageset/Contents.json +++ b/Targets/Lava/Images.xcassets/LogoRounded.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "wr-round.svg", + "filename" : "wr-round-yellow.svg", "idiom" : "universal" } ], diff --git a/Targets/Lava/Images.xcassets/LogoRounded.imageset/wr-round-yellow.svg b/Targets/Lava/Images.xcassets/LogoRounded.imageset/wr-round-yellow.svg new file mode 100644 index 0000000..7a9af26 --- /dev/null +++ b/Targets/Lava/Images.xcassets/LogoRounded.imageset/wr-round-yellow.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Targets/Lava/WaiterRobotLava.plist b/Targets/Lava/WaiterRobotLava.plist index b35ff2a..2a61768 100644 --- a/Targets/Lava/WaiterRobotLava.plist +++ b/Targets/Lava/WaiterRobotLava.plist @@ -26,7 +26,7 @@ CFBundleShortVersionString 2.5.0 CFBundleVersion - 28998383 + 29178434 ITSAppUsesNonExemptEncryption NSAppTransportSecurity @@ -54,10 +54,8 @@ UIImageName LogoRounded UIImageRespectsSafeAreaInsets - + - UILaunchStoryboardName - LaunchScreen.storyboard UIRequiredDeviceCapabilities armv7 diff --git a/Modules/SharedUI/Sources/SharedUI/Resources/Images.xcassets/LogoRounded.imageset/Contents.json b/Targets/Prod/Images.xcassets/LogoRounded.imageset/Contents.json similarity index 100% rename from Modules/SharedUI/Sources/SharedUI/Resources/Images.xcassets/LogoRounded.imageset/Contents.json rename to Targets/Prod/Images.xcassets/LogoRounded.imageset/Contents.json diff --git a/Modules/SharedUI/Sources/SharedUI/Resources/Images.xcassets/LogoRounded.imageset/wr-round.svg b/Targets/Prod/Images.xcassets/LogoRounded.imageset/wr-round.svg similarity index 100% rename from Modules/SharedUI/Sources/SharedUI/Resources/Images.xcassets/LogoRounded.imageset/wr-round.svg rename to Targets/Prod/Images.xcassets/LogoRounded.imageset/wr-round.svg diff --git a/Targets/Prod/WaiterRobot.plist b/Targets/Prod/WaiterRobot.plist index 8dfafcc..713b445 100644 --- a/Targets/Prod/WaiterRobot.plist +++ b/Targets/Prod/WaiterRobot.plist @@ -35,15 +35,15 @@ NSBluetoothAlwaysUsageDescription - We don't use bluetooth + We don't use bluetooth NSCameraUsageDescription Camera is needed to scan QR-Codes NSContactsUsageDescription - We don't use your contacts + We don't use your contacts NSLocationWhenInUseUsageDescription - We don't use your location + We don't use your location NSMotionUsageDescription - We don't use your motion sensors + We don't use your motion sensors UIApplicationSceneManifest UIApplicationSupportsMultipleScenes @@ -54,10 +54,8 @@ UIImageName LogoRounded UIImageRespectsSafeAreaInsets - + - UILaunchStoryboardName - LaunchScreen.storyboard UIRequiredDeviceCapabilities armv7 diff --git a/WaiterRobot.xcodeproj/project.pbxproj b/WaiterRobot.xcodeproj/project.pbxproj index cd41971..c42c9bb 100644 --- a/WaiterRobot.xcodeproj/project.pbxproj +++ b/WaiterRobot.xcodeproj/project.pbxproj @@ -432,7 +432,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -582,7 +582,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; diff --git a/WaiterRobot.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/WaiterRobot.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3ecc5f8..4fd8fe6 100644 --- a/WaiterRobot.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/WaiterRobot.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DatepollSystems/WaiterRobot-Shared-Android.git", "state" : { - "revision" : "43ef7e427e6cfa46c81c526b5e0e291ca227f845", - "version" : "1.6.10" + "revision" : "f2ff44dc52e8df3f4e68da4d05e27ad0b0487a33", + "version" : "1.7.6" } } ], diff --git a/WaiterRobot/Features/Billing/BillingScreen.swift b/WaiterRobot/Features/Billing/BillingScreen.swift index 1c63843..800de70 100644 --- a/WaiterRobot/Features/Billing/BillingScreen.swift +++ b/WaiterRobot/Features/Billing/BillingScreen.swift @@ -8,7 +8,6 @@ struct BillingScreen: View { @EnvironmentObject var navigator: UIPilot @State private var showPayDialog: Bool = false - @State private var showAbortConfirmation = false @StateObject private var viewModel: ObservableBillingViewModel private let table: shared.Table @@ -19,92 +18,122 @@ struct BillingScreen: View { } var body: some View { - let billItems = Array(viewModel.state.billItemsArray) - - content(billItems: billItems) - .navigationTitle(localize.billing.title(value0: table.groupName, value1: table.number.description)) - .navigationBarTitleDisplayMode(.inline) - .customBackNavigation( - title: localize.dialog.cancel(), - icon: nil - ) { - if viewModel.state.hasCustomSelection { - showAbortConfirmation = true - } else { - viewModel.actual.abortBill() - } + BillingScreenView( + table: table, + state: viewModel.state, + abortBill: { viewModel.actual.abortBill() }, + selectAll: { viewModel.actual.selectAll() }, + unselectAll: { viewModel.actual.unselectAll() }, + addItem: { viewModel.actual.addItem(baseProductId: $0, amount: $1) }, + paySelection: { viewModel.actual.paySelection(paymentSheetShown: $0) } + ) + // TODO: make only half screen when ios 15 is dropped + .sheet(isPresented: $showPayDialog) { + PayDialog(viewModel: viewModel) + } + .withViewModel(viewModel, navigator) { effect in + switch onEnum(of: effect) { + case .showPaymentSheet: + showPayDialog = true + case .toast: + break // TODO: add "toast" support } - .confirmationDialog( - localize.billing.notSent.title(), - isPresented: $showAbortConfirmation, - titleVisibility: .visible - ) { - Button(localize.dialog.closeAnyway(), role: .destructive) { - viewModel.actual.abortBill() + + return true + } + } +} + +private struct BillingScreenView: View { + @State private var showPayDialog: Bool = false + @State private var showAbortConfirmation = false + + let table: shared.Table + let state: BillingState + let abortBill: () -> Void + let selectAll: () -> Void + let unselectAll: () -> Void + let addItem: (_ baseProductId: Int64, _ amount: Int32) -> Void + let paySelection: (_ paymentSheetShown: Bool) -> Void + + var body: some View { + ViewStateOverlayView(state: state.paymentState) { + let billItemsState = onEnum(of: state.billItems) + + if case let .loading(ressource) = billItemsState, ressource.data == nil { + ProgressView() + } else { + if case let .error(resource) = billItemsState { + Text("Error \(resource.userMessage())") } - } message: { - Text(localize.billing.notSent.desc()) - } - .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { - if !billItems.isEmpty { - Button { - viewModel.actual.selectAll() - } label: { - Image(systemName: "checkmark") - } - } - if !billItems.isEmpty { - Button { - viewModel.actual.unselectAll() - } label: { - Image(systemName: "xmark") - } - } + if let billItems = Array(state.billItems.data), !billItems.isEmpty { + content(billItems: billItems) + } else { + Text(localize.billing_noOrder(table.groupName, table.number.description)) } } - // TODO: make only half screen when ios 15 is dropped - .sheet(isPresented: $showPayDialog) { - PayDialog(viewModel: viewModel) + } + .navigationTitle(localize.billing_title(table.groupName, table.number.description)) + .navigationBarTitleDisplayMode(.inline) + .customBackNavigation( + title: localize.dialog_cancel(), + icon: nil + ) { + if state.hasCustomSelection { + showAbortConfirmation = true + } else { + abortBill() + } + } + .confirmationDialog( + localize.billing_notSent_title(), + isPresented: $showAbortConfirmation, + titleVisibility: .visible + ) { + Button(localize.dialog_closeAnyway(), role: .destructive) { + abortBill() } - .withViewModel(viewModel, navigator) { effect in - switch onEnum(of: effect) { - case .showPaymentSheet: - showPayDialog = true - case .toast: - break // TODO: add "toast" support + } message: { + Text(localize.billing_notSent_desc()) + } + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + Button { + selectAll() + } label: { + Image(systemName: "checkmark") } - return true + Button { + unselectAll() + } label: { + Image(systemName: "xmark") + } } + } } @ViewBuilder - private func content(billItems: [BillItem]) -> some View { + private func content(billItems: [BillItem]?) -> some View { VStack { List { - if billItems.isEmpty { - Text(localize.billing.noOpenBill(value0: table.groupName, value1: table.number.description)) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - .padding() - } else { + if let billItems, !billItems.isEmpty { Section { - ForEach(billItems, id: \.virtualId) { item in + ForEach(billItems, id: \.baseProductId) { item in BillListItem( item: item, addOne: { - viewModel.actual.addItem(virtualId: item.virtualId, amount: 1) + addItem(item.baseProductId, 1) }, addAll: { - viewModel.actual.addItem(virtualId: item.virtualId, amount: item.ordered - item.selectedForBill) + addItem(item.baseProductId, item.ordered - item.selectedForBill) }, removeOne: { - viewModel.actual.addItem(virtualId: item.virtualId, amount: -1) + addItem(item.baseProductId, -1) }, removeAll: { - viewModel.actual.addItem(virtualId: item.virtualId, amount: -item.selectedForBill) + addItem(item.baseProductId, -item.selectedForBill) } ) } @@ -115,19 +144,24 @@ struct BillingScreen: View { Text("Selected") } } + } else { + Text(localize.billing_noOrder(table.groupName, table.number.description)) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding() } } HStack { - Text("\(localize.billing.total()):") + Text("\(localize.billing_total()):") Spacer() - Text("\(viewModel.state.priceSum)") + Text("\(state.priceSum)") } .font(.title2) .padding() .overlay(alignment: .bottom) { Button { - viewModel.actual.paySelection(paymentSheetShown: false) + paySelection(false) } label: { Image(systemName: "eurosign") .font(.system(.title)) @@ -138,7 +172,7 @@ struct BillingScreen: View { .background(.blue) .mask(Circle()) .shadow(color: Color.black.opacity(0.3), radius: 3, x: 3, y: 3) - .disabled(viewModel.state.viewState != ViewState.Idle.shared || !viewModel.state.hasSelectedItems) + .disabled(state.paymentState != ViewState.Idle.shared || !state.hasSelectedItems) } } } diff --git a/WaiterRobot/Features/Billing/PayDialog.swift b/WaiterRobot/Features/Billing/PayDialog.swift index ae3048a..79d0e06 100644 --- a/WaiterRobot/Features/Billing/PayDialog.swift +++ b/WaiterRobot/Features/Billing/PayDialog.swift @@ -15,14 +15,14 @@ struct PayDialog: View { NavigationView { VStack { HStack { - Text(localize.billing.total() + ":") + Text(localize.billing_total() + ":") .font(.title2) Spacer() Text(viewModel.state.priceSum.description) .font(.title2) } - TextField(localize.billing.given(), text: $moneyGiven) + TextField(localize.billing_given(), text: $moneyGiven) .font(.title) .keyboardType(.numbersAndPunctuation) .onChange(of: moneyGiven) { value in @@ -37,7 +37,7 @@ struct PayDialog: View { ) HStack { - Text(localize.billing.change() + ":") + Text(localize.billing_change() + ":") .font(.title2) Spacer() @@ -52,14 +52,14 @@ struct PayDialog: View { .padding() .toolbar { ToolbarItem(placement: .navigationBarLeading) { - Button(localize.dialog.cancel()) { + Button(localize.dialog_cancel()) { dismiss() } } } .toolbar { ToolbarItem(placement: .confirmationAction) { - Button(localize.billing.pay()) { + Button(localize.billing_pay_cash()) { viewModel.actual.paySelection(paymentSheetShown: true) dismiss() } diff --git a/WaiterRobot/Features/Login/LoginScannerScreen.swift b/WaiterRobot/Features/Login/LoginScannerScreen.swift index a47fe0a..c0c884b 100644 --- a/WaiterRobot/Features/Login/LoginScannerScreen.swift +++ b/WaiterRobot/Features/Login/LoginScannerScreen.swift @@ -20,11 +20,7 @@ struct LoginScannerScreen: View { case let error as ViewState.Error: content() .alert(isPresented: Binding.constant(true)) { - Alert( - title: Text(error.title), - message: Text(error.message), - dismissButton: .cancel(Text("OK"), action: error.onDismiss) - ) + Alert(error.dialog) } default: fatalError("Unexpected ViewState: \(viewModel.state.viewState.description)") @@ -45,14 +41,14 @@ struct LoginScannerScreen: View { } } - Text(localize.login.scanner.desc()) + Text(localize.login_scanner_desc()) .padding() .multilineTextAlignment(.center) Button { viewModel.actual.goBack() } label: { - Text(localize.dialog.cancel()) + Text(localize.dialog_cancel()) } } .withViewModel(viewModel, navigator) diff --git a/WaiterRobot/Features/Login/LoginScreen.swift b/WaiterRobot/Features/Login/LoginScreen.swift index 8b08669..d5057a3 100644 --- a/WaiterRobot/Features/Login/LoginScreen.swift +++ b/WaiterRobot/Features/Login/LoginScreen.swift @@ -13,22 +13,16 @@ struct LoginScreen: View { @State private var debugLoginLink = "" var body: some View { - switch viewModel.state.viewState { - case is ViewState.Loading: + switch onEnum(of: viewModel.state.viewState) { + case .loading: ProgressView() - case is ViewState.Idle: + case .idle: content() - case let error as ViewState.Error: + case let .error(error): content() .alert(isPresented: Binding.constant(true)) { - Alert( - title: Text(error.title), - message: Text(error.message), - dismissButton: .cancel(Text("OK"), action: error.onDismiss) - ) + Alert(error.dialog) } - default: - fatalError("Unexpected ViewState: \(viewModel.state.viewState.description)") } } @@ -44,11 +38,11 @@ struct LoginScreen: View { .onLongPressGesture { showLinkInput = true } - Text(localize.login.title()) + Text(localize.login_title()) .font(.title) .padding() - Text(localize.login.desc()) + Text(localize.login_desc()) .font(.body) .padding() .multilineTextAlignment(.center) @@ -56,19 +50,19 @@ struct LoginScreen: View { Button { viewModel.actual.openScanner() } label: { - Label(localize.login.withQrCode(), systemImage: "qrcode.viewfinder") + Label(localize.login_withQrCode(), systemImage: "qrcode.viewfinder") .font(.title3) } .padding() Spacer() } - .alert(localize.login.title(), isPresented: $showLinkInput) { - TextField(localize.login.debugDialog.inputLabel(), text: $debugLoginLink) - Button(localize.dialog.cancel(), role: .cancel) { + .alert(localize.login_title(), isPresented: $showLinkInput) { + TextField(localize.login_scanner_debugDialog_inputLabel(), text: $debugLoginLink) + Button(localize.dialog_cancel(), role: .cancel) { showLinkInput = false } - Button(localize.login.title()) { + Button(localize.login_title()) { viewModel.actual.onDebugLogin(link: debugLoginLink) } } diff --git a/WaiterRobot/Features/Login/RegisterScreen.swift b/WaiterRobot/Features/Login/RegisterScreen.swift index 66336cd..39f66f2 100644 --- a/WaiterRobot/Features/Login/RegisterScreen.swift +++ b/WaiterRobot/Features/Login/RegisterScreen.swift @@ -21,11 +21,7 @@ struct RegisterScreen: View { case let error as ViewState.Error: content() .alert(isPresented: Binding.constant(true)) { - Alert( - title: Text(error.title), - message: Text(error.message), - dismissButton: .cancel(Text("OK"), action: error.onDismiss) - ) + Alert(error.dialog) } default: fatalError("Unexpected ViewState: \(viewModel.state.viewState.description)") @@ -34,10 +30,10 @@ struct RegisterScreen: View { private func content() -> some View { VStack { - Text(localize.register.name.desc()) + Text(localize.register_name_desc()) .font(.body) - TextField(localize.register.name.title(), text: $name) + TextField(localize.register_name_title(), text: $name) .font(.body) .fixedSize() .padding() @@ -46,7 +42,7 @@ struct RegisterScreen: View { Button { viewModel.actual.cancel() } label: { - Text(localize.dialog.cancel()) + Text(localize.dialog_cancel()) } Spacer() @@ -57,12 +53,12 @@ struct RegisterScreen: View { registerLink: deepLink ) } label: { - Text(localize.register.login()) + Text(localize.register_login()) } } .padding() - Label(localize.register.alreadyRegisteredInfo(), systemImage: "info.circle.fill") + Label(localize.register_alreadyRegisteredInfo(), systemImage: "info.circle.fill") } .padding() .navigationBarHidden(true) diff --git a/WaiterRobot/Features/Order/OrderProductNoteView.swift b/WaiterRobot/Features/Order/OrderProductNoteView.swift index 3e01073..93498de 100644 --- a/WaiterRobot/Features/Order/OrderProductNoteView.swift +++ b/WaiterRobot/Features/Order/OrderProductNoteView.swift @@ -18,7 +18,7 @@ struct OrderProductNoteView: View { var body: some View { NavigationView { content() - .navigationTitle(localize.order.addNoteDialog.title(value0: name)) + .navigationTitle(localize.order_add_note_title(name)) .navigationBarTitleDisplayMode(.inline) } } @@ -26,16 +26,16 @@ struct OrderProductNoteView: View { @ViewBuilder private func content() -> some View { VStack { - Text(localize.order.addNoteDialog.inputLabel()) + Text(localize.order_add_note_input_label()) Group { if #available(iOS 16, *) { - TextField(localize.order.addNoteDialog.inputPlaceholder(), text: $noteText, axis: .vertical) + TextField(localize.order_add_note_input_placeholder(), text: $noteText, axis: .vertical) .lineLimit(5, reservesSpace: true) .toolbarBackground(.visible, for: .bottomBar) } else { // TODO: Maybe change to TextEditor - TextField(localize.order.addNoteDialog.inputPlaceholder(), text: $noteText) + TextField(localize.order_add_note_input_placeholder(), text: $noteText) .lineLimit(5) } } @@ -82,14 +82,14 @@ struct OrderProductNoteView: View { @ViewBuilder private func cancelButton() -> some View { - Button(localize.dialog.cancel(), role: .cancel) { + Button(localize.dialog_cancel(), role: .cancel) { dismiss() } } @ViewBuilder private func clearButton() -> some View { - Button(localize.dialog.clear(), role: .destructive) { + Button(localize.dialog_clear(), role: .destructive) { noteText = "" onSaveNote(nil) dismiss() @@ -99,7 +99,7 @@ struct OrderProductNoteView: View { @ViewBuilder private func saveButton() -> some View { - Button(localize.dialog.save()) { + Button(localize.dialog_save()) { onSaveNote(noteText) dismiss() } diff --git a/WaiterRobot/Features/Order/OrderScreen.swift b/WaiterRobot/Features/Order/OrderScreen.swift index 8e5ed52..2066d63 100644 --- a/WaiterRobot/Features/Order/OrderScreen.swift +++ b/WaiterRobot/Features/Order/OrderScreen.swift @@ -6,7 +6,6 @@ import WRCore struct OrderScreen: View { @EnvironmentObject var navigator: UIPilot - @State private var productName: String = "" @State private var showProductSearch: Bool @State private var showAbortOrderConfirmationDialog = false @@ -23,38 +22,27 @@ struct OrderScreen: View { } var body: some View { - VStack { - switch onEnum(of: viewModel.state.currentOrder) { - case .loading: - ProgressView() - - case let .error(error): - Text(error.userMessage) - .foregroundStyle(.red) - .padding(.horizontal) - - currentOder(error.data) - - case let .success(resource): - currentOder(resource.data) - } + ViewStateOverlayView(state: viewModel.state.orderingState) { + currentOder(Array(viewModel.state.currentOrder)) } - .navigationTitle(localize.order.title(value0: table.groupName, value1: table.number.description)) + .navigationTitle(localize.order_title(table.groupName, table.number.description)) .navigationBarTitleDisplayMode(.large) .navigationBarBackButtonHidden() .confirmationDialog( - localize.order.notSent.title(), + localize.order_notSent_title(), isPresented: $showAbortOrderConfirmationDialog, titleVisibility: .visible ) { - Button(localize.dialog.closeAnyway(), role: .destructive) { + Button(localize.dialog_closeAnyway(), role: .destructive) { viewModel.actual.abortOrder() } } message: { - Text(localize.order.notSent.desc()) + Text(localize.order_notSent_desc()) } .sheet(isPresented: $showProductSearch) { - ProductSearch(viewModel: viewModel) + ProductSearch( + addItem: { viewModel.actual.addItem(product: $0, amount: $1) } + ) } .animation(.default, value: viewModel.state.currentOrder) .withViewModel(viewModel, navigator) @@ -62,15 +50,13 @@ struct OrderScreen: View { @ViewBuilder private func currentOder( - _ currentOrderArray: KotlinArray? + _ currentOrder: [OrderItem] ) -> some View { - let currentOrder = currentOrderArray.map { Array($0) } ?? Array() - VStack(spacing: 0) { if currentOrder.isEmpty { Spacer() - Text(localize.order.addProduct()) + Text(localize.order_product_add()) .multilineTextAlignment(.center) .frame(maxWidth: .infinity) .padding() @@ -116,7 +102,7 @@ struct OrderScreen: View { } .buttonStyle(.primary) } - .customBackNavigation(title: localize.dialog.cancel(), icon: "chevron.backward") { + .customBackNavigation(title: localize.dialog_cancel(), icon: "chevron.backward") { if currentOrder.isEmpty { viewModel.actual.abortOrder() } else { @@ -125,3 +111,9 @@ struct OrderScreen: View { } } } + +#Preview { + PreviewView { + OrderScreen(table: Mock.table(with: 1), initialItemId: 1) + } +} diff --git a/WaiterRobot/Features/Order/Search/AllProductGroupList.swift b/WaiterRobot/Features/Order/Search/AllProductGroupList.swift new file mode 100644 index 0000000..32ae392 --- /dev/null +++ b/WaiterRobot/Features/Order/Search/AllProductGroupList.swift @@ -0,0 +1,39 @@ +import shared +import SharedUI +import SwiftUI +import WRCore + +struct AllProductGroupList: View { + let productGroups: [GroupedProducts] + let onProductClick: (Product) -> Void + + var body: some View { + ScrollView { + ForEach(productGroups, id: \.id) { productGroup in + if !productGroup.products.isEmpty { + Section { + ProductGroupList( + products: productGroup.products, + backgroundColor: Color(hex: productGroup.color), + onProductClick: onProductClick + ) + } header: { + HStack { + Color.lightGray.frame(height: 1) + Text(productGroup.name) + Color.lightGray.frame(height: 1) + } + } + } + } + } + } +} + +#Preview { + AllProductGroupList( + productGroups: Mock.productGroups(groups: 3), + onProductClick: { _ in } + ) + .padding() +} diff --git a/WaiterRobot/Features/Order/Search/ProductGroupList.swift b/WaiterRobot/Features/Order/Search/ProductGroupList.swift new file mode 100644 index 0000000..bb1eb58 --- /dev/null +++ b/WaiterRobot/Features/Order/Search/ProductGroupList.swift @@ -0,0 +1,40 @@ +import shared +import SwiftUI +import WRCore + +struct ProductGroupList: View { + let products: [Product] + let backgroundColor: Color? + let onProductClick: (Product) -> Void + + private let layout = [ + GridItem(.adaptive(minimum: 110)), + ] + + var body: some View { + ScrollView { + LazyVGrid(columns: layout, spacing: 0) { + ForEach(products, id: \.id) { product in + ProductListItem(product: product, backgroundColor: backgroundColor) { + onProductClick(product) + } + .foregroundColor(.blackWhite) + .padding(10) + } + } + } + } +} + +#Preview { + ProductGroupList( + products: [ + Mock.product(with: 1), + Mock.product(with: 2, soldOut: true, allergens: ["A"]), + Mock.product(with: 3, color: "ffaa00", allergens: ["A"]), + Mock.product(with: 4, soldOut: true, color: "ffaa00"), + ], + backgroundColor: .yellow, + onProductClick: { _ in } + ) +} diff --git a/WaiterRobot/Features/Order/ProductListItem.swift b/WaiterRobot/Features/Order/Search/ProductListItem.swift similarity index 60% rename from WaiterRobot/Features/Order/ProductListItem.swift rename to WaiterRobot/Features/Order/Search/ProductListItem.swift index 600dd4c..50f2347 100644 --- a/WaiterRobot/Features/Order/ProductListItem.swift +++ b/WaiterRobot/Features/Order/Search/ProductListItem.swift @@ -1,7 +1,10 @@ import shared import SwiftUI +import WRCore struct ProductListItem: View { + @Environment(\.self) var env + let product: Product let backgroundColor: Color? let onClick: () -> Void @@ -14,26 +17,30 @@ struct ProductListItem: View { onClick: @escaping () -> Void ) { self.product = product - self.backgroundColor = backgroundColor - self.onClick = onClick + if let color = product.color { + self.backgroundColor = Color(hex: color) + } else { + self.backgroundColor = backgroundColor + } var allergens = "" for allergen in self.product.allergens { allergens += "\(allergen.shortName), " } - if allergens.count > 2 { self.allergens = String(allergens.prefix(allergens.count - 2)) } else { self.allergens = "" } + + self.onClick = onClick } var foregroundColor: Color { if product.soldOut { .blackWhite } else if let backgroundColor { - backgroundColor.getContentColor(lightColorScheme: .black, darkColorScheme: .white) + backgroundColor.bestContrastColor(.black, .white, in: env) } else { .blackWhite } @@ -69,22 +76,8 @@ struct ProductListItem: View { #Preview { ProductListItem( - product: Product( - id: 2, - name: "Wine", - price: Money(cents: 290), - soldOut: true, - color: nil, - allergens: [ - Allergen(id: 1, name: "Egg", shortName: "E"), - Allergen(id: 2, name: "Egg2", shortName: "A"), - Allergen(id: 3, name: "Egg3", shortName: "B"), - Allergen(id: 4, name: "Egg4", shortName: "C"), - Allergen(id: 5, name: "Egg5", shortName: "D"), - ], - position: 1 - ), - backgroundColor: .yellow, + product: Mock.product(with: 1, soldOut: false, color: "ffaaee", allergens: ["A"]), + backgroundColor: .red, onClick: {} ) .frame(maxWidth: 100, maxHeight: 100) @@ -92,22 +85,8 @@ struct ProductListItem: View { #Preview { ProductListItem( - product: Product( - id: 2, - name: "Wine", - price: Money(cents: 290), - soldOut: false, - color: nil, - allergens: [ - Allergen(id: 1, name: "Egg", shortName: "E"), - Allergen(id: 2, name: "Egg2", shortName: "A"), - Allergen(id: 3, name: "Egg3", shortName: "B"), - Allergen(id: 4, name: "Egg4", shortName: "C"), - Allergen(id: 5, name: "Egg5", shortName: "D"), - ], - position: 1 - ), - backgroundColor: .yellow, + product: Mock.product(with: 1, soldOut: true, color: "ffaaee", allergens: ["A", "B"]), + backgroundColor: .red, onClick: {} ) .frame(maxWidth: 100, maxHeight: 100) diff --git a/WaiterRobot/Features/Order/Search/ProductSearch.swift b/WaiterRobot/Features/Order/Search/ProductSearch.swift index 21d8b3c..b213e7a 100644 --- a/WaiterRobot/Features/Order/Search/ProductSearch.swift +++ b/WaiterRobot/Features/Order/Search/ProductSearch.swift @@ -3,100 +3,60 @@ import SwiftUI import WRCore struct ProductSearch: View { + let addItem: (_ product: Product, _ amount: Int32) -> Void + @Environment(\.dismiss) private var dismiss - @ObservedObject var viewModel: ObservableOrderViewModel + @ObservedObject private var viewModel = ObservableProductListViewModel() @State private var search: String = "" @State private var selectedTab: Int = 0 - private let layout = [ - GridItem(.adaptive(minimum: 110)), - ] - var body: some View { NavigationView { - switch onEnum(of: viewModel.state.productGroups) { - case .loading: - ProgressView() - case let .error(resource): - productGroupsError(error: resource) - case let .success(resource): - if let productGroups = resource.data { - productsGroupsList(productGroups: productGroups) - } - } - }.observeState(of: viewModel) - } - - @ViewBuilder - private func productsGroupsList(productGroups: KotlinArray) -> some View { - let productGroups = Array(productGroups) - - if productGroups.isEmpty { - Text(localize.productSearch.noProductFound()) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - .padding() - - } else { - VStack { - ProducSearchTabBarHeader(currentTab: $selectedTab, tabBarOptions: getGroupNames(productGroups)) - - TabView(selection: $selectedTab) { - ProductSearchAllTab( - productGroups: productGroups, - columns: layout, - onProductClick: { - viewModel.actual.addItem(product: $0, amount: 1) + content() + .observeState(of: viewModel) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(localize.dialog_cancel()) { dismiss() } - ) - .tag(0) - .padding() - - let enumeratedProductGroups = Array(productGroups.enumerated()) - ForEach(enumeratedProductGroups, id: \.element.id) { index, groupWithProducts in - ScrollView { - LazyVGrid(columns: layout, spacing: 0) { - ProductSearchGroupList( - products: groupWithProducts.products, - backgroundColor: Color(hex: groupWithProducts.color), - onProductClick: { - viewModel.actual.addItem(product: $0, amount: 1) - dismiss() - } - ) - Spacer() - } - .padding() - } - .tag(index + 1) } } - } - .tabViewStyle(.page(indexDisplayMode: .never)) - .searchable(text: $search, placement: .navigationBarDrawer(displayMode: .always)) - .onChange(of: search, perform: { viewModel.actual.filterProducts(filter: $0) }) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button(localize.dialog.cancel()) { + } + } + + @ViewBuilder + private func content() -> some View { + switch onEnum(of: viewModel.state.productGroups) { + case .loading: + ProgressView() + case let .error(resource): + productGroupsError(error: resource) + case let .success(resource): + if let productGroups = Array(resource.data) { + ProductTabView( + productGroups: productGroups, + addItem: { + addItem($0, $1) dismiss() } - } + ) + .searchable(text: $search, placement: .navigationBarDrawer(displayMode: .always)) + .onChange(of: search, perform: { viewModel.actual.filterProducts(filter: $0) }) } } } - private func productGroupsError(error: ResourceError>) -> some View { - Text(error.userMessage) + private func productGroupsError(error: ResourceError>) -> some View { + Text(error.userMessage()) } - private func getGroupNames(_ productGroups: [ProductGroup]) -> [String] { + private func getGroupNames(_ productGroups: [GroupedProducts]) -> [String] { var groupNames = productGroups.map { productGroup in productGroup.name } - groupNames.insert(localize.productSearch.allGroups(), at: 0) + groupNames.insert(localize.productSearch_groups_all(), at: 0) return groupNames } } diff --git a/WaiterRobot/Features/Order/Search/ProductSearchAllTab.swift b/WaiterRobot/Features/Order/Search/ProductSearchAllTab.swift deleted file mode 100644 index b41313b..0000000 --- a/WaiterRobot/Features/Order/Search/ProductSearchAllTab.swift +++ /dev/null @@ -1,60 +0,0 @@ -import shared -import SwiftUI - -struct ProductSearchAllTab: View { - let productGroups: [ProductGroup] - let columns: [GridItem] - let onProductClick: (Product) -> Void - - var body: some View { - ScrollView { - LazyVGrid(columns: columns) { - ForEach(productGroups, id: \.id) { productGroup in - if !productGroup.products.isEmpty { - Section { - ProductSearchGroupList( - products: productGroup.products, - backgroundColor: Color(hex: productGroup.color), - onProductClick: onProductClick - ) - } header: { - HStack { - Color(UIColor.lightGray).frame(height: 1) - Text(productGroup.name) - Color(UIColor.lightGray).frame(height: 1) - } - } - } - } - Spacer() - } - } - } -} - -#Preview { - ProductSearchAllTab( - productGroups: [ - ProductGroup( - id: 1, - name: "Test Group 1", - position: 1, - color: "", - products: [ - Product( - id: 1, - name: "Beer", - price: Money(cents: 450), - soldOut: false, - color: nil, - allergens: [], - position: 1 - ), - ] - ), - ], - columns: [GridItem(.adaptive(minimum: 110))], - onProductClick: { _ in } - ) - .padding() -} diff --git a/WaiterRobot/Features/Order/Search/ProductSearchGroupList.swift b/WaiterRobot/Features/Order/Search/ProductSearchGroupList.swift deleted file mode 100644 index effc113..0000000 --- a/WaiterRobot/Features/Order/Search/ProductSearchGroupList.swift +++ /dev/null @@ -1,38 +0,0 @@ -import shared -import SwiftUI - -struct ProductSearchGroupList: View { - let products: [Product] - let backgroundColor: Color? - let onProductClick: (Product) -> Void - - var body: some View { - ForEach(products, id: \.id) { product in - ProductListItem(product: product, backgroundColor: backgroundColor) { - onProductClick(product) - } - .foregroundColor(.blackWhite) - .padding(10) - } - } -} - -#Preview { - LazyVGrid(columns: [GridItem(.adaptive(minimum: 110))]) { - ProductSearchGroupList( - products: [ - Product( - id: 1, - name: "Beer", - price: Money(cents: 450), - soldOut: false, - color: nil, - allergens: [], - position: 1 - ), - ], - backgroundColor: .yellow, - onProductClick: { _ in } - ) - } -} diff --git a/WaiterRobot/Features/Order/Search/ProductTabView.swift b/WaiterRobot/Features/Order/Search/ProductTabView.swift new file mode 100644 index 0000000..e2e5204 --- /dev/null +++ b/WaiterRobot/Features/Order/Search/ProductTabView.swift @@ -0,0 +1,62 @@ +import shared +import SharedUI +import SwiftUI +import WRCore + +struct ProductTabView: View { + let productGroups: [GroupedProducts] + let addItem: (_ product: Product, _ amount: Int32) -> Void + + @State private var selectedTab: Int = 0 + + var body: some View { + if productGroups.isEmpty { + Text(localize.productSearch_noProductFound()) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding() + } else { + VStack { + TabBarHeader( + currentTab: $selectedTab, + tabBarOptions: getGroupNames(productGroups) + ) + + TabView(selection: $selectedTab) { + AllProductGroupList( + productGroups: productGroups, + onProductClick: { addItem($0, 1) } + ) + .tag(0) + .padding() + + let enumeratedProductGroups = Array(productGroups.enumerated()) + ForEach(enumeratedProductGroups, id: \.element.id) { index, groupedProducts in + ProductGroupList( + products: groupedProducts.products, + backgroundColor: Color(hex: groupedProducts.color), + onProductClick: { addItem($0, 1) } + ).padding() + .tag(index + 1) + } + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + } + } + + private func getGroupNames(_ productGroups: [GroupedProducts]) -> [String] { + var groupNames = productGroups.map { productGroup in + productGroup.name + } + groupNames.insert(localize.productSearch_groups_all(), at: 0) + return groupNames + } +} + +#Preview { + ProductTabView( + productGroups: Mock.productGroups(groups: 3), + addItem: { _, _ in } + ) +} diff --git a/WaiterRobot/Features/Settings/SettingsScreen.swift b/WaiterRobot/Features/Settings/SettingsScreen.swift index 20d9a7b..7b451c9 100644 --- a/WaiterRobot/Features/Settings/SettingsScreen.swift +++ b/WaiterRobot/Features/Settings/SettingsScreen.swift @@ -12,44 +12,24 @@ struct SettingsScreen: View { @StateObject private var viewModel = ObservableSettingsViewModel() var body: some View { - switch viewModel.state.viewState { - case is ViewState.Loading: - ProgressView() - case is ViewState.Idle: - content() - case let error as ViewState.Error: - content() - .alert(isPresented: Binding.constant(true)) { - Alert( - title: Text(error.title), - message: Text(error.message), - dismissButton: .cancel(Text("OK"), action: error.onDismiss) - ) - } - default: - fatalError("Unexpected ViewState: \(viewModel.state.viewState.description)") - } - } - - private func content() -> some View { List { general() payment() - Section(header: Text(localize.settings.about.title())) { - Link(localize.settings.about.privacyPolicy(), destination: URL(string: CommonApp.shared.privacyPolicyUrl)!) + Section(header: Text(localize.settings_about_title())) { + Link(localize.settings_about_privacyPolicy(), destination: URL(string: CommonApp.shared.privacyPolicyUrl)!) } HStack { Spacer() - Text(viewModel.state.versionString) + Text(viewModel.state.versionString()) .font(.footnote) Spacer() } .listRowBackground(Color.clear) } - .navigationTitle(localize.settings.title()) + .navigationTitle(localize.settings_title()) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button { @@ -60,28 +40,28 @@ struct SettingsScreen: View { } } .confirmationDialog( - localize.settings.general.logout.title(value0: CommonApp.shared.settings.organisationName), + localize.settings_general_logout_title(CommonApp.shared.settings.organisationName), isPresented: $showConfirmLogout, titleVisibility: .visible ) { - Button(localize.settings.general.logout.action(), role: .destructive, action: { viewModel.actual.logout() }) - Button(localize.settings.general.keepLoggedIn(), role: .cancel, action: { showConfirmLogout = false }) + Button(localize.settings_general_logout_action(), role: .destructive, action: { viewModel.actual.logout() }) + Button(localize.settings_general_logout_cancel(), role: .cancel, action: { showConfirmLogout = false }) } message: { - Text(localize.settings.general.logout.desc(value0: CommonApp.shared.settings.organisationName)) + Text(localize.settings_general_logout_desc(CommonApp.shared.settings.organisationName)) } .confirmationDialog( - localize.settings.payment.skipMoneyBackDialog.title(), + localize.settings_payment_skipMoneyBackDialog_title(), isPresented: $showConfirmSkipMoneyBackDialog, titleVisibility: .visible ) { - Button(localize.settings.payment.skipMoneyBackDialog.confirmAction(), role: .destructive) { + Button(localize.settings_payment_skipMoneyBackDialog_confirm_action(), role: .destructive) { viewModel.actual.toggleSkipMoneyBackDialog(value: true, confirmed: true) } - Button(localize.dialog.cancel(), role: .cancel) { + Button(localize.dialog_cancel(), role: .cancel) { showConfirmSkipMoneyBackDialog = false } } message: { - Text(localize.settings.payment.skipMoneyBackDialog.confirmDesc()) + Text(localize.settings_payment_skipMoneyBackDialog_confirm_desc()) } .withViewModel(viewModel, navigator) { effect in switch onEnum(of: effect) { @@ -94,10 +74,10 @@ struct SettingsScreen: View { } private func general() -> some View { - Section(header: Text(localize.settings.general.title())) { + Section(header: Text(localize.settings_general_title())) { SettingsItem( icon: "rectangle.portrait.and.arrow.right", - title: localize.settings.general.logout.action(), + title: localize.settings_general_logout_action(), subtitle: "\"\(CommonApp.shared.settings.organisationName)\" / \"\(CommonApp.shared.settings.waiterName)\"", onClick: { showConfirmLogout = true @@ -106,7 +86,7 @@ struct SettingsScreen: View { SettingsItem( icon: "person.3", - title: localize.switchEvent.title(), + title: localize.switchEvent_title(), subtitle: CommonApp.shared.settings.eventName, onClick: { viewModel.actual.switchEvent() @@ -120,8 +100,8 @@ struct SettingsScreen: View { SettingsItem( icon: "arrow.triangle.2.circlepath", - title: localize.settings.general.refresh.title(), - subtitle: localize.settings.general.refresh.desc(), + title: localize.settings_general_refresh_title(), + subtitle: localize.settings_general_refresh_desc(), onClick: { viewModel.actual.refreshAll() } @@ -130,11 +110,11 @@ struct SettingsScreen: View { } private func payment() -> some View { - Section(header: Text(localize.settings.payment.title())) { + Section(header: Text(localize.settings_payment_title())) { SettingsItem( icon: "dollarsign.arrow.circlepath", - title: localize.settings.payment.skipMoneyBackDialog.title(), - subtitle: localize.settings.payment.skipMoneyBackDialog.desc(), + title: localize.settings_payment_skipMoneyBackDialog_title(), + subtitle: localize.settings_payment_skipMoneyBackDialog_desc(), action: { Toggle( isOn: .init( @@ -154,8 +134,8 @@ struct SettingsScreen: View { SettingsItem( icon: "checkmark.square", - title: localize.settings.payment.selectAllProductsByDefault.title(), - subtitle: localize.settings.payment.selectAllProductsByDefault.desc(), + title: localize.settings_payment_selectAllProductsByDefault_title(), + subtitle: localize.settings_payment_selectAllProductsByDefault_desc(), action: { Toggle( isOn: .init( diff --git a/WaiterRobot/Features/Settings/SwitchThemeView.swift b/WaiterRobot/Features/Settings/SwitchThemeView.swift index 623e0a5..c35bd19 100644 --- a/WaiterRobot/Features/Settings/SwitchThemeView.swift +++ b/WaiterRobot/Features/Settings/SwitchThemeView.swift @@ -21,9 +21,9 @@ struct SwitchThemeView: View { .padding(.trailing) .foregroundColor(.blue) - Picker(localize.settings.general.darkMode.title(), selection: $selectedTheme) { + Picker(localize.settings_general_darkMode_title(), selection: $selectedTheme) { ForEach(AppTheme.companion.valueList(), id: \.name) { theme in - Text(theme.settingsText()).tag(theme) + Text(theme.settingsText().localized()).tag(theme) } } .onChange(of: selectedTheme, perform: onChange) diff --git a/WaiterRobot/Features/SwitchEvent/Event.swift b/WaiterRobot/Features/SwitchEvent/Event.swift index 3466694..c5e0597 100644 --- a/WaiterRobot/Features/SwitchEvent/Event.swift +++ b/WaiterRobot/Features/SwitchEvent/Event.swift @@ -1,12 +1,29 @@ import shared import SwiftUI +import WRCore struct Event: View { let event: shared.Event var body: some View { VStack(alignment: .leading) { - Text(event.name) + HStack { + Text(event.name) + + if event.isDemo { + Spacer() + + Text(localize.switchEvent_demoEvent()) + .font(.caption) + .foregroundColor(.white) + .padding(.horizontal, 10) + .padding(.vertical, 2) + .background { + Capsule() + .fill(.darkRed) + } + } + } HStack { Text(event.city) @@ -34,7 +51,8 @@ struct Event: View { endDate: nil, city: "Graz", organisationId: 1, - stripeSettings: shared.Event.StripeSettingsDisabled() + stripeSettings: shared.Event.StripeSettingsDisabled(), + isDemo: true ) ) } diff --git a/WaiterRobot/Features/SwitchEvent/SwitchEventScreen.swift b/WaiterRobot/Features/SwitchEvent/SwitchEventScreen.swift index b32caf3..b138276 100644 --- a/WaiterRobot/Features/SwitchEvent/SwitchEventScreen.swift +++ b/WaiterRobot/Features/SwitchEvent/SwitchEventScreen.swift @@ -11,26 +11,6 @@ struct SwitchEventScreen: View { @State private var selectedEvent: Event? var body: some View { - VStack { - switch onEnum(of: viewModel.state.viewState) { - case .loading: - ProgressView() - case .idle: - content() - case let .error(error): - content() - .alert(isPresented: Binding.constant(true)) { - Alert( - title: Text(error.title), - message: Text(error.message), - dismissButton: .cancel(Text("OK"), action: error.onDismiss) - ) - } - } - }.withViewModel(viewModel, navigator) - } - - private func content() -> some View { VStack { Image(systemName: "person.3") .resizable() @@ -38,22 +18,37 @@ struct SwitchEventScreen: View { .frame(maxHeight: 100) .padding() - Text(localize.switchEvent.desc()) + Text(localize.switchEvent_desc()) .multilineTextAlignment(.center) .frame(maxWidth: .infinity) .padding() Divider() - ScrollView { - if viewModel.state.events.isEmpty { - Text(localize.switchEvent.noEventFound()) + content(viewModel.state.events) + .refreshable { + try? await viewModel.actual.loadEvents().join() + } + + }.withViewModel(viewModel, navigator) + } + + private func content(_ eventResource: shared.Resource>) -> some View { + ScrollView { + let resource = onEnum(of: eventResource) + + if case let .error(error) = resource { + ErrorBar(message: error.userMessage, retryAction: { viewModel.actual.loadEvents() }) + } + if let events = Array(resource.data) { + if events.isEmpty { + Text(localize.switchEvent_noEventFound()) .multilineTextAlignment(.center) .frame(maxWidth: .infinity) .padding() } else { LazyVStack { - ForEach(viewModel.state.events, id: \.id) { event in + ForEach(events, id: \.id) { event in Button { viewModel.actual.onEventSelected(event: event) } label: { @@ -64,9 +59,8 @@ struct SwitchEventScreen: View { } } } - } - .refreshable { - viewModel.actual.loadEvents() + } else { + ProgressView() } } } diff --git a/WaiterRobot/Features/TableDetail/OrderedItemView.swift b/WaiterRobot/Features/TableDetail/OrderedItemView.swift index 669fc37..6413f9d 100644 --- a/WaiterRobot/Features/TableDetail/OrderedItemView.swift +++ b/WaiterRobot/Features/TableDetail/OrderedItemView.swift @@ -26,7 +26,8 @@ struct OrderedItemView: View { baseProductId: 1, name: "Test", amount: 1, - virtualId: 2 + virtualId: 2, + note: "" ), tabbed: {} ) diff --git a/WaiterRobot/Features/TableDetail/TableDetailScreen.swift b/WaiterRobot/Features/TableDetail/TableDetailScreen.swift index 17d00d2..ac6a337 100644 --- a/WaiterRobot/Features/TableDetail/TableDetailScreen.swift +++ b/WaiterRobot/Features/TableDetail/TableDetailScreen.swift @@ -18,14 +18,14 @@ struct TableDetailScreen: View { var body: some View { content() - .navigationTitle(localize.tableDetail.title(value0: table.groupName, value1: table.number.description)) + .navigationTitle(localize.tableDetail_title(table.groupName, table.number.description)) .withViewModel(viewModel, navigator) } // TODO: add refreshing and loading indicator (also check android) private func content() -> some View { VStack { - switch onEnum(of: viewModel.state.orderedItemsResource) { + switch onEnum(of: viewModel.state.orderedItems) { case .loading: ProgressView() @@ -33,7 +33,7 @@ struct TableDetailScreen: View { tableDetailsError(error) case let .success(resource): - if let orderedItems = resource.data as? [OrderedItem] { + if let orderedItems = Array(resource.data) { tableDetails(orderedItems: orderedItems) } } @@ -41,12 +41,11 @@ struct TableDetailScreen: View { } private func tableDetails(orderedItems: [OrderedItem]) -> some View { - // TODO: we need KotlinArray here in shared VStack { if orderedItems.isEmpty { Spacer() - Text(localize.tableDetail.noOrder(value0: table.groupName, value1: table.number.description)) + Text(localize.tableDetail_noOrder(table.groupName, table.number.description)) .multilineTextAlignment(.center) .frame(maxWidth: .infinity) .padding() @@ -85,7 +84,7 @@ struct TableDetailScreen: View { } } - private func tableDetailsError(_ error: ResourceError) -> some View { - Text(error.userMessage) + private func tableDetailsError(_ error: ResourceError>) -> some View { + Text(error.userMessage()) } } diff --git a/WaiterRobot/Features/TableList/TableGroupFilterSheet.swift b/WaiterRobot/Features/TableList/TableGroupFilterSheet.swift new file mode 100644 index 0000000..77e1b35 --- /dev/null +++ b/WaiterRobot/Features/TableList/TableGroupFilterSheet.swift @@ -0,0 +1,88 @@ +import shared +import SwiftUI +import UIPilot +import WRCore + +struct TableGroupFilterSheet: View { + @Environment(\.dismiss) private var dismiss + + @StateObject private var viewModel = ObservableTableGroupFilterViewModel() + + var body: some View { + NavigationView { + content() + .observeState(of: viewModel) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(localize.dialog_cancel()) { + dismiss() + } + } + } + } + } + + @ViewBuilder + private func content() -> some View { + switch onEnum(of: viewModel.state.groups) { + case .loading: + ProgressView() + case let .error(resource): + Text(resource.userMessage()) + case let .success(resource): + TableGroupFilter( + groups: Array(resource.data) ?? [], + showAll: { viewModel.actual.showAll() }, + hideAll: { viewModel.actual.hideAll() }, + onToggle: { viewModel.actual.toggleFilter(tableGroup: $0) } + ) + } + } +} + +private struct TableGroupFilter: View { + let groups: [TableGroup] + let showAll: () -> Void + let hideAll: () -> Void + let onToggle: (TableGroup) -> Void + + var body: some View { + if groups.isEmpty { + // Should not happen as open filter is only shown when there are groups + Text(localize.tableList_noTableFound()) + } else { + ScrollView { + LazyVStack(alignment: .leading) { + ForEach(groups, id: \.id) { group in + HStack { + Circle() + .fill(Color(hex: group.color) ?? Color.gray.opacity(0.3)) + .frame(height: 40) + + Text(group.name) + + Spacer() + + Toggle( + isOn: .init( + get: { !group.hidden }, + set: { _ in onToggle(group) } + ), + label: {} + ).labelsHidden() + }.padding(.horizontal) + } + } + } + } + } +} + +#Preview { + TableGroupFilter( + groups: Mock.tableGroups(groups: 10), + showAll: {}, + hideAll: {}, + onToggle: { _ in } + ) +} diff --git a/WaiterRobot/Features/TableList/TableGroupSection.swift b/WaiterRobot/Features/TableList/TableGroupSection.swift index d994504..6bf3357 100644 --- a/WaiterRobot/Features/TableList/TableGroupSection.swift +++ b/WaiterRobot/Features/TableList/TableGroupSection.swift @@ -4,16 +4,19 @@ import SwiftUI import WRCore struct TableGroupSection: View { - let tableGroup: TableGroup + @Environment(\.self) + private var env + + let groupedTables: GroupedTables let onTableClick: (shared.Table) -> Void var body: some View { Section { - ForEach(tableGroup.tables, id: \.id) { table in + ForEach(groupedTables.tables, id: \.id) { table in TableView( text: table.number.description, hasOrders: table.hasOrders, - backgroundColor: Color(hex: tableGroup.color), + backgroundColor: Color(hex: groupedTables.color), onClick: { onTableClick(table) } @@ -22,10 +25,10 @@ struct TableGroupSection: View { } } header: { HStack { - if let background = Color(hex: tableGroup.color) { + if let background = Color(hex: groupedTables.color) { title(backgroundColor: background) } else { - title(backgroundColor: .gray.opacity(0.3)) + title(backgroundColor: Color.lightGray) } Spacer() @@ -36,9 +39,9 @@ struct TableGroupSection: View { } private func title(backgroundColor: Color) -> some View { - Text(tableGroup.name) + Text(groupedTables.name) .font(.title2) - .foregroundStyle(backgroundColor.getContentColor(lightColorScheme: .black, darkColorScheme: .white)) + .foregroundStyle(backgroundColor.bestContrastColor(.black, .white, in: env)) .padding(6) .background { RoundedRectangle(cornerRadius: 8.0) @@ -50,20 +53,7 @@ struct TableGroupSection: View { #Preview { LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) { TableGroupSection( - tableGroup: TableGroup( - id: 1, - name: "Test Group", - eventId: 1, - position: 1, - color: nil, - hidden: false, - tables: [ - shared.Table(id: 1, number: 1, groupName: "Test Group", hasOrders: true), - shared.Table(id: 2, number: 2, groupName: "Test Group", hasOrders: false), - shared.Table(id: 3, number: 3, groupName: "Test Group", hasOrders: false), - shared.Table(id: 4, number: 4, groupName: "Test Group", hasOrders: true), - ] - ), + groupedTables: Mock.groupedTables().first!, onTableClick: { _ in } ) } diff --git a/WaiterRobot/Features/TableList/TableListFilterRow.swift b/WaiterRobot/Features/TableList/TableListFilterRow.swift deleted file mode 100644 index a3de937..0000000 --- a/WaiterRobot/Features/TableList/TableListFilterRow.swift +++ /dev/null @@ -1,117 +0,0 @@ -import shared -import SwiftUI - -struct TableListFilterRow: View { - let tableGroups: [TableGroup] - let onToggleFilter: (TableGroup) -> Void - let onSelectAll: () -> Void - let onUnselectAll: () -> Void - - var body: some View { - if #available(iOS 16, *) { - newFilter() - } else { - oldFilter() - } - } - - @available(iOS 16, *) - private func newFilter() -> some View { - VStack(spacing: 20) { - DynamicGrid( - horizontalSpacing: 5, - verticalSpacing: 5 - ) { - ForEach(tableGroups, id: \.id) { group in - if group.hidden { - Button { - onToggleFilter(group) - } label: { - Text(group.name) - .padding() - } - .buttonStyle(.gray) - } else { - Button { - onToggleFilter(group) - } label: { - Text(group.name) - .padding() - } - .buttonStyle(.primary) - } - } - } - - HStack { - Button { - onSelectAll() - } label: { - Image(systemName: "rectangle.badge.checkmark") - .imageScale(.large) - .padding(8) - } - .buttonStyle(.primary) - - Button { - onUnselectAll() - } label: { - Image(systemName: "rectangle.badge.xmark") - .imageScale(.large) - .padding(8) - } - .buttonStyle(.gray) - } - .frame(maxWidth: .infinity) - } - } - - private func oldFilter() -> some View { - HStack { - ScrollView(.horizontal) { - HStack { - ForEach(tableGroups, id: \.id) { group in - Button { - onToggleFilter(group) // viewModel.actual.toggleFilter(tableGroup: group) - } label: { - Text(group.name) - } - .buttonStyle(.bordered) - .tint(group.hidden ? .primary : .blue) - } - } - .padding(.horizontal) - } - .padding(.bottom, 4) - - Button { - onSelectAll() - } label: { - Image(systemName: "checkmark") - } - .padding(.trailing) - .disabled(tableGroups.allSatisfy { !$0.hidden }) - - Button { - onUnselectAll() - } label: { - Image(systemName: "xmark") - } - .padding(.trailing) - .disabled(tableGroups.allSatisfy(\.hidden)) - } - } -} - -#Preview { - TableListFilterRow( - tableGroups: [ - TableGroup(id: 1, name: "Test Group1", eventId: 1, position: 1, color: nil, hidden: true, tables: []), - TableGroup(id: 2, name: "Test Group2", eventId: 1, position: 1, color: nil, hidden: false, tables: []), - ], - onToggleFilter: { _ in }, - onSelectAll: {}, - onUnselectAll: {} - ) - .padding() -} diff --git a/WaiterRobot/Features/TableList/TableListScreen.swift b/WaiterRobot/Features/TableList/TableListScreen.swift index 012956e..5b55e45 100644 --- a/WaiterRobot/Features/TableList/TableListScreen.swift +++ b/WaiterRobot/Features/TableList/TableListScreen.swift @@ -16,6 +16,13 @@ struct TableListScreen: View { if #available(iOS 16.0, *) { content() .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showFilters.toggle() + } label: { + Image(systemName: "line.3.horizontal.decrease") + } + } ToolbarItem(placement: .topBarTrailing) { Button { viewModel.actual.openSettings() @@ -24,11 +31,18 @@ struct TableListScreen: View { } } } - .toolbarBackground(.hidden, for: .navigationBar) } else { content() .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItem(placement: .topBarLeading) { + Button { + showFilters.toggle() + } label: { + Image(systemName: "line.3.horizontal.decrease") + } + } + + ToolbarItem(placement: .topBarTrailing) { Button { viewModel.actual.openSettings() } label: { @@ -56,23 +70,32 @@ struct TableListScreen: View { } } .navigationBarTitleDisplayMode(.inline) - .animation(.spring, value: viewModel.state.tableGroupsArray) + .animation(.spring, value: viewModel.state.tableGroups) + .sheet(isPresented: $showFilters) { + TableGroupFilterSheet() + } .withViewModel(viewModel, navigator) } private func content() -> some View { ZStack { - if let data = viewModel.state.tableGroupsArray.data { - tableList(data: data) + if let tableGroups = Array(viewModel.state.tableGroups.data) { + TableListView( + tableGroups: tableGroups, + isDemoEvent: viewModel.state.isDemoEvent, + onTableSelect: { viewModel.actual.onTableClick(table: $0) } + ) + } else { + ProgressView() } - switch onEnum(of: viewModel.state.tableGroupsArray) { + switch onEnum(of: viewModel.state.tableGroups) { case let .error(resource): VStack { Spacer() HStack { - Text(resource.userMessage) + Text(resource.userMessage()) .padding() Spacer() @@ -92,28 +115,11 @@ struct TableListScreen: View { } } } - - @ViewBuilder - private func tableList(data: KotlinArray) -> some View { - let tableGroups = Array(data) - - TableListView( - showFilters: $showFilters, - tableGroups: tableGroups, - onToggleFilter: { viewModel.actual.toggleFilter(tableGroup: $0) }, - onSelectAll: { viewModel.actual.showAll() }, - onUnselectAll: { viewModel.actual.hideAll() }, - onTableSelect: { viewModel.actual.onTableClick(table: $0) } - ) - } } struct TableListView: View { - @Binding var showFilters: Bool - let tableGroups: [TableGroup] - let onToggleFilter: (TableGroup) -> Void - let onSelectAll: () -> Void - let onUnselectAll: () -> Void + let tableGroups: [GroupedTables] + let isDemoEvent: Bool let onTableSelect: (shared.Table) -> Void private let layout = [ @@ -122,25 +128,10 @@ struct TableListView: View { var body: some View { VStack(spacing: 0) { - if tableGroups.count > 1, showFilters { - VStack { - TableListFilterRow( - tableGroups: tableGroups, - onToggleFilter: onToggleFilter, - onSelectAll: onSelectAll, - onUnselectAll: onUnselectAll - ) - } - .padding() - .background(Color(UIColor.systemBackground)) - } - - Divider() - if tableGroups.isEmpty { Spacer() - Text(localize.tableList.noTableFound()) + Text(localize.tableList_noTableFound()) .multilineTextAlignment(.center) .frame(maxWidth: .infinity) .padding() @@ -152,10 +143,10 @@ struct TableListView: View { columns: layout, pinnedViews: [.sectionHeaders] ) { - ForEach(tableGroups.filter { !$0.hidden }, id: \.id) { group in + ForEach(tableGroups, id: \.id) { group in if !group.tables.isEmpty { TableGroupSection( - tableGroup: group, + groupedTables: group, onTableClick: onTableSelect ) } @@ -163,20 +154,12 @@ struct TableListView: View { } .padding() } - .toolbar { - ToolbarItem(placement: .topBarLeading) { - if tableGroups.count > 1 { - Button { - showFilters.toggle() - } label: { - Image(systemName: "slider.horizontal.3") - } - } - } - } + } + + if isDemoEvent { + ErrorBar(message: localize.tableList_demoEventWarning.desc(), initialLines: 1) } } - .animation(.easeIn, value: showFilters) } } @@ -192,11 +175,10 @@ struct TableListView: View { PreviewView { NavigationView { TableListView( - showFilters: .constant(false), - tableGroups: Mock.tableGroups() - ) { _ in - } onSelectAll: {} onUnselectAll: {} onTableSelect: { _ in - } + tableGroups: Mock.groupedTables(), + isDemoEvent: true, + onTableSelect: { _ in } + ) } } } diff --git a/WaiterRobot/Features/TableList/TableView.swift b/WaiterRobot/Features/TableList/TableView.swift index 43fa12a..2622c95 100644 --- a/WaiterRobot/Features/TableList/TableView.swift +++ b/WaiterRobot/Features/TableList/TableView.swift @@ -2,55 +2,55 @@ import SharedUI import SwiftUI struct TableView: View { + @Environment(\.self) + private var env + let text: String let hasOrders: Bool - let backgroundColor: Color? + let backgroundColor: Color let onClick: () -> Void - @Environment(\.colorScheme) - var colorScheme + init(text: String, hasOrders: Bool, backgroundColor: Color?, onClick: @escaping () -> Void) { + self.text = text + self.hasOrders = hasOrders + self.backgroundColor = backgroundColor ?? Color.lightGray + self.onClick = onClick + } var body: some View { Button(action: onClick) { - ZStack { + ZStack(alignment: .topTrailing) { Text(text) .font(.title) .frame(maxWidth: .infinity, maxHeight: .infinity) if hasOrders { - VStack(alignment: .trailing) { - HStack { - Spacer() - - Circle() - .foregroundColor(backgroundColor?.getContentColor(lightColorScheme: Color(.darkRed), darkColorScheme: Color(.lightRed))) - .frame(width: 12) - } - - Spacer() - } - .padding(.top, 10) - .padding(.trailing, 10) + Circle() + .foregroundColor(backgroundColor.bestContrastColor(Color(.darkRed), Color(.lightRed), in: env)) + .frame(width: 12, height: 12) + .padding(.top, 10) + .padding(.trailing, 10) } } } .aspectRatio(1.0, contentMode: .fit) .background { - if let backgroundColor { - RoundedRectangle(cornerRadius: 20) - .foregroundColor(backgroundColor) - } else { - RoundedRectangle(cornerRadius: 20) - .foregroundColor(.gray.opacity(0.3)) - } + RoundedRectangle(cornerRadius: 20) + .foregroundColor(backgroundColor) } - .foregroundStyle(backgroundColor?.getContentColor(lightColorScheme: .black, darkColorScheme: .white) ?? .blackWhite) + .foregroundStyle(backgroundColor.bestContrastColor(.white, .black, in: env)) } } #Preview { VStack { - TableView(text: "1", hasOrders: false, backgroundColor: .green) {} + TableView(text: "1", hasOrders: true, backgroundColor: .blackWhite) {} + .frame(maxWidth: 100) + + TableView(text: "1", hasOrders: false, backgroundColor: .gray) {} + .frame(maxWidth: 100) + + TableView(text: "1", hasOrders: true, backgroundColor: .green) {} .frame(maxWidth: 100) TableView(text: "2", hasOrders: true, backgroundColor: nil) {} diff --git a/WaiterRobot/Features/UpdateApp/UpdateAppScreen.swift b/WaiterRobot/Features/UpdateApp/UpdateAppScreen.swift index 2132f9e..1659d94 100644 --- a/WaiterRobot/Features/UpdateApp/UpdateAppScreen.swift +++ b/WaiterRobot/Features/UpdateApp/UpdateAppScreen.swift @@ -5,7 +5,7 @@ import WRCore struct UpdateAppScreen: View { var body: some View { VStack { - Text(localize.app.forceUpdate.message()) + Text(localize.app_forceUpdate_message()) .multilineTextAlignment(.center) Button { @@ -19,11 +19,11 @@ struct UpdateAppScreen: View { UIApplication.shared.open(url, options: [:], completionHandler: nil) } } label: { - Text(localize.app.forceUpdate.openStore(value0: "App Store")) + Text(localize.app_forceUpdate_openStore("App Store")) }.padding() } .padding() - .navigationTitle(localize.app.forceUpdate.title()) + .navigationTitle(localize.app_forceUpdate_title()) .navigationBarTitleDisplayMode(.inline) } } diff --git a/WaiterRobot/MainView.swift b/WaiterRobot/MainView.swift index 17d12e1..d7fc025 100644 --- a/WaiterRobot/MainView.swift +++ b/WaiterRobot/MainView.swift @@ -63,7 +63,7 @@ struct MainView: View { .withViewModel(viewModel, navigator) { effect in switch onEnum(of: effect) { case let .showSnackBar(snackBar): - snackBarMessage = snackBar.message + snackBarMessage = snackBar.message() DispatchQueue.main.asyncAfter(deadline: .now() + 5) { snackBarMessage = nil } @@ -74,14 +74,14 @@ struct MainView: View { viewModel.actual.onDeepLink(url: url.absoluteString) } .alert( - localize.app.updateAvailable.title(), + localize.app_updateAvailable_title(), isPresented: $showUpdateAvailableAlert ) { - Button(localize.dialog.cancel(), role: .cancel) { + Button(localize.dialog_cancel(), role: .cancel) { showUpdateAvailableAlert = false } - Button(localize.app.forceUpdate.openStore(value0: "App Store")) { + Button(localize.app_forceUpdate_openStore("App Store")) { guard let storeUrl = VersionChecker.shared.storeUrl, let url = URL(string: storeUrl) else { @@ -93,7 +93,7 @@ struct MainView: View { } } } message: { - Text(localize.app.updateAvailable.message()) + Text(localize.app_updateAvailable_message()) } .onAppear { VersionChecker.shared.checkVersion { diff --git a/WaiterRobot/Resources/Images.xcassets/Contents.json b/WaiterRobot/Resources/Images.xcassets/Contents.json deleted file mode 100644 index 73c0059..0000000 --- a/WaiterRobot/Resources/Images.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/WaiterRobot/Resources/Images.xcassets/LogoRounded.imageset/wr-round.svg b/WaiterRobot/Resources/Images.xcassets/LogoRounded.imageset/wr-round.svg deleted file mode 100644 index 14b173f..0000000 --- a/WaiterRobot/Resources/Images.xcassets/LogoRounded.imageset/wr-round.svg +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/WaiterRobot/Ui/LoadingOverlayView.swift b/WaiterRobot/Ui/LoadingOverlayView.swift new file mode 100644 index 0000000..c9567ee --- /dev/null +++ b/WaiterRobot/Ui/LoadingOverlayView.swift @@ -0,0 +1,25 @@ +import SwiftUI + +public struct LoadingOverlayView: View { + let isLoading: Bool + let content: () -> Content + + init(isLoading: Bool, @ViewBuilder content: @escaping () -> Content) { + self.isLoading = isLoading + self.content = content + } + + public var body: some View { + ZStack { + content() + .opacity(isLoading ? 0.5 : 1.0) + + if isLoading { + Color.black.opacity(0.2) + .edgesIgnoringSafeArea(.all) + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + } + } + } +} diff --git a/WaiterRobot/Ui/ViewStateOverlayView.swift b/WaiterRobot/Ui/ViewStateOverlayView.swift new file mode 100644 index 0000000..7d1520d --- /dev/null +++ b/WaiterRobot/Ui/ViewStateOverlayView.swift @@ -0,0 +1,44 @@ +import shared +import SwiftUI + +public struct ViewStateOverlayView: View { + private let state: Skie.Shared.ViewState.__Sealed + private let content: () -> Content + + init(state: ViewState, @ViewBuilder content: @escaping () -> Content) { + self.state = onEnum(of: state) + self.content = content + } + + public var body: some View { + ZStack { + LoadingOverlayView(isLoading: isLoading) { + VStack(alignment: .leading) { + content() + } + } + } + .alert(item: Binding( + get: { dialogState }, + set: { _ in dialogState?.onDismiss() } + )) { dialog in + Alert(dialog) + } + } + + private var isLoading: Bool { + if case .loading = state { + return true + } + return false + } + + private var dialogState: DialogState? { + if case let .error(error) = state { + return error.dialog + } + return nil + } +} + +extension DialogState: Identifiable {} diff --git a/WaiterRobot/Util/Extensions/Color.swift b/WaiterRobot/Util/Extensions/Color.swift index 1da311a..72be7b7 100644 --- a/WaiterRobot/Util/Extensions/Color.swift +++ b/WaiterRobot/Util/Extensions/Color.swift @@ -63,32 +63,47 @@ extension Color { ) } - // Adjust color based on contrast - func getContentColor(lightColorScheme: Color, darkColorScheme: Color) -> Color { - let lightContrast = contrastRatio(with: lightColorScheme) - let darkContrast = contrastRatio(with: darkColorScheme) + /// Adjust color based on contrast + func bestContrastColor(_ color1: Color, _ color2: Color, in env: EnvironmentValues) -> Color { + let backgroundResolved = resolve(in: env) + let color1Resolved = color1.resolve(in: env) + let color2Resolved = color2.resolve(in: env) - return lightContrast > darkContrast ? lightColorScheme : darkColorScheme - } + let contrast1 = Color.Resolved.contrastRatio(foreground: color1Resolved, background: backgroundResolved) + let contrast2 = Color.Resolved.contrastRatio(foreground: color2Resolved, background: backgroundResolved) - // Calculate contrast ratio - private func contrastRatio(with other: Color) -> Double { - let l1 = luminance() - let l2 = other.luminance() - return (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05) + return contrast1 > contrast2 ? color1 : color2 } +} - // Calculate luminance - private func luminance() -> Double { - let components = cgColor?.components ?? [0, 0, 0, 1] - let red = Color.convertSRGBToLinear(components[0]) - let green = Color.convertSRGBToLinear(components[1]) - let blue = Color.convertSRGBToLinear(components[2]) +extension Color.Resolved { + static func contrastRatio(foreground: Color.Resolved, background: Color.Resolved) -> Float { + #if DEBUG + if background.opacity != 1 { + fatalError("Background can not be translucent") + } + #endif + let lum1 = foreground.composite(on: background).luminance() // calculate the luminance when composed on top of background to account for alpha + let lum2 = background.luminance() + let lighter = max(lum1, lum2) + let darker = min(lum1, lum2) + return (lighter + 0.05) / (darker + 0.05) + } - return 0.2126 * red + 0.7152 * green + 0.0722 * blue + func luminance() -> Float { + 0.2126 * linearRed + 0.7152 * linearGreen + 0.0722 * linearBlue } - private static func convertSRGBToLinear(_ component: CGFloat) -> Double { - component <= 0.03928 ? Double(component) / 12.92 : pow((Double(component) + 0.055) / 1.055, 2.4) + private func composite(on background: Color.Resolved) -> Color.Resolved { + if opacity == 1 { return self } + if opacity == 0 { return self } + + let alpha = opacity + background.opacity * (1 - opacity) + + let r = (red * opacity + background.red * background.opacity * (1 - opacity)) / alpha + let g = (green * opacity + background.green * background.opacity * (1 - opacity)) / alpha + let b = (blue * opacity + background.blue * background.opacity * (1 - opacity)) / alpha + + return Color.Resolved(red: r, green: g, blue: b, opacity: alpha) } } diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 48f3c40..3b6fee6 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -2,7 +2,7 @@ default_platform(:ios) platform :ios do before_all do - xcodes(version: "16.1", select_for_current_build_only: true) + xcodes(version: "16.4", select_for_current_build_only: true) end desc "Run all iOS unit and ui tests."