diff --git a/NativeAppTemplate.xcodeproj/project.pbxproj b/NativeAppTemplate.xcodeproj/project.pbxproj index 0976e6b..e1dbf8e 100644 --- a/NativeAppTemplate.xcodeproj/project.pbxproj +++ b/NativeAppTemplate.xcodeproj/project.pbxproj @@ -140,6 +140,10 @@ 01D85AEB2E07CF3600A95798 /* ItemTagEditViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D85AEA2E07CF3600A95798 /* ItemTagEditViewModel.swift */; }; 01D85AEF2E07D20500A95798 /* ItemTagCreateViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D85AEE2E07D20500A95798 /* ItemTagCreateViewModel.swift */; }; 01D85AF32E07D37E00A95798 /* ItemTagListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D85AF22E07D37E00A95798 /* ItemTagListViewModel.swift */; }; + 01D85B442E07ED8700A95798 /* ShopDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D85B432E07ED8700A95798 /* ShopDetailViewModel.swift */; }; + 01D85B462E07F15400A95798 /* PasswordEditViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D85B452E07F15400A95798 /* PasswordEditViewModel.swift */; }; + 01D85B482E07F16100A95798 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D85B472E07F16100A95798 /* SettingsViewModel.swift */; }; + 01D85B4A2E07F16900A95798 /* ShopkeeperEditViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D85B492E07F16900A95798 /* ShopkeeperEditViewModel.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 */; }; @@ -304,6 +308,10 @@ 01D85AEA2E07CF3600A95798 /* ItemTagEditViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTagEditViewModel.swift; sourceTree = ""; }; 01D85AEE2E07D20500A95798 /* ItemTagCreateViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTagCreateViewModel.swift; sourceTree = ""; }; 01D85AF22E07D37E00A95798 /* ItemTagListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTagListViewModel.swift; sourceTree = ""; }; + 01D85B432E07ED8700A95798 /* ShopDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopDetailViewModel.swift; sourceTree = ""; }; + 01D85B452E07F15400A95798 /* PasswordEditViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordEditViewModel.swift; sourceTree = ""; }; + 01D85B472E07F16100A95798 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; + 01D85B492E07F16900A95798 /* ShopkeeperEditViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopkeeperEditViewModel.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 = ""; }; @@ -356,6 +364,7 @@ children = ( 0172787E2D7D933000CE424F /* ShopDetailCardView.swift */, 010F86AD2621A2A900B6C62A /* ShopDetailView.swift */, + 01D85B432E07ED8700A95798 /* ShopDetailViewModel.swift */, ); path = "Shop Detail"; sourceTree = ""; @@ -787,8 +796,11 @@ isa = PBXGroup; children = ( 0106414329AA061100B46FED /* PasswordEditView.swift */, + 01D85B452E07F15400A95798 /* PasswordEditViewModel.swift */, 01E0A59125BD087E00298D35 /* SettingsView.swift */, + 01D85B472E07F16100A95798 /* SettingsViewModel.swift */, 01EE363D29A6DCEB009BCD9D /* ShopkeeperEditView.swift */, + 01D85B492E07F16900A95798 /* ShopkeeperEditViewModel.swift */, ); path = Settings; sourceTree = ""; @@ -945,6 +957,7 @@ "", "", "", + "", ); }; /* End PBXShellScriptBuildPhase section */ @@ -955,6 +968,7 @@ files = ( 0172047925AA8335008FD63B /* UIFont+Extensions.swift in Sources */, 01E2477029A570D300D4B00D /* SignUp.swift in Sources */, + 01D85B4A2E07F16900A95798 /* ShopkeeperEditViewModel.swift in Sources */, 0172785A2D7D83B600CE424F /* NFCManager.swift in Sources */, 0172785B2D7D83B600CE424F /* AppSingletons.swift in Sources */, 017278732D7D87EB00CE424F /* UIImage+Extentions.swift in Sources */, @@ -1051,6 +1065,7 @@ 018E21CD2B36377800FFD1F6 /* MeService.swift in Sources */, 0106414029A9F2EC00B46FED /* AccountPasswordService.swift in Sources */, 01482FA42B351E4100A56D43 /* AcceptPrivacyView.swift in Sources */, + 01D85B462E07F15400A95798 /* PasswordEditViewModel.swift in Sources */, 0172046325AA82BF008FD63B /* OnboardingView.swift in Sources */, 013DE735284E99DF00528CC5 /* ShopCreateView.swift in Sources */, 01D85AE72E07CD4400A95798 /* ItemTagDetailViewModel.swift in Sources */, @@ -1058,6 +1073,7 @@ 0106414229A9F51700B46FED /* AccountPasswordRepository.swift in Sources */, 0172033A25A9642E008FD63B /* JSONAPIDocument.swift in Sources */, 01B526542AF4E36400655131 /* MainTab.swift in Sources */, + 01D85B442E07ED8700A95798 /* ShopDetailViewModel.swift in Sources */, 017203CB25A97090008FD63B /* SessionController.swift in Sources */, 0106413E29A9F1C300B46FED /* UpdatePassword.swift in Sources */, 0172787B2D7D903500CE424F /* ItemTagAdapter.swift in Sources */, @@ -1094,6 +1110,7 @@ 01E2477229A5E30400D4B00D /* Utility.swift in Sources */, 0172046725AA82BF008FD63B /* MainView.swift in Sources */, 0172033825A9642E008FD63B /* JSONAPIError.swift in Sources */, + 01D85B482E07F16100A95798 /* SettingsViewModel.swift in Sources */, ); }; 01D19B3F2D4DE33500BDEAB7 /* Sources */ = { diff --git a/NativeAppTemplate/UI/App Root/MainView.swift b/NativeAppTemplate/UI/App Root/MainView.swift index 3f99004..ac75efc 100644 --- a/NativeAppTemplate/UI/App Root/MainView.swift +++ b/NativeAppTemplate/UI/App Root/MainView.swift @@ -166,7 +166,14 @@ private extension MainView { } func settingsView() -> SettingsView { - .init(accountPasswordRepository: dataManager.accountPasswordRepository) + .init( + viewModel: SettingsViewModel( + sessionController: sessionController, + tabViewModel: tabViewModel, + messageBus: messageBus + ), + accountPasswordRepository: dataManager.accountPasswordRepository + ) } func handleBackgroundTagReading(_ userActivity: NSUserActivity) { diff --git a/NativeAppTemplate/UI/Settings/PasswordEditView.swift b/NativeAppTemplate/UI/Settings/PasswordEditView.swift index d4db1b2..a26856d 100644 --- a/NativeAppTemplate/UI/Settings/PasswordEditView.swift +++ b/NativeAppTemplate/UI/Settings/PasswordEditView.swift @@ -8,48 +8,20 @@ import SwiftUI struct PasswordEditView: View { - @Environment(MessageBus.self) private var messageBus @Environment(\.dismiss) private var dismiss - @State private var isUpdating = false - @State private var currentPassword: String = "" - @State private var password: String = "" - @State private var passwordConfirmation: String = "" - private var accountPasswordRepository: AccountPasswordRepositoryProtocol + @State private var viewModel: PasswordEditViewModel - init( - accountPasswordRepository: AccountPasswordRepositoryProtocol - ) { - self.accountPasswordRepository = accountPasswordRepository - } - - private var hasInvalidData: Bool { - if Utility.isBlank(currentPassword) || - Utility.isBlank(password) || - Utility.isBlank(passwordConfirmation) { - return true - } - - if hasInvalidDataPassword { - return true - } - - return false - } - - private var hasInvalidDataPassword: Bool { - if Utility.isBlank(password) { - return true - } - - if password.count < .minimumPasswordLength { - return true - } - - return false + init(viewModel: PasswordEditViewModel) { + self._viewModel = State(wrappedValue: viewModel) } var body: some View { contentView + .onChange(of: viewModel.shouldDismiss) { _, shouldDismiss in + if shouldDismiss { + dismiss() + } + } } } @@ -58,7 +30,7 @@ private extension PasswordEditView { var contentView: some View { @ViewBuilder var contentView: some View { - if isUpdating { + if viewModel.isBusy { LoadingView() } else { passwordEditView @@ -71,7 +43,7 @@ private extension PasswordEditView { var passwordEditView: some View { Form { Section { - SecureField(String.currentPassword, text: $currentPassword) + SecureField(String.currentPassword, text: $viewModel.currentPassword) .textContentType(.password) .autocapitalization(.none) .autocorrectionDisabled(true) @@ -82,12 +54,12 @@ private extension PasswordEditView { Text(String.weNeedYourCurrentPassword) .font(.uiFootnote) Text(String.currentPasswordIsRequired) - .foregroundStyle(Utility.isBlank(currentPassword) ? .red : .clear) + .foregroundStyle(Utility.isBlank(viewModel.currentPassword) ? .red : .clear) .font(.uiFootnote) } } Section { - SecureField(String.newPassword, text: $password) + SecureField(String.newPassword, text: $viewModel.password) .textContentType(.password) .autocapitalization(.none) .autocorrectionDisabled(true) @@ -95,14 +67,14 @@ private extension PasswordEditView { Text(String.newPassword) } footer: { VStack(alignment: .leading) { - Text("\(Int.minimumPasswordLength) characters minimum.") + Text("\(viewModel.minimumPasswordLength) characters minimum.") .font(.uiFootnote) - if Utility.isBlank(password) { + if Utility.isBlank(viewModel.password) { Text(String.newPasswordIsRequired) .foregroundStyle(.red) .font(.uiFootnote) - } else if hasInvalidDataPassword { + } else if viewModel.hasInvalidDataPassword { Text(String.passwordIsInvalid) .foregroundStyle(.red) .font(.uiFootnote) @@ -110,7 +82,7 @@ private extension PasswordEditView { } } Section { - SecureField(String.confirmNewPassword, text: $passwordConfirmation) + SecureField(String.confirmNewPassword, text: $viewModel.passwordConfirmation) .textContentType(.password) .autocapitalization(.none) .autocorrectionDisabled(true) @@ -119,46 +91,19 @@ private extension PasswordEditView { } footer: { Text(String.confirmNewPasswordIsRequired) .font(.uiFootnote) - .foregroundStyle(Utility.isBlank(passwordConfirmation) ? .red : .clear) + .foregroundStyle(Utility.isBlank(viewModel.passwordConfirmation) ? .red : .clear) } } .navigationTitle(String.updatePassword) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button { - updatePassword() + viewModel.updatePassword() } label: { Text(String.save) } - .disabled(hasInvalidData) - } - } - } - - func updatePassword() { - let whitespacesAndNewlines = CharacterSet.whitespacesAndNewlines - let theCurrentPassword = currentPassword.trimmingCharacters(in: whitespacesAndNewlines) - let thePassword = password.trimmingCharacters(in: whitespacesAndNewlines) - let thePasswordConfirmation = passwordConfirmation.trimmingCharacters(in: whitespacesAndNewlines) - - Task { @MainActor in - isUpdating = true - - do { - let updatePassword = UpdatePassword( - currentPassword: theCurrentPassword, - password: thePassword, - passwordConfirmation: thePasswordConfirmation - ) - - try await accountPasswordRepository.update(updatePassword: updatePassword) - messageBus.post(message: Message(level: .success, message: .passwordUpdated)) - dismiss() - } catch { - messageBus.post(message: Message(level: .error, message: error.localizedDescription, autoDismiss: false)) + .disabled(viewModel.hasInvalidData) } - - isUpdating = false } } } diff --git a/NativeAppTemplate/UI/Settings/PasswordEditViewModel.swift b/NativeAppTemplate/UI/Settings/PasswordEditViewModel.swift new file mode 100644 index 0000000..18f3a3b --- /dev/null +++ b/NativeAppTemplate/UI/Settings/PasswordEditViewModel.swift @@ -0,0 +1,91 @@ +// +// PasswordEditViewModel.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/06/15. +// + +import SwiftUI +import Observation + +@Observable +@MainActor +final class PasswordEditViewModel { + var currentPassword = "" + var password = "" + var passwordConfirmation = "" + var isUpdating = false + var shouldDismiss = false + + private let accountPasswordRepository: AccountPasswordRepositoryProtocol + private let messageBus: MessageBus + + init( + accountPasswordRepository: AccountPasswordRepositoryProtocol, + messageBus: MessageBus + ) { + self.accountPasswordRepository = accountPasswordRepository + self.messageBus = messageBus + } + + var isBusy: Bool { + isUpdating + } + + var hasInvalidData: Bool { + if Utility.isBlank(currentPassword) || + Utility.isBlank(password) || + Utility.isBlank(passwordConfirmation) { + return true + } + + if hasInvalidDataPassword { + return true + } + + return false + } + + var hasInvalidDataPassword: Bool { + if Utility.isBlank(password) { + return true + } + + if password.count < .minimumPasswordLength { + return true + } + + return false + } + + var minimumPasswordLength: Int { + .minimumPasswordLength + } + + func updatePassword() { + let whitespacesAndNewlines = CharacterSet.whitespacesAndNewlines + let theCurrentPassword = currentPassword.trimmingCharacters(in: whitespacesAndNewlines) + let thePassword = password.trimmingCharacters(in: whitespacesAndNewlines) + let thePasswordConfirmation = passwordConfirmation.trimmingCharacters(in: whitespacesAndNewlines) + + Task { + isUpdating = true + + do { + let updatePassword = UpdatePassword( + currentPassword: theCurrentPassword, + password: thePassword, + passwordConfirmation: thePasswordConfirmation + ) + + try await accountPasswordRepository.update(updatePassword: updatePassword) + messageBus.post(message: Message(level: .success, message: .passwordUpdated)) + shouldDismiss = true + } catch { + messageBus.post(message: Message(level: .error, message: error.localizedDescription, autoDismiss: false)) + } + + isUpdating = false + } + } +} diff --git a/NativeAppTemplate/UI/Settings/SettingsView.swift b/NativeAppTemplate/UI/Settings/SettingsView.swift index 85279fa..62b46cb 100644 --- a/NativeAppTemplate/UI/Settings/SettingsView.swift +++ b/NativeAppTemplate/UI/Settings/SettingsView.swift @@ -9,18 +9,18 @@ import SwiftUI import MessageUI struct SettingsView: View { + @Environment(DataManager.self) private var dataManager @Environment(MessageBus.self) private var messageBus - @Environment(\.sessionController) private var sessionController @Environment(TabViewModel.self) private var tabViewModel - @State var isShowingMailView = false - @State var alertNoMail = false - @State var result: Result? + @State private var viewModel: SettingsViewModel private var signUpRepository: SignUpRepositoryProtocol = SignUpRepository() private var accountPasswordRepository: AccountPasswordRepositoryProtocol init( + viewModel: SettingsViewModel, accountPasswordRepository: AccountPasswordRepositoryProtocol ) { + self._viewModel = State(wrappedValue: viewModel) self.accountPasswordRepository = accountPasswordRepository } @@ -28,16 +28,29 @@ struct SettingsView: View { VStack(spacing: 0) { List { Section(header: Text(String.myAccount)) { - if let shopkeeper = sessionController.shopkeeper { + if let shopkeeper = viewModel.shopkeeper { NavigationLink( - destination: ShopkeeperEditView(signUpRepository: signUpRepository, shopkeeper: shopkeeper) + destination: ShopkeeperEditView( + viewModel: ShopkeeperEditViewModel( + signUpRepository: SignUpRepository(), + sessionController: dataManager.sessionController, + messageBus: messageBus, + tabViewModel: tabViewModel, + shopkeeper: shopkeeper + ) + ) ) { Label(String.profile, systemImage: "person") } } - + NavigationLink( - destination: PasswordEditView(accountPasswordRepository: accountPasswordRepository) + destination: PasswordEditView( + viewModel: PasswordEditViewModel( + accountPasswordRepository: dataManager.accountPasswordRepository, + messageBus: messageBus + ) + ) ) { Label(String.password, systemImage: "key") } @@ -62,7 +75,7 @@ struct SettingsView: View { } Button { - MFMailComposeViewController.canSendMail() ? self.isShowingMailView.toggle() : self.alertNoMail.toggle() + MFMailComposeViewController.canSendMail() ? viewModel.isShowingMailView.toggle() : viewModel.alertNoMail.toggle() } label: { Label(String.contact, systemImage: "envelope") } @@ -82,18 +95,18 @@ struct SettingsView: View { Section { VStack { - Text("Logged in as \(sessionController.shopkeeper?.name ?? "")") + Text("Logged in as \(viewModel.shopkeeper?.name ?? "")") MainButtonView(title: "Sign Out", type: .destructive(withArrow: false)) { - signOut() + viewModel.signOut() } } .listRowBackground(Color.clear) } #if DEBUG - if sessionController.isLoggedIn { + if viewModel.isLoggedIn { Section { - Text(verbatim: sessionController.client.accountId) + Text(verbatim: viewModel.accountId) } header: { Text(verbatim: "Account ID") } @@ -103,12 +116,12 @@ struct SettingsView: View { } .navigationTitle(String.settings) .navigationBarTitleDisplayMode(.inline) - .sheet(isPresented: $isShowingMailView) { + .sheet(isPresented: $viewModel.isShowingMailView) { let systemVersion = UIDevice.current.systemVersion let device = Utility.deviceModel MailView( - result: self.$result, + result: $viewModel.result, recipients: [String.supportMail], subject: "\(Bundle.main.displayName) for iPhone support", messageBody: "\n\n\n-----\n\(Bundle.main.displayName) \(Bundle.main.appVersionLong)\n\(device) (\(systemVersion))\n\(Locale.preferredLanguages[0])" @@ -116,25 +129,8 @@ struct SettingsView: View { } .alert( "NO MAIL SETUP", - isPresented: $alertNoMail + isPresented: $viewModel.alertNoMail ) { } } - - func signOut() { - Task { @MainActor in - do { - try await sessionController.logout() -#if DEBUG - messageBus.post(message: Message(level: .success, message: .signedOut)) -#endif - } catch { -#if DEBUG - messageBus.post(message: Message(level: .error, message: "\(String.signedOutError) \(error.localizedDescription)", autoDismiss: false)) -#endif - } - - tabViewModel.selectedTab = .shops - } - } } diff --git a/NativeAppTemplate/UI/Settings/SettingsViewModel.swift b/NativeAppTemplate/UI/Settings/SettingsViewModel.swift new file mode 100644 index 0000000..b3f2e09 --- /dev/null +++ b/NativeAppTemplate/UI/Settings/SettingsViewModel.swift @@ -0,0 +1,53 @@ +// +// SettingsViewModel.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/04/20. +// + +import SwiftUI +import Observation +import MessageUI + +@Observable +@MainActor +final class SettingsViewModel { + var isShowingMailView = false + var alertNoMail = false + var result: Result? + private(set) var messageBus: MessageBus + + private let sessionController: SessionControllerProtocol + private let tabViewModel: TabViewModel + + init( + sessionController: SessionControllerProtocol, + tabViewModel: TabViewModel, + messageBus: MessageBus + ) { + self.sessionController = sessionController + self.tabViewModel = tabViewModel + self.messageBus = messageBus + } + + var shopkeeper: Shopkeeper? { sessionController.shopkeeper } + var isLoggedIn: Bool { sessionController.isLoggedIn } + var accountId: String { sessionController.client.accountId } + + func signOut() { + Task { @MainActor in + do { + try await sessionController.logout() +#if DEBUG + messageBus.post(message: Message(level: .success, message: .signedOut)) +#endif + } catch { +#if DEBUG + messageBus.post(message: Message(level: .error, message: "\(String.signedOutError) \(error.localizedDescription)", autoDismiss: false)) +#endif + } + + tabViewModel.selectedTab = .shops + } + } +} diff --git a/NativeAppTemplate/UI/Settings/ShopkeeperEditView.swift b/NativeAppTemplate/UI/Settings/ShopkeeperEditView.swift index 25ddca1..e7bbc4d 100644 --- a/NativeAppTemplate/UI/Settings/ShopkeeperEditView.swift +++ b/NativeAppTemplate/UI/Settings/ShopkeeperEditView.swift @@ -10,61 +10,19 @@ import SwiftUI struct ShopkeeperEditView: View { @Environment(\.dismiss) private var dismiss @Environment(\.openURL) var openURL - @Environment(MessageBus.self) private var messageBus - @Environment(\.sessionController) private var sessionController - @Environment(TabViewModel.self) private var tabViewModel - @State private var isUpdating = false - @State private var isDeleting = false - @State private var name: String = "" - @State private var email: String = "" - @State private var selectedTimeZone: String - @State private var isShowingDeleteConfirmationDialog = false - private var signUpRepository: SignUpRepositoryProtocol - private var shopkeeper: Shopkeeper + @State private var viewModel: ShopkeeperEditViewModel - init( - signUpRepository: SignUpRepositoryProtocol, - shopkeeper: Shopkeeper - ) { - self.signUpRepository = signUpRepository - self.shopkeeper = shopkeeper - _name = State(initialValue: shopkeeper.name) - _email = State(initialValue: shopkeeper.email) - _selectedTimeZone = State(initialValue: shopkeeper.timeZone) - } - - private var hasInvalidData: Bool { - if Utility.isBlank(name) { - return true - } - - if hasInvalidDataEmail { - return true - } - - if shopkeeper.name == name && - shopkeeper.email == email && - shopkeeper.timeZone == selectedTimeZone { - return true - } - - return false - } - - private var hasInvalidDataEmail: Bool { - if Utility.isBlank(email) { - return true - } - - if !Utility.validateEmail(email) { - return true - } - - return false + init(viewModel: ShopkeeperEditViewModel) { + self._viewModel = State(wrappedValue: viewModel) } var body: some View { contentView + .onChange(of: viewModel.shouldDismiss) { _, shouldDismiss in + if shouldDismiss { + dismiss() + } + } } } @@ -73,7 +31,7 @@ private extension ShopkeeperEditView { var contentView: some View { @ViewBuilder var contentView: some View { - if isUpdating || isDeleting { + if viewModel.isBusy { LoadingView() } else { shopkeeperEditView @@ -86,32 +44,32 @@ private extension ShopkeeperEditView { var shopkeeperEditView: some View { Form { Section { - TextField(String.placeholderFullName, text: $name) + TextField(String.placeholderFullName, text: $viewModel.name) } header: { Text(String.fullName) } footer: { Text(String.fullNameIsRequired) - .foregroundStyle(Utility.isBlank(name) ? .red : .clear) + .foregroundStyle(Utility.isBlank(viewModel.name) ? .red : .clear) } Section { - TextField(String.placeholderEmail, text: $email) + TextField(String.placeholderEmail, text: $viewModel.email) .textContentType(.emailAddress) .autocapitalization(.none) } header: { Text(String.email) } footer: { - if Utility.isBlank(email) { + if Utility.isBlank(viewModel.email) { Text(String.emailIsRequired) .foregroundStyle(.red) - } else if hasInvalidDataEmail { + } else if viewModel.hasInvalidDataEmail { Text(String.emailIsInvalid) .foregroundStyle(.red) } } Section { - Picker(String.timeZone, selection: $selectedTimeZone) { + Picker(String.timeZone, selection: $viewModel.selectedTimeZone) { ForEach(timeZones.keys, id: \.self) { key in Text(timeZones[key]!).tag(key) } @@ -123,21 +81,21 @@ private extension ShopkeeperEditView { Section { MainButtonView(title: String.deleteMyAccount, type: .destructive(withArrow: false)) { - isShowingDeleteConfirmationDialog = true + viewModel.isShowingDeleteConfirmationDialog = true } .listRowBackground(Color.clear) } } .confirmationDialog( String.deleteMyAccount, - isPresented: $isShowingDeleteConfirmationDialog + isPresented: $viewModel.isShowingDeleteConfirmationDialog ) { Button(String.deleteMyAccount, role: .destructive) { - destroyShopkeeper() + viewModel.destroyShopkeeper() } Button(String.cancel, role: .cancel) { - isShowingDeleteConfirmationDialog = false + viewModel.isShowingDeleteConfirmationDialog = false } } message: { Text(String.areYouSure) @@ -146,77 +104,12 @@ private extension ShopkeeperEditView { .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button { - updateShopkeeper() + viewModel.updateShopkeeper() } label: { Text(String.save) } - .disabled(hasInvalidData) + .disabled(viewModel.hasInvalidData) } } } - - func updateShopkeeper() { - let whitespacesAndNewlines = CharacterSet.whitespacesAndNewlines - let theName = name.trimmingCharacters(in: whitespacesAndNewlines) - let theEmail = email.trimmingCharacters(in: whitespacesAndNewlines) - let emailUpdated = theEmail != shopkeeper.email - - Task { @MainActor in - do { - isUpdating = true - - let signUp = SignUp( - name: theName, - email: theEmail, - timeZone: selectedTimeZone - ) - let shopkeeper = try await signUpRepository.update(id: shopkeeper.id, signUp: signUp, networkClient: sessionController.client) - - var newShopkeeper = sessionController.shopkeeper! - - newShopkeeper.email = shopkeeper.email - newShopkeeper.name = shopkeeper.name - newShopkeeper.timeZone = shopkeeper.timeZone - newShopkeeper.uid = shopkeeper.uid - - try sessionController.updateShopkeeper(shopkeeper: newShopkeeper) - - if emailUpdated { - messageBus.post(message: Message(level: .success, message: .reconfirmDescription, autoDismiss: false)) - try await sessionController.logout() - } else { - messageBus.post(message: Message(level: .success, message: .shopkeeperUpdated)) - } - } catch { - messageBus.post(message: Message(level: .error, message: error.localizedDescription, autoDismiss: false)) - } - - isUpdating = false - dismiss() - } - } - - private func destroyShopkeeper() { - Task { @MainActor in - isDeleting = true - - do { - try await signUpRepository.destroy(networkClient: sessionController.client) - messageBus.post(message: Message(level: .success, message: .shopkeeperDeleted)) - } catch { - messageBus.post(message: Message(level: .error, message: "\(String.shopkeeperDeletedError) \(error.localizedDescription)", autoDismiss: false)) - } - - do { - try sessionController.updateShopkeeper(shopkeeper: nil) - } catch { - messageBus.post(message: Message(level: .error, message: "\(String.shopkeeperDeletedError) \(error.localizedDescription)", autoDismiss: false)) - } - - isDeleting = false - // Without this code, error occurs - tabViewModel.selectedTab = .shops - dismiss() - } - } } diff --git a/NativeAppTemplate/UI/Settings/ShopkeeperEditViewModel.swift b/NativeAppTemplate/UI/Settings/ShopkeeperEditViewModel.swift new file mode 100644 index 0000000..dba91b7 --- /dev/null +++ b/NativeAppTemplate/UI/Settings/ShopkeeperEditViewModel.swift @@ -0,0 +1,144 @@ +// +// ShopkeeperEditViewModel.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/06/15. +// + +import SwiftUI +import Observation + +@Observable +@MainActor +final class ShopkeeperEditViewModel { + var name: String + var email: String + var selectedTimeZone: String + var isUpdating = false + var isDeleting = false + var isShowingDeleteConfirmationDialog = false + var shouldDismiss = false + + private let signUpRepository: SignUpRepositoryProtocol + private let sessionController: SessionControllerProtocol + private let messageBus: MessageBus + private let tabViewModel: TabViewModel + private let shopkeeper: Shopkeeper + + init( + signUpRepository: SignUpRepositoryProtocol, + sessionController: SessionControllerProtocol, + messageBus: MessageBus, + tabViewModel: TabViewModel, + shopkeeper: Shopkeeper + ) { + self.signUpRepository = signUpRepository + self.sessionController = sessionController + self.messageBus = messageBus + self.tabViewModel = tabViewModel + self.shopkeeper = shopkeeper + self.name = shopkeeper.name + self.email = shopkeeper.email + self.selectedTimeZone = shopkeeper.timeZone + } + + var isBusy: Bool { + isUpdating || isDeleting + } + + var hasInvalidData: Bool { + if Utility.isBlank(name) { + return true + } + + if hasInvalidDataEmail { + return true + } + + if shopkeeper.name == name && + shopkeeper.email == email && + shopkeeper.timeZone == selectedTimeZone { + return true + } + + return false + } + + var hasInvalidDataEmail: Bool { + if Utility.isBlank(email) { + return true + } + + if !Utility.validateEmail(email) { + return true + } + + return false + } + + func updateShopkeeper() { + let whitespacesAndNewlines = CharacterSet.whitespacesAndNewlines + let theName = name.trimmingCharacters(in: whitespacesAndNewlines) + let theEmail = email.trimmingCharacters(in: whitespacesAndNewlines) + let emailUpdated = theEmail != shopkeeper.email + + Task { + do { + isUpdating = true + + let signUp = SignUp( + name: theName, + email: theEmail, + timeZone: selectedTimeZone + ) + let updatedShopkeeper = try await signUpRepository.update(id: shopkeeper.id, signUp: signUp, networkClient: sessionController.client) + + var newShopkeeper = sessionController.shopkeeper! + + newShopkeeper.email = updatedShopkeeper.email + newShopkeeper.name = updatedShopkeeper.name + newShopkeeper.timeZone = updatedShopkeeper.timeZone + newShopkeeper.uid = updatedShopkeeper.uid + + try sessionController.updateShopkeeper(shopkeeper: newShopkeeper) + + if emailUpdated { + messageBus.post(message: Message(level: .success, message: .reconfirmDescription, autoDismiss: false)) + try await sessionController.logout() + } else { + messageBus.post(message: Message(level: .success, message: .shopkeeperUpdated)) + } + + shouldDismiss = true + } catch { + messageBus.post(message: Message(level: .error, message: error.localizedDescription, autoDismiss: false)) + } + + isUpdating = false + } + } + + func destroyShopkeeper() { + Task { + isDeleting = true + + do { + try await signUpRepository.destroy(networkClient: sessionController.client) + messageBus.post(message: Message(level: .success, message: .shopkeeperDeleted)) + } catch { + messageBus.post(message: Message(level: .error, message: "\(String.shopkeeperDeletedError) \(error.localizedDescription)", autoDismiss: false)) + } + + do { + try sessionController.updateShopkeeper(shopkeeper: nil) + } catch { + messageBus.post(message: Message(level: .error, message: "\(String.shopkeeperDeletedError) \(error.localizedDescription)", autoDismiss: false)) + } + + isDeleting = false + // Without this code, error occurs + tabViewModel.selectedTab = .shops + shouldDismiss = true + } + } +} diff --git a/NativeAppTemplate/UI/Shop Detail/ShopDetailView.swift b/NativeAppTemplate/UI/Shop Detail/ShopDetailView.swift index 7853696..b8d1e31 100644 --- a/NativeAppTemplate/UI/Shop Detail/ShopDetailView.swift +++ b/NativeAppTemplate/UI/Shop Detail/ShopDetailView.swift @@ -26,31 +26,10 @@ struct ShopDetailView: View { @Environment(\.dismiss) private var dismiss @Environment(\.mainTab) private var mainTab @Environment(TabViewModel.self) private var tabViewModel - @Environment(MessageBus.self) private var messageBus - @Environment(\.sessionController) private var sessionController - @State private var isFetching = true - @State private var isResetting = false - @State private var isCompleting = false - @State private var itemTags: [ItemTag]? - private let shopRepository: ShopRepositoryProtocol - private let itemTagRepository: ItemTagRepositoryProtocol - private var shopId: String + @State private var viewModel: ShopDetailViewModel - 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: ShopDetailViewModel) { + self._viewModel = State(wrappedValue: viewModel) } } @@ -59,10 +38,15 @@ extension ShopDetailView { var body: some View { contentView .onAppear { - tabViewModel.showingDetailView[mainTab] = true + viewModel.setTabViewModelShowingDetailViewToTrue() } .task { - reload() + viewModel.reload() + } + .onChange(of: viewModel.shouldDismiss) { _, shouldDismiss in + if shouldDismiss { + dismiss() + } } } } @@ -72,17 +56,17 @@ private extension ShopDetailView { var contentView: some View { @ViewBuilder var contentView: some View { - if isFetching || isResetting || isCompleting { + if viewModel.isBusy { LoadingView() - } else { - shopDetailView + } else if let shop = viewModel.shop { + shopDetailView(shop: shop) } } return contentView } - var header: some View { + func header(shop: Shop) -> some View { ScrollView(.horizontal) { VStack(alignment: .leading, spacing: 0) { let tip = ReadInstructionsTip() @@ -96,7 +80,7 @@ private extension ShopDetailView { .font(.uiCaption) .foregroundStyle(.contentText) HStack { - let openServerNumberTagsWebpage = "\(String.open) [\(String.serverNumberTagsWebpage)](\(shop.wrappedValue.displayShopServerUrl))." + let openServerNumberTagsWebpage = "\(String.open) [\(String.serverNumberTagsWebpage)](\(shop.displayShopServerUrl))." Text(.init(openServerNumberTagsWebpage)) .font(.uiCaption) .foregroundStyle(.contentText) @@ -124,17 +108,17 @@ private extension ShopDetailView { } var cardsView: some View { - ForEach(itemTagRepository.itemTags, id: \.id) { itemTag in + ForEach(viewModel.itemTags, id: \.id) { itemTag in ShopDetailCardView(itemTag: itemTag) .swipeActions(edge: .trailing, allowsFullSwipe: false) { if itemTag.state == ItemTagState.idled { - Button { completeTag(itemTagId: itemTag.id) } label: { + Button { viewModel.completeTag(itemTagId: itemTag.id) } label: { Label(String.complete, systemImage: "bolt.fill") .labelStyle(.titleOnly) } .tint(.blue) } else { - Button(role: .destructive) { resetTag(itemTagId: itemTag.id) } label: { + Button(role: .destructive) { viewModel.resetTag(itemTagId: itemTag.id) } label: { Label(String.reset, systemImage: "trash") .labelStyle(.titleOnly) } @@ -145,9 +129,9 @@ private extension ShopDetailView { } } - var shopDetailView: some View { + func shopDetailView(shop: Shop) -> some View { VStack { - header + header(shop: shop) .padding(.top) .padding(.horizontal, 8) List { @@ -155,27 +139,21 @@ private extension ShopDetailView { cardsView } header: { EmptyView() - .id(ScrollToTopID(mainTab: mainTab, detail: true)) + .id(viewModel.scrollToTopID()) } } .scrollContentBackground(.hidden) .accessibility(identifier: "shopDetailView") .refreshable { - reload() + viewModel.reload() } } - .navigationTitle(shop.wrappedValue.name) + .navigationTitle(viewModel.shop?.name ?? "") .toolbar { ToolbarItem(placement: .navigationBarTrailing) { NavigationLink( destination: ShopSettingsView( - viewModel: ShopSettingsViewModel( - sessionController: sessionController, - shopRepository: shopRepository, - itemTagRepository: itemTagRepository, - messageBus: messageBus, - shopId: shop.wrappedValue.id - ) + viewModel: viewModel.createShopSettingsViewModel() ) ) { Image(systemName: "gearshape.fill") @@ -183,71 +161,4 @@ private extension ShopDetailView { } } } - - func reload() { - // Avoid fetching shop detail error - guard sessionController.isLoggedIn else { - return - } - - fetchShopDetail() - } - - private func fetchShopDetail() { - Task { @MainActor in - isFetching = true - - do { - _ = try await shopRepository.fetchDetail(id: shopId) - _ = try await itemTagRepository.fetchAll(shopId: shopId) - isFetching = false - } catch { - messageBus.post( - message: Message( - level: .error, - message: error.localizedDescription, - autoDismiss: false - ) - ) - - dismiss() - } - } - } - - func completeTag(itemTagId: String) { - Task { @MainActor in - isCompleting = true - - do { - let itemTag = try await itemTagRepository.complete(id: itemTagId) - if itemTag.alreadyCompleted! { - messageBus.post(message: Message(level: .warning, message: .itemTagAlreadyCompleted, autoDismiss: false)) - } else { - messageBus.post(message: Message(level: .success, message: .itemTagCompleted)) - } - } catch { - messageBus.post(message: Message(level: .error, message: "\(String.itemTagCompletedError) \(error.localizedDescription)", autoDismiss: false)) - } - - isCompleting = false - reload() - } - } - - func resetTag(itemTagId: String) { - Task { @MainActor in - isResetting = true - - do { - _ = try await itemTagRepository.reset(id: itemTagId) - messageBus.post(message: Message(level: .success, message: .itemTagReset)) - } catch { - messageBus.post(message: Message(level: .error, message: "\(String.itemTagResetError) \(error.localizedDescription)", autoDismiss: false)) - } - - isResetting = false - reload() - } - } } diff --git a/NativeAppTemplate/UI/Shop Detail/ShopDetailViewModel.swift b/NativeAppTemplate/UI/Shop Detail/ShopDetailViewModel.swift new file mode 100644 index 0000000..c7ed8bc --- /dev/null +++ b/NativeAppTemplate/UI/Shop Detail/ShopDetailViewModel.swift @@ -0,0 +1,144 @@ +// +// ShopDetailViewModel.swift +// NativeAppTemplate +// +// Created by Claude on 2025/06/22. +// + +import SwiftUI +import Observation + +@Observable +@MainActor +final class ShopDetailViewModel { + var isFetching = true + var isResetting = false + var isCompleting = false + var itemTags: [ItemTag] = [] + var shouldDismiss: Bool = false + var shopId: String + private(set) var shop: Shop? + + private let sessionController: SessionControllerProtocol + private let shopRepository: ShopRepositoryProtocol + private let itemTagRepository: ItemTagRepositoryProtocol + private let tabViewModel: TabViewModel + private let mainTab: MainTab + let messageBus: MessageBus + + init( + sessionController: SessionControllerProtocol, + shopRepository: ShopRepositoryProtocol, + itemTagRepository: ItemTagRepositoryProtocol, + tabViewModel: TabViewModel, + mainTab: MainTab, + messageBus: MessageBus, + shopId: String + ) { + self.sessionController = sessionController + self.shopRepository = shopRepository + self.itemTagRepository = itemTagRepository + self.tabViewModel = tabViewModel + self.mainTab = mainTab + self.messageBus = messageBus + self.shopId = shopId + } + + var isBusy: Bool { + isFetching || isResetting || isCompleting + } + + var isLoggedIn: Bool { sessionController.isLoggedIn } + + func reload() { + guard sessionController.isLoggedIn else { return } + fetchShopDetail() + } + + private func fetchShopDetail() { + Task { + isFetching = true + + do { + shop = try await shopRepository.fetchDetail(id: shopId) + itemTags = try await itemTagRepository.fetchAll(shopId: shopId) + isFetching = false + } catch { + messageBus.post( + message: Message( + level: .error, + message: error.localizedDescription, + autoDismiss: false + ) + ) + shouldDismiss = true + } + } + } + + func completeTag(itemTagId: String) { + Task { + isCompleting = true + + do { + let itemTag = try await itemTagRepository.complete(id: itemTagId) + if itemTag.alreadyCompleted == true { + messageBus.post(message: Message(level: .warning, message: .itemTagAlreadyCompleted, autoDismiss: false)) + } else { + messageBus.post(message: Message(level: .success, message: .itemTagCompleted)) + } + } catch { + messageBus.post( + message: Message( + level: .error, + message: "\(String.itemTagCompletedError) \(error.localizedDescription)", + autoDismiss: false + ) + ) + } + + isCompleting = false + reload() + } + } + + func resetTag(itemTagId: String) { + Task { + isResetting = true + + do { + _ = try await itemTagRepository.reset(id: itemTagId) + messageBus.post(message: Message(level: .success, message: .itemTagReset)) + } catch { + messageBus.post( + message: Message( + level: .error, + message: "\(String.itemTagResetError) \(error.localizedDescription)", + autoDismiss: false + ) + ) + } + + isResetting = false + reload() + } + } + + func setTabViewModelShowingDetailViewToTrue() { + tabViewModel.showingDetailView[mainTab] = true + } + + func scrollToTopID() -> ScrollToTopID { + ScrollToTopID(mainTab: mainTab, detail: true) + } + + func createShopSettingsViewModel() -> ShopSettingsViewModel { + ShopSettingsViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + itemTagRepository: itemTagRepository, + messageBus: messageBus, + shopId: shopId + ) + } +} diff --git a/NativeAppTemplate/UI/Shop List/ShopListView.swift b/NativeAppTemplate/UI/Shop List/ShopListView.swift index 3aa4f51..d234b56 100644 --- a/NativeAppTemplate/UI/Shop List/ShopListView.swift +++ b/NativeAppTemplate/UI/Shop List/ShopListView.swift @@ -45,6 +45,8 @@ struct TapShopBelowTip: Tip { struct ShopListView: View { @Environment(DataManager.self) private var dataManager + @Environment(TabViewModel.self) private var tabViewModel + @Environment(\.mainTab) private var mainTab @Environment(MessageBus.self) private var messageBus @State private var viewModel: ShopListViewModel @@ -132,9 +134,15 @@ private extension ShopListView { } .navigationDestination(for: Shop.self) { shop in ShopDetailView( - shopRepository: viewModel.shopRepository, - itemTagRepository: viewModel.itemTagRepository, - shopId: shop.id + viewModel: ShopDetailViewModel( + sessionController: dataManager.sessionController, + shopRepository: viewModel.shopRepository, + itemTagRepository: viewModel.itemTagRepository, + tabViewModel: tabViewModel, + mainTab: mainTab, + messageBus: messageBus, + shopId: shop.id + ) ) } .accessibility(identifier: "shopListView") diff --git a/NativeAppTemplateTests/UI/Settings/PasswordEditViewModelTest.swift b/NativeAppTemplateTests/UI/Settings/PasswordEditViewModelTest.swift new file mode 100644 index 0000000..5dce0f0 --- /dev/null +++ b/NativeAppTemplateTests/UI/Settings/PasswordEditViewModelTest.swift @@ -0,0 +1,238 @@ +// +// PasswordEditViewModelTest.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/06/20. +// + +import Testing +import Foundation +@testable import NativeAppTemplate + +@MainActor +@Suite +struct PasswordEditViewModelTest { + let accountPasswordRepository = TestAccountPasswordRepository( + accountPasswordService: AccountPasswordService() + ) + let messageBus = MessageBus() + + @Test + func initializesCorrectly() { + let viewModel = PasswordEditViewModel( + accountPasswordRepository: accountPasswordRepository, + messageBus: messageBus + ) + + #expect(viewModel.currentPassword == "") + #expect(viewModel.password == "") + #expect(viewModel.passwordConfirmation == "") + #expect(viewModel.isUpdating == false) + #expect(viewModel.shouldDismiss == false) + #expect(viewModel.isBusy == false) + } + + @Test + func busyStateReflectsUpdatingState() { + let viewModel = PasswordEditViewModel( + accountPasswordRepository: accountPasswordRepository, + messageBus: messageBus + ) + + #expect(viewModel.isBusy == false) + + viewModel.isUpdating = true + #expect(viewModel.isBusy == true) + + viewModel.isUpdating = false + #expect(viewModel.isBusy == false) + } + + @Test + func minimumPasswordLengthReturnsCorrectValue() { + let viewModel = PasswordEditViewModel( + accountPasswordRepository: accountPasswordRepository, + messageBus: messageBus + ) + + #expect(viewModel.minimumPasswordLength == .minimumPasswordLength) + } + + @Test("Password validation", arguments: [ + ("", true), // blank password + ("a", true), // too short (minimum is 8) + ("ab", true), // too short (minimum is 8) + ("abc", true), // too short (minimum is 8) + ("abcd", true), // too short (minimum is 8) + ("abcde", true), // too short (minimum is 8) + ("abcdef", true), // too short (minimum is 8) + ("abcdefg", true), // too short (minimum is 8) + ("abcdefgh", false), // meets minimum length of 8 + ("verylongpassword", false) // definitely meets minimum + ]) + func passwordValidation(password: String, shouldBeInvalid: Bool) { + let viewModel = PasswordEditViewModel( + accountPasswordRepository: accountPasswordRepository, + messageBus: messageBus + ) + + viewModel.password = password + + #expect(viewModel.hasInvalidDataPassword == shouldBeInvalid) + } + + @Test("Form validation - blank fields", arguments: [ + ("", "password", "password", true), // blank current password + ("current", "", "password", true), // blank password + ("current", "password", "", true), // blank confirmation + ("current", "password", "password", false) // all filled + ]) + func formValidationBlankFields( + currentPassword: String, + password: String, + passwordConfirmation: String, + shouldBeInvalid: Bool + ) { + let viewModel = PasswordEditViewModel( + accountPasswordRepository: accountPasswordRepository, + messageBus: messageBus + ) + + viewModel.currentPassword = currentPassword + viewModel.password = password + viewModel.passwordConfirmation = passwordConfirmation + + #expect(viewModel.hasInvalidData == shouldBeInvalid) + } + + @Test + func formValidationWithInvalidPassword() { + let viewModel = PasswordEditViewModel( + accountPasswordRepository: accountPasswordRepository, + messageBus: messageBus + ) + + // Set valid current password and confirmation, but invalid password + viewModel.currentPassword = "currentpassword" + viewModel.password = "a" // too short + viewModel.passwordConfirmation = "a" + + #expect(viewModel.hasInvalidData == true) + #expect(viewModel.hasInvalidDataPassword == true) + } + + @Test + func updatePasswordSuccess() async { + let viewModel = PasswordEditViewModel( + accountPasswordRepository: accountPasswordRepository, + messageBus: messageBus + ) + + viewModel.currentPassword = "currentpassword" + viewModel.password = "newpassword" + viewModel.passwordConfirmation = "newpassword" + + let updateTask = Task { + viewModel.updatePassword() + } + await updateTask.value + + #expect(viewModel.isUpdating == false) + #expect(viewModel.shouldDismiss == true) + #expect(messageBus.currentMessage != nil) + #expect(messageBus.currentMessage!.level == .success) + #expect(messageBus.currentMessage!.message == .passwordUpdated) + } + + @Test + func updatePasswordFailure() async { + accountPasswordRepository.error = NativeAppTemplateAPIError.requestFailed(nil, 422, "Current password is incorrect") + + let viewModel = PasswordEditViewModel( + accountPasswordRepository: accountPasswordRepository, + messageBus: messageBus + ) + + viewModel.currentPassword = "wrongpassword" + viewModel.password = "newpassword" + viewModel.passwordConfirmation = "newpassword" + + let updateTask = Task { + viewModel.updatePassword() + } + await updateTask.value + + #expect(viewModel.isUpdating == false) + #expect(viewModel.shouldDismiss == false) + #expect(messageBus.currentMessage != nil) + #expect(messageBus.currentMessage!.level == .error) + #expect(messageBus.currentMessage!.autoDismiss == false) + } + + @Test + func updatePasswordTrimsWhitespace() async { + let viewModel = PasswordEditViewModel( + accountPasswordRepository: accountPasswordRepository, + messageBus: messageBus + ) + + viewModel.currentPassword = " currentpassword " + viewModel.password = " newpassword " + viewModel.passwordConfirmation = " newpassword " + + let updateTask = Task { + viewModel.updatePassword() + } + await updateTask.value + + #expect(viewModel.shouldDismiss == true) + #expect(messageBus.currentMessage?.level == .success) + } + + @Test + func busyStateDuringUpdate() async { + let viewModel = PasswordEditViewModel( + accountPasswordRepository: accountPasswordRepository, + messageBus: messageBus + ) + + viewModel.currentPassword = "currentpassword" + viewModel.password = "newpassword" + viewModel.passwordConfirmation = "newpassword" + + let updateTask = Task { + viewModel.updatePassword() + } + + // Check busy state immediately after starting + #expect(viewModel.isBusy == viewModel.isUpdating) + + await updateTask.value + + #expect(viewModel.isBusy == false) + #expect(viewModel.isUpdating == false) + } + + @Test + func passwordLengthValidationAtMinimumBoundary() { + let viewModel = PasswordEditViewModel( + accountPasswordRepository: accountPasswordRepository, + messageBus: messageBus + ) + + // Test password just under the minimum (7 characters) + viewModel.password = String(repeating: "a", count: 7) + #expect(viewModel.hasInvalidDataPassword == true) + + // Test password at the minimum (8 characters) + viewModel.password = String(repeating: "a", count: 8) + #expect(viewModel.hasInvalidDataPassword == false) + + // Test password over the minimum (9 characters) + viewModel.password = String(repeating: "a", count: 9) + #expect(viewModel.hasInvalidDataPassword == false) + + // Verify the minimum matches the constant + #expect(viewModel.minimumPasswordLength == 8) + } +} diff --git a/NativeAppTemplateTests/UI/Settings/SettingsViewModelTest.swift b/NativeAppTemplateTests/UI/Settings/SettingsViewModelTest.swift new file mode 100644 index 0000000..b341982 --- /dev/null +++ b/NativeAppTemplateTests/UI/Settings/SettingsViewModelTest.swift @@ -0,0 +1,165 @@ +// +// SettingsViewModelTest.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/06/20. +// + +import Testing +import Foundation +@testable import NativeAppTemplate + +@MainActor +@Suite +struct SettingsViewModelTest { + let sessionController = TestSessionController() + let tabViewModel = TabViewModel() + let messageBus = MessageBus() + + @Test + func initializesCorrectly() { + let viewModel = SettingsViewModel( + sessionController: sessionController, + tabViewModel: tabViewModel, + messageBus: messageBus + ) + + #expect(viewModel.isShowingMailView == false) + #expect(viewModel.alertNoMail == false) + #expect(viewModel.result == nil) + #expect(viewModel.messageBus === messageBus) + } + + @Test + func shopkeeperPropertyReflectsSessionController() { + let mockShopkeeper = Shopkeeper( + id: "test-id", + accountId: "test-account-id", + personalAccountId: "test-personal-id", + accountOwnerId: "test-owner-id", + accountName: "Test Account", + email: "test@example.com", + name: "Test User", + timeZone: "Tokyo", + uid: "test-uid", + token: "test-token", + client: "test-client", + expiry: "test-expiry" + )! + + sessionController.shopkeeper = mockShopkeeper + + let viewModel = SettingsViewModel( + sessionController: sessionController, + tabViewModel: tabViewModel, + messageBus: messageBus + ) + + #expect(viewModel.shopkeeper?.id == "test-id") + #expect(viewModel.shopkeeper?.name == "Test User") + #expect(viewModel.shopkeeper?.email == "test@example.com") + } + + @Test("Is logged in status", arguments: [true, false]) + func isLoggedInReflectsSessionController(isLoggedIn: Bool) { + sessionController.userState = isLoggedIn ? .loggedIn : .notLoggedIn + + let viewModel = SettingsViewModel( + sessionController: sessionController, + tabViewModel: tabViewModel, + messageBus: messageBus + ) + + #expect(viewModel.isLoggedIn == isLoggedIn) + } + + @Test + func accountIdReflectsSessionController() { + let viewModel = SettingsViewModel( + sessionController: sessionController, + tabViewModel: tabViewModel, + messageBus: messageBus + ) + + #expect(viewModel.accountId == sessionController.client.accountId) + } + + @Test + func signOutSuccess() async { + sessionController.userState = .loggedIn + tabViewModel.selectedTab = .scan + + let viewModel = SettingsViewModel( + sessionController: sessionController, + tabViewModel: tabViewModel, + messageBus: messageBus + ) + + viewModel.signOut() + + // Wait for async task to complete + try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + #expect(sessionController.userState == .notLoggedIn) + #expect(tabViewModel.selectedTab == .shops) +#if DEBUG + #expect(messageBus.currentMessage != nil) + #expect(messageBus.currentMessage!.level == .success) + #expect(messageBus.currentMessage!.message == .signedOut) +#endif + } + + @Test + func signOutWithError() async { + sessionController.userState = .loggedIn + tabViewModel.selectedTab = .scan + + // Force an error by setting the session state to make logout fail + // Note: TestSessionController doesn't naturally throw errors, so this test + // demonstrates the error handling structure + let viewModel = SettingsViewModel( + sessionController: sessionController, + tabViewModel: tabViewModel, + messageBus: messageBus + ) + + viewModel.signOut() + + // Wait for async task to complete + try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + // Even if logout succeeds in test environment, tab should still be set to shops + #expect(tabViewModel.selectedTab == .shops) + } + + @Test + func statePropertiesAreObservable() { + let viewModel = SettingsViewModel( + sessionController: sessionController, + tabViewModel: tabViewModel, + messageBus: messageBus + ) + + // Test that properties can be set (indicating they're observable) + viewModel.isShowingMailView = true + #expect(viewModel.isShowingMailView == true) + + viewModel.alertNoMail = true + #expect(viewModel.alertNoMail == true) + + viewModel.result = .success(.sent) + #expect(viewModel.result != nil) + } + + @Test + func messageBusIsAccessible() { + let viewModel = SettingsViewModel( + sessionController: sessionController, + tabViewModel: tabViewModel, + messageBus: messageBus + ) + + // Verify messageBus is properly accessible and is the same instance + #expect(viewModel.messageBus === messageBus) + } +} diff --git a/NativeAppTemplateTests/UI/Settings/ShopkeeperEditViewModelTest.swift b/NativeAppTemplateTests/UI/Settings/ShopkeeperEditViewModelTest.swift new file mode 100644 index 0000000..5852026 --- /dev/null +++ b/NativeAppTemplateTests/UI/Settings/ShopkeeperEditViewModelTest.swift @@ -0,0 +1,465 @@ +// +// ShopkeeperEditViewModelTest.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/06/20. +// + +// swiftlint:disable file_length + +import Testing +import Foundation +@testable import NativeAppTemplate + +@MainActor +@Suite +struct ShopkeeperEditViewModelTest { // swiftlint:disable:this type_body_length + let signUpRepository = TestSignUpRepository() + let sessionController = TestSessionController() + let messageBus = MessageBus() + let tabViewModel = TabViewModel() + + var testShopkeeper: Shopkeeper { + Shopkeeper( + id: "test-shopkeeper-id", + accountId: "test-account-id", + personalAccountId: "test-personal-id", + accountOwnerId: "test-owner-id", + accountName: "Test Account", + email: "test@example.com", + name: "Test User", + timeZone: "Tokyo", + uid: "test-uid", + token: "test-token", + client: "test-client", + expiry: "test-expiry" + )! + } + + @Test + func initializesCorrectly() { + let shopkeeper = testShopkeeper + + let viewModel = ShopkeeperEditViewModel( + signUpRepository: signUpRepository, + sessionController: sessionController, + messageBus: messageBus, + tabViewModel: tabViewModel, + shopkeeper: shopkeeper + ) + + #expect(viewModel.name == "Test User") + #expect(viewModel.email == "test@example.com") + #expect(viewModel.selectedTimeZone == "Tokyo") + #expect(viewModel.isUpdating == false) + #expect(viewModel.isDeleting == false) + #expect(viewModel.isShowingDeleteConfirmationDialog == false) + #expect(viewModel.shouldDismiss == false) + } + + @Test + func busyStateReflectsUpdatingAndDeletingStates() { + let viewModel = ShopkeeperEditViewModel( + signUpRepository: signUpRepository, + sessionController: sessionController, + messageBus: messageBus, + tabViewModel: tabViewModel, + shopkeeper: testShopkeeper + ) + + #expect(viewModel.isBusy == false) + + viewModel.isUpdating = true + #expect(viewModel.isBusy == true) + + viewModel.isUpdating = false + viewModel.isDeleting = true + #expect(viewModel.isBusy == true) + + viewModel.isDeleting = false + #expect(viewModel.isBusy == false) + + // Both updating and deleting + viewModel.isUpdating = true + viewModel.isDeleting = true + #expect(viewModel.isBusy == true) + } + + @Test("Email validation", arguments: [ + ("", true), // blank email + ("invalid", true), // no @ symbol + ("invalid@", true), // no domain + ("@invalid.com", true), // no local part + ("test@example.com", false), // valid email + ("user.name+tag@example.co.uk", false), // complex valid email + ("test@", true), // missing domain + ("test@.com", true), // missing domain name + ("test @example.com", true) // space in email + ]) + func emailValidation(email: String, shouldBeInvalid: Bool) { + let viewModel = ShopkeeperEditViewModel( + signUpRepository: signUpRepository, + sessionController: sessionController, + messageBus: messageBus, + tabViewModel: tabViewModel, + shopkeeper: testShopkeeper + ) + + viewModel.email = email + + #expect(viewModel.hasInvalidDataEmail == shouldBeInvalid) + } + + @Test("Form validation - blank name", arguments: [ + ("", true), // blank name + (" ", true), // whitespace only name + ("Valid Name", false) // valid name + ]) + func formValidationBlankName(name: String, shouldBeInvalid: Bool) { + let viewModel = ShopkeeperEditViewModel( + signUpRepository: signUpRepository, + sessionController: sessionController, + messageBus: messageBus, + tabViewModel: tabViewModel, + shopkeeper: testShopkeeper + ) + + viewModel.name = name + viewModel.email = "different@example.com" // Make sure data is changed + + #expect(viewModel.hasInvalidData == shouldBeInvalid) + } + + @Test + func formValidationWithInvalidEmail() { + let viewModel = ShopkeeperEditViewModel( + signUpRepository: signUpRepository, + sessionController: sessionController, + messageBus: messageBus, + tabViewModel: tabViewModel, + shopkeeper: testShopkeeper + ) + + viewModel.name = "Valid Name" + viewModel.email = "invalid-email" + + #expect(viewModel.hasInvalidData == true) + #expect(viewModel.hasInvalidDataEmail == true) + } + + @Test + func formValidationWhenDataUnchanged() { + let viewModel = ShopkeeperEditViewModel( + signUpRepository: signUpRepository, + sessionController: sessionController, + messageBus: messageBus, + tabViewModel: tabViewModel, + shopkeeper: testShopkeeper + ) + + // Keep all data the same as original shopkeeper + #expect(viewModel.name == testShopkeeper.name) + #expect(viewModel.email == testShopkeeper.email) + #expect(viewModel.selectedTimeZone == testShopkeeper.timeZone) + + #expect(viewModel.hasInvalidData == true) // Should be invalid when unchanged + } + + @Test + func formValidationWhenDataChanged() { + let viewModel = ShopkeeperEditViewModel( + signUpRepository: signUpRepository, + sessionController: sessionController, + messageBus: messageBus, + tabViewModel: tabViewModel, + shopkeeper: testShopkeeper + ) + + viewModel.name = "New Name" + #expect(viewModel.hasInvalidData == false) + + viewModel.name = testShopkeeper.name // reset name + viewModel.email = "new@example.com" + #expect(viewModel.hasInvalidData == false) + + viewModel.email = testShopkeeper.email // reset email + viewModel.selectedTimeZone = "Osaka" + #expect(viewModel.hasInvalidData == false) + } + + @Test + func updateShopkeeperSuccess() async { + sessionController.shopkeeper = testShopkeeper + + let viewModel = ShopkeeperEditViewModel( + signUpRepository: signUpRepository, + sessionController: sessionController, + messageBus: messageBus, + tabViewModel: tabViewModel, + shopkeeper: testShopkeeper + ) + + viewModel.name = "Updated Name" + viewModel.email = "updated@example.com" + viewModel.selectedTimeZone = "Osaka" + + let updateTask = Task { + viewModel.updateShopkeeper() + } + await updateTask.value + + #expect(viewModel.isUpdating == false) + #expect(viewModel.shouldDismiss == true) + #expect(messageBus.currentMessage != nil) + #expect(messageBus.currentMessage!.level == .success) + } + + @Test + func updateShopkeeperWithEmailChangeTriggersReconfirmation() async { + sessionController.shopkeeper = testShopkeeper + + let viewModel = ShopkeeperEditViewModel( + signUpRepository: signUpRepository, + sessionController: sessionController, + messageBus: messageBus, + tabViewModel: tabViewModel, + shopkeeper: testShopkeeper + ) + + viewModel.email = "newemail@example.com" // Email change + + let updateTask = Task { + viewModel.updateShopkeeper() + } + await updateTask.value + + #expect(viewModel.shouldDismiss == true) + #expect(messageBus.currentMessage != nil) + #expect(messageBus.currentMessage!.level == .success) + #expect(messageBus.currentMessage!.message == .reconfirmDescription) + #expect(messageBus.currentMessage!.autoDismiss == false) + #expect(sessionController.userState == .notLoggedIn) // Should be logged out + } + + @Test + func updateShopkeeperWithoutEmailChangeShowsNormalSuccess() async { + sessionController.shopkeeper = testShopkeeper + + let viewModel = ShopkeeperEditViewModel( + signUpRepository: signUpRepository, + sessionController: sessionController, + messageBus: messageBus, + tabViewModel: tabViewModel, + shopkeeper: testShopkeeper + ) + + viewModel.name = "Updated Name" // Name change only, no email change + + let updateTask = Task { + viewModel.updateShopkeeper() + } + await updateTask.value + + #expect(messageBus.currentMessage != nil) + #expect(messageBus.currentMessage!.level == .success) + #expect(messageBus.currentMessage!.message == .shopkeeperUpdated) + #expect(sessionController.userState == .loggedIn) // Should remain logged in + } + + @Test + func updateShopkeeperFailure() async { + signUpRepository.error = NativeAppTemplateAPIError.requestFailed(nil, 422, "Update failed") + sessionController.shopkeeper = testShopkeeper + + let viewModel = ShopkeeperEditViewModel( + signUpRepository: signUpRepository, + sessionController: sessionController, + messageBus: messageBus, + tabViewModel: tabViewModel, + shopkeeper: testShopkeeper + ) + + viewModel.name = "Updated Name" + + let updateTask = Task { + viewModel.updateShopkeeper() + } + await updateTask.value + + #expect(viewModel.isUpdating == false) + #expect(viewModel.shouldDismiss == false) + #expect(messageBus.currentMessage != nil) + #expect(messageBus.currentMessage!.level == .error) + #expect(messageBus.currentMessage!.autoDismiss == false) + } + + @Test + func updateShopkeeperTrimsWhitespace() async { + sessionController.shopkeeper = testShopkeeper + + let viewModel = ShopkeeperEditViewModel( + signUpRepository: signUpRepository, + sessionController: sessionController, + messageBus: messageBus, + tabViewModel: tabViewModel, + shopkeeper: testShopkeeper + ) + + viewModel.name = " Updated Name " + viewModel.email = " updated@example.com " + + let updateTask = Task { + viewModel.updateShopkeeper() + } + await updateTask.value + + #expect(viewModel.shouldDismiss == true) + #expect(messageBus.currentMessage?.level == .success) + } + + @Test + func destroyShopkeeperSuccess() async { + sessionController.shopkeeper = testShopkeeper + tabViewModel.selectedTab = .scan + + let viewModel = ShopkeeperEditViewModel( + signUpRepository: signUpRepository, + sessionController: sessionController, + messageBus: messageBus, + tabViewModel: tabViewModel, + shopkeeper: testShopkeeper + ) + + let destroyTask = Task { + viewModel.destroyShopkeeper() + } + await destroyTask.value + + #expect(viewModel.isDeleting == false) + #expect(viewModel.shouldDismiss == true) + #expect(tabViewModel.selectedTab == .shops) + #expect(messageBus.currentMessage != nil) + #expect(messageBus.currentMessage!.level == .success) + #expect(messageBus.currentMessage!.message == .shopkeeperDeleted) + #expect(sessionController.shopkeeper == nil) + } + + @Test + func destroyShopkeeperFailure() async { + signUpRepository.error = NativeAppTemplateAPIError.requestFailed(nil, 500, "Delete failed") + sessionController.shopkeeper = testShopkeeper + + let viewModel = ShopkeeperEditViewModel( + signUpRepository: signUpRepository, + sessionController: sessionController, + messageBus: messageBus, + tabViewModel: tabViewModel, + shopkeeper: testShopkeeper + ) + + let destroyTask = Task { + viewModel.destroyShopkeeper() + } + await destroyTask.value + + #expect(viewModel.isDeleting == false) + #expect(viewModel.shouldDismiss == true) // Still dismisses even on failure + #expect(tabViewModel.selectedTab == .shops) + #expect(messageBus.currentMessage != nil) + #expect(messageBus.currentMessage!.level == .error) + #expect(messageBus.currentMessage!.autoDismiss == false) + } + + @Test + func busyStateDuringUpdate() async { + sessionController.shopkeeper = testShopkeeper + + let viewModel = ShopkeeperEditViewModel( + signUpRepository: signUpRepository, + sessionController: sessionController, + messageBus: messageBus, + tabViewModel: tabViewModel, + shopkeeper: testShopkeeper + ) + + viewModel.name = "Updated Name" + + let updateTask = Task { + viewModel.updateShopkeeper() + } + + // Check busy state immediately after starting + #expect(viewModel.isBusy == viewModel.isUpdating) + + await updateTask.value + + #expect(viewModel.isBusy == false) + #expect(viewModel.isUpdating == false) + } + + @Test + func busyStateDuringDeletion() async { + sessionController.shopkeeper = testShopkeeper + + let viewModel = ShopkeeperEditViewModel( + signUpRepository: signUpRepository, + sessionController: sessionController, + messageBus: messageBus, + tabViewModel: tabViewModel, + shopkeeper: testShopkeeper + ) + + let destroyTask = Task { + viewModel.destroyShopkeeper() + } + + // Check busy state immediately after starting + #expect(viewModel.isBusy == viewModel.isDeleting) + + await destroyTask.value + + #expect(viewModel.isBusy == false) + #expect(viewModel.isDeleting == false) + } + + @Test + func dialogStateManagement() { + let viewModel = ShopkeeperEditViewModel( + signUpRepository: signUpRepository, + sessionController: sessionController, + messageBus: messageBus, + tabViewModel: tabViewModel, + shopkeeper: testShopkeeper + ) + + #expect(viewModel.isShowingDeleteConfirmationDialog == false) + + viewModel.isShowingDeleteConfirmationDialog = true + #expect(viewModel.isShowingDeleteConfirmationDialog == true) + + viewModel.isShowingDeleteConfirmationDialog = false + #expect(viewModel.isShowingDeleteConfirmationDialog == false) + } + + @Test("Time zone validation", arguments: [ + "Tokyo", + "Osaka", + "UTC", + "America/New_York", + "Europe/London" + ]) + func timeZoneValidation(timeZone: String) { + let viewModel = ShopkeeperEditViewModel( + signUpRepository: signUpRepository, + sessionController: sessionController, + messageBus: messageBus, + tabViewModel: tabViewModel, + shopkeeper: testShopkeeper + ) + + viewModel.selectedTimeZone = timeZone + viewModel.name = "Different Name" // Make sure data is changed + + #expect(viewModel.hasInvalidData == false) // Any time zone string should be valid + } +} diff --git a/NativeAppTemplateTests/UI/Shop Detail/ShopDetailViewModelTest.swift b/NativeAppTemplateTests/UI/Shop Detail/ShopDetailViewModelTest.swift new file mode 100644 index 0000000..ba9a742 --- /dev/null +++ b/NativeAppTemplateTests/UI/Shop Detail/ShopDetailViewModelTest.swift @@ -0,0 +1,367 @@ +// +// ShopDetailViewModelTest.swift +// NativeAppTemplate +// +// Created by Claude on 2025/06/22. +// + +import Testing +import Foundation +@testable import NativeAppTemplate + +@MainActor +@Suite +struct ShopDetailViewModelTest { // swiftlint:disable:this type_body_length + var shops: [Shop] { + [ + mockShop(id: "1", name: "Shop 1"), + mockShop(id: "2", name: "Shop 2") + ] + } + + var itemTags: [ItemTag] { + [ + mockItemTag(id: "1", shopId: "1", queueNumber: "A001"), + mockItemTag(id: "2", shopId: "1", queueNumber: "A002"), + mockItemTag(id: "3", shopId: "1", queueNumber: "A003") + ] + } + + let sessionController = TestSessionController() + let shopRepository = TestShopRepository( + shopsService: ShopsService() + ) + let itemTagRepository = TestItemTagRepository( + itemTagsService: ItemTagsService() + ) + let tabViewModel = TabViewModel() + let mainTab = MainTab.shops + let messageBus = MessageBus() + let shopId = "1" + + @Test + func stateIsInitiallyLoading() { + let viewModel = ShopDetailViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + itemTagRepository: itemTagRepository, + tabViewModel: tabViewModel, + mainTab: mainTab, + messageBus: messageBus, + shopId: shopId + ) + + #expect(viewModel.isFetching) + #expect(viewModel.isBusy) + } + + @Test + func reload() async { + shopRepository.setShops(shops: shops) + itemTagRepository.setItemTags(itemTags: itemTags) + + let viewModel = ShopDetailViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + itemTagRepository: itemTagRepository, + tabViewModel: tabViewModel, + mainTab: mainTab, + messageBus: messageBus, + shopId: shopId + ) + + #expect(viewModel.isFetching == true) + #expect(viewModel.isBusy) + + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + let shop = shops.first { $0.id == shopId }! + + #expect(viewModel.shop == shop) + #expect(viewModel.itemTags == itemTags) + #expect(viewModel.isFetching == false) + #expect(viewModel.isBusy == false) + } + + @Test + func reloadFailed() async { + shopRepository.setShops(shops: shops) + itemTagRepository.setItemTags(itemTags: itemTags) + + let message = "Internal server error." + let httpResponseCode = 500 + shopRepository.error = NativeAppTemplateAPIError.requestFailed(nil, httpResponseCode, message) + + let viewModel = ShopDetailViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + itemTagRepository: itemTagRepository, + tabViewModel: tabViewModel, + mainTab: mainTab, + messageBus: messageBus, + shopId: shopId + ) + + #expect(viewModel.isFetching == true) + #expect(viewModel.isBusy) + + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + #expect(viewModel.messageBus.currentMessage!.message == "\(message) [Status: \(httpResponseCode)]") + #expect(viewModel.shouldDismiss) + } + + @Test + func completeTag() async { + shopRepository.setShops(shops: shops) + itemTagRepository.setItemTags(itemTags: itemTags) + + let viewModel = ShopDetailViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + itemTagRepository: itemTagRepository, + tabViewModel: tabViewModel, + mainTab: mainTab, + messageBus: messageBus, + shopId: shopId + ) + + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + let shop = shops.first { $0.id == shopId }! + #expect(viewModel.shop == shop) + + let completeTagTask = Task { + viewModel.completeTag(itemTagId: itemTags.first!.id) + } + await completeTagTask.value + + let message = String.itemTagCompleted + + #expect(viewModel.messageBus.currentMessage!.message == message) + } + + @Test + func completeTagWhenAlreadyCompleted() async { + shopRepository.setShops(shops: shops) + var modifiedItemTags = itemTags + modifiedItemTags[0].alreadyCompleted = true + + itemTagRepository.setItemTags(itemTags: modifiedItemTags) + + let viewModel = ShopDetailViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + itemTagRepository: itemTagRepository, + tabViewModel: tabViewModel, + mainTab: mainTab, + messageBus: messageBus, + shopId: shopId + ) + + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + let shop = shops.first { $0.id == shopId }! + #expect(viewModel.shop == shop) + + let completeTagTask = Task { + viewModel.completeTag(itemTagId: modifiedItemTags.first!.id) + } + await completeTagTask.value + + let message = String.itemTagAlreadyCompleted + + #expect(viewModel.messageBus.currentMessage!.message == message) + } + + @Test + func completeTagFailed() async { + shopRepository.setShops(shops: shops) + itemTagRepository.setItemTags(itemTags: itemTags) + + let viewModel = ShopDetailViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + itemTagRepository: itemTagRepository, + tabViewModel: tabViewModel, + mainTab: mainTab, + messageBus: messageBus, + shopId: shopId + ) + + 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 + itemTagRepository.error = NativeAppTemplateAPIError.requestFailed(nil, httpResponseCode, message) + + let completeTagTask = Task { + viewModel.completeTag(itemTagId: itemTags.first!.id) + } + await completeTagTask.value + + #expect(viewModel.messageBus.currentMessage!.message == + "\(String.itemTagCompletedError) \(message) [Status: \(httpResponseCode)]") + } + + @Test + func resetTag() async { + shopRepository.setShops(shops: shops) + itemTagRepository.setItemTags(itemTags: itemTags) + + let viewModel = ShopDetailViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + itemTagRepository: itemTagRepository, + tabViewModel: tabViewModel, + mainTab: mainTab, + messageBus: messageBus, + shopId: shopId + ) + + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + let shop = shops.first { $0.id == shopId }! + #expect(viewModel.shop == shop) + + let resetTagTask = Task { + viewModel.resetTag(itemTagId: itemTags.first!.id) + } + await resetTagTask.value + + let message = String.itemTagReset + + #expect(viewModel.messageBus.currentMessage!.message == message) + } + + @Test + func resetTagFailed() async { + shopRepository.setShops(shops: shops) + itemTagRepository.setItemTags(itemTags: itemTags) + + let viewModel = ShopDetailViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + itemTagRepository: itemTagRepository, + tabViewModel: tabViewModel, + mainTab: mainTab, + messageBus: messageBus, + shopId: shopId + ) + + 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 + itemTagRepository.error = NativeAppTemplateAPIError.requestFailed(nil, httpResponseCode, message) + + let resetTagTask = Task { + viewModel.resetTag(itemTagId: itemTags.first!.id) + } + await resetTagTask.value + + #expect(viewModel.messageBus.currentMessage!.message == + "\(String.itemTagResetError) \(message) [Status: \(httpResponseCode)]") + } + + @Test + func setTabViewModelShowingDetailViewToTrue() { + tabViewModel.showingDetailView[mainTab] = false + + let viewModel = ShopDetailViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + itemTagRepository: itemTagRepository, + tabViewModel: tabViewModel, + mainTab: mainTab, + messageBus: messageBus, + shopId: shopId + ) + + viewModel.setTabViewModelShowingDetailViewToTrue() + + #expect(tabViewModel.showingDetailView[mainTab] == true) + } + + @Test + func scrollToTopID() { + let viewModel = ShopDetailViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + itemTagRepository: itemTagRepository, + tabViewModel: tabViewModel, + mainTab: mainTab, + messageBus: messageBus, + shopId: shopId + ) + + let scrollToTopID = viewModel.scrollToTopID() + + #expect(scrollToTopID == ScrollToTopID(mainTab: mainTab, detail: true)) + } + + 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" + ) + } + + private func mockItemTag( + id: String = UUID().uuidString, + shopId: String = UUID().uuidString, + queueNumber: String = "Mock ItemTag" + ) -> ItemTag { + + let dateString = "2025-05-18 18:00:00 UTC" + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss 'UTC'" + let date = formatter.date(from: dateString)! + + return ItemTag( + id: id, + shopId: shopId, + queueNumber: queueNumber, + state: .idled, + scanState: .unscanned, + createdAt: date, + customerReadAt: nil, + completedAt: nil, + shopName: "Mock ItemTag", + alreadyCompleted: false + ) + } +}