diff --git a/NativeAppTemplate.xcodeproj/project.pbxproj b/NativeAppTemplate.xcodeproj/project.pbxproj index bd51b64..6eba32c 100644 --- a/NativeAppTemplate.xcodeproj/project.pbxproj +++ b/NativeAppTemplate.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 0110A15F2AC816F5003EDCBA /* SendConfirmation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0110A15E2AC816F5003EDCBA /* SendConfirmation.swift */; }; 0110A1612AC81978003EDCBA /* ResendConfirmationInstructionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0110A1602AC81978003EDCBA /* ResendConfirmationInstructionsView.swift */; }; 0114F3AC2E079BD100F4A1DD /* ShopListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0114F3AB2E079BD100F4A1DD /* ShopListViewModel.swift */; }; + 0114F4032E07A88000F4A1DD /* ShopCreateViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0114F4022E07A88000F4A1DD /* ShopCreateViewModel.swift */; }; 011586122B567363005E8E8F /* SignUpOrSignInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011586112B567363005E8E8F /* SignUpOrSignInView.swift */; }; 011DDC21287669EA00C6C21F /* SignUpRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011DDC20287669EA00C6C21F /* SignUpRepository.swift */; }; 011DDC2328766C5E00C6C21F /* SignUpService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011DDC2228766C5D00C6C21F /* SignUpService.swift */; }; @@ -132,6 +133,9 @@ 01B6F5AB2601F84700397E66 /* PermissionsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B6F5AA2601F84700397E66 /* PermissionsRequest.swift */; }; 01B9E45228A5070D00CAC681 /* ShopkeeperSignInAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B9E45128A5070D00CAC681 /* ShopkeeperSignInAdapter.swift */; }; 01BE4F1D29CA6F8C002008BE /* TimeZoneData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01BE4F1C29CA6F8C002008BE /* TimeZoneData.swift */; }; + 01D85A962E07C78400A95798 /* NumberTagsWebpageListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D85A952E07C78400A95798 /* NumberTagsWebpageListViewModel.swift */; }; + 01D85A9A2E07C85900A95798 /* ShopBasicSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D85A992E07C85900A95798 /* ShopBasicSettingsViewModel.swift */; }; + 01D85A9E2E07C9BD00A95798 /* ShopSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D85A9D2E07C9BD00A95798 /* ShopSettingsViewModel.swift */; }; 01D8AE8B2AB453C1009AFFBA /* ShopBasicSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D8AE8A2AB453C1009AFFBA /* ShopBasicSettingsView.swift */; }; 01DCE23F298FA3B300BA311D /* ShopListCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01DCE23E298FA3B300BA311D /* ShopListCardView.swift */; }; 01E0A59C25BD088600298D35 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01E0A59125BD087E00298D35 /* SettingsView.swift */; }; @@ -174,6 +178,7 @@ 0110A15E2AC816F5003EDCBA /* SendConfirmation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendConfirmation.swift; sourceTree = ""; }; 0110A1602AC81978003EDCBA /* ResendConfirmationInstructionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResendConfirmationInstructionsView.swift; sourceTree = ""; }; 0114F3AB2E079BD100F4A1DD /* ShopListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopListViewModel.swift; sourceTree = ""; }; + 0114F4022E07A88000F4A1DD /* ShopCreateViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopCreateViewModel.swift; sourceTree = ""; }; 011586112B567363005E8E8F /* SignUpOrSignInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpOrSignInView.swift; sourceTree = ""; }; 011DDC20287669EA00C6C21F /* SignUpRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpRepository.swift; sourceTree = ""; }; 011DDC2228766C5D00C6C21F /* SignUpService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpService.swift; sourceTree = ""; }; @@ -288,6 +293,9 @@ 01B9E45128A5070D00CAC681 /* ShopkeeperSignInAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopkeeperSignInAdapter.swift; sourceTree = ""; }; 01BE4F1C29CA6F8C002008BE /* TimeZoneData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeZoneData.swift; sourceTree = ""; }; 01D19B432D4DE33500BDEAB7 /* NativeAppTemplateTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = NativeAppTemplateTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 01D85A952E07C78400A95798 /* NumberTagsWebpageListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumberTagsWebpageListViewModel.swift; sourceTree = ""; }; + 01D85A992E07C85900A95798 /* ShopBasicSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopBasicSettingsViewModel.swift; sourceTree = ""; }; + 01D85A9D2E07C9BD00A95798 /* ShopSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopSettingsViewModel.swift; sourceTree = ""; }; 01D8AE8A2AB453C1009AFFBA /* ShopBasicSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopBasicSettingsView.swift; sourceTree = ""; }; 01DCE23E298FA3B300BA311D /* ShopListCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopListCardView.swift; sourceTree = ""; }; 01E0A59125BD087E00298D35 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; @@ -428,9 +436,14 @@ 01467355299901E50005423D /* Shop Settings */ = { isa = PBXGroup; children = ( + 017278952D7D99D100CE424F /* ItemTag Detail */, + 017278992D7D99D100CE424F /* ItemTag List */, 017278912D7D99B900CE424F /* NumberTagsWebpageListView.swift */, + 01D85A952E07C78400A95798 /* NumberTagsWebpageListViewModel.swift */, 01D8AE8A2AB453C1009AFFBA /* ShopBasicSettingsView.swift */, + 01D85A992E07C85900A95798 /* ShopBasicSettingsViewModel.swift */, 01467356299902230005423D /* ShopSettingsView.swift */, + 01D85A9D2E07C9BD00A95798 /* ShopSettingsViewModel.swift */, ); path = "Shop Settings"; sourceTree = ""; @@ -439,11 +452,10 @@ isa = PBXGroup; children = ( 013DE734284E99DF00528CC5 /* ShopCreateView.swift */, + 0114F4022E07A88000F4A1DD /* ShopCreateViewModel.swift */, 01DCE23E298FA3B300BA311D /* ShopListCardView.swift */, 010F86BD2622F9C900B6C62A /* ShopListView.swift */, 0114F3AB2E079BD100F4A1DD /* ShopListViewModel.swift */, - 017278952D7D99D100CE424F /* ItemTag Detail */, - 017278992D7D99D100CE424F /* ItemTag List */, ); path = "Shop List"; sourceTree = ""; @@ -918,6 +930,8 @@ "", "", "", + "", + "", ); }; /* End PBXShellScriptBuildPhase section */ @@ -975,6 +989,7 @@ 01E0A5B625BD0FCD00298D35 /* LoadingView.swift in Sources */, 0114F3AC2E079BD100F4A1DD /* ShopListViewModel.swift in Sources */, 0172051A25AAF6C0008FD63B /* SessionsService.swift in Sources */, + 01D85A962E07C78400A95798 /* NumberTagsWebpageListViewModel.swift in Sources */, 017204D125AA8479008FD63B /* DataState.swift in Sources */, 012643372B3554AD00D4E9BD /* AcceptTermsView.swift in Sources */, 0172033E25A9642E008FD63B /* Parameters.swift in Sources */, @@ -998,6 +1013,7 @@ 0172788D2D7D936E00CE424F /* CustomerScannedTag.swift in Sources */, 017278902D7D936E00CE424F /* TagView.swift in Sources */, 0106414429AA061100B46FED /* PasswordEditView.swift in Sources */, + 0114F4032E07A88000F4A1DD /* ShopCreateViewModel.swift in Sources */, 0172786B2D7D840A00CE424F /* ShowTagInfoScanResult.swift in Sources */, 017204D925AA847E008FD63B /* ShopRepository.swift in Sources */, 017278612D7D83E700CE424F /* ItemTagData.swift in Sources */, @@ -1012,9 +1028,11 @@ 01E0A63025BD53FD00298D35 /* Shop.swift in Sources */, 017278072D7D4F5800CE424F /* OnboardingRepository.swift in Sources */, 0135E7192D7E33F9004AD8FA /* CompleteScanResultView.swift in Sources */, + 01D85A9A2E07C85900A95798 /* ShopBasicSettingsViewModel.swift in Sources */, 0135E71A2D7E33F9004AD8FA /* ShowTagInfoScanResultView.swift in Sources */, 0135E71B2D7E33F9004AD8FA /* ScanView.swift in Sources */, 013292BE262C3EA400690B75 /* LoggedInShopkeeper.swift in Sources */, + 01D85A9E2E07C9BD00A95798 /* ShopSettingsViewModel.swift in Sources */, 0172035825A9642E008FD63B /* ShopsService.swift in Sources */, 018E21CD2B36377800FFD1F6 /* MeService.swift in Sources */, 0106414029A9F2EC00B46FED /* AccountPasswordService.swift in Sources */, diff --git a/NativeAppTemplate/UI/App Root/MainView.swift b/NativeAppTemplate/UI/App Root/MainView.swift index 3b9f18b..3f99004 100644 --- a/NativeAppTemplate/UI/App Root/MainView.swift +++ b/NativeAppTemplate/UI/App Root/MainView.swift @@ -153,7 +153,8 @@ private extension MainView { shopRepository: dataManager.shopRepository, itemTagRepository: dataManager.itemTagRepository, tabViewModel: tabViewModel, - mainTab: .shops + mainTab: .shops, + messageBus: messageBus ) return ShopListView(viewModel: viewModel) } diff --git a/NativeAppTemplate/UI/Shop Detail/ShopDetailView.swift b/NativeAppTemplate/UI/Shop Detail/ShopDetailView.swift index 9e8c4b9..7853696 100644 --- a/NativeAppTemplate/UI/Shop Detail/ShopDetailView.swift +++ b/NativeAppTemplate/UI/Shop Detail/ShopDetailView.swift @@ -169,9 +169,13 @@ private extension ShopDetailView { ToolbarItem(placement: .navigationBarTrailing) { NavigationLink( destination: ShopSettingsView( - shopRepository: shopRepository, - itemTagRepository: itemTagRepository, - shopId: shop.wrappedValue.id + viewModel: ShopSettingsViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + itemTagRepository: itemTagRepository, + messageBus: messageBus, + shopId: shop.wrappedValue.id + ) ) ) { Image(systemName: "gearshape.fill") diff --git a/NativeAppTemplate/UI/Shop List/ShopCreateView.swift b/NativeAppTemplate/UI/Shop List/ShopCreateView.swift index 3928c3c..131d019 100644 --- a/NativeAppTemplate/UI/Shop List/ShopCreateView.swift +++ b/NativeAppTemplate/UI/Shop List/ShopCreateView.swift @@ -9,60 +9,47 @@ import SwiftUI struct ShopCreateView: View { @Environment(\.dismiss) private var dismiss - @Environment(\.sessionController) private var sessionController - @Environment(MessageBus.self) private var messageBus - private var shopRepository: ShopRepositoryProtocol - @State private var name = "" - @State private var description = "" - @State private var selectedTimeZone: String - @State private var isCreating = false + @State private var viewModel: ShopCreateViewModel - init( - shopRepository: ShopRepositoryProtocol - ) { - self.shopRepository = shopRepository - _selectedTimeZone = State(initialValue: Utility.currentTimeZone()) + init(viewModel: ShopCreateViewModel) { + self._viewModel = State(wrappedValue: viewModel) } - private var hasInvalidData: Bool { Utility.isBlank(name) } - var body: some View { contentView + .onChange(of: viewModel.shouldDismiss) { + if viewModel.shouldDismiss { + dismiss() + } + } } -} - -// MARK: - private -private extension ShopCreateView { - var contentView: some View { - @ViewBuilder var contentView: some View { - if isCreating { - LoadingView() - } else { - shopCreateView - } + @ViewBuilder + private var contentView: some View { + if viewModel.isCreating { + LoadingView() + } else { + shopCreateForm } - - return contentView } - private var shopCreateView: some View { + private var shopCreateForm: some View { NavigationStack { Form { Section { - TextField(String.name, text: $name) + TextField(String.name, text: $viewModel.name) } footer: { Text(String.shopNameIsRequired) - .foregroundStyle(Utility.isBlank(name) ? .red : .clear) + .foregroundStyle(viewModel.hasInvalidData ? .red : .clear) } Section { - TextField(String.descriptionString, text: $description, axis: .vertical) + TextField(String.descriptionString, text: $viewModel.description, axis: .vertical) .lineLimit(10, reservesSpace: true) } - + Section { - Picker(String.timeZone, selection: $selectedTimeZone) { + Picker(String.timeZone, selection: $viewModel.selectedTimeZone) { ForEach(timeZones.keys, id: \.self) { key in Text(timeZones[key]!).tag(key) } @@ -72,54 +59,18 @@ private extension ShopCreateView { .navigationTitle(String.addShop) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - Button { - createShop() - } label: { - Text(String.save) + Button(String.save) { + viewModel.createShop() } - .disabled(hasInvalidData) + .disabled(viewModel.hasInvalidData) } + ToolbarItem(placement: .navigationBarLeading) { - Button { + Button(String.cancel) { dismiss() - } label: { - Text(String.cancel) } } } } } - - func createShop() { - Task { @MainActor in - isCreating = true - - do { - let shop = Shop( - id: "", - name: name, - description: description, - timeZone: selectedTimeZone - ) - _ = try await shopRepository.create(shop: shop) - messageBus.post(message: Message(level: .success, message: .shopCreated)) - } catch { - messageBus.post( - message: Message( - level: .error, - message: error.localizedDescription, - autoDismiss: false - ) - ) - - // e.g. Limit shopps count error - guard case NativeAppTemplateAPIError.requestFailed(_, 422, _) = error else { - try await sessionController.logout() - return - } - } - - dismiss() - } - } } diff --git a/NativeAppTemplate/UI/Shop List/ShopCreateViewModel.swift b/NativeAppTemplate/UI/Shop List/ShopCreateViewModel.swift new file mode 100644 index 0000000..99f1fc1 --- /dev/null +++ b/NativeAppTemplate/UI/Shop List/ShopCreateViewModel.swift @@ -0,0 +1,66 @@ +// +// ShopCreateViewModel.swift +// NativeAppTemplate +// +// Created by Claude on 2025/06/22. +// + +import Foundation +import Observation +import SwiftUI + +@Observable +@MainActor +final class ShopCreateViewModel { + var name: String = "" + var description: String = "" + var selectedTimeZone: String = Utility.currentTimeZone() + var isCreating = false + + private let sessionController: SessionControllerProtocol + private let shopRepository: ShopRepositoryProtocol + private(set) var messageBus: MessageBus + var shouldDismiss: Bool = false + + init( + sessionController: SessionControllerProtocol, + shopRepository: ShopRepositoryProtocol, + messageBus: MessageBus + ) { + self.sessionController = sessionController + self.shopRepository = shopRepository + self.messageBus = messageBus + } + + var hasInvalidData: Bool { + Utility.isBlank(name) + } + + func createShop() { + Task { + isCreating = true + + do { + let shop = Shop( + id: "", + name: name, + description: description, + timeZone: selectedTimeZone + ) + _ = try await shopRepository.create(shop: shop) + messageBus.post(message: Message(level: .success, message: .shopCreated)) + shouldDismiss = true + } catch { + messageBus.post(message: Message(level: .error, message: error.localizedDescription, autoDismiss: false)) + + // e.g. Limit shops count error + guard case NativeAppTemplateAPIError.requestFailed(_, 422, _) = error else { + try await sessionController.logout() + return + } + + shouldDismiss = true + } + } + } +} diff --git a/NativeAppTemplate/UI/Shop List/ShopListView.swift b/NativeAppTemplate/UI/Shop List/ShopListView.swift index 7ee6854..3aa4f51 100644 --- a/NativeAppTemplate/UI/Shop List/ShopListView.swift +++ b/NativeAppTemplate/UI/Shop List/ShopListView.swift @@ -44,8 +44,11 @@ struct TapShopBelowTip: Tip { } struct ShopListView: View { + @Environment(DataManager.self) private var dataManager + @Environment(MessageBus.self) private var messageBus + @State private var viewModel: ShopListViewModel - + init(viewModel: ShopListViewModel) { self._viewModel = State(wrappedValue: viewModel) } @@ -157,7 +160,13 @@ private extension ShopListView { onDismiss: { viewModel.reload() }, content: { - ShopCreateView(shopRepository: viewModel.shopRepository) + ShopCreateView( + viewModel: ShopCreateViewModel( + sessionController: dataManager.sessionController, + shopRepository: dataManager.shopRepository, + messageBus: messageBus + ) + ) } ) } diff --git a/NativeAppTemplate/UI/Shop List/ShopListViewModel.swift b/NativeAppTemplate/UI/Shop List/ShopListViewModel.swift index 6ad7a64..95d52c5 100644 --- a/NativeAppTemplate/UI/Shop List/ShopListViewModel.swift +++ b/NativeAppTemplate/UI/Shop List/ShopListViewModel.swift @@ -26,19 +26,22 @@ final class ShopListViewModel { private let sessionController: SessionControllerProtocol private let tabViewModel: TabViewModel private let mainTab: MainTab + private let messageBus: MessageBus init( sessionController: SessionControllerProtocol, shopRepository: ShopRepositoryProtocol, itemTagRepository: ItemTagRepositoryProtocol, tabViewModel: TabViewModel, - mainTab: MainTab + mainTab: MainTab, + messageBus: MessageBus ) { self.sessionController = sessionController self.shopRepository = shopRepository self.itemTagRepository = itemTagRepository self.tabViewModel = tabViewModel self.mainTab = mainTab + self.messageBus = messageBus } func reload() { diff --git a/NativeAppTemplate/UI/Shop List/ItemTag Detail/ItemTagDetailView.swift b/NativeAppTemplate/UI/Shop Settings/ItemTag Detail/ItemTagDetailView.swift similarity index 100% rename from NativeAppTemplate/UI/Shop List/ItemTag Detail/ItemTagDetailView.swift rename to NativeAppTemplate/UI/Shop Settings/ItemTag Detail/ItemTagDetailView.swift diff --git a/NativeAppTemplate/UI/Shop List/ItemTag Detail/ItemTagEditView.swift b/NativeAppTemplate/UI/Shop Settings/ItemTag Detail/ItemTagEditView.swift similarity index 100% rename from NativeAppTemplate/UI/Shop List/ItemTag Detail/ItemTagEditView.swift rename to NativeAppTemplate/UI/Shop Settings/ItemTag Detail/ItemTagEditView.swift diff --git a/NativeAppTemplate/UI/Shop List/ItemTag List/ItemTagCreateView.swift b/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagCreateView.swift similarity index 100% rename from NativeAppTemplate/UI/Shop List/ItemTag List/ItemTagCreateView.swift rename to NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagCreateView.swift diff --git a/NativeAppTemplate/UI/Shop List/ItemTag List/ItemTagListCardView.swift b/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagListCardView.swift similarity index 100% rename from NativeAppTemplate/UI/Shop List/ItemTag List/ItemTagListCardView.swift rename to NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagListCardView.swift diff --git a/NativeAppTemplate/UI/Shop List/ItemTag List/ItemTagListView.swift b/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagListView.swift similarity index 100% rename from NativeAppTemplate/UI/Shop List/ItemTag List/ItemTagListView.swift rename to NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagListView.swift diff --git a/NativeAppTemplate/UI/Shop Settings/NumberTagsWebpageListView.swift b/NativeAppTemplate/UI/Shop Settings/NumberTagsWebpageListView.swift index d27e520..a5572f5 100644 --- a/NativeAppTemplate/UI/Shop Settings/NumberTagsWebpageListView.swift +++ b/NativeAppTemplate/UI/Shop Settings/NumberTagsWebpageListView.swift @@ -22,13 +22,10 @@ enum NumberTagsWebpageListType: String, Identifiable, CaseIterable, Codable, Has } struct NumberTagsWebpageListView: View { - @Environment(MessageBus.self) private var messageBus - private var shop: Shop + @State private var viewModel: NumberTagsWebpageListViewModel - init( - shop: Shop - ) { - self.shop = shop + init(viewModel: NumberTagsWebpageListViewModel) { + self._viewModel = State(wrappedValue: viewModel) } } @@ -52,7 +49,7 @@ private extension NumberTagsWebpageListView { var numberTagsWebpageListView: some View { VStack { - Text(shop.name) + Text(viewModel.shop.name) .font(.uiTitle1) .foregroundStyle(.titleText) .padding(.top, 24) @@ -60,12 +57,12 @@ private extension NumberTagsWebpageListView { switch numberTagsWebpageListType { case .server: Section { - Link(numberTagsWebpageListType.displayString, destination: shop.displayShopServerUrl) + Link(numberTagsWebpageListType.displayString, destination: viewModel.shop.displayShopServerUrl) } header: { Label(String("Server"), systemImage: "storefront") } footer: { Button(String.copyWebpageUrl) { - copyWebpageUrl(shop.displayShopServerUrl.absoluteString) + viewModel.copyWebpageUrl(viewModel.shop.displayShopServerUrl.absoluteString) } } .listRowBackground(Color.cardBackground) @@ -74,9 +71,4 @@ private extension NumberTagsWebpageListView { } .navigationTitle(String.shopSettingsNumberTagsWebpageLabel) } - - func copyWebpageUrl(_ url: String) { - UIPasteboard.general.setValue(url, forPasteboardType: UTType.plainText.identifier) - messageBus.post(message: Message(level: .success, message: .webpageUrlCopied)) - } } diff --git a/NativeAppTemplate/UI/Shop Settings/NumberTagsWebpageListViewModel.swift b/NativeAppTemplate/UI/Shop Settings/NumberTagsWebpageListViewModel.swift new file mode 100644 index 0000000..baa67e5 --- /dev/null +++ b/NativeAppTemplate/UI/Shop Settings/NumberTagsWebpageListViewModel.swift @@ -0,0 +1,31 @@ +// +// NumberTagsWebpageListViewModel.swift +// NativeAppTemplate +// +// Created by Claude on 2025/06/22. +// + +import SwiftUI +import UniformTypeIdentifiers +import Observation + +@Observable +@MainActor +final class NumberTagsWebpageListViewModel { + let shop: Shop + + private let messageBus: MessageBus + + init( + shop: Shop, + messageBus: MessageBus + ) { + self.shop = shop + self.messageBus = messageBus + } + + func copyWebpageUrl(_ url: String) { + UIPasteboard.general.setValue(url, forPasteboardType: UTType.plainText.identifier) + messageBus.post(message: Message(level: .success, message: .webpageUrlCopied)) + } +} diff --git a/NativeAppTemplate/UI/Shop Settings/ShopBasicSettingsView.swift b/NativeAppTemplate/UI/Shop Settings/ShopBasicSettingsView.swift index 1e4190f..2a3ed0b 100644 --- a/NativeAppTemplate/UI/Shop Settings/ShopBasicSettingsView.swift +++ b/NativeAppTemplate/UI/Shop Settings/ShopBasicSettingsView.swift @@ -9,51 +9,21 @@ import SwiftUI struct ShopBasicSettingsView: View { @Environment(\.dismiss) private var dismiss - @Environment(MessageBus.self) private var messageBus - @Environment(\.sessionController) private var sessionController - private var shopRepository: ShopRepositoryProtocol - @State private var isFetching = true - @State private var isUpdating = false - @State private var name = "" - @State private var description = "" - @State private var selectedTimeZone = String.defaultTimeZone - private var shopId: String + @State private var viewModel: ShopBasicSettingsViewModel - private var shop: Binding { - Binding { - shopRepository.findBy(id: shopId) - } set: { _ in - } - } - - init( - shopRepository: ShopRepositoryProtocol, - shopId: String - ) { - self.shopRepository = shopRepository - self.shopId = shopId - } - - private var hasInvalidData: Bool { - if Utility.isBlank(name) { - return true - } - - let wrappedShop = shop.wrappedValue - - if wrappedShop.name == name && - wrappedShop.description == description && - wrappedShop.timeZone == selectedTimeZone { - return true - } - - return false + init(viewModel: ShopBasicSettingsViewModel) { + self._viewModel = State(wrappedValue: viewModel) } var body: some View { contentView .task { - reload() + viewModel.reload() + } + .onChange(of: viewModel.shouldDismiss) { _, shouldDismiss in + if shouldDismiss { + dismiss() + } } } } @@ -63,7 +33,7 @@ private extension ShopBasicSettingsView { var contentView: some View { @ViewBuilder var contentView: some View { - if isFetching || isUpdating { + if viewModel.isBusy { LoadingView() } else { shopBasicSettingsView @@ -76,24 +46,24 @@ private extension ShopBasicSettingsView { var shopBasicSettingsView: some View { Form { Section { - TextField(String.shopName, text: $name) + TextField(String.shopName, text: $viewModel.name) } header: { Text(String.shopName) } footer: { Text(String.shopNameIsRequired) .font(.uiFootnote) - .foregroundStyle(Utility.isBlank(name) ? .red : .clear) + .foregroundStyle(Utility.isBlank(viewModel.name) ? .red : .clear) } Section { - TextField(String.descriptionString, text: $description, axis: .vertical) + TextField(String.descriptionString, text: $viewModel.description, axis: .vertical) .lineLimit(10, reservesSpace: true) } header: { Text(String.descriptionString) } Section { - Picker(String.timeZone, selection: $selectedTimeZone) { + Picker(String.timeZone, selection: $viewModel.selectedTimeZone) { ForEach(timeZones.keys, id: \.self) { key in Text(timeZones[key]!).tag(key) } @@ -105,57 +75,12 @@ private extension ShopBasicSettingsView { .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button { - updateShop() + viewModel.updateShop() } label: { Text(String.save) } - .disabled(hasInvalidData) - } - } - } - - func reload() { - fetchShopDetail() - } - - private func fetchShopDetail() { - Task { @MainActor in - isFetching = true - - do { - _ = try await shopRepository.fetchDetail(id: shopId) - - name = shop.wrappedValue.name - description = shop.wrappedValue.description - selectedTimeZone = shop.wrappedValue.timeZone - - isFetching = false - } catch { - messageBus.post(message: Message(level: .error, message: error.localizedDescription, autoDismiss: false)) - dismiss() + .disabled(viewModel.hasInvalidData) } } } - - func updateShop() { - Task { @MainActor in - isUpdating = true - - do { - let shop = Shop( - id: shop.id, - name: name, - description: description, - timeZone: selectedTimeZone - ) - _ = try await shopRepository.update(id: shop.id, shop: shop) - messageBus.post(message: Message(level: .success, message: .basicSettingsUpdated)) - } catch { - messageBus.post(message: Message(level: .error, message: error.localizedDescription, autoDismiss: false)) - } - - isUpdating = false - dismiss() - } - } } diff --git a/NativeAppTemplate/UI/Shop Settings/ShopBasicSettingsViewModel.swift b/NativeAppTemplate/UI/Shop Settings/ShopBasicSettingsViewModel.swift new file mode 100644 index 0000000..22c1a1b --- /dev/null +++ b/NativeAppTemplate/UI/Shop Settings/ShopBasicSettingsViewModel.swift @@ -0,0 +1,105 @@ +// +// ShopBasicSettingsViewModel.swift +// NativeAppTemplate +// +// Created by Claude on 2025/06/22. +// + +import SwiftUI +import Observation + +@Observable +@MainActor +final class ShopBasicSettingsViewModel { + var isFetching = true + var isUpdating = false + var name = "" + var description = "" + var selectedTimeZone = String.defaultTimeZone + var shouldDismiss: Bool = false + private(set) var shop: Shop? + + private let sessionController: SessionControllerProtocol + private let shopRepository: ShopRepositoryProtocol + private(set) var messageBus: MessageBus + let shopId: String + + init( + sessionController: SessionControllerProtocol, + shopRepository: ShopRepositoryProtocol, + messageBus: MessageBus, + shopId: String + ) { + self.sessionController = sessionController + self.shopRepository = shopRepository + self.messageBus = messageBus + self.shopId = shopId + } + + var isBusy: Bool { + isFetching || isUpdating + } + + var hasInvalidData: Bool { + if Utility.isBlank(name) { + return true + } + + guard let shop = shop else { return true } + + if shop.name == name && + shop.description == description && + shop.timeZone == selectedTimeZone { + return true + } + + return false + } + + func reload() { + Task { @MainActor in + isFetching = true + + do { + shop = try await shopRepository.fetchDetail(id: shopId) + + guard let shop = shop else { + messageBus.post(message: Message(level: .error, message: "Shop not found", autoDismiss: false)) + shouldDismiss = true + return + } + + name = shop.name + description = shop.description + selectedTimeZone = shop.timeZone + + isFetching = false + } catch { + messageBus.post(message: Message(level: .error, message: error.localizedDescription, autoDismiss: false)) + shouldDismiss = true + } + } + } + + func updateShop() { + Task { @MainActor in + isUpdating = true + + do { + let shop = Shop( + id: shopId, + name: name, + description: description, + timeZone: selectedTimeZone + ) + _ = try await shopRepository.update(id: shop.id, shop: shop) + messageBus.post(message: Message(level: .success, message: .basicSettingsUpdated)) + } catch { + messageBus.post(message: Message(level: .error, message: error.localizedDescription, autoDismiss: false)) + } + + isUpdating = false + shouldDismiss = true + } + } +} diff --git a/NativeAppTemplate/UI/Shop Settings/ShopSettingsView.swift b/NativeAppTemplate/UI/Shop Settings/ShopSettingsView.swift index d402c80..2cead88 100644 --- a/NativeAppTemplate/UI/Shop Settings/ShopSettingsView.swift +++ b/NativeAppTemplate/UI/Shop Settings/ShopSettingsView.swift @@ -9,32 +9,10 @@ import SwiftUI struct ShopSettingsView: View { @Environment(\.dismiss) private var dismiss - @Environment(MessageBus.self) private var messageBus - @Environment(\.sessionController) private var sessionController - @State private var isFetching = true - @State private var isResetting = false - @State private var isDeleting = false - @State private var isShowingResetConfirmationDialog = false - @State private var isShowingDeleteConfirmationDialog = false - private let shopRepository: ShopRepositoryProtocol - private let itemTagRepository: ItemTagRepositoryProtocol - private var shopId: String + @State private var viewModel: ShopSettingsViewModel - private var shop: Binding { - Binding { - shopRepository.findBy(id: shopId) - } set: { _ in - } - } - - init( - shopRepository: ShopRepositoryProtocol, - itemTagRepository: ItemTagRepositoryProtocol, - shopId: String - ) { - self.shopRepository = shopRepository - self.itemTagRepository = itemTagRepository - self.shopId = shopId + init(viewModel: ShopSettingsViewModel) { + self._viewModel = State(wrappedValue: viewModel) } } @@ -43,7 +21,12 @@ extension ShopSettingsView { var body: some View { contentView .task { - reload() + viewModel.reload() + } + .onChange(of: viewModel.shouldDismiss) { _, shouldDismiss in + if shouldDismiss { + dismiss() + } } } } @@ -53,7 +36,7 @@ private extension ShopSettingsView { var contentView: some View { @ViewBuilder var contentView: some View { - if isFetching || isResetting || isDeleting { + if viewModel.isBusy { LoadingView() } else { shopSettingsView @@ -65,15 +48,19 @@ private extension ShopSettingsView { var shopSettingsView: some View { VStack { - Text(shop.wrappedValue.name) - .font(.uiTitle1) - .foregroundStyle(.titleText) - .padding(.top, 24) + if let shop = viewModel.shop { + Text(shop.name) + .font(.uiTitle1) + .foregroundStyle(.titleText) + .padding(.top, 24) + } List { Section { NavigationLink { - ShopBasicSettingsView(shopRepository: shopRepository, shopId: shop.id) + ShopBasicSettingsView( + viewModel: viewModel.createShopBasicSettingsViewModel() + ) } label: { Label(String.shopSettingsBasicSettingsLabel, systemImage: "storefront") } @@ -81,30 +68,36 @@ private extension ShopSettingsView { } Section { - NavigationLink { - ItemTagListView( - itemTagRepository: itemTagRepository, - shop: shop.wrappedValue - ) - } label: { - Label(String.shopSettingsManageNumberTagsLabel, systemImage: "rectangle.stack") + if let shop = viewModel.shop { + NavigationLink { + ItemTagListView( + itemTagRepository: viewModel.itemTagRepository, + shop: shop + ) + } label: { + Label(String.shopSettingsManageNumberTagsLabel, systemImage: "rectangle.stack") + } + .listRowBackground(Color.cardBackground) } - .listRowBackground(Color.cardBackground) } Section { - NavigationLink { - NumberTagsWebpageListView(shop: shop.wrappedValue) - } label: { - Label(String.shopSettingsNumberTagsWebpageLabel, systemImage: "globe") + if viewModel.shop != nil { + NavigationLink { + NumberTagsWebpageListView( + viewModel: viewModel.createNumberTagsWebpageListViewModel() + ) + } label: { + Label(String.shopSettingsNumberTagsWebpageLabel, systemImage: "globe") + } + .listRowBackground(Color.cardBackground) } } - .listRowBackground(Color.cardBackground) Section { VStack(spacing: 8) { MainButtonView(title: String.resetNumberTags, type: .destructive(withArrow: false)) { - isShowingResetConfirmationDialog = true + viewModel.isShowingResetConfirmationDialog = true } .listRowBackground(Color.clear) Text(String.resetNumberTagsDescription) @@ -115,7 +108,7 @@ private extension ShopSettingsView { .listRowBackground(Color.clear) MainButtonView(title: String.deleteShop, type: .destructive(withArrow: false)) { - isShowingDeleteConfirmationDialog = true + viewModel.isShowingDeleteConfirmationDialog = true } .listRowBackground(Color.clear) } @@ -124,84 +117,35 @@ private extension ShopSettingsView { .padding(.top) } .refreshable { - reload() + viewModel.reload() } } .navigationTitle(String.shopSettingsLabel) .confirmationDialog( String.resetNumberTags, - isPresented: $isShowingResetConfirmationDialog + isPresented: $viewModel.isShowingResetConfirmationDialog ) { Button(String.resetNumberTags, role: .destructive) { - resetShop() + viewModel.resetShop() } Button(String.cancel, role: .cancel) { - isShowingResetConfirmationDialog = false + viewModel.isShowingResetConfirmationDialog = false } } message: { Text(String.areYouSure) } .confirmationDialog( String.deleteShop, - isPresented: $isShowingDeleteConfirmationDialog + isPresented: $viewModel.isShowingDeleteConfirmationDialog ) { Button(String.deleteShop, role: .destructive) { - destroyShop() + viewModel.destroyShop() } Button(String.cancel, role: .cancel) { - isShowingDeleteConfirmationDialog = false + viewModel.isShowingDeleteConfirmationDialog = false } } message: { Text(String.areYouSure) } } - - func reload() { - fetchShopDetail() - } - - private func fetchShopDetail() { - Task { @MainActor in - isFetching = true - - do { - _ = try await shopRepository.fetchDetail(id: shopId) - isFetching = false - } catch { - messageBus.post(message: Message(level: .error, message: error.localizedDescription, autoDismiss: false)) - isFetching = false - dismiss() - } - } - } - - private func resetShop () { - Task { @MainActor in - isResetting = true - - do { - try await shopRepository.reset(id: shop.id) - messageBus.post(message: Message(level: .success, message: .shopReset)) - } catch { - messageBus.post(message: Message(level: .error, message: "\(String.shopResetError) \(error.localizedDescription)", autoDismiss: false)) - } - - dismiss() - } - } - - private func destroyShop () { - Task { @MainActor in - isDeleting = true - - do { - try await shopRepository.destroy(id: shop.id) - messageBus.post(message: Message(level: .success, message: .shopDeleted)) - sessionController.shouldPopToRootView = true - } catch { - messageBus.post(message: Message(level: .error, message: "\(String.shopDeletedError) \(error.localizedDescription)", autoDismiss: false)) - try await sessionController.logout() - } - } - } } diff --git a/NativeAppTemplate/UI/Shop Settings/ShopSettingsViewModel.swift b/NativeAppTemplate/UI/Shop Settings/ShopSettingsViewModel.swift new file mode 100644 index 0000000..f57c1a0 --- /dev/null +++ b/NativeAppTemplate/UI/Shop Settings/ShopSettingsViewModel.swift @@ -0,0 +1,108 @@ +// +// ShopSettingsViewModel.swift +// NativeAppTemplate +// +// Created by Claude on 2025/06/22. +// + +import SwiftUI +import Observation + +@Observable +@MainActor +final class ShopSettingsViewModel { + var isFetching = true + var isResetting = false + var isDeleting = false + var isShowingResetConfirmationDialog = false + var isShowingDeleteConfirmationDialog = false + var shouldDismiss: Bool = false + private(set) var shop: Shop? + + private let sessionController: SessionControllerProtocol + private let shopRepository: ShopRepositoryProtocol + let itemTagRepository: ItemTagRepositoryProtocol + private(set) var messageBus: MessageBus + let shopId: String + + init( + sessionController: SessionControllerProtocol, + shopRepository: ShopRepositoryProtocol, + itemTagRepository: ItemTagRepositoryProtocol, + messageBus: MessageBus, + shopId: String + ) { + self.sessionController = sessionController + self.shopRepository = shopRepository + self.itemTagRepository = itemTagRepository + self.messageBus = messageBus + self.shopId = shopId + } + + var isBusy: Bool { + isFetching || isResetting || isDeleting + } + + func createShopBasicSettingsViewModel() -> ShopBasicSettingsViewModel { + ShopBasicSettingsViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + messageBus: messageBus, + shopId: shopId + ) + } + + func createNumberTagsWebpageListViewModel() -> NumberTagsWebpageListViewModel { + guard let shop = shop else { + fatalError("Shop must be loaded before creating NumberTagsWebpageListViewModel") + } + return NumberTagsWebpageListViewModel( + shop: shop, + messageBus: messageBus + ) + } + + func reload() { + Task { + isFetching = true + do { + shop = try await shopRepository.fetchDetail(id: shopId) + } catch { + messageBus.post(message: .init(level: .error, message: error.localizedDescription, autoDismiss: false)) + shouldDismiss = true + } + isFetching = false + } + } + + func resetShop() { + guard let shop else { return } + + Task { + isResetting = true + do { + try await shopRepository.reset(id: shop.id) + messageBus.post(message: .init(level: .success, message: .shopReset)) + } catch { + messageBus.post(message: .init(level: .error, message: "\(String.shopResetError) \(error.localizedDescription)", autoDismiss: false)) + } + shouldDismiss = true + } + } + + func destroyShop() { + guard let shop else { return } + + Task { + isDeleting = true + do { + try await shopRepository.destroy(id: shop.id) + messageBus.post(message: .init(level: .success, message: .shopDeleted)) + sessionController.shouldPopToRootView = true + } catch { + messageBus.post(message: .init(level: .error, message: "\(String.shopDeletedError) \(error.localizedDescription)", autoDismiss: false)) + try await sessionController.logout() + } + } + } +} diff --git a/NativeAppTemplateTests/UI/Shop List/ShopCreateViewModelTest.swift b/NativeAppTemplateTests/UI/Shop List/ShopCreateViewModelTest.swift new file mode 100644 index 0000000..66a47ab --- /dev/null +++ b/NativeAppTemplateTests/UI/Shop List/ShopCreateViewModelTest.swift @@ -0,0 +1,164 @@ +// +// ShopCreateViewModelTest.swift +// NativeAppTemplate +// +// Created by Claude on 2025/06/22. +// + +import Testing +import Foundation +@testable import NativeAppTemplate + +@MainActor +@Suite +struct ShopCreateViewModelTest { + let sessionController = TestSessionController() + let shopRepository = TestShopRepository( + shopsService: ShopsService() + ) + let messageBus = MessageBus() + + @Test + func stateIsInitiallyNotLoading() { + let viewModel = ShopCreateViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + messageBus: messageBus + ) + + #expect(viewModel.isCreating == false) + } + + @Test("Has invalid data", arguments: ["", "Shop Name 1"]) + func hasInvalidData(name: String) { + let viewModel = ShopCreateViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + messageBus: messageBus + ) + + viewModel.name = name + #expect(viewModel.hasInvalidData == (name == "" ? true : false)) + } + + @Test + func createShop() async { + let viewModel = ShopCreateViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + messageBus: messageBus + ) + + let createdShopsCount = shopRepository.createdShopsCount + + let newName = "New Shop Name" + let newTimeZone = "Osaka" + let newDescription = "New Shop Description" + + viewModel.name = newName + viewModel.selectedTimeZone = newTimeZone + viewModel.description = newDescription + + // https://stackoverflow.com/a/75618551/1160200 + let createShopTask = Task { + viewModel.createShop() + } + await createShopTask.value + + let latestShop = shopRepository.shops.last! + + let message = String.shopCreated + + #expect(viewModel.messageBus.currentMessage!.message == message) + #expect(viewModel.isCreating) + #expect(latestShop.name == newName) + #expect(latestShop.timeZone == newTimeZone) + #expect(latestShop.description == newDescription) + #expect(shopRepository.shops.count == createdShopsCount + 1) + #expect(viewModel.shouldDismiss) + } + + @Test + func createShopFailed() async { + let viewModel = ShopCreateViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + messageBus: messageBus + ) + + let createdShopsCount = shopRepository.createdShopsCount + + let newName = "New Shop Name" + let newTimeZone = "Osaka" + let newDescription = "New Shop Description" + + viewModel.name = newName + viewModel.selectedTimeZone = newTimeZone + viewModel.description = newDescription + + let message = "You can create up to 99 shops across all organizations." + let httpResponseCode = 422 + + shopRepository.error = NativeAppTemplateAPIError.requestFailed(nil, 422, message) + + // https://stackoverflow.com/a/75618551/1160200 + let createShopTask = Task { + viewModel.createShop() + } + await createShopTask.value + + #expect(viewModel.messageBus.currentMessage!.message == "\(message) [Status: \(httpResponseCode)]") + #expect(viewModel.isCreating) + #expect(shopRepository.shops.count == createdShopsCount) + #expect(viewModel.shouldDismiss) + } + + @Test + func createShopFailedNot422() async { + let viewModel = ShopCreateViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + messageBus: messageBus + ) + + let createdShopsCount = shopRepository.createdShopsCount + + let newName = "New Shop Name" + let newTimeZone = "Osaka" + let newDescription = "New Shop Description" + + viewModel.name = newName + viewModel.selectedTimeZone = newTimeZone + viewModel.description = newDescription + + let message = "Internal server error." + let httpResponseCode = 500 + + shopRepository.error = NativeAppTemplateAPIError.requestFailed(nil, httpResponseCode, message) + + // https://stackoverflow.com/a/75618551/1160200 + let createShopTask = Task { + viewModel.createShop() + } + await createShopTask.value + + #expect(viewModel.messageBus.currentMessage!.message == "\(message) [Status: \(httpResponseCode)]") + #expect(viewModel.isCreating) + #expect(shopRepository.shops.count == createdShopsCount) + #expect(viewModel.shouldDismiss == false) + } + + @Test + func initialValues() { + let viewModel = ShopCreateViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + messageBus: messageBus + ) + + #expect(viewModel.name == "") + #expect(viewModel.description == "") + #expect(viewModel.selectedTimeZone == Utility.currentTimeZone()) + #expect(viewModel.shouldDismiss == false) + } +} diff --git a/NativeAppTemplateTests/UI/Shop List/ShopListViewModelTest.swift b/NativeAppTemplateTests/UI/Shop List/ShopListViewModelTest.swift index 93d1a5d..c336b37 100644 --- a/NativeAppTemplateTests/UI/Shop List/ShopListViewModelTest.swift +++ b/NativeAppTemplateTests/UI/Shop List/ShopListViewModelTest.swift @@ -31,6 +31,7 @@ struct ShopListViewModelTest { ) let tabViewModel = TabViewModel() let mainTab = MainTab.shops + let messageBus = MessageBus() @Test func leftInShopSlots() { @@ -42,7 +43,8 @@ struct ShopListViewModelTest { shopRepository: shopRepository, itemTagRepository: itemTagRepository, tabViewModel: tabViewModel, - mainTab: mainTab + mainTab: mainTab, + messageBus: messageBus ) #expect(viewModel.leftInShopSlots == 3) @@ -59,7 +61,8 @@ struct ShopListViewModelTest { shopRepository: shopRepository, itemTagRepository: itemTagRepository, tabViewModel: tabViewModel, - mainTab: mainTab + mainTab: mainTab, + messageBus: messageBus ) viewModel.reload() @@ -76,7 +79,8 @@ struct ShopListViewModelTest { shopRepository: shopRepository, itemTagRepository: itemTagRepository, tabViewModel: tabViewModel, - mainTab: mainTab + mainTab: mainTab, + messageBus: messageBus ) viewModel.reload() @@ -94,7 +98,8 @@ struct ShopListViewModelTest { shopRepository: shopRepository, itemTagRepository: itemTagRepository, tabViewModel: tabViewModel, - mainTab: mainTab + mainTab: mainTab, + messageBus: messageBus ) viewModel.reload() @@ -109,7 +114,8 @@ struct ShopListViewModelTest { shopRepository: shopRepository, itemTagRepository: itemTagRepository, tabViewModel: tabViewModel, - mainTab: mainTab + mainTab: mainTab, + messageBus: messageBus ) #expect(viewModel.isShowingCreateSheet == false) @@ -132,7 +138,8 @@ struct ShopListViewModelTest { shopRepository: shopRepository, itemTagRepository: itemTagRepository, tabViewModel: tabViewModel, - mainTab: mainTab + mainTab: mainTab, + messageBus: messageBus ) viewModel.setTabViewModelShowingDetailViewToFalse() @@ -147,7 +154,8 @@ struct ShopListViewModelTest { shopRepository: shopRepository, itemTagRepository: itemTagRepository, tabViewModel: tabViewModel, - mainTab: mainTab + mainTab: mainTab, + messageBus: messageBus ) let scrollToTopID = viewModel.scrollToTopID() @@ -166,7 +174,8 @@ struct ShopListViewModelTest { shopRepository: shopRepository, itemTagRepository: itemTagRepository, tabViewModel: tabViewModel, - mainTab: mainTab + mainTab: mainTab, + messageBus: messageBus ) viewModel.reload() @@ -187,7 +196,8 @@ struct ShopListViewModelTest { shopRepository: shopRepository, itemTagRepository: itemTagRepository, tabViewModel: tabViewModel, - mainTab: mainTab + mainTab: mainTab, + messageBus: messageBus ) viewModel.reload() diff --git a/NativeAppTemplateTests/UI/Shop Settings/NumberTagsWebpageListViewModelTest.swift b/NativeAppTemplateTests/UI/Shop Settings/NumberTagsWebpageListViewModelTest.swift new file mode 100644 index 0000000..62f43af --- /dev/null +++ b/NativeAppTemplateTests/UI/Shop Settings/NumberTagsWebpageListViewModelTest.swift @@ -0,0 +1,127 @@ +// +// NumberTagsWebpageListViewModelTest.swift +// NativeAppTemplate +// +// Created by Claude on 2025/06/22. +// + +import Testing +import Foundation +@testable import NativeAppTemplate + +@MainActor +@Suite +struct NumberTagsWebpageListViewModelTest { + let messageBus = MessageBus() + + var shop: Shop { mockShop(id: "test-shop-id", name: "Shop 1") } + + @Test + func initializesCorrectly() { + let viewModel = NumberTagsWebpageListViewModel( + shop: shop, + messageBus: messageBus + ) + + #expect(viewModel.shop.id == shop.id) + #expect(viewModel.shop.name == shop.name) + #expect(viewModel.shop.displayShopServerPath == shop.displayShopServerPath) + } + + @Test + func shopPropertyIsAccessible() { + let viewModel = NumberTagsWebpageListViewModel( + shop: shop, + messageBus: messageBus + ) + + #expect(viewModel.shop == shop) + #expect(viewModel.shop.displayShopServerUrl.absoluteString.contains("test-shop-id")) + } + + @Test + func copyWebpageUrlPostsSuccessMessage() { + let viewModel = NumberTagsWebpageListViewModel( + shop: shop, + messageBus: messageBus + ) + + let testUrl = "https://example.com/test-url" + + viewModel.copyWebpageUrl(testUrl) + + #expect(messageBus.currentMessage != nil) + #expect(messageBus.currentMessage!.level == .success) + #expect(messageBus.currentMessage!.message == .webpageUrlCopied) + } + + @Test("Copy webpage URL with different URLs", arguments: [ + "https://api.nativeapptemplate.com/display/shops/123?type=server", + "https://example.com/test", + "http://localhost:3000/path", + "https://shop.example.com/page?param=value" + ]) + func copyWebpageUrlWithDifferentUrls(url: String) { + let localMessageBus = MessageBus() + let viewModel = NumberTagsWebpageListViewModel( + shop: shop, + messageBus: localMessageBus + ) + + viewModel.copyWebpageUrl(url) + + #expect(localMessageBus.currentMessage != nil) + #expect(localMessageBus.currentMessage!.level == .success) + #expect(localMessageBus.currentMessage!.message == .webpageUrlCopied) + } + + @Test + func copyWebpageUrlWithEmptyString() { + let viewModel = NumberTagsWebpageListViewModel( + shop: shop, + messageBus: messageBus + ) + + viewModel.copyWebpageUrl("") + + #expect(messageBus.currentMessage != nil) + #expect(messageBus.currentMessage!.level == .success) + #expect(messageBus.currentMessage!.message == .webpageUrlCopied) + } + + @Test + func multipleMessagesClearPrevious() { + let viewModel = NumberTagsWebpageListViewModel( + shop: shop, + messageBus: messageBus + ) + + // First copy + viewModel.copyWebpageUrl("https://first.com") + let firstMessage = messageBus.currentMessage + + // Second copy + viewModel.copyWebpageUrl("https://second.com") + let secondMessage = messageBus.currentMessage + + #expect(firstMessage != nil) + #expect(secondMessage != nil) + #expect(firstMessage!.message == .webpageUrlCopied) + #expect(secondMessage!.message == .webpageUrlCopied) + #expect(firstMessage!.level == .success) + #expect(secondMessage!.level == .success) + } + + private func mockShop(id: String = UUID().uuidString, name: String = "Mock Shop") -> Shop { + Shop( + id: id, + name: name, + description: "This is a mock shop for testing", + timeZone: "Tokyo", + itemTagsCount: 10, + scannedItemTagsCount: 5, + completedItemTagsCount: 3, + displayShopServerPath: "https://api.nativeapptemplate.com/display/shops/\(id)?type=server" + ) + } +} diff --git a/NativeAppTemplateTests/UI/Shop Settings/ShopBasicSettingsViewModelTest.swift b/NativeAppTemplateTests/UI/Shop Settings/ShopBasicSettingsViewModelTest.swift new file mode 100644 index 0000000..8d7c7db --- /dev/null +++ b/NativeAppTemplateTests/UI/Shop Settings/ShopBasicSettingsViewModelTest.swift @@ -0,0 +1,237 @@ +// +// ShopBasicSettingsViewModelTest.swift +// NativeAppTemplate +// +// Created by Claude on 2025/06/22. +// + +import Testing +import Foundation +@testable import NativeAppTemplate + +@MainActor +@Suite +struct ShopBasicSettingsViewModelTest { + var shops: [Shop] { + [ + mockShop(id: "1", name: "Shop 1"), + mockShop(id: "2", name: "Shop 2"), + mockShop(id: "3", name: "Shop 3"), + mockShop(id: "4", name: "Shop 4"), + mockShop(id: "5", name: "Shop 5") + ] + } + + let sessionController = TestSessionController() + let shopRepository = TestShopRepository( + shopsService: ShopsService() + ) + let messageBus = MessageBus() + let shopId = "1" + + let tabViewModel = TabViewModel() + let mainTab = MainTab.shops + + @Test + func stateIsInitiallyLoading() { + let viewModel = ShopBasicSettingsViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + messageBus: messageBus, + shopId: shopId + ) + + #expect(viewModel.isFetching) + #expect(viewModel.isBusy) + } + + @Test("Has invalid data", arguments: ["", "Shop Name 1"]) + func hasInvalidData(name: String) async { + shopRepository.setShops(shops: shops) + + let viewModel = ShopBasicSettingsViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + messageBus: messageBus, + shopId: shopId + ) + + // https://stackoverflow.com/a/75618551/1160200 + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + viewModel.name = name + #expect(viewModel.hasInvalidData == (name == "" ? true : false)) + } + + @Test("Has invalid data when inputting all same data", arguments: ["Shop 1", "New Shop 1"]) + func hasInvalidDataWhenInputtingAllSameData(name: String) async { + shopRepository.setShops(shops: shops) + + let viewModel = ShopBasicSettingsViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + messageBus: messageBus, + shopId: shopId + ) + + // https://stackoverflow.com/a/75618551/1160200 + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + viewModel.name = name + #expect(viewModel.hasInvalidData == (name == "Shop 1" ? true : false)) + } + + @Test + func reload() async { + shopRepository.setShops(shops: shops) + + let viewModel = ShopBasicSettingsViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + messageBus: messageBus, + shopId: shopId + ) + + // https://stackoverflow.com/a/75618551/1160200 + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + let shop = shops.first { $0.id == shopId }! + #expect(viewModel.name == shop.name) + #expect(viewModel.isFetching == false) + #expect(viewModel.isBusy == false) + } + + @Test + func reloadFailed() async { + shopRepository.setShops(shops: shops) + let message = "Internal server error." + let httpResponseCode = 500 + + shopRepository.error = NativeAppTemplateAPIError.requestFailed(nil, httpResponseCode, message) + + let viewModel = ShopBasicSettingsViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + messageBus: messageBus, + shopId: shopId + ) + + // https://stackoverflow.com/a/75618551/1160200 + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + #expect(viewModel.messageBus.currentMessage!.message == "\(message) [Status: \(httpResponseCode)]") + #expect(viewModel.shouldDismiss) + } + + @Test + func updateShop() async { + shopRepository.setShops(shops: shops) + + let viewModel = ShopBasicSettingsViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + messageBus: messageBus, + shopId: shopId + ) + + // https://stackoverflow.com/a/75618551/1160200 + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + let newName = "New Shop Name" + let newTimeZone = "Osaka" + let newDescription = "New Shop Name" + + viewModel.name = newName + viewModel.selectedTimeZone = newTimeZone + viewModel.description = newDescription + + // https://stackoverflow.com/a/75618551/1160200 + let updateShopTask = Task { + viewModel.updateShop() + } + await updateShopTask.value + + let latestShop = shopRepository.shops.first { $0.id == shopId }! + + #expect(latestShop.name == newName) + #expect(latestShop.timeZone == newTimeZone) + #expect(latestShop.description == newDescription) + + let message = String.basicSettingsUpdated + + #expect(viewModel.messageBus.currentMessage!.message == message) + #expect(viewModel.isUpdating == false) + #expect(viewModel.isBusy == false) + #expect(viewModel.shouldDismiss) + } + + @Test + func updateShopFailed() async { + shopRepository.setShops(shops: shops) + + let viewModel = ShopBasicSettingsViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + messageBus: messageBus, + shopId: shopId + ) + + // https://stackoverflow.com/a/75618551/1160200 + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + let newName = "New Shop Name" + let newTimeZone = "Osaka" + let newDescription = "New Shop Name" + + viewModel.name = newName + viewModel.selectedTimeZone = newTimeZone + viewModel.description = newDescription + + let message = "Internal server error." + let httpResponseCode = 500 + + shopRepository.error = NativeAppTemplateAPIError.requestFailed(nil, httpResponseCode, message) + + // https://stackoverflow.com/a/75618551/1160200 + let updateShopTask = Task { + viewModel.updateShop() + } + await updateShopTask.value + + #expect(viewModel.messageBus.currentMessage!.message == "\(message) [Status: \(httpResponseCode)]") + #expect(viewModel.isUpdating == false) + #expect(viewModel.isBusy == false) + #expect(viewModel.shouldDismiss) + } + + private func mockShop(id: String = UUID().uuidString, name: String = "Mock Shop") -> Shop { + Shop( + id: id, + name: name, + description: "This is a mock shop for testing", + timeZone: "Tokyo", + itemTagsCount: 10, + scannedItemTagsCount: 5, + completedItemTagsCount: 3, + displayShopServerPath: "https://api.nativeapptemplate.com/display/shops/\(id)?type=server" + ) + } +} diff --git a/NativeAppTemplateTests/UI/Shop Settings/ShopSettingsViewModelTest.swift b/NativeAppTemplateTests/UI/Shop Settings/ShopSettingsViewModelTest.swift new file mode 100644 index 0000000..2af56de --- /dev/null +++ b/NativeAppTemplateTests/UI/Shop Settings/ShopSettingsViewModelTest.swift @@ -0,0 +1,264 @@ +// +// ShopSettingsViewModelTest.swift +// NativeAppTemplate +// +// Created by Claude on 2025/06/22. +// + +import Testing +import Foundation +@testable import NativeAppTemplate + +@MainActor +@Suite +struct ShopSettingsViewModelTest { + var shops: [Shop] { + [ + mockShop(id: "1", name: "Shop 1"), + mockShop(id: "2", name: "Shop 2"), + mockShop(id: "3", name: "Shop 3"), + mockShop(id: "4", name: "Shop 4"), + mockShop(id: "5", name: "Shop 5") + ] + } + + let sessionController = TestSessionController() + let shopRepository = TestShopRepository( + shopsService: ShopsService() + ) + let itemTagRepository = TestItemTagRepository( + itemTagsService: ItemTagsService() + ) + let messageBus = MessageBus() + let shopId = "1" + + @Test + func stateIsInitiallyLoading() { + let viewModel = ShopSettingsViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + itemTagRepository: itemTagRepository, + messageBus: messageBus, + shopId: shopId + ) + + #expect(viewModel.isFetching) + #expect(viewModel.isBusy) + } + + @Test + func reload() async { + shopRepository.setShops(shops: shops) + + let viewModel = ShopSettingsViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + itemTagRepository: itemTagRepository, + messageBus: messageBus, + shopId: shopId + ) + + #expect(viewModel.isFetching == true) + #expect(viewModel.isBusy) + + // https://stackoverflow.com/a/75618551/1160200 + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + let shop = shops.first { $0.id == shopId }! + #expect(viewModel.shop == shop) + #expect(viewModel.isFetching == false) + #expect(viewModel.isBusy == false) + } + + @Test + func reloadFailed() async { + shopRepository.setShops(shops: shops) + + let message = "Internal server error." + let httpResponseCode = 500 + shopRepository.error = NativeAppTemplateAPIError.requestFailed(nil, httpResponseCode, message) + + let viewModel = ShopSettingsViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + itemTagRepository: itemTagRepository, + messageBus: messageBus, + shopId: shopId + ) + + #expect(viewModel.isFetching == true) + #expect(viewModel.isBusy) + + // https://stackoverflow.com/a/75618551/1160200 + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + #expect(viewModel.messageBus.currentMessage!.message == "\(message) [Status: \(httpResponseCode)]") + #expect(viewModel.shouldDismiss) + #expect(viewModel.isFetching == false) + #expect(viewModel.isBusy == false) + } + + @Test + func resetShop() async { + shopRepository.setShops(shops: shops) + + let viewModel = ShopSettingsViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + itemTagRepository: itemTagRepository, + messageBus: messageBus, + shopId: shopId + ) + + // https://stackoverflow.com/a/75618551/1160200 + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + let shop = shops.first { $0.id == shopId }! + #expect(viewModel.shop == shop) + + // https://stackoverflow.com/a/75618551/1160200 + let resetShopTask = Task { + viewModel.resetShop() + } + await resetShopTask.value + + let message = String.shopReset + + #expect(viewModel.messageBus.currentMessage!.message == message) + #expect(viewModel.isResetting) + #expect(viewModel.isBusy) + #expect(viewModel.shouldDismiss) + } + + @Test + func resetShopFailed() async { + shopRepository.setShops(shops: shops) + + let viewModel = ShopSettingsViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + itemTagRepository: itemTagRepository, + messageBus: messageBus, + shopId: shopId + ) + + // https://stackoverflow.com/a/75618551/1160200 + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + let shop = shops.first { $0.id == shopId }! + #expect(viewModel.shop == shop) + + let message = "Internal server error." + let httpResponseCode = 500 + shopRepository.error = NativeAppTemplateAPIError.requestFailed(nil, httpResponseCode, message) + + // https://stackoverflow.com/a/75618551/1160200 + let resetShopTask = Task { + viewModel.resetShop() + } + await resetShopTask.value + + #expect(viewModel.messageBus.currentMessage!.message == + "\(String.shopResetError) \(message) [Status: \(httpResponseCode)]") + #expect(viewModel.isResetting) + #expect(viewModel.isBusy) + #expect(viewModel.shouldDismiss) + } + + @Test + func destroyShop() async { + shopRepository.setShops(shops: shops) + + let viewModel = ShopSettingsViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + itemTagRepository: itemTagRepository, + messageBus: messageBus, + shopId: shopId + ) + + // https://stackoverflow.com/a/75618551/1160200 + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + let shop = shops.first { $0.id == shopId }! + #expect(viewModel.shop == shop) + + #expect(sessionController.shouldPopToRootView == false) + + // https://stackoverflow.com/a/75618551/1160200 + let destroyShopTask = Task { + viewModel.destroyShop() + } + await destroyShopTask.value + + #expect(viewModel.isDeleting) + #expect(viewModel.isBusy) + #expect(sessionController.shouldPopToRootView) + } + + @Test + func destroyShopFailed() async { + shopRepository.setShops(shops: shops) + + let viewModel = ShopSettingsViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + itemTagRepository: itemTagRepository, + messageBus: messageBus, + shopId: shopId + ) + + // https://stackoverflow.com/a/75618551/1160200 + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + let shop = shops.first { $0.id == shopId }! + #expect(viewModel.shop == shop) + + let message = "Internal server error." + let httpResponseCode = 500 + shopRepository.error = NativeAppTemplateAPIError.requestFailed(nil, httpResponseCode, message) + + // https://stackoverflow.com/a/75618551/1160200 + let destroyShopTask = Task { + viewModel.destroyShop() + } + await destroyShopTask.value + + #expect(viewModel.messageBus.currentMessage!.message == + "\(String.shopDeletedError) \(message) [Status: \(httpResponseCode)]") + #expect(viewModel.isDeleting) + #expect(viewModel.isBusy) + #expect(sessionController.userState == .notLoggedIn) + } + + private func mockShop(id: String = UUID().uuidString, name: String = "Mock Shop") -> Shop { + Shop( + id: id, + name: name, + description: "This is a mock shop for testing", + timeZone: "Tokyo", + itemTagsCount: 10, + scannedItemTagsCount: 5, + completedItemTagsCount: 3, + displayShopServerPath: "https://api.nativeapptemplate.com/display/shops/\(id)?type=server" + ) + } +}