Skip to content

Commit 00d52a3

Browse files
authored
Merge pull request #8509 from woocommerce/feat/8378-sc-profiler-country
Store creation M3: store country profiler question UI (not integrated to the flow)
2 parents 4494971 + eae9a5b commit 00d52a3

File tree

7 files changed

+471
-0
lines changed

7 files changed

+471
-0
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import SwiftUI
2+
3+
/// A button for a country that the user can select for their store during the store creation profiler flow.
4+
struct StoreCreationCountryButton: View {
5+
private let countryCode: SiteAddress.CountryCode
6+
@ObservedObject private var viewModel: StoreCreationCountryQuestionViewModel
7+
8+
init(countryCode: SiteAddress.CountryCode, viewModel: StoreCreationCountryQuestionViewModel) {
9+
self.countryCode = countryCode
10+
self.viewModel = viewModel
11+
}
12+
13+
var body: some View {
14+
Button(action: {
15+
viewModel.selectCountry(countryCode)
16+
}, label: {
17+
HStack(spacing: 24) {
18+
if let flagEmoji = countryCode.flagEmoji {
19+
Text(flagEmoji)
20+
}
21+
Text(countryCode.readableCountry)
22+
Spacer()
23+
}
24+
})
25+
.buttonStyle(SelectableSecondaryButtonStyle(isSelected: viewModel.selectedCountryCode == countryCode))
26+
}
27+
}
28+
29+
struct StoreCreationCountryButton_Previews: PreviewProvider {
30+
static var previews: some View {
31+
VStack {
32+
StoreCreationCountryButton(countryCode: .US,
33+
viewModel: .init(storeName: "", onContinue: { _ in }))
34+
StoreCreationCountryButton(countryCode: .UM,
35+
viewModel: .init(storeName: "", onContinue: { _ in }))
36+
}
37+
}
38+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import SwiftUI
2+
3+
/// Hosting controller that wraps the `StoreCreationCountryQuestionView`.
4+
final class StoreCreationCountryQuestionHostingController: UIHostingController<StoreCreationCountryQuestionView> {
5+
init(viewModel: StoreCreationCountryQuestionViewModel) {
6+
super.init(rootView: StoreCreationCountryQuestionView(viewModel: viewModel))
7+
}
8+
9+
@available(*, unavailable)
10+
required dynamic init?(coder aDecoder: NSCoder) {
11+
fatalError("init(coder:) has not been implemented")
12+
}
13+
14+
override func viewDidLoad() {
15+
super.viewDidLoad()
16+
17+
configureTransparentNavigationBar()
18+
}
19+
}
20+
21+
/// Shows the store country question in the store creation flow.
22+
struct StoreCreationCountryQuestionView: View {
23+
@StateObject private var viewModel: StoreCreationCountryQuestionViewModel
24+
25+
init(viewModel: StoreCreationCountryQuestionViewModel) {
26+
self._viewModel = StateObject(wrappedValue: viewModel)
27+
}
28+
29+
var body: some View {
30+
RequiredStoreCreationProfilerQuestionView(viewModel: viewModel) {
31+
VStack(spacing: 32) {
32+
if let currentCountryCode = viewModel.currentCountryCode {
33+
StoreCreationCountrySectionView(header: Localization.currentLocationHeader,
34+
countryCodes: [currentCountryCode],
35+
viewModel: viewModel)
36+
}
37+
StoreCreationCountrySectionView(header: Localization.otherCountriesHeader,
38+
countryCodes: viewModel.countryCodes,
39+
viewModel: viewModel)
40+
}
41+
}
42+
}
43+
}
44+
45+
private extension StoreCreationCountryQuestionView {
46+
enum Localization {
47+
static let currentLocationHeader = NSLocalizedString(
48+
"CURRENT LOCATION",
49+
comment: "Header of the current country in the store creation country question.")
50+
static let otherCountriesHeader = NSLocalizedString(
51+
"COUNTRIES",
52+
comment: "Header of a list of other countries in the store creation country question.")
53+
}
54+
}
55+
56+
struct StoreCreationCountryQuestionView_Previews: PreviewProvider {
57+
static var previews: some View {
58+
NavigationView {
59+
StoreCreationCountryQuestionView(viewModel: .init(storeName: "only in 2023",
60+
currentLocale: Locale.init(identifier: "en_US"),
61+
onContinue: { _ in }))
62+
}
63+
}
64+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import Combine
2+
import Foundation
3+
4+
/// View model for `StoreCreationCountryQuestionView`, an optional profiler question about store country in the store creation flow.
5+
@MainActor
6+
final class StoreCreationCountryQuestionViewModel: StoreCreationProfilerQuestionViewModel, ObservableObject {
7+
typealias CountryCode = SiteAddress.CountryCode
8+
9+
let topHeader: String
10+
11+
let title: String = Localization.title
12+
13+
let subtitle: String = Localization.subtitle
14+
15+
/// Question content.
16+
/// TODO: 8378 - update values when API is ready.
17+
let countryCodes: [CountryCode]
18+
19+
/// The estimated country code given the current device locale.
20+
let currentCountryCode: CountryCode?
21+
22+
/// The currently selected country code.
23+
@Published private(set) var selectedCountryCode: CountryCode?
24+
25+
/// Whether the continue button is enabled.
26+
@Published private var isContinueButtonEnabledValue: Bool = false
27+
28+
private let onContinue: (CountryCode) -> Void
29+
30+
init(storeName: String,
31+
currentLocale: Locale = .current,
32+
onContinue: @escaping (CountryCode) -> Void) {
33+
self.topHeader = storeName
34+
self.onContinue = onContinue
35+
36+
currentCountryCode = currentLocale.regionCode.map { CountryCode(rawValue: $0) } ?? nil
37+
selectedCountryCode = currentCountryCode
38+
39+
let allCountryCodes = CountryCode.allCases
40+
.sorted(by: { $0.readableCountry < $1.readableCountry })
41+
if let currentCountryCode {
42+
countryCodes = {
43+
var countryCodes = allCountryCodes
44+
countryCodes.removeAll(where: { $0 == currentCountryCode })
45+
return countryCodes
46+
}()
47+
} else {
48+
countryCodes = allCountryCodes
49+
}
50+
51+
$selectedCountryCode
52+
.map { $0 != nil }
53+
.assign(to: &$isContinueButtonEnabledValue)
54+
}
55+
}
56+
57+
extension StoreCreationCountryQuestionViewModel: RequiredStoreCreationProfilerQuestionViewModel {
58+
var isContinueButtonEnabled: AnyPublisher<Bool, Never> {
59+
$isContinueButtonEnabledValue.eraseToAnyPublisher()
60+
}
61+
62+
func continueButtonTapped() async {
63+
guard let selectedCountryCode else {
64+
return
65+
}
66+
onContinue(selectedCountryCode)
67+
}
68+
}
69+
70+
extension StoreCreationCountryQuestionViewModel {
71+
func selectCountry(_ countryCode: CountryCode) {
72+
selectedCountryCode = countryCode
73+
}
74+
}
75+
76+
private extension StoreCreationCountryQuestionViewModel {
77+
enum Localization {
78+
static let title = NSLocalizedString(
79+
"Confirm your location",
80+
comment: "Title of the store creation profiler question about the store country."
81+
)
82+
static let subtitle = NSLocalizedString(
83+
"We’ll use this information to get a head start on setting up payments, shipping, and taxes.",
84+
comment: "Subtitle of the store creation profiler question about the store country."
85+
)
86+
}
87+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import SwiftUI
2+
3+
/// Shows a header label and a list of countries for selection in the store creation profiler flow.
4+
struct StoreCreationCountrySectionView: View {
5+
private let header: String
6+
private let countryCodes: [SiteAddress.CountryCode]
7+
@ObservedObject private var viewModel: StoreCreationCountryQuestionViewModel
8+
9+
init(header: String, countryCodes: [SiteAddress.CountryCode], viewModel: StoreCreationCountryQuestionViewModel) {
10+
self.header = header
11+
self.countryCodes = countryCodes
12+
self.viewModel = viewModel
13+
}
14+
15+
var body: some View {
16+
VStack(alignment: .leading, spacing: 8) {
17+
Text(header)
18+
.footnoteStyle()
19+
VStack(spacing: 16) {
20+
ForEach(countryCodes, id: \.self) { countryCode in
21+
StoreCreationCountryButton(countryCode: countryCode,
22+
viewModel: viewModel)
23+
}
24+
}
25+
}
26+
}
27+
}
28+
29+
struct StoreCreationCountrySectionView_Previews: PreviewProvider {
30+
static var previews: some View {
31+
StoreCreationCountrySectionView(header: "EXAMPLES", countryCodes: [.FJ, .UM, .US], viewModel: .init(storeName: "", onContinue: { _ in }))
32+
}
33+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import Combine
2+
import SwiftUI
3+
4+
/// Handles the navigation action and provides the continue button state in a required profiler question view during store creation.
5+
/// The question is not skippable.
6+
protocol RequiredStoreCreationProfilerQuestionViewModel {
7+
/// Called when the continue button is tapped.
8+
func continueButtonTapped() async
9+
10+
/// Whether the continue button is enabled for the user to continue.
11+
var isContinueButtonEnabled: AnyPublisher<Bool, Never> { get }
12+
}
13+
14+
/// Shows a mandatory profiler question during the store creation flow with a generic content.
15+
struct RequiredStoreCreationProfilerQuestionView<QuestionContent: View>: View {
16+
private let viewModel: StoreCreationProfilerQuestionViewModel & RequiredStoreCreationProfilerQuestionViewModel
17+
@ViewBuilder private let questionContent: () -> QuestionContent
18+
@State private var isWaitingForCompletion: Bool = false
19+
@State private var isContinueButtonEnabled: Bool = false
20+
21+
init(viewModel: StoreCreationProfilerQuestionViewModel & RequiredStoreCreationProfilerQuestionViewModel,
22+
@ViewBuilder questionContent: @escaping () -> QuestionContent) {
23+
self.viewModel = viewModel
24+
self.questionContent = questionContent
25+
}
26+
27+
var body: some View {
28+
ScrollView {
29+
StoreCreationProfilerQuestionView<QuestionContent>(viewModel: viewModel, questionContent: questionContent)
30+
}
31+
.safeAreaInset(edge: .bottom) {
32+
VStack {
33+
Divider()
34+
.frame(height: Layout.dividerHeight)
35+
.foregroundColor(Color(.separator))
36+
Button(Localization.continueButtonTitle) {
37+
Task { @MainActor in
38+
isWaitingForCompletion = true
39+
await viewModel.continueButtonTapped()
40+
isWaitingForCompletion = false
41+
}
42+
}
43+
.buttonStyle(PrimaryLoadingButtonStyle(isLoading: isWaitingForCompletion))
44+
.disabled(!isContinueButtonEnabled)
45+
.padding(Layout.defaultPadding)
46+
}
47+
.background(Color(.systemBackground))
48+
}
49+
// Disables large title to avoid a large gap below the navigation bar.
50+
.navigationBarTitleDisplayMode(.inline)
51+
.onReceive(viewModel.isContinueButtonEnabled) { isContinueButtonEnabled in
52+
self.isContinueButtonEnabled = isContinueButtonEnabled
53+
}
54+
}
55+
}
56+
57+
private enum Layout {
58+
static let dividerHeight: CGFloat = 1
59+
static let defaultPadding: EdgeInsets = .init(top: 10, leading: 16, bottom: 10, trailing: 16)
60+
}
61+
62+
private enum Localization {
63+
static let continueButtonTitle = NSLocalizedString("Continue", comment: "Title of the button to continue with a profiler question.")
64+
}
65+
66+
#if DEBUG
67+
68+
private final class StoreCreationQuestionPreviewViewModel: StoreCreationProfilerQuestionViewModel, RequiredStoreCreationProfilerQuestionViewModel {
69+
let topHeader: String = "Store name"
70+
let title: String = "This question is required"
71+
let subtitle: String = "Choose an option to continue."
72+
@Published private var isContinueButtonEnabledValue: Bool = false
73+
74+
var isContinueButtonEnabled: AnyPublisher<Bool, Never> {
75+
$isContinueButtonEnabledValue.eraseToAnyPublisher()
76+
}
77+
func continueButtonTapped() async {}
78+
}
79+
80+
struct RequiredStoreCreationProfilerQuestionView_Previews: PreviewProvider {
81+
static var previews: some View {
82+
NavigationView {
83+
RequiredStoreCreationProfilerQuestionView(viewModel: StoreCreationQuestionPreviewViewModel()) {
84+
Text("question content")
85+
}
86+
}
87+
}
88+
}
89+
90+
#endif

0 commit comments

Comments
 (0)