-
Notifications
You must be signed in to change notification settings - Fork 121
[Woo POS][Barcodes] Scanner set up flow structure #15883
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
770ec65
58b363d
548e4fb
2c1b3c7
512b836
ccecc97
f090e16
6f102c7
147870a
edaf727
049b3b2
34d44f7
99a862b
aee2986
4fc9582
7f4c149
17799fb
2487604
ebf97cf
e39dd9f
5e405e7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,149 @@ | ||
| import SwiftUI | ||
|
|
||
| @available(iOS 17.0, *) | ||
| struct PointOfSaleBarcodeScannerSetup: View { | ||
| @Binding var isPresented: Bool | ||
| @State private var flowManager: PointOfSaleBarcodeScannerSetupFlowManager | ||
|
|
||
| init(isPresented: Binding<Bool>) { | ||
| self._isPresented = isPresented | ||
| self.flowManager = PointOfSaleBarcodeScannerSetupFlowManager(isPresented: isPresented) | ||
| } | ||
|
|
||
| var body: some View { | ||
| VStack(spacing: POSSpacing.xxLarge) { | ||
| // Header | ||
| PointOfSaleModalHeader(isPresented: $isPresented, | ||
| title: .constant(AttributedString(currentTitle))) | ||
|
|
||
| VStack { | ||
| currentContent | ||
| Spacer() | ||
| } | ||
| .scrollVerticallyIfNeeded() | ||
|
|
||
| // Bottom buttons | ||
| PointOfSaleFlowButtonsView(configuration: flowManager.buttonConfiguration) | ||
| } | ||
| .padding(POSPadding.xxLarge) | ||
| .background(Color.posSurfaceBright) | ||
| .containerRelativeFrame([.horizontal, .vertical]) { length, _ in | ||
| max(length * 0.75, Constants.modalFrameMaxSmallDimension) | ||
staskus marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| .onAppear { | ||
| ServiceLocator.analytics.track(.pointOfSaleBarcodeScannerSetupFlowShown) | ||
| } | ||
| } | ||
|
|
||
| // MARK: - Computed Properties | ||
| private var currentTitle: String { | ||
staskus marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| switch flowManager.currentState { | ||
| case .scannerSelection: | ||
| return Localization.setupHeading | ||
| case .setupFlow: | ||
| return flowManager.getCurrentStep()?.title ?? Localization.setupHeading | ||
| } | ||
| } | ||
|
|
||
| @ViewBuilder | ||
| private var currentContent: some View { | ||
| switch flowManager.currentState { | ||
| case .scannerSelection: | ||
| PointOfSaleBarcodeScannerSetupSelectionView(options: scannerOptions) { scannerType in | ||
| flowManager.selectScanner(scannerType) | ||
| } | ||
| case .setupFlow: | ||
| if let step = flowManager.getCurrentStep() { | ||
| AnyView(step.content) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private var scannerOptions: [PointOfSaleBarcodeScannerSetupFlowOption] { | ||
| [ | ||
| PointOfSaleBarcodeScannerSetupFlowOption( | ||
| title: Localization.socketS720Title, | ||
| subtitle: Localization.socketS720Subtitle, | ||
| scannerType: .socketS720 | ||
| ), | ||
| PointOfSaleBarcodeScannerSetupFlowOption( | ||
| title: Localization.starBSH20BTitle, | ||
| subtitle: Localization.starBSH20BSubtitle, | ||
| scannerType: .starBSH20B | ||
| ), | ||
| PointOfSaleBarcodeScannerSetupFlowOption( | ||
| title: Localization.tbcScannerTitle, | ||
| subtitle: Localization.tbcScannerSubtitle, | ||
| scannerType: .tbcScanner | ||
| ), | ||
| PointOfSaleBarcodeScannerSetupFlowOption( | ||
| title: Localization.otherTitle, | ||
| subtitle: Localization.otherSubtitle, | ||
| scannerType: .other | ||
| ) | ||
| ] | ||
| } | ||
| } | ||
|
|
||
| // MARK: - Constants | ||
| private enum Constants { | ||
| static var modalFrameMaxSmallDimension: CGFloat { 752 } | ||
| } | ||
|
|
||
| // MARK: - Private Localization Extension | ||
| @available(iOS 17.0, *) | ||
| private extension PointOfSaleBarcodeScannerSetup { | ||
staskus marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| enum Localization { | ||
| static let setupHeading = NSLocalizedString( | ||
| "pos.barcodeScannerSetup.heading", | ||
| value: "Barcode Scanner Setup", | ||
| comment: "Heading for the barcode scanner setup flow in POS" | ||
| ) | ||
|
|
||
| static let socketS720Title = NSLocalizedString( | ||
| "pos.barcodeScannerSetup.socketS720.title", | ||
| value: "Socket S720", | ||
| comment: "Title for Socket S720 scanner option in barcode scanner setup" | ||
| ) | ||
| static let socketS720Subtitle = NSLocalizedString( | ||
| "pos.barcodeScannerSetup.socketS720.subtitle", | ||
| value: "Small handheld scanner with a charging dock or stand", | ||
| comment: "Subtitle for Socket S720 scanner option in barcode scanner setup" | ||
| ) | ||
| static let starBSH20BTitle = NSLocalizedString( | ||
| "pos.barcodeScannerSetup.starBSH20B.title", | ||
| value: "Star BSH-20B", | ||
| comment: "Title for Star BSH-20B scanner option in barcode scanner setup" | ||
| ) | ||
| static let starBSH20BSubtitle = NSLocalizedString( | ||
| "pos.barcodeScannerSetup.starBSH20B.subtitle", | ||
| value: "Ergonomic scanner with a stand", | ||
| comment: "Subtitle for Star BSH-20B scanner option in barcode scanner setup" | ||
| ) | ||
| static let tbcScannerTitle = NSLocalizedString( | ||
| "pos.barcodeScannerSetup.tbcScanner.title", | ||
| value: "Scanner TBC", | ||
| comment: "Title for TBC scanner option in barcode scanner setup" | ||
| ) | ||
| static let tbcScannerSubtitle = NSLocalizedString( | ||
| "pos.barcodeScannerSetup.tbcScanner.subtitle", | ||
| value: "Recommended scanner", | ||
| comment: "Subtitle for TBC scanner option in barcode scanner setup" | ||
| ) | ||
| static let otherTitle = NSLocalizedString( | ||
| "pos.barcodeScannerSetup.other.title", | ||
| value: "Other", | ||
| comment: "Title for other scanner option in barcode scanner setup" | ||
| ) | ||
| static let otherSubtitle = NSLocalizedString( | ||
| "pos.barcodeScannerSetup.other.subtitle", | ||
| value: "General scanner setup instructions", | ||
| comment: "Subtitle for other scanner option in barcode scanner setup" | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| @available(iOS 17.0, *) | ||
| #Preview { | ||
| PointOfSaleBarcodeScannerSetup(isPresented: .constant(true)) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,157 @@ | ||
| import SwiftUI | ||
|
|
||
| // MARK: - Point of Sale Barcode Scanner Setup Flow | ||
| @available(iOS 17.0, *) | ||
staskus marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| @Observable | ||
| class PointOfSaleBarcodeScannerSetupFlow { | ||
| private let scannerType: PointOfSaleBarcodeScannerType | ||
| private let onComplete: () -> Void | ||
| private let onBackToSelection: () -> Void | ||
| private var currentStepIndex: Int = 0 | ||
|
|
||
| init(scannerType: PointOfSaleBarcodeScannerType, | ||
| onComplete: @escaping () -> Void, | ||
| onBackToSelection: @escaping () -> Void) { | ||
| self.scannerType = scannerType | ||
| self.onComplete = onComplete | ||
| self.onBackToSelection = onBackToSelection | ||
| } | ||
|
|
||
| var currentStep: PointOfSaleBarcodeScannerSetupStep? { | ||
| steps[safe: currentStepIndex] | ||
| } | ||
|
|
||
| var isComplete: Bool { | ||
| currentStepIndex >= steps.count - 1 | ||
| } | ||
|
|
||
| var nextButtonTitle: String { | ||
| isComplete ? Localization.doneButtonTitle : Localization.nextButtonTitle | ||
| } | ||
|
|
||
| func nextStep() { | ||
| if currentStepIndex < steps.count - 1 { | ||
| currentStepIndex += 1 | ||
| } else { | ||
| onComplete() | ||
| } | ||
| } | ||
|
|
||
| func previousStep() { | ||
| if currentStepIndex > 0 { | ||
| currentStepIndex -= 1 | ||
| } else { | ||
| onBackToSelection() | ||
| } | ||
| } | ||
|
|
||
| func restartFlow() { | ||
| currentStepIndex = 0 | ||
| } | ||
|
|
||
| func getButtonConfiguration() -> PointOfSaleFlowButtonConfiguration { | ||
| guard let step = currentStep else { | ||
| return .noButtons() | ||
| } | ||
|
|
||
| // Use step customization if available | ||
| if let customization = step.buttonCustomization { | ||
| return customization.customizeButtons(for: self) | ||
| } | ||
|
|
||
| // Default button configuration | ||
| return PointOfSaleFlowButtonConfiguration( | ||
| primaryButton: PointOfSaleFlowButtonConfiguration.ButtonConfig( | ||
| title: nextButtonTitle, | ||
| action: { [weak self] in | ||
| self?.nextStep() | ||
| } | ||
| ), | ||
| secondaryButton: PointOfSaleFlowButtonConfiguration.ButtonConfig( | ||
| title: Localization.backButtonTitle, | ||
| action: { [weak self] in | ||
| self?.previousStep() | ||
| } | ||
| ) | ||
| ) | ||
| } | ||
|
|
||
| private var steps: [PointOfSaleBarcodeScannerSetupStep] { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is nice, it will be handy to have all the steps in one place 👍
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah. If we need to in future, we can pass them in and define them separately. I'm wary of generalising this too much right now, but also want to keep it open to that; we may have other flows in POS in future, e.g. printer set up, or even configuring a customer's choices for a subscription/booking cart item. |
||
| switch scannerType { | ||
| case .socketS720: | ||
| return [ | ||
| createWelcomeStep(title: "Socket S720 Setup") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This doesn't work fully, at least not yet. If I define more steps, the UI is not updated.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Interesting. I'll have a play, thanks. I would expect it to work, but I pulled multi-step flows out a while ago while focusing on the structure and getting the PR to a managable size. My guess is that there's some failing of observation going on.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wow, a simple fix indeed! Thanks. 👍 |
||
| // TODO: Add more steps for Socket S720 WOOMOB-698 | ||
| ] | ||
| case .starBSH20B: | ||
| return [ | ||
| createWelcomeStep(title: "Star BSH-20B Setup") | ||
| // TODO: Add more steps for Star BSH-20B WOOMOB-696 | ||
| ] | ||
| case .tbcScanner: | ||
| return [ | ||
| createWelcomeStep(title: "TBC Scanner Setup") | ||
| // TODO: Add more steps for TBC Scanner WOOMOB-699 | ||
| ] | ||
| case .other: | ||
| return [ | ||
| PointOfSaleBarcodeScannerSetupStep( | ||
| title: "General Scanner Setup", | ||
| content: { BarcodeScannerInformationContent() } | ||
| ) | ||
| ] | ||
| } | ||
| } | ||
|
|
||
| private func createWelcomeStep(title: String) -> PointOfSaleBarcodeScannerSetupStep { | ||
| PointOfSaleBarcodeScannerSetupStep( | ||
| title: title, | ||
| content: { PointOfSaleBarcodeScannerWelcomeView(title: title) }, | ||
| buttonCustomization: PointOfSaleBarcodeScannerWelcomeButtonCustomization() | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| // MARK: - Button Customizations | ||
| @available(iOS 17.0, *) | ||
| struct PointOfSaleBarcodeScannerWelcomeButtonCustomization: PointOfSaleBarcodeScannerButtonCustomization { | ||
staskus marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| func customizeButtons(for flow: PointOfSaleBarcodeScannerSetupFlow) -> PointOfSaleFlowButtonConfiguration { | ||
| return PointOfSaleFlowButtonConfiguration( | ||
| primaryButton: PointOfSaleFlowButtonConfiguration.ButtonConfig( | ||
| title: Localization.doneButtonTitle, | ||
| action: { flow.nextStep() } | ||
| ), | ||
| secondaryButton: nil | ||
| ) | ||
| } | ||
|
|
||
| private enum Localization { | ||
| static let doneButtonTitle = NSLocalizedString( | ||
| "pos.barcodeScannerSetup.done.button.title", | ||
| value: "Done", | ||
| comment: "Title for the done button in barcode scanner setup navigation" | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| // MARK: - Private Localization Extension | ||
| @available(iOS 17.0, *) | ||
| private extension PointOfSaleBarcodeScannerSetupFlow { | ||
| enum Localization { | ||
| static let doneButtonTitle = NSLocalizedString( | ||
| "pos.barcodeScannerSetup.done.button.title", | ||
| value: "Done", | ||
| comment: "Title for the done button in barcode scanner setup navigation" | ||
| ) | ||
| static let nextButtonTitle = NSLocalizedString( | ||
| "pos.barcodeScannerSetup.next.button.title", | ||
| value: "Next", | ||
| comment: "Title for the next button in barcode scanner setup navigation" | ||
| ) | ||
| static let backButtonTitle = NSLocalizedString( | ||
| "pos.barcodeScannerSetup.back.button.title", | ||
| value: "Back", | ||
| comment: "Title for the back button in barcode scanner setup navigation" | ||
| ) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| import SwiftUI | ||
|
|
||
| // MARK: - Point of Sale Barcode Scanner Setup Flow Manager | ||
| @available(iOS 17.0, *) | ||
| @Observable | ||
| class PointOfSaleBarcodeScannerSetupFlowManager { | ||
| var currentState: PointOfSaleBarcodeScannerSetupFlowState = .scannerSelection | ||
| @ObservationIgnored @Binding var isPresented: Bool | ||
| private var currentFlow: PointOfSaleBarcodeScannerSetupFlow? | ||
|
|
||
| init(isPresented: Binding<Bool>) { | ||
| self._isPresented = isPresented | ||
| } | ||
|
|
||
| func selectScanner(_ scannerType: PointOfSaleBarcodeScannerType) { | ||
| currentFlow = PointOfSaleBarcodeScannerSetupFlow(scannerType: scannerType, onComplete: { [weak self] in | ||
| self?.isPresented = false | ||
| }, onBackToSelection: { [weak self] in | ||
| self?.goBackToSelection() | ||
| }) | ||
| currentState = .setupFlow(scannerType) | ||
| } | ||
|
|
||
| func goBackToSelection() { | ||
| currentState = .scannerSelection | ||
| currentFlow = nil | ||
| } | ||
|
|
||
| func nextStep() { | ||
| currentFlow?.nextStep() | ||
| } | ||
|
|
||
| func previousStep() { | ||
| currentFlow?.previousStep() | ||
| } | ||
|
|
||
| func getCurrentStep() -> PointOfSaleBarcodeScannerSetupStep? { | ||
| currentFlow?.currentStep | ||
| } | ||
|
|
||
| func isComplete() -> Bool { | ||
| currentFlow?.isComplete ?? false | ||
| } | ||
|
|
||
| var buttonConfiguration: PointOfSaleFlowButtonConfiguration { | ||
| switch currentState { | ||
| case .scannerSelection: | ||
| return .noButtons() | ||
| case .setupFlow: | ||
| guard let flow = currentFlow else { | ||
| return .noButtons() | ||
| } | ||
|
|
||
| return flow.getButtonConfiguration() | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe wrapping the content within the
TabViewwill help with transition animations, although it could be tricky, especially if we don't know how many steps we have until the initial selection is made. Nevertheless, I would consider something like this here.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes... that's definitely worth trying, though I do want to keep it open to branching flows in future – e.g. additional steps after a failure for troubleshooting, going back to an arbitrary point in the flow.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TabView seems to be only partially suitable for this dynamic approach. Some online answers suggest that it's needed to change
.id(..)ofTabViewwhenever the content changes. I haven't tested it, though.This is where it becomes interesting with the dynamic approach. Question whether we want to define within the steps of the flow more like a tree, to see possible paths if failures happen.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, potentially. Let's avoid complicating it too much for now though.
The transitions in my early experiments could work pretty well like this, so we'll see how we get on 😊
Aside: controls for flows like this really feel like something a mobile platform should provide, given how common they are.