diff --git a/NativeAppTemplate.xcodeproj/project.pbxproj b/NativeAppTemplate.xcodeproj/project.pbxproj index 6eba32c..0976e6b 100644 --- a/NativeAppTemplate.xcodeproj/project.pbxproj +++ b/NativeAppTemplate.xcodeproj/project.pbxproj @@ -136,6 +136,10 @@ 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 */; }; + 01D85AE72E07CD4400A95798 /* ItemTagDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D85AE62E07CD4400A95798 /* ItemTagDetailViewModel.swift */; }; + 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 */; }; 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 */; }; @@ -296,6 +300,10 @@ 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 = ""; }; + 01D85AE62E07CD4400A95798 /* ItemTagDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTagDetailViewModel.swift; sourceTree = ""; }; + 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 = ""; }; 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 = ""; }; @@ -724,7 +732,9 @@ isa = PBXGroup; children = ( 017278932D7D99D100CE424F /* ItemTagDetailView.swift */, + 01D85AE62E07CD4400A95798 /* ItemTagDetailViewModel.swift */, 017278942D7D99D100CE424F /* ItemTagEditView.swift */, + 01D85AEA2E07CF3600A95798 /* ItemTagEditViewModel.swift */, ); path = "ItemTag Detail"; sourceTree = ""; @@ -733,8 +743,10 @@ isa = PBXGroup; children = ( 017278962D7D99D100CE424F /* ItemTagCreateView.swift */, + 01D85AEE2E07D20500A95798 /* ItemTagCreateViewModel.swift */, 017278972D7D99D100CE424F /* ItemTagListCardView.swift */, 017278982D7D99D100CE424F /* ItemTagListView.swift */, + 01D85AF22E07D37E00A95798 /* ItemTagListViewModel.swift */, ); path = "ItemTag List"; sourceTree = ""; @@ -932,6 +944,7 @@ "", "", "", + "", ); }; /* End PBXShellScriptBuildPhase section */ @@ -962,6 +975,7 @@ 01FC03E22B3329B700E6CD8E /* NeedAppUpdatesView.swift in Sources */, 0172033725A9642E008FD63B /* JSONAPIResource.swift in Sources */, 01B37C7629B0960700BF5B2D /* ForgotPasswordView.swift in Sources */, + 01D85AEF2E07D20500A95798 /* ItemTagCreateViewModel.swift in Sources */, 01E0A5B725BD0FCD00298D35 /* OfflineView.swift in Sources */, 0110A15F2AC816F5003EDCBA /* SendConfirmation.swift in Sources */, 0199CD2A2E07512100109DC6 /* OnboardingRepositoryProtocol.swift in Sources */, @@ -1039,6 +1053,7 @@ 01482FA42B351E4100A56D43 /* AcceptPrivacyView.swift in Sources */, 0172046325AA82BF008FD63B /* OnboardingView.swift in Sources */, 013DE735284E99DF00528CC5 /* ShopCreateView.swift in Sources */, + 01D85AE72E07CD4400A95798 /* ItemTagDetailViewModel.swift in Sources */, 017203B625A96FD6008FD63B /* View+Extensions.swift in Sources */, 0106414229A9F51700B46FED /* AccountPasswordRepository.swift in Sources */, 0172033A25A9642E008FD63B /* JSONAPIDocument.swift in Sources */, @@ -1061,6 +1076,7 @@ 0172789A2D7D99D100CE424F /* ItemTagListCardView.swift in Sources */, 0172789B2D7D99D100CE424F /* ItemTagListView.swift in Sources */, 0172789C2D7D99D100CE424F /* ItemTagDetailView.swift in Sources */, + 01D85AEB2E07CF3600A95798 /* ItemTagEditViewModel.swift in Sources */, 0172789D2D7D99D100CE424F /* ItemTagCreateView.swift in Sources */, 0172789E2D7D99D100CE424F /* ItemTagEditView.swift in Sources */, 0172786F2D7D87D000CE424F /* String+Extensions.swift in Sources */, @@ -1069,6 +1085,7 @@ 01E0A59C25BD088600298D35 /* SettingsView.swift in Sources */, 0172052525AAFA43008FD63B /* Shopkeeper.swift in Sources */, 017278752D7D8FAC00CE424F /* ItemTagRepository.swift in Sources */, + 01D85AF32E07D37E00A95798 /* ItemTagListViewModel.swift in Sources */, 0172047E25AA8343008FD63B /* Font+Extensions.swift in Sources */, 0172034825A9642E008FD63B /* Request.swift in Sources */, 0172048025AA8343008FD63B /* UIColor+Extensions.swift in Sources */, diff --git a/NativeAppTemplate/UI/Shop Settings/ItemTag Detail/ItemTagDetailView.swift b/NativeAppTemplate/UI/Shop Settings/ItemTag Detail/ItemTagDetailView.swift index fcae284..876e93d 100644 --- a/NativeAppTemplate/UI/Shop Settings/ItemTag Detail/ItemTagDetailView.swift +++ b/NativeAppTemplate/UI/Shop Settings/ItemTag Detail/ItemTagDetailView.swift @@ -11,44 +11,21 @@ import CoreNFC struct ItemTagDetailView: View { @Environment(\.dismiss) private var dismiss - @Environment(MessageBus.self) private var messageBus - @Environment(\.sessionController) private var sessionController - private var itemTagRepository: ItemTagRepositoryProtocol - @StateObject private var nfcManager = appSingletons.nfcManager - @State private var isLocked = false - @State private var isShowingEditSheet = false - @State private var isShowingDeleteConfirmationDialog = false - @State private var isFetching = true - @State private var isGeneratingQrCode = false - @State private var isDeleting = false - @State private var customerTagQrCodeImage: UIImage? - private let qrCodeGenerator = QRCodeGenerator() - private let imageSaver = ImageSaver() + @State private var viewModel: ItemTagDetailViewModel - private var shop: Shop - private var itemTagId: String - - private var itemTag: Binding { - Binding { - itemTagRepository.findBy(id: itemTagId) - } set: { _ in - } - } - - init( - itemTagRepository: ItemTagRepositoryProtocol, - shop: Shop, - itemTagId: String - ) { - self.itemTagRepository = itemTagRepository - self.shop = shop - self.itemTagId = itemTagId + init(viewModel: ItemTagDetailViewModel) { + self._viewModel = State(wrappedValue: viewModel) } var body: some View { contentView .task { - reload() + viewModel.reload() + } + .onChange(of: viewModel.shouldDismiss) { _, shouldDismiss in + if shouldDismiss { + dismiss() + } } } } @@ -58,7 +35,7 @@ private extension ItemTagDetailView { var contentView: some View { @ViewBuilder var contentView: some View { - if isFetching || isDeleting || isGeneratingQrCode { + if viewModel.isBusy { LoadingView() } else { itemTagDetailView @@ -76,26 +53,28 @@ private extension ItemTagDetailView { .font(.title2) .padding(.top, 8) - Text(shop.name) + Text(viewModel.shop.name) .font(.title3) .padding(.top, 16) - Text(String(itemTag.wrappedValue.queueNumber)) - .font(.largeTitle) - .bold() - .padding(.top, 8) - .foregroundStyle(.lightestAccent) + if let itemTag = viewModel.itemTag { + Text(String(itemTag.queueNumber)) + .font(.largeTitle) + .bold() + .padding(.top, 8) + .foregroundStyle(.lightestAccent) + } } GroupBox(label: Label(String("Lock"), systemImage: "lock") ) { - Toggle(isOn: $isLocked) { + Toggle(isOn: $viewModel.isLocked) { Text(verbatim: "Lock") } .dynamicTypeSize(...DynamicTypeSize.large) .frame(width: 96) .tint(.lockForeground) - if isLocked { + if viewModel.isLocked { Text(String.youCannotUndoAfterLockingTag) .font(.uiFootnote) .foregroundStyle(.alarm) @@ -106,22 +85,7 @@ private extension ItemTagDetailView { GroupBox(label: Label(String("Server"), systemImage: "storefront") ) { MainButtonView(title: String.writeServerTag, type: .server(withArrow: false)) { - guard NFCNDEFReaderSession.readingAvailable else { - messageBus.post( - message: Message( - level: .error, - message: String.thisDeviceDoesNotSupportTagScanning, - autoDismiss: false - ) - ) - return - } - - let ndefMessage = createNdefMessage(itemTag: itemTag.wrappedValue, itemTagType: .server) - - Task { - await nfcManager.startWriting(ndefMessage: ndefMessage, isLock: isLocked) - } + viewModel.writeServerTag() } .padding() } @@ -130,48 +94,17 @@ private extension ItemTagDetailView { GroupBox(label: Label(String("Customer"), systemImage: "person.2") ) { MainButtonView(title: String.writeCustomerTag, type: .customer(withArrow: false)) { - guard NFCNDEFReaderSession.readingAvailable else { - messageBus.post( - message: Message( - level: .error, - message: String.thisDeviceDoesNotSupportTagScanning, - autoDismiss: false - ) - ) - return - } - - let ndefMessage = createNdefMessage(itemTag: itemTag.wrappedValue, itemTagType: .customer) - - Task { - await nfcManager.startWriting(ndefMessage: ndefMessage, isLock: isLocked) - } + viewModel.writeCustomerTag() } .padding() - if let customerTagQrCodeImage = customerTagQrCodeImage { + if let customerTagQrCodeImage = viewModel.customerTagQrCodeImage { Image(uiImage: customerTagQrCodeImage) .resizable() .frame(width: 96, height: 96) Button { - getSaveToPhotoAlbumPermissionIfNeeded { granted in - guard granted else { return } - - imageSaver.save(image: customerTagQrCodeImage) { error in - if let error { - messageBus.post( - message: Message( - level: .error, - message: "\(String.customerQrCodeImageSavedToPhotoAlbumError)(\(error))", - autoDismiss: false - ) - ) - } else { - messageBus.post(message: Message(level: .success, message: .customerQrCodeImageSavedToPhotoAlbum)) - } - } - } + viewModel.saveImageToPhotoAlbum() } label: { Text(String.saveToPhotoAlbum) } @@ -185,23 +118,30 @@ private extension ItemTagDetailView { } } .sheet( - isPresented: $isShowingEditSheet, + isPresented: $viewModel.isShowingEditSheet, onDismiss: { - reload() + viewModel.reload() }, content: { - ItemTagEditView(itemTagRepository: itemTagRepository, itemTagId: itemTagId) + ItemTagEditView( + viewModel: ItemTagEditViewModel( + itemTagRepository: viewModel.itemTagRepository, + messageBus: viewModel.messageBus, + sessionController: viewModel.sessionController, + itemTagId: viewModel.itemTagId + ) + ) } ) .confirmationDialog( String.buttonDeleteTag, - isPresented: $isShowingDeleteConfirmationDialog + isPresented: $viewModel.isShowingDeleteConfirmationDialog ) { Button(String.buttonDeleteTag, role: .destructive) { - destroyItemTag() + viewModel.destroyItemTag() } Button(String.cancel, role: .cancel) { - isShowingDeleteConfirmationDialog = false + viewModel.isShowingDeleteConfirmationDialog = false } } message: { Text(String.areYouSure) @@ -209,14 +149,14 @@ private extension ItemTagDetailView { .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button { - isShowingEditSheet.toggle() + viewModel.isShowingEditSheet.toggle() } label: { Text(String.edit) } } ToolbarItem(placement: .navigationBarTrailing) { Button { - isShowingDeleteConfirmationDialog.toggle() + viewModel.isShowingDeleteConfirmationDialog.toggle() } label: { Image(systemName: "trash") } @@ -224,84 +164,13 @@ private extension ItemTagDetailView { } } - private func reload() { - fetchItemTagDetail() - } - - private func reloadCustomerTagQrCodeImage() { - isGeneratingQrCode = true - - let scanUrl = itemTag.wrappedValue.scanUrl(itemTagType: ItemTagType.customer) - - customerTagQrCodeImage = qrCodeGenerator.generateWithCenterText( - inputText: scanUrl.absoluteString, - centerText: String(itemTag.wrappedValue.queueNumber) - ) - - isGeneratingQrCode = false - } - - private func fetchItemTagDetail() { - Task { @MainActor in - do { - isFetching = true - _ = try await itemTagRepository.fetchDetail(id: itemTagId) - isFetching = false - } catch { - messageBus.post(message: Message(level: .error, message: error.localizedDescription, autoDismiss: false)) - dismiss() - } - } - } - private var generateCustomerQrCodeView: some View { VStack { Button { - reloadCustomerTagQrCodeImage() + viewModel.generateCustomerQrCode() } label: { Text(String.generateCustomerQrCode) } } } - - private func destroyItemTag() { - Task { @MainActor in - isDeleting = true - - do { - try await itemTagRepository.destroy(id: itemTag.id) - messageBus.post(message: Message(level: .success, message: .itemTagDeleted)) - } catch { - messageBus.post(message: Message(level: .error, message: "\(String.itemTagDeletedError) \(error.localizedDescription)", autoDismiss: false)) - } - - dismiss() - } - } - - private func createNdefMessage(itemTag: ItemTag, itemTagType: ItemTagType) -> NFCNDEFMessage { - let scanUrl = itemTag.scanUrl(itemTagType: itemTagType) - let urlPayload = NFCNDEFPayload.wellKnownTypeURIPayload(url: scanUrl) - let androidAarPayloadData = String.androidAar.data(using: .utf8)! - let androidAarPayload = NFCNDEFPayload(format: .nfcExternal, type: Data(String.androidAarNfcndefPayloadType.utf8), identifier: Data(), payload: androidAarPayloadData) - - let ndefMessage = if itemTagType == ItemTagType.server { - NFCNDEFMessage(records: [urlPayload!, androidAarPayload]) - } else { - NFCNDEFMessage(records: [urlPayload!]) - } - - return ndefMessage - } - - private func getSaveToPhotoAlbumPermissionIfNeeded(completionHandler: @escaping (Bool) -> Void) { - guard PHPhotoLibrary.authorizationStatus(for: .addOnly) != .authorized else { - completionHandler(true) - return - } - - PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in - completionHandler(status == .authorized ? true : false) - } - } } diff --git a/NativeAppTemplate/UI/Shop Settings/ItemTag Detail/ItemTagDetailViewModel.swift b/NativeAppTemplate/UI/Shop Settings/ItemTag Detail/ItemTagDetailViewModel.swift new file mode 100644 index 0000000..64a96d2 --- /dev/null +++ b/NativeAppTemplate/UI/Shop Settings/ItemTag Detail/ItemTagDetailViewModel.swift @@ -0,0 +1,194 @@ +// +// ItemTagDetailViewModel.swift +// NativeAppTemplate +// +// Created by Claude on 2025/06/22. +// + +import SwiftUI +import Observation +import Photos +import CoreNFC + +@Observable +@MainActor +final class ItemTagDetailViewModel { + var isLocked = false + var isShowingEditSheet = false + var isShowingDeleteConfirmationDialog = false + var isFetching = true + var isGeneratingQrCode = false + var isDeleting = false + var customerTagQrCodeImage: UIImage? + var shouldDismiss = false + private(set) var itemTag: ItemTag? + + let itemTagRepository: ItemTagRepositoryProtocol + let messageBus: MessageBus + let sessionController: SessionControllerProtocol + private let nfcManager: NFCManager + private let qrCodeGenerator = QRCodeGenerator() + private let imageSaver = ImageSaver() + let shop: Shop + let itemTagId: String + + init( + itemTagRepository: ItemTagRepositoryProtocol, + messageBus: MessageBus, + sessionController: SessionControllerProtocol, + nfcManager: NFCManager, + shop: Shop, + itemTagId: String + ) { + self.itemTagRepository = itemTagRepository + self.messageBus = messageBus + self.sessionController = sessionController + self.nfcManager = nfcManager + self.shop = shop + self.itemTagId = itemTagId + } + + var isBusy: Bool { + isFetching || isDeleting || isGeneratingQrCode + } + + func reload() { + fetchItemTagDetail() + } + + func generateCustomerQrCode() { + guard let itemTag = itemTag else { return } + + isGeneratingQrCode = true + + let scanUrl = itemTag.scanUrl(itemTagType: ItemTagType.customer) + + customerTagQrCodeImage = qrCodeGenerator.generateWithCenterText( + inputText: scanUrl.absoluteString, + centerText: String(itemTag.queueNumber) + ) + + isGeneratingQrCode = false + } + + func writeServerTag() { + guard let itemTag = itemTag else { return } + + guard NFCNDEFReaderSession.readingAvailable else { + messageBus.post( + message: Message( + level: .error, + message: String.thisDeviceDoesNotSupportTagScanning, + autoDismiss: false + ) + ) + return + } + + let ndefMessage = createNdefMessage(itemTag: itemTag, itemTagType: .server) + + Task { + await nfcManager.startWriting(ndefMessage: ndefMessage, isLock: isLocked) + } + } + + func writeCustomerTag() { + guard let itemTag = itemTag else { return } + + guard NFCNDEFReaderSession.readingAvailable else { + messageBus.post( + message: Message( + level: .error, + message: String.thisDeviceDoesNotSupportTagScanning, + autoDismiss: false + ) + ) + return + } + + let ndefMessage = createNdefMessage(itemTag: itemTag, itemTagType: .customer) + + Task { + await nfcManager.startWriting(ndefMessage: ndefMessage, isLock: isLocked) + } + } + + func saveImageToPhotoAlbum() { + guard let customerTagQrCodeImage = customerTagQrCodeImage else { return } + + getSaveToPhotoAlbumPermissionIfNeeded { granted in + guard granted else { return } + + self.imageSaver.save(image: customerTagQrCodeImage) { error in + if let error { + self.messageBus.post( + message: Message( + level: .error, + message: "\(String.customerQrCodeImageSavedToPhotoAlbumError)(\(error))", + autoDismiss: false + ) + ) + } else { + self.messageBus.post(message: Message(level: .success, message: .customerQrCodeImageSavedToPhotoAlbum)) + } + } + } + } + + func destroyItemTag() { + guard let itemTag = itemTag else { return } + + Task { + isDeleting = true + + do { + try await itemTagRepository.destroy(id: itemTag.id) + messageBus.post(message: Message(level: .success, message: .itemTagDeleted)) + } catch { + messageBus.post(message: Message(level: .error, message: "\(String.itemTagDeletedError) \(error.localizedDescription)", autoDismiss: false)) + } + + shouldDismiss = true + } + } + + private func fetchItemTagDetail() { + Task { + do { + isFetching = true + itemTag = try await itemTagRepository.fetchDetail(id: itemTagId) + } catch { + messageBus.post(message: Message(level: .error, message: error.localizedDescription, autoDismiss: false)) + shouldDismiss = true + } + + isFetching = false + } + } + + private func createNdefMessage(itemTag: ItemTag, itemTagType: ItemTagType) -> NFCNDEFMessage { + let scanUrl = itemTag.scanUrl(itemTagType: itemTagType) + let urlPayload = NFCNDEFPayload.wellKnownTypeURIPayload(url: scanUrl) + let androidAarPayloadData = String.androidAar.data(using: .utf8)! + let androidAarPayload = NFCNDEFPayload(format: .nfcExternal, type: Data(String.androidAarNfcndefPayloadType.utf8), identifier: Data(), payload: androidAarPayloadData) + + let ndefMessage = if itemTagType == ItemTagType.server { + NFCNDEFMessage(records: [urlPayload!, androidAarPayload]) + } else { + NFCNDEFMessage(records: [urlPayload!]) + } + + return ndefMessage + } + + private func getSaveToPhotoAlbumPermissionIfNeeded(completionHandler: @escaping (Bool) -> Void) { + guard PHPhotoLibrary.authorizationStatus(for: .addOnly) != .authorized else { + completionHandler(true) + return + } + + PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in + completionHandler(status == .authorized ? true : false) + } + } +} diff --git a/NativeAppTemplate/UI/Shop Settings/ItemTag Detail/ItemTagEditView.swift b/NativeAppTemplate/UI/Shop Settings/ItemTag Detail/ItemTagEditView.swift index 429429e..413a587 100644 --- a/NativeAppTemplate/UI/Shop Settings/ItemTag Detail/ItemTagEditView.swift +++ b/NativeAppTemplate/UI/Shop Settings/ItemTag Detail/ItemTagEditView.swift @@ -9,61 +9,21 @@ import SwiftUI struct ItemTagEditView: View { @Environment(\.dismiss) private var dismiss - @Environment(MessageBus.self) private var messageBus - @Environment(\.sessionController) private var sessionController - private var itemTagRepository: ItemTagRepositoryProtocol - @State private var queueNumber = "" - @State private var isFetching = true - @State private var isUpdating = false - private var itemTagId: String + @State private var viewModel: ItemTagEditViewModel - private var itemTag: Binding { - Binding { - itemTagRepository.findBy(id: itemTagId) - } set: { _ in - } - } - - init( - itemTagRepository: ItemTagRepositoryProtocol, - itemTagId: String - ) { - self.itemTagRepository = itemTagRepository - self.itemTagId = itemTagId + init(viewModel: ItemTagEditViewModel) { + self._viewModel = State(wrappedValue: viewModel) } - private var hasInvalidData: Bool { - if hasInvalidDataQueueNumber { - return true - } - - if itemTag.wrappedValue.queueNumber == queueNumber { - return true - } - - return false - } - - private var hasInvalidDataQueueNumber: Bool { - if Utility.isBlank(queueNumber) { - return true - } - - if !queueNumber.isAlphanumeric(ignoreDiacritics: true) { - return true - } - - if !(2 <= queueNumber.count && queueNumber.count <= sessionController.maximumQueueNumberLength) { - return true - } - - return false - } - var body: some View { contentView .task { - reload() + viewModel.reload() + } + .onChange(of: viewModel.shouldDismiss) { _, shouldDismiss in + if shouldDismiss { + dismiss() + } } } } @@ -73,7 +33,7 @@ private extension ItemTagEditView { var contentView: some View { @ViewBuilder var contentView: some View { - if isFetching || isUpdating { + if viewModel.isBusy { LoadingView() } else { itemTagEditView @@ -87,22 +47,22 @@ private extension ItemTagEditView { NavigationStack { Form { Section { - TextField(String("A001"), text: $queueNumber) + TextField(String("A001"), text: $viewModel.queueNumber) .keyboardType(.asciiCapable) - .onChange(of: queueNumber) { - queueNumber = String(queueNumber.prefix(sessionController.maximumQueueNumberLength)) + .onChange(of: viewModel.queueNumber) { _, _ in + viewModel.validateQueueNumberLength() } } header: { Text(String.tagNumber) } footer: { VStack(alignment: .leading) { - Text("Tag Number must be a 2-\(sessionController.maximumQueueNumberLength) alphanumeric characters.") + Text("Tag Number must be a 2-\(viewModel.maximumQueueNumberLength) alphanumeric characters.") .font(.uiFootnote) Text(String.zeroPadding) .font(.uiFootnote) Text(String.tagNumberIsInvalid) .font(.uiFootnote) - .foregroundStyle(hasInvalidDataQueueNumber ? .red : .clear) + .foregroundStyle(viewModel.hasInvalidDataQueueNumber ? .red : .clear) } } } @@ -110,11 +70,11 @@ private extension ItemTagEditView { .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button { - updateItemTag() + viewModel.updateItemTag() } label: { Text(String.save) } - .disabled(hasInvalidData) + .disabled(viewModel.hasInvalidData) } ToolbarItem(placement: .navigationBarLeading) { Button { @@ -126,43 +86,4 @@ private extension ItemTagEditView { } } } - - func reload() { - fetchItemTagDetail() - } - - private func fetchItemTagDetail() { - Task { @MainActor in - isFetching = true - - do { - _ = try await itemTagRepository.fetchDetail(id: itemTagId) - - queueNumber = String(itemTag.wrappedValue.queueNumber) - - isFetching = false - } catch { - messageBus.post(message: Message(level: .error, message: error.localizedDescription, autoDismiss: false)) - isFetching = false - dismiss() - } - } - } - - func updateItemTag() { - Task { @MainActor in - isUpdating = true - - do { - let itemTag = ItemTag(queueNumber: queueNumber) - _ = try await itemTagRepository.update(id: itemTagId, itemTag: itemTag) - messageBus.post(message: Message(level: .success, message: .itemTagUpdated)) - } catch { - messageBus.post(message: Message(level: .error, message: error.localizedDescription, autoDismiss: false)) - } - - isUpdating = false - dismiss() - } - } } diff --git a/NativeAppTemplate/UI/Shop Settings/ItemTag Detail/ItemTagEditViewModel.swift b/NativeAppTemplate/UI/Shop Settings/ItemTag Detail/ItemTagEditViewModel.swift new file mode 100644 index 0000000..1e087f1 --- /dev/null +++ b/NativeAppTemplate/UI/Shop Settings/ItemTag Detail/ItemTagEditViewModel.swift @@ -0,0 +1,121 @@ +// +// ItemTagEditViewModel.swift +// NativeAppTemplate +// +// Created by Claude on 2025/06/22. +// + +import SwiftUI +import Observation + +@Observable +@MainActor +final class ItemTagEditViewModel { + var queueNumber = "" + var isFetching = true + var isUpdating = false + var shouldDismiss = false + private(set) var itemTag: ItemTag? + + private let itemTagRepository: ItemTagRepositoryProtocol + private let messageBus: MessageBus + private let sessionController: SessionControllerProtocol + private let itemTagId: String + + init( + itemTagRepository: ItemTagRepositoryProtocol, + messageBus: MessageBus, + sessionController: SessionControllerProtocol, + itemTagId: String + ) { + self.itemTagRepository = itemTagRepository + self.messageBus = messageBus + self.sessionController = sessionController + self.itemTagId = itemTagId + } + + var isBusy: Bool { + isFetching || isUpdating + } + + var hasInvalidData: Bool { + guard let itemTag = itemTag else { return true } + + if hasInvalidDataQueueNumber { + return true + } + + if itemTag.queueNumber == queueNumber { + return true + } + + return false + } + + var hasInvalidDataQueueNumber: Bool { + if Utility.isBlank(queueNumber) { + return true + } + + if !queueNumber.isAlphanumeric(ignoreDiacritics: true) { + return true + } + + if !(2 <= queueNumber.count && queueNumber.count <= maximumQueueNumberLength) { + return true + } + + return false + } + + var maximumQueueNumberLength: Int { + sessionController.maximumQueueNumberLength + } + + func reload() { + fetchItemTagDetail() + } + + func validateQueueNumberLength() { + queueNumber = String(queueNumber.prefix(maximumQueueNumberLength)) + } + + func updateItemTag() { + Task { + isUpdating = true + + do { + let itemTag = ItemTag( + id: itemTagId, + queueNumber: queueNumber + ) + + _ = try await itemTagRepository.update(id: itemTagId, itemTag: itemTag) + messageBus.post(message: Message(level: .success, message: .itemTagUpdated)) + } catch { + messageBus.post(message: Message(level: .error, message: error.localizedDescription, autoDismiss: false)) + } + + isUpdating = false + shouldDismiss = true + } + } + + private func fetchItemTagDetail() { + Task { + isFetching = true + + do { + itemTag = try await itemTagRepository.fetchDetail(id: itemTagId) + if let itemTag = itemTag { + queueNumber = String(itemTag.queueNumber) + } + } catch { + messageBus.post(message: Message(level: .error, message: error.localizedDescription, autoDismiss: false)) + shouldDismiss = true + } + + isFetching = false + } + } +} diff --git a/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagCreateView.swift b/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagCreateView.swift index 11ce3a1..b1a17c4 100644 --- a/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagCreateView.swift +++ b/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagCreateView.swift @@ -9,47 +9,19 @@ import SwiftUI struct ItemTagCreateView: View { @Environment(\.dismiss) private var dismiss - @Environment(MessageBus.self) private var messageBus - @Environment(\.sessionController) private var sessionController - private var itemTagRepository: ItemTagRepositoryProtocol - @State private var queueNumber = "" - @State private var isCreating = false - private var shopId: String + @State private var viewModel: ItemTagCreateViewModel - init( - itemTagRepository: ItemTagRepositoryProtocol, - shopId: String - ) { - self.itemTagRepository = itemTagRepository - self.shopId = shopId - } - - private var hasInvalidData: Bool { - if hasInvalidDataQueueNumber { - return true - } - - return false - } - - private var hasInvalidDataQueueNumber: Bool { - if Utility.isBlank(queueNumber) { - return true - } - - if !queueNumber.isAlphanumeric(ignoreDiacritics: true) { - return true - } - - if !(2 <= queueNumber.count && queueNumber.count <= sessionController.maximumQueueNumberLength) { - return true - } - - return false + init(viewModel: ItemTagCreateViewModel) { + 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 ItemTagCreateView { var contentView: some View { @ViewBuilder var contentView: some View { - if isCreating { + if viewModel.isBusy { LoadingView() } else { itemTagCreateView @@ -72,22 +44,22 @@ private extension ItemTagCreateView { NavigationStack { Form { Section { - TextField(String("A001"), text: $queueNumber) + TextField(String("A001"), text: $viewModel.queueNumber) .keyboardType(.asciiCapable) - .onChange(of: queueNumber) { - queueNumber = String(queueNumber.prefix(sessionController.maximumQueueNumberLength)) + .onChange(of: viewModel.queueNumber) { _, _ in + viewModel.validateQueueNumberLength() } } header: { Text(String.tagNumber) } footer: { VStack(alignment: .leading) { - Text("Tag Number must be a 2-\(sessionController.maximumQueueNumberLength) alphanumeric characters.") + Text("Tag Number must be a 2-\(viewModel.maximumQueueNumberLength) alphanumeric characters.") .font(.uiFootnote) Text(String.zeroPadding) .font(.uiFootnote) Text(String.tagNumberIsInvalid) .font(.uiFootnote) - .foregroundStyle(hasInvalidDataQueueNumber ? .red : .clear) + .foregroundStyle(viewModel.hasInvalidDataQueueNumber ? .red : .clear) } } } @@ -95,11 +67,11 @@ private extension ItemTagCreateView { .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button { - createItemTag() + viewModel.createItemTag() } label: { Text(String.save) } - .disabled(hasInvalidData) + .disabled(viewModel.hasInvalidData) } ToolbarItem(placement: .navigationBarLeading) { Button { @@ -111,26 +83,4 @@ private extension ItemTagCreateView { } } } - - func createItemTag() { - Task { @MainActor in - isCreating = true - - do { - let itemTag = ItemTag(queueNumber: queueNumber) - _ = try await itemTagRepository.create(shopId: shopId, itemTag: itemTag) - messageBus.post(message: Message(level: .success, message: .itemTagCreated)) - } catch { - messageBus.post( - message: Message( - level: .error, - message: error.localizedDescription, - autoDismiss: false - ) - ) - } - - dismiss() - } - } } diff --git a/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagCreateViewModel.swift b/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagCreateViewModel.swift new file mode 100644 index 0000000..ef6239c --- /dev/null +++ b/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagCreateViewModel.swift @@ -0,0 +1,88 @@ +// +// ItemTagCreateViewModel.swift +// NativeAppTemplate +// +// Created by Claude on 2025/06/22. +// + +import SwiftUI +import Observation + +@Observable +@MainActor +final class ItemTagCreateViewModel { + var queueNumber = "" + var isCreating = false + var shouldDismiss = false + + private let itemTagRepository: ItemTagRepositoryProtocol + private let messageBus: MessageBus + private let sessionController: SessionControllerProtocol + private let shopId: String + + init( + itemTagRepository: ItemTagRepositoryProtocol, + messageBus: MessageBus, + sessionController: SessionControllerProtocol, + shopId: String + ) { + self.itemTagRepository = itemTagRepository + self.messageBus = messageBus + self.sessionController = sessionController + self.shopId = shopId + } + + var isBusy: Bool { + isCreating + } + + var hasInvalidData: Bool { + hasInvalidDataQueueNumber + } + + var hasInvalidDataQueueNumber: Bool { + if Utility.isBlank(queueNumber) { + return true + } + + if !queueNumber.isAlphanumeric(ignoreDiacritics: true) { + return true + } + + if !(2 <= queueNumber.count && queueNumber.count <= maximumQueueNumberLength) { + return true + } + + return false + } + + var maximumQueueNumberLength: Int { + sessionController.maximumQueueNumberLength + } + + func validateQueueNumberLength() { + queueNumber = String(queueNumber.prefix(maximumQueueNumberLength)) + } + + func createItemTag() { + Task { + isCreating = true + + do { + let itemTag = ItemTag(queueNumber: queueNumber) + _ = try await itemTagRepository.create(shopId: shopId, itemTag: itemTag) + messageBus.post(message: Message(level: .success, message: .itemTagCreated)) + } catch { + messageBus.post( + message: Message( + level: .error, + message: error.localizedDescription, + autoDismiss: false + ) + ) + } + + shouldDismiss = true + } + } +} diff --git a/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagListView.swift b/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagListView.swift index f068444..1882b2d 100644 --- a/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagListView.swift +++ b/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagListView.swift @@ -8,26 +8,18 @@ import SwiftUI struct ItemTagListView: View { + @Environment(DataManager.self) private var dataManager @Environment(MessageBus.self) private var messageBus - @Environment(\.sessionController) private var sessionController - private var itemTagRepository: ItemTagRepositoryProtocol - @State private var isShowingCreateSheet = false - @State private var isDeleting = false - @State private var isShowingDeleteConfirmationDialog = false - private let shop: Shop - - init( - itemTagRepository: ItemTagRepositoryProtocol, - shop: Shop - ) { - self.itemTagRepository = itemTagRepository - self.shop = shop + @State private var viewModel: ItemTagListViewModel + + init(viewModel: ItemTagListViewModel) { + self._viewModel = State(initialValue: viewModel) } - + var body: some View { contentView .task { - reload() + viewModel.reload() } } } @@ -36,10 +28,10 @@ struct ItemTagListView: View { private extension ItemTagListView { var contentView: some View { @ViewBuilder var contentView: some View { - if isDeleting { + if viewModel.isBusy { LoadingView() } else { - switch itemTagRepository.state { + switch viewModel.state { case .initial, .loading: LoadingView() case .hasData: @@ -55,24 +47,26 @@ private extension ItemTagListView { var itemTagListView: some View { VStack { - Text(shop.name) + Text(viewModel.shop.name) .font(.uiTitle1) .foregroundStyle(.titleText) .padding(.top, 24) .multilineTextAlignment(.center) - if itemTagRepository.isEmpty { + if viewModel.isEmpty { noResultsView } else { - List(itemTagRepository.itemTags) { itemTag in + List(viewModel.itemTags) { itemTag in NavigationLink( - destination: ItemTagDetailView(itemTagRepository: itemTagRepository, shop: shop, itemTagId: itemTag.id) + destination: ItemTagDetailView( + viewModel: viewModel.createItemTagDetailViewModel(itemTagId: itemTag.id) + ) ) { ItemTagListCardView( itemTag: itemTag ) .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { destroyItemTag(itemTagId: itemTag.id) } label: { + Button(role: .destructive) { viewModel.destroyItemTag(itemTagId: itemTag.id) } label: { Label(String.delete, systemImage: "trash") .labelStyle(.titleOnly) } @@ -82,7 +76,7 @@ private extension ItemTagListView { .listRowBackground(Color.cardBackground) } .refreshable { - reload() + viewModel.reload() } } } @@ -90,41 +84,23 @@ private extension ItemTagListView { .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button { - isShowingCreateSheet.toggle() + viewModel.isShowingCreateSheet.toggle() } label: { Image(systemName: "plus") } } } - .sheet(isPresented: $isShowingCreateSheet, + .sheet(isPresented: $viewModel.isShowingCreateSheet, onDismiss: { - reload() + viewModel.reload() }, content: { - ItemTagCreateView(itemTagRepository: itemTagRepository, shopId: shop.id) + ItemTagCreateView( + viewModel: viewModel.createItemTagCreateViewModel() + ) } ) } - func reload() { - itemTagRepository.reload(shopId: shop.id) - } - - func destroyItemTag(itemTagId: String) { - Task { @MainActor in - isDeleting = true - - do { - try await itemTagRepository.destroy(id: itemTagId) - messageBus.post(message: Message(level: .success, message: .itemTagDeleted)) - } catch { - messageBus.post(message: Message(level: .error, message: "\(String.itemTagDeletedError) \(error.localizedDescription)", autoDismiss: false)) - } - - isDeleting = false - reload() - } - } - var noResultsView: some View { VStack { Image(systemName: "01.square") @@ -138,7 +114,7 @@ private extension ItemTagListView { .padding() MainButtonView(title: String.addTag, type: .primary(withArrow: false)) { - isShowingCreateSheet.toggle() + viewModel.isShowingCreateSheet.toggle() } .padding() @@ -148,6 +124,6 @@ private extension ItemTagListView { } var reloadView: some View { - ErrorView(buttonAction: reload) + ErrorView(buttonAction: viewModel.reload) } } diff --git a/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagListViewModel.swift b/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagListViewModel.swift new file mode 100644 index 0000000..b74425e --- /dev/null +++ b/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagListViewModel.swift @@ -0,0 +1,83 @@ +// +// ItemTagListViewModel.swift +// NativeAppTemplate +// +// Created by Claude on 2025/06/22. +// + +import SwiftUI +import Observation + +@Observable +@MainActor +final class ItemTagListViewModel { + var isShowingCreateSheet = false + var isDeleting = false + var isShowingDeleteConfirmationDialog = false + var state: DataState { itemTagRepository.state } + var itemTags: [ItemTag] { itemTagRepository.itemTags } + private let itemTagRepository: ItemTagRepositoryProtocol + private let messageBus: MessageBus + private let sessionController: SessionControllerProtocol + let shop: Shop + + init( + itemTagRepository: ItemTagRepositoryProtocol, + messageBus: MessageBus, + sessionController: SessionControllerProtocol, + shop: Shop + ) { + self.itemTagRepository = itemTagRepository + self.messageBus = messageBus + self.sessionController = sessionController + self.shop = shop + } + + var isBusy: Bool { + isDeleting + } + + var isEmpty: Bool { + itemTags.isEmpty + } + + func reload() { + itemTagRepository.reload(shopId: shop.id) + } + + func destroyItemTag(itemTagId: String) { + Task { + isDeleting = true + + do { + try await itemTagRepository.destroy(id: itemTagId) + messageBus.post(message: Message(level: .success, message: .itemTagDeleted)) + reload() + } catch { + messageBus.post(message: Message(level: .error, message: "\(String.itemTagDeletedError) \(error.localizedDescription)", autoDismiss: false)) + } + + isDeleting = false + } + } + + func createItemTagDetailViewModel(itemTagId: String) -> ItemTagDetailViewModel { + ItemTagDetailViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + nfcManager: appSingletons.nfcManager, + shop: shop, + itemTagId: itemTagId + ) + } + + func createItemTagCreateViewModel() -> ItemTagCreateViewModel { + ItemTagCreateViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + shopId: shop.id + ) + } +} diff --git a/NativeAppTemplate/UI/Shop Settings/ShopSettingsView.swift b/NativeAppTemplate/UI/Shop Settings/ShopSettingsView.swift index 2cead88..3c4f226 100644 --- a/NativeAppTemplate/UI/Shop Settings/ShopSettingsView.swift +++ b/NativeAppTemplate/UI/Shop Settings/ShopSettingsView.swift @@ -8,7 +8,9 @@ import SwiftUI struct ShopSettingsView: View { + @Environment(DataManager.self) private var dataManager @Environment(\.dismiss) private var dismiss + @Environment(MessageBus.self) private var messageBus @State private var viewModel: ShopSettingsViewModel init(viewModel: ShopSettingsViewModel) { @@ -71,8 +73,12 @@ private extension ShopSettingsView { if let shop = viewModel.shop { NavigationLink { ItemTagListView( - itemTagRepository: viewModel.itemTagRepository, - shop: shop + viewModel: ItemTagListViewModel( + itemTagRepository: dataManager.itemTagRepository, + messageBus: messageBus, + sessionController: dataManager.sessionController, + shop: shop + ) ) } label: { Label(String.shopSettingsManageNumberTagsLabel, systemImage: "rectangle.stack") diff --git a/NativeAppTemplateTests/UI/Shop Settings/ItemTag Detail/ItemTagDetailViewModelTest.swift b/NativeAppTemplateTests/UI/Shop Settings/ItemTag Detail/ItemTagDetailViewModelTest.swift new file mode 100644 index 0000000..9fe2562 --- /dev/null +++ b/NativeAppTemplateTests/UI/Shop Settings/ItemTag Detail/ItemTagDetailViewModelTest.swift @@ -0,0 +1,344 @@ +// +// ItemTagDetailViewModelTest.swift +// NativeAppTemplate +// +// Created by Claude on 2025/06/22. +// + +import Testing +import Foundation +@testable import NativeAppTemplate + +@MainActor +@Suite +struct ItemTagDetailViewModelTest { // swiftlint:disable:this type_body_length + let sessionController = TestSessionController() + let itemTagRepository = TestItemTagRepository( + itemTagsService: ItemTagsService() + ) + let messageBus = MessageBus() + let nfcManager = NFCManager() + var shop: Shop { mockShop(id: "1", name: "Test Shop") } + let itemTagId = "test-item-tag-id" + + var testItemTag: ItemTag { + ItemTag( + id: itemTagId, + shopId: shop.id, + queueNumber: "A01", + state: .idled, + scanState: .unscanned, + createdAt: Date(), + customerReadAt: nil, + completedAt: nil, + shopName: shop.name, + alreadyCompleted: false + ) + } + + @Test + func initializesCorrectly() { + let viewModel = ItemTagDetailViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + nfcManager: nfcManager, + shop: shop, + itemTagId: itemTagId + ) + + #expect(viewModel.isLocked == false) + #expect(viewModel.isShowingEditSheet == false) + #expect(viewModel.isShowingDeleteConfirmationDialog == false) + #expect(viewModel.isFetching == true) + #expect(viewModel.isGeneratingQrCode == false) + #expect(viewModel.isDeleting == false) + #expect(viewModel.customerTagQrCodeImage == nil) + #expect(viewModel.shouldDismiss == false) + #expect(viewModel.itemTag == nil) + #expect(viewModel.shop.id == shop.id) + #expect(viewModel.itemTagId == itemTagId) + } + + @Test + func busyState() { + let viewModel = ItemTagDetailViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + nfcManager: nfcManager, + shop: shop, + itemTagId: itemTagId + ) + + // Initially fetching + #expect(viewModel.isBusy == true) + #expect(viewModel.isFetching == true) + + // When generating QR code + viewModel.isGeneratingQrCode = true + #expect(viewModel.isBusy == true) + + // When deleting + viewModel.isFetching = false + viewModel.isGeneratingQrCode = false + viewModel.isDeleting = true + #expect(viewModel.isBusy == true) + + // When none are busy + viewModel.isDeleting = false + #expect(viewModel.isBusy == false) + } + + @Test + func reloadCallsFetchItemTagDetail() async { + itemTagRepository.setItemTags(itemTags: [testItemTag]) + + let viewModel = ItemTagDetailViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + nfcManager: nfcManager, + shop: shop, + itemTagId: itemTagId + ) + + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + #expect(viewModel.isFetching == false) + #expect(viewModel.itemTag != nil) + #expect(viewModel.itemTag?.id == itemTagId) + #expect(viewModel.itemTag?.queueNumber == "A01") + } + + @Test + func fetchItemTagDetailFailure() async { + let message = "Item tag not found" + let httpResponseCode = 404 + itemTagRepository.error = NativeAppTemplateAPIError.requestFailed(nil, httpResponseCode, message) + + let viewModel = ItemTagDetailViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + nfcManager: nfcManager, + shop: shop, + itemTagId: itemTagId + ) + + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + #expect(viewModel.isFetching == false) + #expect(viewModel.shouldDismiss == true) + #expect(messageBus.currentMessage != nil) + #expect(messageBus.currentMessage!.level == .error) + #expect(messageBus.currentMessage!.autoDismiss == false) + } + + @Test + func generateCustomerQrCode() async { + itemTagRepository.setItemTags(itemTags: [testItemTag]) + + let viewModel = ItemTagDetailViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + nfcManager: nfcManager, + shop: shop, + itemTagId: itemTagId + ) + + // Load the item tag first + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + viewModel.generateCustomerQrCode() + + #expect(viewModel.isGeneratingQrCode == false) + #expect(viewModel.customerTagQrCodeImage != nil) + } + + @Test + func generateCustomerQrCodeWithoutItemTag() { + let viewModel = ItemTagDetailViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + nfcManager: nfcManager, + shop: shop, + itemTagId: itemTagId + ) + + // itemTag is nil + viewModel.generateCustomerQrCode() + + #expect(viewModel.customerTagQrCodeImage == nil) + } + + @Test + func destroyItemTagSuccess() async { + itemTagRepository.setItemTags(itemTags: [testItemTag]) + + let viewModel = ItemTagDetailViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + nfcManager: nfcManager, + shop: shop, + itemTagId: itemTagId + ) + + // Load the item tag first + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + let destroyTask = Task { + viewModel.destroyItemTag() + } + await destroyTask.value + + #expect(viewModel.shouldDismiss == true) + #expect(messageBus.currentMessage != nil) + #expect(messageBus.currentMessage!.level == .success) + #expect(messageBus.currentMessage!.message == .itemTagDeleted) + #expect(itemTagRepository.itemTags.count == 0) // Item should be deleted + } + + @Test + func destroyItemTagFailure() async { + itemTagRepository.setItemTags(itemTags: [testItemTag]) + + let viewModel = ItemTagDetailViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + nfcManager: nfcManager, + shop: shop, + itemTagId: itemTagId + ) + + // Load the item tag first + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + // Set error after loading + let message = "Delete failed" + let httpResponseCode = 500 + itemTagRepository.error = NativeAppTemplateAPIError.requestFailed(nil, httpResponseCode, message) + + let destroyTask = Task { + viewModel.destroyItemTag() + } + await destroyTask.value + + #expect(viewModel.shouldDismiss == true) + #expect(messageBus.currentMessage != nil) + #expect(messageBus.currentMessage!.level == .error) + #expect(messageBus.currentMessage!.autoDismiss == false) + #expect(messageBus.currentMessage!.message.contains(.itemTagDeletedError)) + #expect(itemTagRepository.itemTags.count == 1) // Item should still exist + } + + @Test + func destroyItemTagWithoutItemTag() async { + let viewModel = ItemTagDetailViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + nfcManager: nfcManager, + shop: shop, + itemTagId: itemTagId + ) + + // itemTag is nil + let destroyTask = Task { + viewModel.destroyItemTag() + } + await destroyTask.value + + #expect(viewModel.isDeleting == false) + #expect(viewModel.shouldDismiss == false) + #expect(messageBus.currentMessage == nil) // No message should be posted + } + + @Test + func busyStateDuringDeletion() async { + itemTagRepository.setItemTags(itemTags: [testItemTag]) + + let viewModel = ItemTagDetailViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + nfcManager: nfcManager, + shop: shop, + itemTagId: itemTagId + ) + + // Load the item tag first + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + let destroyTask = Task { + viewModel.destroyItemTag() + } + + // Check busy state immediately after starting + #expect(viewModel.isBusy == viewModel.isDeleting) + + await destroyTask.value + } + + @Test + func dialogStateManagement() { + let viewModel = ItemTagDetailViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + nfcManager: nfcManager, + shop: shop, + itemTagId: itemTagId + ) + + // Test initial state + #expect(viewModel.isShowingEditSheet == false) + #expect(viewModel.isShowingDeleteConfirmationDialog == false) + #expect(viewModel.isLocked == false) + + // Test state changes + viewModel.isShowingEditSheet = true + #expect(viewModel.isShowingEditSheet == true) + + viewModel.isShowingDeleteConfirmationDialog = true + #expect(viewModel.isShowingDeleteConfirmationDialog == true) + + viewModel.isLocked = true + #expect(viewModel.isLocked == 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" + ) + } +} diff --git a/NativeAppTemplateTests/UI/Shop Settings/ItemTag Detail/ItemTagEditViewModelTest.swift b/NativeAppTemplateTests/UI/Shop Settings/ItemTag Detail/ItemTagEditViewModelTest.swift new file mode 100644 index 0000000..088c505 --- /dev/null +++ b/NativeAppTemplateTests/UI/Shop Settings/ItemTag Detail/ItemTagEditViewModelTest.swift @@ -0,0 +1,352 @@ +// +// ItemTagEditViewModelTest.swift +// NativeAppTemplate +// +// Created by Claude on 2025/06/22. +// + +import Testing +import Foundation +@testable import NativeAppTemplate + +@MainActor +@Suite +struct ItemTagEditViewModelTest { // swiftlint:disable:this type_body_length + let sessionController = TestSessionController() + let itemTagRepository = TestItemTagRepository( + itemTagsService: ItemTagsService() + ) + let messageBus = MessageBus() + let itemTagId = "test-item-tag-id" + + var testItemTag: ItemTag { + ItemTag( + id: itemTagId, + shopId: "test-shop-id", + queueNumber: "A01", + state: .idled, + scanState: .unscanned, + createdAt: Date(), + customerReadAt: nil, + completedAt: nil, + shopName: "Test Shop", + alreadyCompleted: false + ) + } + + @Test + func initializesCorrectly() { + let viewModel = ItemTagEditViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + itemTagId: itemTagId + ) + + #expect(viewModel.queueNumber == "") + #expect(viewModel.isFetching == true) + #expect(viewModel.isUpdating == false) + #expect(viewModel.shouldDismiss == false) + #expect(viewModel.itemTag == nil) + } + + @Test + func maximumQueueNumberLength() { + sessionController.maximumQueueNumberLength = 6 + + let viewModel = ItemTagEditViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + itemTagId: itemTagId + ) + + #expect(viewModel.maximumQueueNumberLength == 6) + } + + @Test + func busyState() { + let viewModel = ItemTagEditViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + itemTagId: itemTagId + ) + + // Initially fetching + #expect(viewModel.isBusy == true) + #expect(viewModel.isFetching == true) + + // When updating + viewModel.isUpdating = true + #expect(viewModel.isBusy == true) + + viewModel.isFetching = false + viewModel.isUpdating = false + #expect(viewModel.isBusy == false) + } + + @Test + func reloadFetchesItemTagDetail() async { + itemTagRepository.setItemTags(itemTags: [testItemTag]) + + let viewModel = ItemTagEditViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + itemTagId: itemTagId + ) + + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + #expect(viewModel.isFetching == false) + #expect(viewModel.itemTag != nil) + #expect(viewModel.itemTag?.id == itemTagId) + #expect(viewModel.queueNumber == "A01") + } + + @Test + func fetchDetailFailure() async { + let message = "Item tag not found" + let httpResponseCode = 404 + itemTagRepository.error = NativeAppTemplateAPIError.requestFailed(nil, httpResponseCode, message) + + let viewModel = ItemTagEditViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + itemTagId: itemTagId + ) + + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + #expect(viewModel.isFetching == false) + #expect(viewModel.shouldDismiss == true) + #expect(messageBus.currentMessage != nil) + #expect(messageBus.currentMessage!.level == .error) + #expect(messageBus.currentMessage!.autoDismiss == false) + } + + @Test("Queue number validation", arguments: [ + ("", true), // blank + ("a", true), // too short + ("ab", false), // minimum valid + ("abc", false), // valid + ("abcd", false), // valid + ("ab!", true), // non-alphanumeric + ("a b", true), // contains space + ("12", false), // numbers are valid + ("a1", false) // alphanumeric is valid + ]) + func queueNumberValidation(queueNumber: String, shouldBeInvalid: Bool) async { + sessionController.maximumQueueNumberLength = 5 + itemTagRepository.setItemTags(itemTags: [testItemTag]) + + let viewModel = ItemTagEditViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + itemTagId: itemTagId + ) + + // Load the item tag first + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + viewModel.queueNumber = queueNumber + + #expect(viewModel.hasInvalidDataQueueNumber == shouldBeInvalid) + } + + @Test + func hasInvalidDataWithUnchangedQueueNumber() async { + itemTagRepository.setItemTags(itemTags: [testItemTag]) + + let viewModel = ItemTagEditViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + itemTagId: itemTagId + ) + + // Load the item tag first + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + // Queue number is same as original - should be invalid + #expect(viewModel.queueNumber == "A01") + #expect(viewModel.hasInvalidData == true) + + // Change to different valid queue number - should be valid + viewModel.queueNumber = "B01" + #expect(viewModel.hasInvalidData == false) + + // Change to invalid queue number - should be invalid + viewModel.queueNumber = "!" + #expect(viewModel.hasInvalidData == true) + } + + @Test + func validateQueueNumberLengthTruncatesCorrectly() { + sessionController.maximumQueueNumberLength = 3 + + let viewModel = ItemTagEditViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + itemTagId: itemTagId + ) + + viewModel.queueNumber = "ABCDEFGH" + viewModel.validateQueueNumberLength() + + #expect(viewModel.queueNumber == "ABC") + } + + @Test + func updateItemTagSuccess() async { + itemTagRepository.setItemTags(itemTags: [testItemTag]) + + let viewModel = ItemTagEditViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + itemTagId: itemTagId + ) + + // Load the item tag first + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + // Change to new queue number + viewModel.queueNumber = "B02" + + let updateTask = Task { + viewModel.updateItemTag() + } + await updateTask.value + + #expect(viewModel.isUpdating == false) + #expect(viewModel.shouldDismiss == true) + #expect(messageBus.currentMessage != nil) + #expect(messageBus.currentMessage!.level == .success) + #expect(messageBus.currentMessage!.message == .itemTagUpdated) + + // Check that repository was updated + let updatedItemTag = itemTagRepository.findBy(id: itemTagId) + #expect(updatedItemTag.queueNumber == "B02") + } + + @Test + func updateItemTagFailure() async { + itemTagRepository.setItemTags(itemTags: [testItemTag]) + + let viewModel = ItemTagEditViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + itemTagId: itemTagId + ) + + // Load the item tag first + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + // Set error after loading + let message = "Update failed" + let httpResponseCode = 500 + itemTagRepository.error = NativeAppTemplateAPIError.requestFailed(nil, httpResponseCode, message) + + viewModel.queueNumber = "B02" + + let updateTask = Task { + viewModel.updateItemTag() + } + await updateTask.value + + #expect(viewModel.isUpdating == false) + #expect(viewModel.shouldDismiss == true) + #expect(messageBus.currentMessage != nil) + #expect(messageBus.currentMessage!.level == .error) + #expect(messageBus.currentMessage!.autoDismiss == false) + } + + @Test + func busyStateDuringUpdate() async { + itemTagRepository.setItemTags(itemTags: [testItemTag]) + + let viewModel = ItemTagEditViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + itemTagId: itemTagId + ) + + // Load the item tag first + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + viewModel.queueNumber = "B02" + + let updateTask = Task { + viewModel.updateItemTag() + } + + // Check busy state immediately after starting + #expect(viewModel.isBusy == viewModel.isUpdating) + + await updateTask.value + + #expect(viewModel.isBusy == false) + #expect(viewModel.isUpdating == false) + } + + @Test("Form validation with different maximum lengths", arguments: [2, 4, 6, 8]) + func formValidationWithDifferentMaxLengths(maxLength: Int) async { + sessionController.maximumQueueNumberLength = maxLength + itemTagRepository.setItemTags(itemTags: [testItemTag]) + + let viewModel = ItemTagEditViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + itemTagId: itemTagId + ) + + // Load the item tag first + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + // Test exactly at the limit + viewModel.queueNumber = String(repeating: "B", count: maxLength) + #expect(viewModel.hasInvalidDataQueueNumber == false) + + // Test one over the limit + viewModel.queueNumber = String(repeating: "B", count: maxLength + 1) + #expect(viewModel.hasInvalidDataQueueNumber == true) + + // Test truncation + viewModel.validateQueueNumberLength() + #expect(viewModel.queueNumber.count == maxLength) + #expect(viewModel.hasInvalidDataQueueNumber == false) + #expect(viewModel.hasInvalidData == false) // Should be valid since it's different from original + } +} diff --git a/NativeAppTemplateTests/UI/Shop Settings/ItemTag List/ItemTagCreateViewModelTest.swift b/NativeAppTemplateTests/UI/Shop Settings/ItemTag List/ItemTagCreateViewModelTest.swift new file mode 100644 index 0000000..a917e11 --- /dev/null +++ b/NativeAppTemplateTests/UI/Shop Settings/ItemTag List/ItemTagCreateViewModelTest.swift @@ -0,0 +1,193 @@ +// +// ItemTagCreateViewModelTest.swift +// NativeAppTemplate +// +// Created by Claude on 2025/06/22. +// + +import Testing +import Foundation +@testable import NativeAppTemplate + +@MainActor +@Suite +struct ItemTagCreateViewModelTest { + let sessionController = TestSessionController() + let itemTagRepository = TestItemTagRepository( + itemTagsService: ItemTagsService() + ) + let messageBus = MessageBus() + let shopId = "test-shop-id" + + @Test + func initializesCorrectly() { + let viewModel = ItemTagCreateViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + shopId: shopId + ) + + #expect(viewModel.queueNumber == "") + #expect(viewModel.isCreating == false) + #expect(viewModel.shouldDismiss == false) + #expect(viewModel.isBusy == false) + } + + @Test + func maximumQueueNumberLength() { + sessionController.maximumQueueNumberLength = 5 + + let viewModel = ItemTagCreateViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + shopId: shopId + ) + + #expect(viewModel.maximumQueueNumberLength == 5) + } + + @Test("Queue number validation - invalid cases", arguments: [ + ("", true), // blank + ("a", true), // too short + ("ab", false), // minimum valid + ("abc", false), // valid + ("abcd", false), // valid + ("abcde", false), // maximum valid (assuming max length 5) + ("abcdef", true), // too long (will be truncated but still invalid in this test) + ("ab!", true), // non-alphanumeric + ("a b", true), // contains space + ("12", false), // numbers are valid + ("a1", false) // alphanumeric is valid + ]) + func queueNumberValidation(queueNumber: String, shouldBeInvalid: Bool) { + sessionController.maximumQueueNumberLength = 5 + + let viewModel = ItemTagCreateViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + shopId: shopId + ) + + viewModel.queueNumber = queueNumber + + #expect(viewModel.hasInvalidDataQueueNumber == shouldBeInvalid) + #expect(viewModel.hasInvalidData == shouldBeInvalid) + } + + @Test + func validateQueueNumberLengthTruncatesCorrectly() { + sessionController.maximumQueueNumberLength = 4 + + let viewModel = ItemTagCreateViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + shopId: shopId + ) + + viewModel.queueNumber = "abcdefgh" + viewModel.validateQueueNumberLength() + + #expect(viewModel.queueNumber == "abcd") + } + + @Test + func createItemTagSuccess() async { + let viewModel = ItemTagCreateViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + shopId: shopId + ) + + viewModel.queueNumber = "ABC1" + + let createTask = Task { + viewModel.createItemTag() + } + await createTask.value + + #expect(viewModel.shouldDismiss == true) + #expect(messageBus.currentMessage != nil) + #expect(messageBus.currentMessage!.level == .success) + #expect(messageBus.currentMessage!.message == .itemTagCreated) + #expect(itemTagRepository.itemTags.count == 1) + #expect(itemTagRepository.itemTags.first?.queueNumber == "ABC1") + } + + @Test + func createItemTagFailure() async { + let message = "Internal server error." + let httpResponseCode = 500 + itemTagRepository.error = NativeAppTemplateAPIError.requestFailed(nil, httpResponseCode, message) + + let viewModel = ItemTagCreateViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + shopId: shopId + ) + + viewModel.queueNumber = "ABC1" + + let createTask = Task { + viewModel.createItemTag() + } + await createTask.value + + #expect(viewModel.shouldDismiss == true) + #expect(messageBus.currentMessage != nil) + #expect(messageBus.currentMessage!.level == .error) + #expect(messageBus.currentMessage!.autoDismiss == false) + #expect(itemTagRepository.itemTags.count == 0) + } + + @Test + func busyStateDuringCreation() async { + let viewModel = ItemTagCreateViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + shopId: shopId + ) + + viewModel.queueNumber = "ABC1" + + let createTask = Task { + viewModel.createItemTag() + } + + // Check busy state immediately after starting + #expect(viewModel.isBusy == viewModel.isCreating) + + await createTask.value + } + + @Test("Form validation with different maximum lengths", arguments: [2, 4, 6, 8]) + func formValidationWithDifferentMaxLengths(maxLength: Int) { + sessionController.maximumQueueNumberLength = maxLength + + let viewModel = ItemTagCreateViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + shopId: shopId + ) + + // Test exactly at the limit + viewModel.queueNumber = String(repeating: "A", count: maxLength) + #expect(viewModel.hasInvalidData == false) + + // Test one over the limit + viewModel.queueNumber = String(repeating: "A", count: maxLength + 1) + #expect(viewModel.hasInvalidData == true) + + // Test truncation + viewModel.validateQueueNumberLength() + #expect(viewModel.queueNumber.count == maxLength) + #expect(viewModel.hasInvalidData == false) + } +} diff --git a/NativeAppTemplateTests/UI/Shop Settings/ItemTag List/ItemTagListViewModelTest.swift b/NativeAppTemplateTests/UI/Shop Settings/ItemTag List/ItemTagListViewModelTest.swift new file mode 100644 index 0000000..2586703 --- /dev/null +++ b/NativeAppTemplateTests/UI/Shop Settings/ItemTag List/ItemTagListViewModelTest.swift @@ -0,0 +1,252 @@ +// +// ItemTagListViewModelTest.swift +// NativeAppTemplate +// +// Created by Claude on 2025/06/22. +// + +import Testing +import Foundation +@testable import NativeAppTemplate + +@MainActor +@Suite +struct ItemTagListViewModelTest { + var itemTags: [ItemTag] { + [ + mockItemTag(id: "1", queueNumber: "A01"), + mockItemTag(id: "2", queueNumber: "A02"), + mockItemTag(id: "3", queueNumber: "A03"), + mockItemTag(id: "4", queueNumber: "B01"), + mockItemTag(id: "5", queueNumber: "B02") + ] + } + + let sessionController = TestSessionController() + let itemTagRepository = TestItemTagRepository( + itemTagsService: ItemTagsService() + ) + let messageBus = MessageBus() + var shop: Shop { mockShop(id: "1", name: "Test Shop") } + + @Test + func initializesCorrectly() { + let viewModel = ItemTagListViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + shop: shop + ) + + #expect(viewModel.isShowingCreateSheet == false) + #expect(viewModel.isDeleting == false) + #expect(viewModel.isShowingDeleteConfirmationDialog == false) + #expect(viewModel.isBusy == false) + #expect(viewModel.shop.id == shop.id) + } + + @Test + func stateReflectsRepository() { + itemTagRepository.state = .loading + + let viewModel = ItemTagListViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + shop: shop + ) + + #expect(viewModel.state == .loading) + + itemTagRepository.state = .hasData + #expect(viewModel.state == .hasData) + + itemTagRepository.state = .failed + #expect(viewModel.state == .failed) + } + + @Test + func itemTagsReflectRepository() { + itemTagRepository.setItemTags(itemTags: itemTags) + + let viewModel = ItemTagListViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + shop: shop + ) + + #expect(viewModel.itemTags.count == 5) + #expect(viewModel.itemTags.first?.queueNumber == "A01") + #expect(viewModel.isEmpty == false) + } + + @Test + func isEmptyWhenNoItemTags() { + itemTagRepository.setItemTags(itemTags: []) + + let viewModel = ItemTagListViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + shop: shop + ) + + #expect(viewModel.isEmpty == true) + #expect(viewModel.itemTags.isEmpty == true) + } + + @Test + func reloadCallsRepositoryWithShopId() { + let viewModel = ItemTagListViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + shop: shop + ) + + // Initially should be .initial + #expect(itemTagRepository.state == .initial) + + viewModel.reload() + + // After reload, state should change to .hasData (success case) + #expect(itemTagRepository.state == .hasData) + } + + @Test + func reloadWithError() { + let message = "Network error" + let httpResponseCode = 500 + itemTagRepository.error = NativeAppTemplateAPIError.requestFailed(nil, httpResponseCode, message) + + let viewModel = ItemTagListViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + shop: shop + ) + + viewModel.reload() + + #expect(itemTagRepository.state == .failed) + } + + @Test + func destroyItemTagSuccess() async { + itemTagRepository.setItemTags(itemTags: itemTags) + + let viewModel = ItemTagListViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + shop: shop + ) + + let itemTagIdToDelete = "1" + + let destroyTask = Task { + viewModel.destroyItemTag(itemTagId: itemTagIdToDelete) + } + await destroyTask.value + + #expect(viewModel.isDeleting == false) + #expect(messageBus.currentMessage != nil) + #expect(messageBus.currentMessage!.level == .success) + #expect(messageBus.currentMessage!.message == .itemTagDeleted) + #expect(itemTagRepository.itemTags.count == 4) // One deleted + #expect(itemTagRepository.itemTags.first { $0.id == itemTagIdToDelete } == nil) + } + + @Test + func destroyItemTagFailure() async { + itemTagRepository.setItemTags(itemTags: itemTags) + let message = "Delete failed" + let httpResponseCode = 500 + itemTagRepository.error = NativeAppTemplateAPIError.requestFailed(nil, httpResponseCode, message) + + let viewModel = ItemTagListViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + shop: shop + ) + + let destroyTask = Task { + viewModel.destroyItemTag(itemTagId: "1") + } + await destroyTask.value + + #expect(viewModel.isDeleting == false) + #expect(messageBus.currentMessage != nil) + #expect(messageBus.currentMessage!.level == .error) + #expect(messageBus.currentMessage!.autoDismiss == false) + #expect(messageBus.currentMessage!.message.contains(String.itemTagDeletedError)) + #expect(itemTagRepository.itemTags.count == 5) // Nothing deleted + } + + @Test + func busyStateDuringDeletion() async { + itemTagRepository.setItemTags(itemTags: itemTags) + + let viewModel = ItemTagListViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + shop: shop + ) + + let destroyTask = Task { + viewModel.destroyItemTag(itemTagId: "1") + } + + // 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 = ItemTagListViewModel( + itemTagRepository: itemTagRepository, + messageBus: messageBus, + sessionController: sessionController, + shop: shop + ) + + // Test initial state + #expect(viewModel.isShowingCreateSheet == false) + #expect(viewModel.isShowingDeleteConfirmationDialog == false) + + // Test state changes + viewModel.isShowingCreateSheet = true + #expect(viewModel.isShowingCreateSheet == true) + + viewModel.isShowingDeleteConfirmationDialog = true + #expect(viewModel.isShowingDeleteConfirmationDialog == true) + } + + private func mockItemTag(id: String = UUID().uuidString, queueNumber: String = "A01") -> ItemTag { + ItemTag( + id: id, + queueNumber: queueNumber + ) + } + + 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" + ) + } +}