diff --git a/NativeAppTemplate.xcodeproj/project.pbxproj b/NativeAppTemplate.xcodeproj/project.pbxproj index 4a1bfc8..2a06746 100644 --- a/NativeAppTemplate.xcodeproj/project.pbxproj +++ b/NativeAppTemplate.xcodeproj/project.pbxproj @@ -29,6 +29,10 @@ 012009FC299F1E190078A1F9 /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 012009FB299F1E190078A1F9 /* OrderedCollections */; }; 012643372B3554AD00D4E9BD /* AcceptTermsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 012643362B3554AD00D4E9BD /* AcceptTermsView.swift */; }; 013292BE262C3EA400690B75 /* LoggedInShopkeeper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 013292BD262C3EA400690B75 /* LoggedInShopkeeper.swift */; }; + 0135E7192D7E33F9004AD8FA /* CompleteScanResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0135E7152D7E33F9004AD8FA /* CompleteScanResultView.swift */; }; + 0135E71A2D7E33F9004AD8FA /* ShowTagInfoScanResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0135E7172D7E33F9004AD8FA /* ShowTagInfoScanResultView.swift */; }; + 0135E71B2D7E33F9004AD8FA /* ScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0135E7162D7E33F9004AD8FA /* ScanView.swift */; }; + 0135E8E42D7E4478004AD8FA /* SampleCode.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 0135E8E22D7E4478004AD8FA /* SampleCode.xcconfig */; }; 013DE735284E99DF00528CC5 /* ShopCreateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 013DE734284E99DF00528CC5 /* ShopCreateView.swift */; }; 01467357299902230005423D /* ShopSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01467356299902230005423D /* ShopSettingsView.swift */; }; 01482FA42B351E4100A56D43 /* AcceptPrivacyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01482FA32B351E4100A56D43 /* AcceptPrivacyView.swift */; }; @@ -75,6 +79,38 @@ 0172052F25AC41A7008FD63B /* SessionRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172052E25AC41A7008FD63B /* SessionRequest.swift */; }; 017278072D7D4F5800CE424F /* OnboardingRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017278062D7D4F5800CE424F /* OnboardingRepository.swift */; }; 017278092D7D4F7400CE424F /* Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017278082D7D4F7400CE424F /* Onboarding.swift */; }; + 0172785A2D7D83B600CE424F /* NFCManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017278592D7D83B600CE424F /* NFCManager.swift */; }; + 0172785B2D7D83B600CE424F /* AppSingletons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017278582D7D83B600CE424F /* AppSingletons.swift */; }; + 017278612D7D83E700CE424F /* ItemTagData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172785D2D7D83E700CE424F /* ItemTagData.swift */; }; + 017278622D7D83E700CE424F /* ItemTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172785C2D7D83E700CE424F /* ItemTag.swift */; }; + 017278632D7D83E700CE424F /* ItemTagState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172785F2D7D83E700CE424F /* ItemTagState.swift */; }; + 017278642D7D83E700CE424F /* ItemTagInfoFromNdefMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172785E2D7D83E700CE424F /* ItemTagInfoFromNdefMessage.swift */; }; + 017278652D7D83E700CE424F /* ItemTagType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017278602D7D83E700CE424F /* ItemTagType.swift */; }; + 017278682D7D83F600CE424F /* ScanState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017278672D7D83F600CE424F /* ScanState.swift */; }; + 017278692D7D83F600CE424F /* ScanResultError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017278662D7D83F600CE424F /* ScanResultError.swift */; }; + 0172786B2D7D840A00CE424F /* ShowTagInfoScanResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172786A2D7D840A00CE424F /* ShowTagInfoScanResult.swift */; }; + 0172786F2D7D87D000CE424F /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172786E2D7D87D000CE424F /* String+Extensions.swift */; }; + 017278702D7D87D000CE424F /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172786C2D7D87D000CE424F /* Date+Extensions.swift */; }; + 017278712D7D87D000CE424F /* DateFormatter+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172786D2D7D87D000CE424F /* DateFormatter+Extensions.swift */; }; + 017278732D7D87EB00CE424F /* UIImage+Extentions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017278722D7D87EB00CE424F /* UIImage+Extentions.swift */; }; + 017278752D7D8FAC00CE424F /* ItemTagRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017278742D7D8FAC00CE424F /* ItemTagRepository.swift */; }; + 017278772D7D8FF100CE424F /* ItemTagsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017278762D7D8FF100CE424F /* ItemTagsService.swift */; }; + 017278792D7D900100CE424F /* ItemTagsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017278782D7D900100CE424F /* ItemTagsRequest.swift */; }; + 0172787B2D7D903500CE424F /* ItemTagAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172787A2D7D903500CE424F /* ItemTagAdapter.swift */; }; + 0172787D2D7D92DF00CE424F /* CompleteScanResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172787C2D7D92DF00CE424F /* CompleteScanResult.swift */; }; + 0172787F2D7D933000CE424F /* ShopDetailCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172787E2D7D933000CE424F /* ShopDetailCardView.swift */; }; + 017278822D7D935700CE424F /* QRCodeGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017278812D7D935700CE424F /* QRCodeGenerator.swift */; }; + 017278832D7D935700CE424F /* ImageSaver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017278802D7D935700CE424F /* ImageSaver.swift */; }; + 0172788B2D7D936E00CE424F /* CompletedTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017278842D7D936E00CE424F /* CompletedTag.swift */; }; + 0172788C2D7D936E00CE424F /* IdlingTagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017278872D7D936E00CE424F /* IdlingTagView.swift */; }; + 0172788D2D7D936E00CE424F /* CustomerScannedTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017278862D7D936E00CE424F /* CustomerScannedTag.swift */; }; + 017278902D7D936E00CE424F /* TagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017278892D7D936E00CE424F /* TagView.swift */; }; + 017278922D7D99B900CE424F /* NumberTagsWebpageListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017278912D7D99B900CE424F /* NumberTagsWebpageListView.swift */; }; + 0172789A2D7D99D100CE424F /* ItemTagListCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017278972D7D99D100CE424F /* ItemTagListCardView.swift */; }; + 0172789B2D7D99D100CE424F /* ItemTagListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017278982D7D99D100CE424F /* ItemTagListView.swift */; }; + 0172789C2D7D99D100CE424F /* ItemTagDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017278932D7D99D100CE424F /* ItemTagDetailView.swift */; }; + 0172789D2D7D99D100CE424F /* ItemTagCreateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017278962D7D99D100CE424F /* ItemTagCreateView.swift */; }; + 0172789E2D7D99D100CE424F /* ItemTagEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017278942D7D99D100CE424F /* ItemTagEditView.swift */; }; 0182D37025B258A7001E881D /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 0182D36F25B258A7001E881D /* KeychainAccess */; }; 0182D37825B277FA001E881D /* KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0182D37625B277D6001E881D /* KeychainStore.swift */; }; 0182D38225B296B9001E881D /* ShopkeeperAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0182D38125B296B9001E881D /* ShopkeeperAdapter.swift */; }; @@ -142,6 +178,10 @@ 011F6DF9259EF16600BED22E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 012643362B3554AD00D4E9BD /* AcceptTermsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcceptTermsView.swift; sourceTree = ""; }; 013292BD262C3EA400690B75 /* LoggedInShopkeeper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggedInShopkeeper.swift; sourceTree = ""; }; + 0135E7152D7E33F9004AD8FA /* CompleteScanResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompleteScanResultView.swift; sourceTree = ""; }; + 0135E7162D7E33F9004AD8FA /* ScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanView.swift; sourceTree = ""; }; + 0135E7172D7E33F9004AD8FA /* ShowTagInfoScanResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowTagInfoScanResultView.swift; sourceTree = ""; }; + 0135E8E22D7E4478004AD8FA /* SampleCode.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = SampleCode.xcconfig; sourceTree = ""; }; 013DE734284E99DF00528CC5 /* ShopCreateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopCreateView.swift; sourceTree = ""; }; 01467356299902230005423D /* ShopSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopSettingsView.swift; sourceTree = ""; }; 01482FA32B351E4100A56D43 /* AcceptPrivacyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcceptPrivacyView.swift; sourceTree = ""; }; @@ -187,6 +227,39 @@ 0172052E25AC41A7008FD63B /* SessionRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionRequest.swift; sourceTree = ""; }; 017278062D7D4F5800CE424F /* OnboardingRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingRepository.swift; sourceTree = ""; }; 017278082D7D4F7400CE424F /* Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Onboarding.swift; sourceTree = ""; }; + 0172782B2D7D575900CE424F /* NativeAppTemplate.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NativeAppTemplate.entitlements; sourceTree = ""; }; + 017278582D7D83B600CE424F /* AppSingletons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSingletons.swift; sourceTree = ""; }; + 017278592D7D83B600CE424F /* NFCManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NFCManager.swift; sourceTree = ""; }; + 0172785C2D7D83E700CE424F /* ItemTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTag.swift; sourceTree = ""; }; + 0172785D2D7D83E700CE424F /* ItemTagData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTagData.swift; sourceTree = ""; }; + 0172785E2D7D83E700CE424F /* ItemTagInfoFromNdefMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTagInfoFromNdefMessage.swift; sourceTree = ""; }; + 0172785F2D7D83E700CE424F /* ItemTagState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTagState.swift; sourceTree = ""; }; + 017278602D7D83E700CE424F /* ItemTagType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTagType.swift; sourceTree = ""; }; + 017278662D7D83F600CE424F /* ScanResultError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanResultError.swift; sourceTree = ""; }; + 017278672D7D83F600CE424F /* ScanState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanState.swift; sourceTree = ""; }; + 0172786A2D7D840A00CE424F /* ShowTagInfoScanResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowTagInfoScanResult.swift; sourceTree = ""; }; + 0172786C2D7D87D000CE424F /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = ""; }; + 0172786D2D7D87D000CE424F /* DateFormatter+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DateFormatter+Extensions.swift"; sourceTree = ""; }; + 0172786E2D7D87D000CE424F /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = ""; }; + 017278722D7D87EB00CE424F /* UIImage+Extentions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Extentions.swift"; sourceTree = ""; }; + 017278742D7D8FAC00CE424F /* ItemTagRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTagRepository.swift; sourceTree = ""; }; + 017278762D7D8FF100CE424F /* ItemTagsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTagsService.swift; sourceTree = ""; }; + 017278782D7D900100CE424F /* ItemTagsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTagsRequest.swift; sourceTree = ""; }; + 0172787A2D7D903500CE424F /* ItemTagAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTagAdapter.swift; sourceTree = ""; }; + 0172787C2D7D92DF00CE424F /* CompleteScanResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompleteScanResult.swift; sourceTree = ""; }; + 0172787E2D7D933000CE424F /* ShopDetailCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopDetailCardView.swift; sourceTree = ""; }; + 017278802D7D935700CE424F /* ImageSaver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageSaver.swift; sourceTree = ""; }; + 017278812D7D935700CE424F /* QRCodeGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeGenerator.swift; sourceTree = ""; }; + 017278842D7D936E00CE424F /* CompletedTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletedTag.swift; sourceTree = ""; }; + 017278862D7D936E00CE424F /* CustomerScannedTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerScannedTag.swift; sourceTree = ""; }; + 017278872D7D936E00CE424F /* IdlingTagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdlingTagView.swift; sourceTree = ""; }; + 017278892D7D936E00CE424F /* TagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagView.swift; sourceTree = ""; }; + 017278912D7D99B900CE424F /* NumberTagsWebpageListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumberTagsWebpageListView.swift; sourceTree = ""; }; + 017278932D7D99D100CE424F /* ItemTagDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTagDetailView.swift; sourceTree = ""; }; + 017278942D7D99D100CE424F /* ItemTagEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTagEditView.swift; sourceTree = ""; }; + 017278962D7D99D100CE424F /* ItemTagCreateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTagCreateView.swift; sourceTree = ""; }; + 017278972D7D99D100CE424F /* ItemTagListCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTagListCardView.swift; sourceTree = ""; }; + 017278982D7D99D100CE424F /* ItemTagListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTagListView.swift; sourceTree = ""; }; 0182D37625B277D6001E881D /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStore.swift; sourceTree = ""; }; 0182D38125B296B9001E881D /* ShopkeeperAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopkeeperAdapter.swift; sourceTree = ""; }; 0182D39925B4424B001E881D /* LoggedInShopkeeperKeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggedInShopkeeperKeychainStore.swift; sourceTree = ""; }; @@ -256,6 +329,7 @@ 01011B542864434900B70D04 /* Shop Detail */ = { isa = PBXGroup; children = ( + 0172787E2D7D933000CE424F /* ShopDetailCardView.swift */, 010F86AD2621A2A900B6C62A /* ShopDetailView.swift */, ); path = "Shop Detail"; @@ -267,6 +341,7 @@ 011F6DEF259EF16400BED22E /* NativeAppTemplate */, 01D19B442D4DE33500BDEAB7 /* NativeAppTemplateTests */, 011F6DEE259EF16400BED22E /* Products */, + 0135E8E32D7E4478004AD8FA /* Configuration */, 012D037429DF805400C58977 /* Frameworks */, ); sourceTree = ""; @@ -285,9 +360,12 @@ children = ( 011F6DF0259EF16400BED22E /* App.swift */, 017203A225A96F7A008FD63B /* Constants.swift */, + 017278582D7D83B600CE424F /* AppSingletons.swift */, + 017278592D7D83B600CE424F /* NFCManager.swift */, 01BE4F1C29CA6F8C002008BE /* TimeZoneData.swift */, 011F6DF4259EF16600BED22E /* Assets.xcassets */, 011F6DF9259EF16600BED22E /* Info.plist */, + 0172782B2D7D575900CE424F /* NativeAppTemplate.entitlements */, 015C78042B72DA2C00B6523C /* PrivacyInfo.xcprivacy */, 0172049125AA8449008FD63B /* Data */, 017203A725A96FBF008FD63B /* Extensions */, @@ -320,9 +398,28 @@ name = Frameworks; sourceTree = ""; }; + 0135E7182D7E33F9004AD8FA /* Scan */ = { + isa = PBXGroup; + children = ( + 0135E7152D7E33F9004AD8FA /* CompleteScanResultView.swift */, + 0135E7162D7E33F9004AD8FA /* ScanView.swift */, + 0135E7172D7E33F9004AD8FA /* ShowTagInfoScanResultView.swift */, + ); + path = Scan; + sourceTree = ""; + }; + 0135E8E32D7E4478004AD8FA /* Configuration */ = { + isa = PBXGroup; + children = ( + 0135E8E22D7E4478004AD8FA /* SampleCode.xcconfig */, + ); + name = Configuration; + sourceTree = ""; + }; 01467355299901E50005423D /* Shop Settings */ = { isa = PBXGroup; children = ( + 017278912D7D99B900CE424F /* NumberTagsWebpageListView.swift */, 01D8AE8A2AB453C1009AFFBA /* ShopBasicSettingsView.swift */, 01467356299902230005423D /* ShopSettingsView.swift */, ); @@ -335,6 +432,8 @@ 013DE734284E99DF00528CC5 /* ShopCreateView.swift */, 01DCE23E298FA3B300BA311D /* ShopListCardView.swift */, 010F86BD2622F9C900B6C62A /* ShopListView.swift */, + 017278952D7D99D100CE424F /* ItemTag Detail */, + 017278992D7D99D100CE424F /* ItemTag List */, ); path = "Shop List"; sourceTree = ""; @@ -384,6 +483,7 @@ isa = PBXGroup; children = ( 0106413B29A9EDFF00B46FED /* AccountPasswordRequest.swift */, + 017278782D7D900100CE424F /* ItemTagsRequest.swift */, 018E21CA2B36367F00FFD1F6 /* MeRequest.swift */, 0172031125A9642E008FD63B /* Parameters.swift */, 01B6F5AA2601F84700397E66 /* PermissionsRequest.swift */, @@ -406,6 +506,7 @@ 0172032225A9642E008FD63B /* EntityAdapters */ = { isa = PBXGroup; children = ( + 0172787A2D7D903500CE424F /* ItemTagAdapter.swift */, 0172032C25A9642E008FD63B /* ShopAdapter.swift */, 0182D38125B296B9001E881D /* ShopkeeperAdapter.swift */, 01B9E45128A5070D00CAC681 /* ShopkeeperSignInAdapter.swift */, @@ -417,6 +518,7 @@ isa = PBXGroup; children = ( 0106413F29A9F2EC00B46FED /* AccountPasswordService.swift */, + 017278762D7D8FF100CE424F /* ItemTagsService.swift */, 018E21CC2B36377800FFD1F6 /* MeService.swift */, 01B6F5A72601F83400397E66 /* PermissionsService.swift */, 0172033225A9642E008FD63B /* Service.swift */, @@ -428,13 +530,22 @@ 0172036325A96E04008FD63B /* Models */ = { isa = PBXGroup; children = ( + 0172787C2D7D92DF00CE424F /* CompleteScanResult.swift */, + 0172785C2D7D83E700CE424F /* ItemTag.swift */, + 0172785D2D7D83E700CE424F /* ItemTagData.swift */, + 0172785E2D7D83E700CE424F /* ItemTagInfoFromNdefMessage.swift */, + 0172785F2D7D83E700CE424F /* ItemTagState.swift */, + 017278602D7D83E700CE424F /* ItemTagType.swift */, 01B526532AF4E36400655131 /* MainTab.swift */, 017278082D7D4F7400CE424F /* Onboarding.swift */, + 017278662D7D83F600CE424F /* ScanResultError.swift */, + 017278672D7D83F600CE424F /* ScanState.swift */, 01B526552AF4E82A00655131 /* ScrollToTopID.swift */, 0110A15E2AC816F5003EDCBA /* SendConfirmation.swift */, 0150A36529B14BB300907F96 /* SendResetPassword.swift */, 01E0A62F25BD53FD00298D35 /* Shop.swift */, 0172052425AAFA43008FD63B /* Shopkeeper.swift */, + 0172786A2D7D840A00CE424F /* ShowTagInfoScanResult.swift */, 01E2476F29A570D300D4B00D /* SignUp.swift */, 0106413D29A9F1C300B46FED /* UpdatePassword.swift */, ); @@ -445,7 +556,11 @@ isa = PBXGroup; children = ( 01E727202B020ECC004AC043 /* Bundle+Extensions.swift */, + 0172786C2D7D87D000CE424F /* Date+Extensions.swift */, + 0172786D2D7D87D000CE424F /* DateFormatter+Extensions.swift */, + 0172786E2D7D87D000CE424F /* String+Extensions.swift */, 017203A825A96FBF008FD63B /* UIApplication+DismissKeyboard.swift */, + 017278722D7D87EB00CE424F /* UIImage+Extentions.swift */, 017203AB25A96FBF008FD63B /* View+Extensions.swift */, ); path = Extensions; @@ -496,6 +611,7 @@ children = ( 0172045625AA8275008FD63B /* App Root */, 01E0A5B125BD0FC600298D35 /* Empty States */, + 0135E7182D7E33F9004AD8FA /* Scan */, 01E0A59025BD087E00298D35 /* Settings */, 01E0A5DF25BD148800298D35 /* Shared */, 01011B542864434900B70D04 /* Shop Detail */, @@ -560,12 +676,44 @@ 017204F825AA85F3008FD63B /* Utilities */ = { isa = PBXGroup; children = ( + 017278802D7D935700CE424F /* ImageSaver.swift */, 017204F925AA85F3008FD63B /* MessageBus.swift */, + 017278812D7D935700CE424F /* QRCodeGenerator.swift */, 01E2477129A5E30400D4B00D /* Utility.swift */, ); path = Utilities; sourceTree = ""; }; + 0172788A2D7D936E00CE424F /* Tags */ = { + isa = PBXGroup; + children = ( + 017278842D7D936E00CE424F /* CompletedTag.swift */, + 017278862D7D936E00CE424F /* CustomerScannedTag.swift */, + 017278872D7D936E00CE424F /* IdlingTagView.swift */, + 017278892D7D936E00CE424F /* TagView.swift */, + ); + path = Tags; + sourceTree = ""; + }; + 017278952D7D99D100CE424F /* ItemTag Detail */ = { + isa = PBXGroup; + children = ( + 017278932D7D99D100CE424F /* ItemTagDetailView.swift */, + 017278942D7D99D100CE424F /* ItemTagEditView.swift */, + ); + path = "ItemTag Detail"; + sourceTree = ""; + }; + 017278992D7D99D100CE424F /* ItemTag List */ = { + isa = PBXGroup; + children = ( + 017278962D7D99D100CE424F /* ItemTagCreateView.swift */, + 017278972D7D99D100CE424F /* ItemTagListCardView.swift */, + 017278982D7D99D100CE424F /* ItemTagListView.swift */, + ); + path = "ItemTag List"; + sourceTree = ""; + }; 0182D37425B277D6001E881D /* KeychainStore */ = { isa = PBXGroup; children = ( @@ -589,6 +737,7 @@ isa = PBXGroup; children = ( 0106414129A9F51700B46FED /* AccountPasswordRepository.swift */, + 017278742D7D8FAC00CE424F /* ItemTagRepository.swift */, 017204A925AA8449008FD63B /* ShopRepository.swift */, ); path = Repositories; @@ -618,6 +767,7 @@ 01E0A5DF25BD148800298D35 /* Shared */ = { isa = PBXGroup; children = ( + 0172788A2D7D936E00CE424F /* Tags */, 01E0A5F925BD148800298D35 /* MainButtonView.swift */, ); path = Shared; @@ -735,6 +885,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0135E8E42D7E4478004AD8FA /* SampleCode.xcconfig in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -769,12 +920,17 @@ files = ( 0172047925AA8335008FD63B /* UIFont+Extensions.swift in Sources */, 01E2477029A570D300D4B00D /* SignUp.swift in Sources */, + 0172785A2D7D83B600CE424F /* NFCManager.swift in Sources */, + 0172785B2D7D83B600CE424F /* AppSingletons.swift in Sources */, + 017278732D7D87EB00CE424F /* UIImage+Extentions.swift in Sources */, 0172052F25AC41A7008FD63B /* SessionRequest.swift in Sources */, + 017278772D7D8FF100CE424F /* ItemTagsService.swift in Sources */, 017204C025AA846D008FD63B /* TabViewModel.swift in Sources */, 01B9E45228A5070D00CAC681 /* ShopkeeperSignInAdapter.swift in Sources */, 0172040025AA6775008FD63B /* LoginRepository.swift in Sources */, 0172034B25A9642E008FD63B /* EntityAdapter.swift in Sources */, 010A30A528A4A285001D6BD1 /* DataCacheUpdate.swift in Sources */, + 017278922D7D99B900CE424F /* NumberTagsWebpageListView.swift in Sources */, 01E0A60C25BD440300298D35 /* SignInEmailAndPasswordView.swift in Sources */, 0172033925A9642E008FD63B /* JSONAPIRelationship.swift in Sources */, 01B526562AF4E82A00655131 /* ScrollToTopID.swift in Sources */, @@ -787,6 +943,7 @@ 01E0A5B725BD0FCD00298D35 /* OfflineView.swift in Sources */, 0110A15F2AC816F5003EDCBA /* SendConfirmation.swift in Sources */, 0172033D25A9642E008FD63B /* NativeAppTemplateEnvironment.swift in Sources */, + 0172787F2D7D933000CE424F /* ShopDetailCardView.swift in Sources */, 01EE363E29A6DCEB009BCD9D /* ShopkeeperEditView.swift in Sources */, 0182D38225B296B9001E881D /* ShopkeeperAdapter.swift in Sources */, 01BE4F1D29CA6F8C002008BE /* TimeZoneData.swift in Sources */, @@ -811,6 +968,11 @@ 0172033E25A9642E008FD63B /* Parameters.swift in Sources */, 0150A36629B14BB300907F96 /* SendResetPassword.swift in Sources */, 017204B625AA8467008FD63B /* DataManager.swift in Sources */, + 017278682D7D83F600CE424F /* ScanState.swift in Sources */, + 017278692D7D83F600CE424F /* ScanResultError.swift in Sources */, + 0172787D2D7D92DF00CE424F /* CompleteScanResult.swift in Sources */, + 017278822D7D935700CE424F /* QRCodeGenerator.swift in Sources */, + 017278832D7D935700CE424F /* ImageSaver.swift in Sources */, 01D8AE8B2AB453C1009AFFBA /* ShopBasicSettingsView.swift in Sources */, 01E0A60125BD149200298D35 /* MainButtonView.swift in Sources */, 0182D39A25B4424B001E881D /* LoggedInShopkeeperKeychainStore.swift in Sources */, @@ -819,11 +981,24 @@ 01E0A62225BD4A7800298D35 /* Shopkeeper+Backdoor.swift in Sources */, 0106413C29A9EDFF00B46FED /* AccountPasswordRequest.swift in Sources */, 0172035625A9642E008FD63B /* ShopAdapter.swift in Sources */, + 0172788B2D7D936E00CE424F /* CompletedTag.swift in Sources */, + 0172788C2D7D936E00CE424F /* IdlingTagView.swift in Sources */, + 0172788D2D7D936E00CE424F /* CustomerScannedTag.swift in Sources */, + 017278902D7D936E00CE424F /* TagView.swift in Sources */, 0106414429AA061100B46FED /* PasswordEditView.swift in Sources */, + 0172786B2D7D840A00CE424F /* ShowTagInfoScanResult.swift in Sources */, 017204D925AA847E008FD63B /* ShopRepository.swift in Sources */, + 017278612D7D83E700CE424F /* ItemTagData.swift in Sources */, + 017278622D7D83E700CE424F /* ItemTag.swift in Sources */, + 017278632D7D83E700CE424F /* ItemTagState.swift in Sources */, + 017278642D7D83E700CE424F /* ItemTagInfoFromNdefMessage.swift in Sources */, + 017278652D7D83E700CE424F /* ItemTagType.swift in Sources */, 0172046625AA82BF008FD63B /* MessageBarView.swift in Sources */, 01E0A63025BD53FD00298D35 /* Shop.swift in Sources */, 017278072D7D4F5800CE424F /* OnboardingRepository.swift in Sources */, + 0135E7192D7E33F9004AD8FA /* CompleteScanResultView.swift in Sources */, + 0135E71A2D7E33F9004AD8FA /* ShowTagInfoScanResultView.swift in Sources */, + 0135E71B2D7E33F9004AD8FA /* ScanView.swift in Sources */, 013292BE262C3EA400690B75 /* LoggedInShopkeeper.swift in Sources */, 0172035825A9642E008FD63B /* ShopsService.swift in Sources */, 018E21CD2B36377800FFD1F6 /* MeService.swift in Sources */, @@ -837,19 +1012,30 @@ 01B526542AF4E36400655131 /* MainTab.swift in Sources */, 017203CB25A97090008FD63B /* SessionController.swift in Sources */, 0106413E29A9F1C300B46FED /* UpdatePassword.swift in Sources */, + 0172787B2D7D903500CE424F /* ItemTagAdapter.swift in Sources */, 010F86AE2621A2A900B6C62A /* ShopDetailView.swift in Sources */, 011F6DF1259EF16400BED22E /* App.swift in Sources */, 01FA23A12B00CE5700F1D446 /* MailView.swift in Sources */, 017278092D7D4F7400CE424F /* Onboarding.swift in Sources */, 01467357299902230005423D /* ShopSettingsView.swift in Sources */, + 017278792D7D900100CE424F /* ItemTagsRequest.swift in Sources */, 0172048125AA8343008FD63B /* Color+Extensions.swift in Sources */, 011DDC2528766CEC00C6C21F /* SignUpRequest.swift in Sources */, 017203EB25AA6606008FD63B /* Logger.swift in Sources */, 0172046425AA82BF008FD63B /* SnackbarView.swift in Sources */, 018E21CB2B36367F00FFD1F6 /* MeRequest.swift in Sources */, 01E0A5B825BD0FCD00298D35 /* ErrorView.swift in Sources */, + 0172789A2D7D99D100CE424F /* ItemTagListCardView.swift in Sources */, + 0172789B2D7D99D100CE424F /* ItemTagListView.swift in Sources */, + 0172789C2D7D99D100CE424F /* ItemTagDetailView.swift in Sources */, + 0172789D2D7D99D100CE424F /* ItemTagCreateView.swift in Sources */, + 0172789E2D7D99D100CE424F /* ItemTagEditView.swift in Sources */, + 0172786F2D7D87D000CE424F /* String+Extensions.swift in Sources */, + 017278702D7D87D000CE424F /* Date+Extensions.swift in Sources */, + 017278712D7D87D000CE424F /* DateFormatter+Extensions.swift in Sources */, 01E0A59C25BD088600298D35 /* SettingsView.swift in Sources */, 0172052525AAFA43008FD63B /* Shopkeeper.swift in Sources */, + 017278752D7D8FAC00CE424F /* ItemTagRepository.swift in Sources */, 0172047E25AA8343008FD63B /* Font+Extensions.swift in Sources */, 0172034825A9642E008FD63B /* Request.swift in Sources */, 0172048025AA8343008FD63B /* UIColor+Extensions.swift in Sources */, @@ -1016,8 +1202,9 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = ""; BUNDLE_ID_SUFFIX = .dev; CODE_SIGN_ENTITLEMENTS = NativeAppTemplate/NativeAppTemplate.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_ASSET_PATHS = "\"NativeAppTemplate/Preview Content\""; DEVELOPMENT_TEAM = NNYDL5U3V3; ENABLE_PREVIEWS = YES; @@ -1030,10 +1217,12 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.2; - PRODUCT_BUNDLE_IDENTIFIER = "com.nativeapptemplate.NativeAppTemplate.ios$(BUNDLE_ID_SUFFIX)"; - "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]" = com.nativeapptemplate.NativeAppTemplateFree.ios; + MARKETING_VERSION = 2.0.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.nativeapptemplate.NativeAppTemplateFree.ios${SAMPLE_CODE_DISAMBIGUATOR}"; + "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]" = "com.nativeapptemplate.NativeAppTemplateFree.ios${SAMPLE_CODE_DISAMBIGUATOR}"; PRODUCT_NAME = NativeAppTemplate; + PROVISIONING_PROFILE_SPECIFIER = ""; + SAMPLE_CODE_DISAMBIGUATOR = "${DEVELOPMENT_TEAM}"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; @@ -1050,8 +1239,9 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = ""; BUNDLE_ID_SUFFIX = ""; CODE_SIGN_ENTITLEMENTS = NativeAppTemplate/NativeAppTemplate.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_ASSET_PATHS = "\"NativeAppTemplate/Preview Content\""; DEVELOPMENT_TEAM = NNYDL5U3V3; ENABLE_PREVIEWS = YES; @@ -1064,10 +1254,12 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.2; - PRODUCT_BUNDLE_IDENTIFIER = "com.nativeapptemplate.NativeAppTemplate.ios$(BUNDLE_ID_SUFFIX)"; - "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]" = com.nativeapptemplate.NativeAppTemplateFree.ios; + MARKETING_VERSION = 2.0.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.nativeapptemplate.NativeAppTemplateFree.ios${SAMPLE_CODE_DISAMBIGUATOR}"; + "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]" = "com.nativeapptemplate.NativeAppTemplateFree.ios${SAMPLE_CODE_DISAMBIGUATOR}"; PRODUCT_NAME = NativeAppTemplate; + PROVISIONING_PROFILE_SPECIFIER = ""; + SAMPLE_CODE_DISAMBIGUATOR = "${DEVELOPMENT_TEAM}"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; @@ -1147,7 +1339,7 @@ BUNDLE_ID_SUFFIX = .beta; CODE_SIGN_ENTITLEMENTS = NativeAppTemplate/NativeAppTemplate.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_ASSET_PATHS = "\"NativeAppTemplate/Preview Content\""; DEVELOPMENT_TEAM = NNYDL5U3V3; ENABLE_PREVIEWS = YES; @@ -1160,9 +1352,10 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.2; - PRODUCT_BUNDLE_IDENTIFIER = "com.nativeapptemplate.NativeAppTemplate.ios$(BUNDLE_ID_SUFFIX)"; + MARKETING_VERSION = 2.0.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.nativeapptemplate.NativeAppTemplateFree.ios${SAMPLE_CODE_DISAMBIGUATOR}$(BUNDLE_ID_SUFFIX)"; PRODUCT_NAME = NativeAppTemplate; + SAMPLE_CODE_DISAMBIGUATOR = "${DEVELOPMENT_TEAM}"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; diff --git a/NativeAppTemplate/App.swift b/NativeAppTemplate/App.swift index ff4d9ac..7ec5f88 100644 --- a/NativeAppTemplate/App.swift +++ b/NativeAppTemplate/App.swift @@ -31,6 +31,8 @@ struct App { dataManager = nativeAppTemplateObjects.dataManager messageBus = nativeAppTemplateObjects.messageBus +// Tips.showAllTipsForTesting() + try? Tips.configure() } } diff --git a/NativeAppTemplate/AppSingletons.swift b/NativeAppTemplate/AppSingletons.swift new file mode 100644 index 0000000..8ce69e9 --- /dev/null +++ b/NativeAppTemplate/AppSingletons.swift @@ -0,0 +1,12 @@ +import Foundation + +@MainActor +struct AppSingletons { + var nfcManager: NFCManager + + init(nfcManager: NFCManager? = nil) { + self.nfcManager = nfcManager ?? NFCManager.shared + } +} + +@MainActor var appSingletons = AppSingletons() diff --git a/NativeAppTemplate/Assets.xcassets/Colours/Tags/personalTagBackground.colorset/Contents.json b/NativeAppTemplate/Assets.xcassets/Colours/Backgrounds/coloredPrimaryBackground.colorset/Contents.json similarity index 83% rename from NativeAppTemplate/Assets.xcassets/Colours/Tags/personalTagBackground.colorset/Contents.json rename to NativeAppTemplate/Assets.xcassets/Colours/Backgrounds/coloredPrimaryBackground.colorset/Contents.json index 7d3f481..dea20a0 100644 --- a/NativeAppTemplate/Assets.xcassets/Colours/Tags/personalTagBackground.colorset/Contents.json +++ b/NativeAppTemplate/Assets.xcassets/Colours/Backgrounds/coloredPrimaryBackground.colorset/Contents.json @@ -6,8 +6,8 @@ "components" : { "alpha" : "1.000", "blue" : "0xFF", - "green" : "0xEC", - "red" : "0xB3" + "green" : "0xF8", + "red" : "0xE3" } }, "idiom" : "universal" diff --git a/NativeAppTemplate/Assets.xcassets/Colours/Tags/personalTagForeground.colorset/Contents.json b/NativeAppTemplate/Assets.xcassets/Colours/Backgrounds/coloredPrimaryForeground.colorset/Contents.json similarity index 100% rename from NativeAppTemplate/Assets.xcassets/Colours/Tags/personalTagForeground.colorset/Contents.json rename to NativeAppTemplate/Assets.xcassets/Colours/Backgrounds/coloredPrimaryForeground.colorset/Contents.json diff --git a/NativeAppTemplate/Assets.xcassets/Colours/Backgrounds/coloredSecondaryBackground.colorset/Contents.json b/NativeAppTemplate/Assets.xcassets/Colours/Backgrounds/coloredSecondaryBackground.colorset/Contents.json new file mode 100644 index 0000000..9cdca7e --- /dev/null +++ b/NativeAppTemplate/Assets.xcassets/Colours/Backgrounds/coloredSecondaryBackground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFA", + "green" : "0xF7", + "red" : "0xF5" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NativeAppTemplate/Assets.xcassets/Colours/Backgrounds/coloredSecondaryForeground.colorset/Contents.json b/NativeAppTemplate/Assets.xcassets/Colours/Backgrounds/coloredSecondaryForeground.colorset/Contents.json new file mode 100644 index 0000000..6204bd1 --- /dev/null +++ b/NativeAppTemplate/Assets.xcassets/Colours/Backgrounds/coloredSecondaryForeground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x33", + "green" : "0x29", + "red" : "0x1F" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NativeAppTemplate/Assets.xcassets/Colours/Tags/currentAccountTagForeground.colorset/Contents.json b/NativeAppTemplate/Assets.xcassets/Colours/Backgrounds/failureBackground.colorset/Contents.json similarity index 100% rename from NativeAppTemplate/Assets.xcassets/Colours/Tags/currentAccountTagForeground.colorset/Contents.json rename to NativeAppTemplate/Assets.xcassets/Colours/Backgrounds/failureBackground.colorset/Contents.json diff --git a/NativeAppTemplate/Assets.xcassets/Colours/Backgrounds/successBackground.colorset/Contents.json b/NativeAppTemplate/Assets.xcassets/Colours/Backgrounds/successBackground.colorset/Contents.json new file mode 100644 index 0000000..47b7a58 --- /dev/null +++ b/NativeAppTemplate/Assets.xcassets/Colours/Backgrounds/successBackground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x40", + "green" : "0x4D", + "red" : "0x01" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NativeAppTemplate/Assets.xcassets/Colours/Backgrounds/successSecondaryForeground.colorset/Contents.json b/NativeAppTemplate/Assets.xcassets/Colours/Backgrounds/successSecondaryForeground.colorset/Contents.json new file mode 100644 index 0000000..4b343d1 --- /dev/null +++ b/NativeAppTemplate/Assets.xcassets/Colours/Backgrounds/successSecondaryForeground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE2", + "green" : "0xF7", + "red" : "0xC6" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NativeAppTemplate/Assets.xcassets/Colours/Button/coloredPrimaryButtonForeground.colorset/Contents.json b/NativeAppTemplate/Assets.xcassets/Colours/Button/coloredPrimaryButtonForeground.colorset/Contents.json new file mode 100644 index 0000000..15e2907 --- /dev/null +++ b/NativeAppTemplate/Assets.xcassets/Colours/Button/coloredPrimaryButtonForeground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x88", + "green" : "0x53", + "red" : "0x03" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NativeAppTemplate/Assets.xcassets/Colours/Button/coloredSecondaryButtonForeground.colorset/Contents.json b/NativeAppTemplate/Assets.xcassets/Colours/Button/coloredSecondaryButtonForeground.colorset/Contents.json new file mode 100644 index 0000000..6204bd1 --- /dev/null +++ b/NativeAppTemplate/Assets.xcassets/Colours/Button/coloredSecondaryButtonForeground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x33", + "green" : "0x29", + "red" : "0x1F" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NativeAppTemplate/Assets.xcassets/Colours/Tags/currentAccountTagBackground.colorset/Contents.json b/NativeAppTemplate/Assets.xcassets/Colours/Button/failureSecondaryForeground.colorset/Contents.json similarity index 100% rename from NativeAppTemplate/Assets.xcassets/Colours/Tags/currentAccountTagBackground.colorset/Contents.json rename to NativeAppTemplate/Assets.xcassets/Colours/Button/failureSecondaryForeground.colorset/Contents.json diff --git a/NativeAppTemplate/Assets.xcassets/Colours/Tags/completedTagBackground.colorset/Contents.json b/NativeAppTemplate/Assets.xcassets/Colours/Tags/completedTagBackground.colorset/Contents.json new file mode 100644 index 0000000..4b343d1 --- /dev/null +++ b/NativeAppTemplate/Assets.xcassets/Colours/Tags/completedTagBackground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE2", + "green" : "0xF7", + "red" : "0xC6" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NativeAppTemplate/Assets.xcassets/Colours/Tags/currentAccountTagBorder.colorset/Contents.json b/NativeAppTemplate/Assets.xcassets/Colours/Tags/completedTagBorder.colorset/Contents.json similarity index 100% rename from NativeAppTemplate/Assets.xcassets/Colours/Tags/currentAccountTagBorder.colorset/Contents.json rename to NativeAppTemplate/Assets.xcassets/Colours/Tags/completedTagBorder.colorset/Contents.json diff --git a/NativeAppTemplate/Assets.xcassets/Colours/Tags/completedTagForeground.colorset/Contents.json b/NativeAppTemplate/Assets.xcassets/Colours/Tags/completedTagForeground.colorset/Contents.json new file mode 100644 index 0000000..47b7a58 --- /dev/null +++ b/NativeAppTemplate/Assets.xcassets/Colours/Tags/completedTagForeground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x40", + "green" : "0x4D", + "red" : "0x01" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NativeAppTemplate/Assets.xcassets/Colours/Tags/customerScannedTagBackground.colorset/Contents.json b/NativeAppTemplate/Assets.xcassets/Colours/Tags/customerScannedTagBackground.colorset/Contents.json new file mode 100644 index 0000000..1c6de4c --- /dev/null +++ b/NativeAppTemplate/Assets.xcassets/Colours/Tags/customerScannedTagBackground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xD2", + "green" : "0xB8", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NativeAppTemplate/Assets.xcassets/Colours/Tags/personalTagBorder.colorset/Contents.json b/NativeAppTemplate/Assets.xcassets/Colours/Tags/customerScannedTagBorder.colorset/Contents.json similarity index 100% rename from NativeAppTemplate/Assets.xcassets/Colours/Tags/personalTagBorder.colorset/Contents.json rename to NativeAppTemplate/Assets.xcassets/Colours/Tags/customerScannedTagBorder.colorset/Contents.json diff --git a/NativeAppTemplate/Assets.xcassets/Colours/Tags/customerScannedTagForeground.colorset/Contents.json b/NativeAppTemplate/Assets.xcassets/Colours/Tags/customerScannedTagForeground.colorset/Contents.json new file mode 100644 index 0000000..17bc8b7 --- /dev/null +++ b/NativeAppTemplate/Assets.xcassets/Colours/Tags/customerScannedTagForeground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x42", + "green" : "0x00", + "red" : "0x62" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NativeAppTemplate/Assets.xcassets/Colours/Tags/idlingTagBackground.colorset/Contents.json b/NativeAppTemplate/Assets.xcassets/Colours/Tags/idlingTagBackground.colorset/Contents.json new file mode 100644 index 0000000..c6fec15 --- /dev/null +++ b/NativeAppTemplate/Assets.xcassets/Colours/Tags/idlingTagBackground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xEB", + "green" : "0xE7", + "red" : "0xE4" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NativeAppTemplate/Assets.xcassets/Colours/Tags/idlingTagBorder.colorset/Contents.json b/NativeAppTemplate/Assets.xcassets/Colours/Tags/idlingTagBorder.colorset/Contents.json new file mode 100644 index 0000000..97650a1 --- /dev/null +++ b/NativeAppTemplate/Assets.xcassets/Colours/Tags/idlingTagBorder.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NativeAppTemplate/Assets.xcassets/Colours/Tags/idlingTagForeground.colorset/Contents.json b/NativeAppTemplate/Assets.xcassets/Colours/Tags/idlingTagForeground.colorset/Contents.json new file mode 100644 index 0000000..6204bd1 --- /dev/null +++ b/NativeAppTemplate/Assets.xcassets/Colours/Tags/idlingTagForeground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x33", + "green" : "0x29", + "red" : "0x1F" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NativeAppTemplate/Assets.xcassets/Colours/Text/coloredPrimaryFootnoteText.colorset/Contents.json b/NativeAppTemplate/Assets.xcassets/Colours/Text/coloredPrimaryFootnoteText.colorset/Contents.json new file mode 100644 index 0000000..0ce6eec --- /dev/null +++ b/NativeAppTemplate/Assets.xcassets/Colours/Text/coloredPrimaryFootnoteText.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xA3", + "green" : "0x69", + "red" : "0x0B" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NativeAppTemplate/Assets.xcassets/Colours/Text/coloredSecondaryFootnoteText.colorset/Contents.json b/NativeAppTemplate/Assets.xcassets/Colours/Text/coloredSecondaryFootnoteText.colorset/Contents.json new file mode 100644 index 0000000..ef05487 --- /dev/null +++ b/NativeAppTemplate/Assets.xcassets/Colours/Text/coloredSecondaryFootnoteText.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x4B", + "green" : "0x3F", + "red" : "0x32" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NativeAppTemplate/Assets.xcassets/Colours/Text/secondaryText.colorset/Contents.json b/NativeAppTemplate/Assets.xcassets/Colours/Text/secondaryText.colorset/Contents.json new file mode 100644 index 0000000..a0f9bb9 --- /dev/null +++ b/NativeAppTemplate/Assets.xcassets/Colours/Text/secondaryText.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x7C", + "green" : "0x6E", + "red" : "0x61" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x7C", + "green" : "0x6E", + "red" : "0x61" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x7C", + "green" : "0x6E", + "red" : "0x61" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NativeAppTemplate/Assets.xcassets/Colours/Write to Tag/Contents.json b/NativeAppTemplate/Assets.xcassets/Colours/Write to Tag/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/NativeAppTemplate/Assets.xcassets/Colours/Write to Tag/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NativeAppTemplate/Assets.xcassets/Colours/Write to Tag/customerBackground.colorset/Contents.json b/NativeAppTemplate/Assets.xcassets/Colours/Write to Tag/customerBackground.colorset/Contents.json new file mode 100644 index 0000000..dea20a0 --- /dev/null +++ b/NativeAppTemplate/Assets.xcassets/Colours/Write to Tag/customerBackground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xF8", + "red" : "0xE3" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NativeAppTemplate/Assets.xcassets/Colours/Write to Tag/customerForeground.colorset/Contents.json b/NativeAppTemplate/Assets.xcassets/Colours/Write to Tag/customerForeground.colorset/Contents.json new file mode 100644 index 0000000..15e2907 --- /dev/null +++ b/NativeAppTemplate/Assets.xcassets/Colours/Write to Tag/customerForeground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x88", + "green" : "0x53", + "red" : "0x03" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NativeAppTemplate/Assets.xcassets/Colours/Write to Tag/lockBackground.colorset/Contents.json b/NativeAppTemplate/Assets.xcassets/Colours/Write to Tag/lockBackground.colorset/Contents.json new file mode 100644 index 0000000..1e684e1 --- /dev/null +++ b/NativeAppTemplate/Assets.xcassets/Colours/Write to Tag/lockBackground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xEA", + "green" : "0xFB", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NativeAppTemplate/Assets.xcassets/Colours/Write to Tag/lockForeground.colorset/Contents.json b/NativeAppTemplate/Assets.xcassets/Colours/Write to Tag/lockForeground.colorset/Contents.json new file mode 100644 index 0000000..2311ae5 --- /dev/null +++ b/NativeAppTemplate/Assets.xcassets/Colours/Write to Tag/lockForeground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x0B", + "green" : "0x2B", + "red" : "0x8D" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NativeAppTemplate/Assets.xcassets/Colours/Write to Tag/serverBackground.colorset/Contents.json b/NativeAppTemplate/Assets.xcassets/Colours/Write to Tag/serverBackground.colorset/Contents.json new file mode 100644 index 0000000..937e5f4 --- /dev/null +++ b/NativeAppTemplate/Assets.xcassets/Colours/Write to Tag/serverBackground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xEC", + "green" : "0xE3", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NativeAppTemplate/Assets.xcassets/Colours/Write to Tag/serverForeground.colorset/Contents.json b/NativeAppTemplate/Assets.xcassets/Colours/Write to Tag/serverForeground.colorset/Contents.json new file mode 100644 index 0000000..17bc8b7 --- /dev/null +++ b/NativeAppTemplate/Assets.xcassets/Colours/Write to Tag/serverForeground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x42", + "green" : "0x00", + "red" : "0x62" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NativeAppTemplate/Assets.xcassets/Colours/alarm.colorset/Contents.json b/NativeAppTemplate/Assets.xcassets/Colours/alarm.colorset/Contents.json new file mode 100644 index 0000000..da33105 --- /dev/null +++ b/NativeAppTemplate/Assets.xcassets/Colours/alarm.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x4E", + "green" : "0x4E", + "red" : "0xEF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NativeAppTemplate/Constants.swift b/NativeAppTemplate/Constants.swift index 5a31d7e..cba663d 100644 --- a/NativeAppTemplate/Constants.swift +++ b/NativeAppTemplate/Constants.swift @@ -14,9 +14,9 @@ extension Int { extension String { #if DEBUG -// static let scheme: String = "http" -// static let domain: String = "192.168.1.21" -// static let port: String = "3000" + // static let scheme: String = "http" + // static let domain: String = "192.168.1.21" + // static let port: String = "3000" static let scheme: String = "https" static let domain: String = "api.nativeapptemplate.com" static let port: String = "" @@ -26,26 +26,33 @@ extension String { static let port: String = "" #endif + static let androidAar: String = "com.nativeapptemplate.nativeapptemplatefree" + static let androidAarNfcndefPayloadType: String = "android.com:pkg" + // This is for MyTurnTag Creator. Replace this. static let appStoreUrl: String = "https://apps.apple.com/app/myturntag-creator/id1516198303" + static let scanPath: String = "scan" + static let scanPathCustomer: String = "scan_customer" + static let placeholderFullName: String = "John Smith" static let placeholderEmail: String = "you@example.com" static let placeholderPassword: String = "password" static let defaultTimeZone: String = "London" - + static let keychainAccountLoggedInShopkeeper = "com.nativeapptemplate.NativeAppTemplateFree.LoggedInShopkeeperAccount" static let keychainServiceLoggedInShopkeeper = "com.nativeapptemplate.NativeAppTemplateFree.LoggedInShopkeeperService" static let shops = "Shops" - static let loading = "Loading..." + static let scan = "Scan" static let settings = "Settings" - + static let loading = "Loading..." + // MARK: Resend Confirmation Instructions View static let buttonSendMeConfirmationInstructions = "Resend confirmation instructions" static let didntReceiveConfirmationInstructions = "Didn't receive confirmation instructions?" - + // MARK: Forgot Password View static let buttonSendMeResetPasswordInstructions = "Send me reset password instructions" static let forgotYourPassword = "Forgot your password?" @@ -60,19 +67,66 @@ extension String { static let shopNameIsRequired = "Shop name is required." static let timeZone = "Time Zone" static let createShopsLabel = "Create shops" - + static let tapShopBelow = "Tap a shop below." + static let haveFun = "Have fun!" + + // MARK: Shop Detail View + static let swipeNumberTagBelow = "Swipe a number tag below." + static let tapDisplayedButton = "Tap the displayed button." + static let serverNumberTagsWebpageWillBeUpdated = "The Server Number Tags Webpage will be updated." + static let readInstructions = "Read Instructions" + static let serverNumberTagsWebpage = "Server Number Tags Webpage" + // MARK: Shop Settings View static let shopSettingsLabel = "Shop Settings" static let shopSettingsBasicSettingsLabel = "Basic Settings" + static let shopSettingsManageNumberTagsLabel = "Manage Number Tags" + static let shopSettingsNumberTagsWebpageLabel = "Number Tags Webpage" + static let resetNumberTagsDescription = "Reset all number tag statuses." + static let resetNumberTags = "Reset Number Tags" + + // MARK: Number Tags Web Pages + static let copyWebpageUrl = "Copy the above webpage URL" + static let webpageUrlCopied = "Webpage URL copied." + + // MARK: Item Tag View + static let tagNumber = "Tag Number" + static let editTag = "Edit Tag" + static let addTag = "Add Tag" + static let addTagDescription = "Add a new number tag and start changing the tag status." + static let deleteTag = "Delete tag" + static let buttonDeleteTag = "Delete Tag" + static let tagNumberIsInvalid = "Tag number is invalid." + static let writeServerTag = "Write Server Tag" + static let writeCustomerTag = "Write Customer Tag" + static let youCannotUndoAfterLockingTag = "You cannot undo. After locking the tag, you can no longer write data to it." + static let zeroPadding = "Zero padding(e.g. 07)." + static let writingSucceeded = "Writing succeeded!" + + // MARK: Scan View + static let completeScan = "Complete Scan" + static let showTagInfoScan = "Show Tag Info Scan" + static let tagInfo = "Tag info" + static let readOnly = "Read Only" + static let writable = "Writable" + static let completeScanHelp = "Read a NFC Number Tag for changing the Number Tag status." + static let showTagInfoScanHelp = "Read a NFC Number Tag for showing the Number Tag information." + static let deviceDoesNotSupportScan = "This device doesn't support tag scanning." + static let holdYourIPhoneNearTheItem = "Hold your iPhone near the item to learn more about it." + static let tagNotValid = "Tag not valid." + static let moreThan1TagsWasFound = "More than 1 tags was found. Please present only 1 tag." + static let tagIsNotWritable = "Tag is not writable." + static let tagIsNotNdefFormatted = "Tag is not NDEF formatted." // MARK: Settings View static let supportMail: String = "support@nativeapptemplate.com" static let supportWebsiteUrl: String = "https://nativeapptemplate.com" static let howToUseUrl: String = "https://myturntag.com/how" + static let faqsUrl: String = "https://nativeapptemplate.com/faqs" static let discussionsUrl: String = "https://github.com/nativeapptemplate/NativeAppTemplate-Free-iOS/discussions" static let privacyPolicyUrl: String = "https://nativeapptemplate.com/privacy" static let termsOfUseUrl: String = "https://nativeapptemplate.com/terms" - + static let myAccount = "My Account" static let profile = "Profile" static let information = "Information" @@ -102,21 +156,40 @@ extension String { static let basicSettingsUpdated = "Basic settings updated successfully." static let shopDeleted = "Shop deleted successfully." static let shopDeletedError = "There was a problem deleting the shop." + static let shopReset = "All number tags reset." + static let shopResetError = "There was a problem resetting number tags." + + static let itemTagCreated = "Tag created successfully." + static let itemTagUpdated = "Tag updated successfully." + static let itemTagDeleted = "Tag deleted successfully." + static let itemTagDeletedError = "There was a problem deleting the tag." + static let itemTagCompleted = "Tag completed successfully." + static let itemTagCompletedError = "There was a problem completing the tag." + static let itemTagReset = "Tag reset successfully." + static let itemTagResetError = "There was a problem resetting the tag." + static let itemTagAlreadyCompleted = "Tag already completed." + static let messageWrittenOnTagIsWrong = "The message written on the tag is wrong." + static let scanServerTag = "This tag is a \"CUSTOMER\" tag. Scan a \"SERVER\" tag!" + + static let customerQrCodeImageSavedToPhotoAlbum = "Customer QR code image saved to Photo Album successfully." + static let customerQrCodeImageSavedToPhotoAlbumError = "There was a problem saving Customer QR code image to Photo Album." + static let saveToPhotoAlbum = "Save to Photo Album" + static let generateCustomerQrCode = "Generate Customer QR code" static let shopkeeperCreated = "Account created successfully." static let shopkeeperCreatedError = "There was a problem creating the account." static let signedUpButUnconfirmed = "A message with a confirmation link has been sent to your email address. Please follow the link to activate your account." - + static let shopkeeperUpdated = "Account updated successfully." static let shopkeeperDeleted = "Account deleted successfully." static let shopkeeperDeletedError = "There was a problem deleting the account." - + static let confirmedPrivacyVersionUpdated = "Privacy policy accepted successfully." static let confirmedPrivacyVersionUpdatedError = "There was a problem accepting the privacy policy." - + static let confirmedTermsVersionUpdated = "Terms of use accepted successfully." static let confirmedTermsVersionUpdatedError = "There was a problem accepting the terms of use." - + static let signedOut = "Signed out successfully." static let signedOutError = "There was a problem signing out." @@ -125,14 +198,14 @@ extension String { static let sentResetPasswordInstruction = "An email has been sent the email containing instructions for resetting your password." static let sentResetPasswordInstructionError = "Unable to find user with the email." - + static let sentConfirmationInstruction = "An email has been sent the email containing instructions for confirming your email address." static let sentConfirmationInstructionError = "Unable to find user with the email." - + static let pleaseSignIn = "Please sign in." static let updateApp = "Update App" static let installNewVersionApp = "Please install new version app." - + // MARK: Onboarding static let signIn = "Sign In" static let signUp = "Sign Up" @@ -154,9 +227,7 @@ extension String { static let onboardingDescription11 = String(localized: "The customer\'s **Number Tags Webpage** displays the completed **Customer Tag**(A07).") static let onboardingDescription12 = String(localized: "The customer returns the **Customer Tag**.") static let onboardingDescription13 = String(localized: "The customer finally got the delicious KILITANPO!") - - static let unauthorized = "You are not authorized to perform this action." - + // MARK: Other static let yes = "Yes" static let ok = "OK" // swiftlint:disable:this identifier_name @@ -179,7 +250,10 @@ extension String { static let role = "Role" static let createShops = "Create shops." static let createTags = "Create tags." + static let complete = "Complete" static let open = "Open" + static let learnMore = "Learn More" + static let instructions = "Instructions" static let forceSignOut = "Force Sign Out?" static let signOut = "Sign Out" static let noConnection = "No Connection" @@ -189,6 +263,10 @@ extension String { static let backToStartScreen = "Back to Start Screen" static let fullName = "Full Name" static let fullNameIsRequired = "Full name is required." + static let reset = "Reset" + static let unknownNdefStatus = "Unknown NDEF status" + static let noRecrodsFound = "No recrods found" + static let thisDeviceDoesNotSupportTagScanning = "This device doesn't support tag scanning." } extension TimeInterval { diff --git a/NativeAppTemplate/Data/DataManager.swift b/NativeAppTemplate/Data/DataManager.swift index 374c666..a0351c2 100644 --- a/NativeAppTemplate/Data/DataManager.swift +++ b/NativeAppTemplate/Data/DataManager.swift @@ -16,6 +16,7 @@ import SwiftUI // Repositories private(set) var accountPasswordRepository: AccountPasswordRepository! private(set) var shopRepository: ShopRepository! + private(set) var itemTagRepository: ItemTagRepository! private(set) var isRebuildingRepositories = false // MARK: - Initializers @@ -37,9 +38,12 @@ import SwiftUI let accountPasswordService = AccountPasswordService(networkClient: sessionController.client) let shopsService = ShopsService(networkClient: sessionController.client) + let itemTagsService = ItemTagsService(networkClient: sessionController.client) + accountPasswordRepository = AccountPasswordRepository(accountPasswordService: accountPasswordService) shopRepository = ShopRepository(shopsService: shopsService) - + itemTagRepository = ItemTagRepository(itemTagsService: itemTagsService) + isRebuildingRepositories = false } } diff --git a/NativeAppTemplate/Data/Repositories/ItemTagRepository.swift b/NativeAppTemplate/Data/Repositories/ItemTagRepository.swift new file mode 100644 index 0000000..78ea765 --- /dev/null +++ b/NativeAppTemplate/Data/Repositories/ItemTagRepository.swift @@ -0,0 +1,155 @@ +// +// ItemTagRepository.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/01. +// + +import SwiftUI + +@MainActor @Observable class ItemTagRepository { + let itemTagsService: ItemTagsService + + var itemTags: [ItemTag] = [] + var state: DataState = .initial + + init(itemTagsService: ItemTagsService) { + self.itemTagsService = itemTagsService + } + + var isEmpty: Bool { itemTags.isEmpty } + + func findBy(id: String) -> ItemTag { + let itemTag = itemTags.first { $0.id == id } + return itemTag! + } + + func reload(shopId: String) { + if Task.isCancelled { + return + } + + if state == .loading { + return + } + + state = .loading + + Task { @MainActor in + do { + itemTags = try await itemTagsService.allItemTags(shopId: shopId) + state = .hasData + } catch { + state = .failed + Failure + .fetch(from: Self.self, reason: error.localizedDescription) + .log() + } + } + } + + func fetchAll(shopId: String) async throws -> [ItemTag] { + do { + itemTags = try await itemTagsService.allItemTags(shopId: shopId) + return itemTags + } catch { + Failure + .fetch(from: Self.self, reason: error.localizedDescription) + .log() + throw error + } + } + + func fetchDetail(id: String) async throws -> ItemTag { + do { + let itemTag = try await itemTagsService.itemTagDetail(id: id) + let itemTagIndex = (itemTags.firstIndex { $0.id == itemTag.id }) + if itemTagIndex != nil { + itemTags[itemTagIndex!] = itemTag + } + + return itemTag + } catch { + Failure + .fetch(from: Self.self, reason: error.localizedDescription) + .log() + throw error + } + } + + func create(shopId: String, itemTag: ItemTag) async throws -> ItemTag { + do { + let createdItemTag = try await itemTagsService.makeItemTag(shopId: shopId, itemTag: itemTag) + return createdItemTag + } catch { + Failure + .create(from: Self.self, reason: error.localizedDescription) + .log() + throw error + } + } + + func update(id: String, itemTag: ItemTag) async throws -> ItemTag { + do { + let updatedItemTag = try await itemTagsService.updateItemTag(id: id, itemTag: itemTag) + let itemTagIndex = (itemTags.firstIndex { $0.id == updatedItemTag.id }) + if itemTagIndex != nil { + itemTags[itemTagIndex!] = updatedItemTag + } + + return updatedItemTag + } catch { + Failure + .update(from: Self.self, reason: error.localizedDescription) + .log() + throw error + } + } + + func destroy(id: String) async throws { + do { + try await itemTagsService.destroyItemTag(id: id) + } catch { + Failure + .destroy(from: Self.self, reason: error.localizedDescription) + .log() + throw error + } + } + + func complete(id: String) async throws -> ItemTag { + do { + let completedItemTag = try await itemTagsService.completeItemTag(id: id) + let itemTagIndex = (itemTags.firstIndex { $0.id == completedItemTag.id }) + if itemTagIndex != nil { + itemTags[itemTagIndex!] = completedItemTag + } + + return completedItemTag + } catch { + self.state = .failed + Failure + .update(from: Self.self, reason: error.localizedDescription) + .log() + throw error + } + } + + func reset(id: String) async throws -> ItemTag { + do { + let resetItemTag = try await itemTagsService.resetItemTag(id: id) + let itemTagIndex = (itemTags.firstIndex { $0.id == resetItemTag.id }) + if itemTagIndex != nil { + itemTags[itemTagIndex!] = resetItemTag + } + + return resetItemTag + } catch { + self.state = .failed + Failure + .update(from: Self.self, reason: error.localizedDescription) + .log() + throw error + } + } +} diff --git a/NativeAppTemplate/Data/Repositories/ShopRepository.swift b/NativeAppTemplate/Data/Repositories/ShopRepository.swift index 3e18480..f7b6925 100644 --- a/NativeAppTemplate/Data/Repositories/ShopRepository.swift +++ b/NativeAppTemplate/Data/Repositories/ShopRepository.swift @@ -14,7 +14,7 @@ import SwiftUI private(set) var state: DataState = .initial private(set) var limitCount = 0 private(set) var createdShopsCount = 0 - + init( shopsService: ShopsService ) { @@ -32,7 +32,7 @@ import SwiftUI if Task.isCancelled { return } - + if state == .loading { return } @@ -108,4 +108,15 @@ import SwiftUI throw error } } + + func reset(id: String) async throws { + do { + try await shopsService.resetShop(id: id) + } catch { + Failure + .destroy(from: Self.self, reason: error.localizedDescription) + .log() + throw error + } + } } diff --git a/NativeAppTemplate/Extensions/Date+Extensions.swift b/NativeAppTemplate/Extensions/Date+Extensions.swift new file mode 100644 index 0000000..389a507 --- /dev/null +++ b/NativeAppTemplate/Extensions/Date+Extensions.swift @@ -0,0 +1,30 @@ +// +// Date+Extensions.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2023/11/13. +// + +import Foundation + +extension Date { + func dateByAddingNumberOfSeconds(_ seconds: Int) -> Date { + let timeInterval = TimeInterval(seconds) + return addingTimeInterval(timeInterval) + } + + var cardDateString: String { + let formatter = DateFormatter.cardDateFormatter + return formatter.string(from: self) + } + + var cardTimeString: String { + let formatter = DateFormatter.cardTimeFormatter + return formatter.string(from: self) + } + + var cardTimeAgoInWordsDateString: String { + let formatter = DateFormatter.timeAgoInWordsDateFormatter + return formatter.string(from: self) + } +} diff --git a/NativeAppTemplate/Extensions/DateFormatter+Extensions.swift b/NativeAppTemplate/Extensions/DateFormatter+Extensions.swift new file mode 100644 index 0000000..fc06760 --- /dev/null +++ b/NativeAppTemplate/Extensions/DateFormatter+Extensions.swift @@ -0,0 +1,59 @@ +// +// DateFormatter+Extensions.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2023/11/13. +// + +import Foundation + +extension String { + static let cardDateString: String = "MMM dd yyyy" + static let cardTimeString: String = "HH:mm" +} + +extension ISO8601DateFormatter { + convenience init(_ formatOptions: Options, timeZone: TimeZone = TimeZone(secondsFromGMT: 0)!) { + self.init() + self.formatOptions = formatOptions + self.timeZone = timeZone + } +} +extension Formatter { + nonisolated(unsafe) static let iso8601 = ISO8601DateFormatter([.withInternetDateTime, .withFractionalSeconds]) + static let isoDateUtc = { + let dateFormatter = DateFormatter.formatter(for: "yyyy-MM-dd") + dateFormatter.timeZone = NSTimeZone(name: "UTC")! as TimeZone + return dateFormatter + }() +} + +extension String { + var iso8601: Date? { + Formatter.iso8601.date(from: self) + } +} + +extension DateFormatter { + static let cardDateFormatter: DateFormatter = { + DateFormatter.formatter(for: .cardDateString) + }() + + static let cardTimeFormatter: DateFormatter = { + DateFormatter.formatter(for: .cardTimeString) + }() + + static let timeAgoInWordsDateFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .short + dateFormatter.timeStyle = .medium + dateFormatter.doesRelativeDateFormatting = true + return dateFormatter + }() + + static func formatter(for dateString: String) -> DateFormatter { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = dateString + return dateFormatter + } +} diff --git a/NativeAppTemplate/Extensions/String+Extensions.swift b/NativeAppTemplate/Extensions/String+Extensions.swift new file mode 100644 index 0000000..7b3921f --- /dev/null +++ b/NativeAppTemplate/Extensions/String+Extensions.swift @@ -0,0 +1,38 @@ +// +// String+Extensions.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2024/01/04. +// + +import UIKit + +extension String { + /// Generates a `UIImage` instance from this string using a specified + /// attributes and size. + /// + /// - Parameters: + /// - attributes: to draw this string with. Default is `nil`. + /// - size: of the image to return. + /// - Returns: a `UIImage` instance from this string using a specified + /// attributes and size, or `nil` if the operation fails. + func image(withAttributes attributes: [NSAttributedString.Key: Any]? = nil, size: CGSize? = nil) -> UIImage? { + let size = size ?? (self as NSString).size(withAttributes: attributes) + return UIGraphicsImageRenderer(size: size).image { _ in + (self as NSString).draw(in: CGRect(origin: .zero, size: size), + withAttributes: attributes) + } + } + + func isAlphanumeric() -> Bool { + self.rangeOfCharacter(from: CharacterSet.alphanumerics.inverted) == nil && !self.isEmpty + } + + func isAlphanumeric(ignoreDiacritics: Bool = false) -> Bool { + if ignoreDiacritics { + return self.range(of: "[^a-zA-Z0-9]", options: .regularExpression) == nil && !self.isEmpty + } else { + return self.isAlphanumeric() + } + } +} diff --git a/NativeAppTemplate/Extensions/UIImage+Extentions.swift b/NativeAppTemplate/Extensions/UIImage+Extentions.swift new file mode 100644 index 0000000..626f949 --- /dev/null +++ b/NativeAppTemplate/Extensions/UIImage+Extentions.swift @@ -0,0 +1,33 @@ +// +// UIImage+Extentions.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/04. +// + +import UIKit + +extension UIImage { + func composited(withSmallCenterImage centerImage: UIImage) -> UIImage { + UIGraphicsImageRenderer(size: self.size).image { context in + let imageWidth = context.format.bounds.width + let imageHeight = context.format.bounds.height + let centerImageLength = imageWidth < imageHeight ? imageWidth / 5 : imageHeight / 5 + let centerImageRadius = centerImageLength * 0.2 + + draw(in: CGRect(origin: CGPoint(x: 0, y: 0), + size: context.format.bounds.size)) + + let centerImageRect = CGRect(x: (imageWidth - centerImageLength) / 2, + y: (imageHeight - centerImageLength) / 2, + width: centerImageLength, + height: centerImageLength) + + let roundedRectPath = UIBezierPath(roundedRect: centerImageRect, + cornerRadius: centerImageRadius) + roundedRectPath.addClip() + + centerImage.draw(in: centerImageRect) + } + } +} diff --git a/NativeAppTemplate/Info.plist b/NativeAppTemplate/Info.plist index a834022..af66419 100644 --- a/NativeAppTemplate/Info.plist +++ b/NativeAppTemplate/Info.plist @@ -2,10 +2,22 @@ + com.apple.developer.nfc.readersession.iso7816.select-identifiers + + D2760000850101 + + com.apple.developer.nfc.readersession.felica.systemcodes + + 12FC + + NSPhotoLibraryAddUsageDescription + Save a QR code image including web page link used by this app. + NFCReaderUsageDescription + This app uses a NFC to write the application info to the NFC number tag or to read the NFC number tag into the application. CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - NativeAppTemplate + NativeAppTemplate Free CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier diff --git a/NativeAppTemplate/Models/CompleteScanResult.swift b/NativeAppTemplate/Models/CompleteScanResult.swift new file mode 100644 index 0000000..6bd5942 --- /dev/null +++ b/NativeAppTemplate/Models/CompleteScanResult.swift @@ -0,0 +1,35 @@ +// +// CompleteScanResult.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/04. +// + +import Foundation + +enum CompleteScanResultType { + case idled + case completed + case reset + case failed + + var displayString: String { + switch self { + case .idled: + return "Idling" + case .completed: + return "Completed!" + case .reset: + return "Reset!" + case .failed: + return "Failed" + } + } +} + +struct CompleteScanResult { + var itemTag: ItemTag? + var type: CompleteScanResultType = .idled + var message = "" + var scannedAt = Date.now +} diff --git a/NativeAppTemplate/Models/ItemTag.swift b/NativeAppTemplate/Models/ItemTag.swift new file mode 100644 index 0000000..c881ca3 --- /dev/null +++ b/NativeAppTemplate/Models/ItemTag.swift @@ -0,0 +1,35 @@ +// +// ItemTag.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/01. +// + +import Foundation + +struct ItemTag: Codable, Hashable, Identifiable, Sendable { + var id: String = "" + var shopId: String = "" + var queueNumber: String = "" + var state = ItemTagState.idled + var scanState = ScanState.unscanned + var createdAt = Date.now + var customerReadAt: Date? + var completedAt: Date? + var shopName: String = "" + var alreadyCompleted: Bool? +} + +extension ItemTag { + func scanUrl(itemTagType: ItemTagType) -> URL { + Utility.scanUrl(itemTagId: id, itemTagType: itemTagType.toJson()) + } + + func toJson() -> [String: Any] { + ["item_tag": + [ + "queue_number": queueNumber + ] + ] + } +} diff --git a/NativeAppTemplate/Models/ItemTagData.swift b/NativeAppTemplate/Models/ItemTagData.swift new file mode 100644 index 0000000..aaf9541 --- /dev/null +++ b/NativeAppTemplate/Models/ItemTagData.swift @@ -0,0 +1,28 @@ +// +// ItemTagData.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/04. +// + +import Foundation + +struct ItemTagData: Identifiable { + var id: String { + itemTagId + } + var itemTagId: String + var itemTagType: ItemTagType + var isReadOnly: Bool + var scannedAt: Date +} + +// MARK: - Equatable +extension ItemTagData: Equatable { + static func == (lhs: ItemTagData, rhs: ItemTagData) -> Bool { + lhs.itemTagId == rhs.itemTagId && + lhs.itemTagType == rhs.itemTagType && + lhs.isReadOnly == rhs.isReadOnly && + lhs.scannedAt == rhs.scannedAt + } +} diff --git a/NativeAppTemplate/Models/ItemTagInfoFromNdefMessage.swift b/NativeAppTemplate/Models/ItemTagInfoFromNdefMessage.swift new file mode 100644 index 0000000..d212160 --- /dev/null +++ b/NativeAppTemplate/Models/ItemTagInfoFromNdefMessage.swift @@ -0,0 +1,22 @@ +// +// ItemTagInfoFromNdefMessage.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/04. +// + +import Foundation + +struct ItemTagInfoFromNdefMessage { + var id: String + var type: String + var success: Bool + var message: String + + init() { + self.id = "" + self.type = "" + self.success = false + self.message = .messageWrittenOnTagIsWrong + } +} diff --git a/NativeAppTemplate/Models/ItemTagState.swift b/NativeAppTemplate/Models/ItemTagState.swift new file mode 100644 index 0000000..d366f44 --- /dev/null +++ b/NativeAppTemplate/Models/ItemTagState.swift @@ -0,0 +1,33 @@ +// +// ItemTagState.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/01. +// + +enum ItemTagState: String, CaseIterable, Identifiable, Codable { + case idled, + completed + + var id: Self { self } + + init(string: String) { + switch string { + case "idled": + self = .idled + case "completed": + self = .completed + default: + self = .idled + } + } + + var displayString: String { + switch self { + case .idled: + return "Idling" + case .completed: + return "Completed" + } + } +} diff --git a/NativeAppTemplate/Models/ItemTagType.swift b/NativeAppTemplate/Models/ItemTagType.swift new file mode 100644 index 0000000..301ba4d --- /dev/null +++ b/NativeAppTemplate/Models/ItemTagType.swift @@ -0,0 +1,44 @@ +// +// ItemTagType.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/01. +// + +import Foundation + +enum ItemTagType: String, CaseIterable, Identifiable, Codable { + case server + case customer + + var id: Self { self } + + init(string: String) { + switch string { + case "server": + self = .server + case "customer": + self = .customer + default: + self = .server + } + } + + func toJson() -> String { + switch self { + case .server: + return "server" + case .customer: + return "customer" + } + } + + var displayString: String { + switch self { + case .server: + return "Server" + case .customer: + return "Customer" + } + } +} diff --git a/NativeAppTemplate/Models/MainTab.swift b/NativeAppTemplate/Models/MainTab.swift index e71a944..db7dfaa 100644 --- a/NativeAppTemplate/Models/MainTab.swift +++ b/NativeAppTemplate/Models/MainTab.swift @@ -10,6 +10,7 @@ import SwiftUI enum MainTab { case shops + case scan case settings } diff --git a/NativeAppTemplate/Models/ScanResultError.swift b/NativeAppTemplate/Models/ScanResultError.swift new file mode 100644 index 0000000..2ea7f65 --- /dev/null +++ b/NativeAppTemplate/Models/ScanResultError.swift @@ -0,0 +1,21 @@ +// +// ScanResultError.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/04. +// + +import Foundation + +enum ScanResultError: Error { + case failed(String) +} + +extension ScanResultError: LocalizedError { + var errorDescription: String? { + switch self { + case .failed(let message): + return message + } + } +} diff --git a/NativeAppTemplate/Models/ScanState.swift b/NativeAppTemplate/Models/ScanState.swift new file mode 100644 index 0000000..dd6d8ee --- /dev/null +++ b/NativeAppTemplate/Models/ScanState.swift @@ -0,0 +1,42 @@ +// +// ScanState.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/01. +// + +enum ScanState: String, Identifiable, CaseIterable, Codable { + case unscanned, + scanned + + var id: Self { self } + + init(string: String) { + switch string { + case "unscanned": + self = .unscanned + case "scanned": + self = .scanned + default: + self = .unscanned + } + } + + func toJson() -> String { + switch self { + case .unscanned: + return "unscanned" + case .scanned: + return "scanned" + } + } + + var displayString: String { + switch self { + case .unscanned: + return "Unscanned" + case .scanned: + return "Scanned" + } + } +} diff --git a/NativeAppTemplate/Models/Shop.swift b/NativeAppTemplate/Models/Shop.swift index 6450b3e..d2aab90 100644 --- a/NativeAppTemplate/Models/Shop.swift +++ b/NativeAppTemplate/Models/Shop.swift @@ -12,9 +12,17 @@ struct Shop: Codable, Identifiable, Sendable { var name: String var description: String var timeZone: String + var itemTagsCount: Int = 0 + var scannedItemTagsCount: Int = 0 + var completedItemTagsCount: Int = 0 + var displayShopServerPath: String = "" } extension Shop { + var displayShopServerUrl: URL { + URL(string: "\(NativeAppTemplateEnvironment.prod.baseURL.absoluteString)\(displayShopServerPath)")! + } + func toJsonForCreate() -> [String: Any] { [ "shop": [ @@ -24,7 +32,7 @@ extension Shop { ] as [String: Any] ] } - + func toJsonForUpdate() -> [String: Any] { [ "shop": [ @@ -39,8 +47,12 @@ extension Shop { extension Shop: Hashable { static func == (lhs: Shop, rhs: Shop) -> Bool { lhs.id == rhs.id && - lhs.name == rhs.name && - lhs.description == rhs.description && - lhs.timeZone == rhs.timeZone + lhs.name == rhs.name && + lhs.description == rhs.description && + lhs.timeZone == rhs.timeZone && + lhs.itemTagsCount == rhs.itemTagsCount && + lhs.scannedItemTagsCount == rhs.scannedItemTagsCount && + lhs.completedItemTagsCount == rhs.completedItemTagsCount && + lhs.displayShopServerPath == rhs.displayShopServerPath } } diff --git a/NativeAppTemplate/Models/ShowTagInfoScanResult.swift b/NativeAppTemplate/Models/ShowTagInfoScanResult.swift new file mode 100644 index 0000000..a421659 --- /dev/null +++ b/NativeAppTemplate/Models/ShowTagInfoScanResult.swift @@ -0,0 +1,23 @@ +// +// ShowTagInfoScanResult.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/04. +// + +import Foundation + +enum ShowTagInfoScanResultType { + case idled + case succeeded + case failed +} + +struct ShowTagInfoScanResult { + var itemTag: ItemTag? + var itemTagType: ItemTagType = .server + var isReadOnly = false + var type: ShowTagInfoScanResultType = .idled + var message = "" + var scannedAt = Date.now +} diff --git a/NativeAppTemplate/NFCManager.swift b/NativeAppTemplate/NFCManager.swift new file mode 100644 index 0000000..dffaba1 --- /dev/null +++ b/NativeAppTemplate/NFCManager.swift @@ -0,0 +1,265 @@ +// +// NFCManager.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/04. +// + +import Foundation +import CoreNFC + +protocol NFCManagerProtocol { + @MainActor var scanResult: Result? { get } + @MainActor var isScanResultChanged: Bool { get } + @MainActor var isScanResultChangedForTesting: Bool { get } + + func startReading() async + func startReadingForTesting() async + + func startWriting(ndefMessage: sending NFCNDEFMessage, isLock: Bool) async +} + +final class NFCManager: NSObject, ObservableObject, @unchecked Sendable { + @MainActor static let shared = NFCManager() + + @MainActor @Published var scanResult: Result? + @MainActor @Published var isScanResultChanged = false + @MainActor @Published var isScanResultChangedForTesting = false + + private var internalScanResult: Result? { + @Sendable didSet { + Task { [internalScanResult] in + await MainActor.run { + self.scanResult = internalScanResult + } + } + } + } + + private var internalIsScanResultChanged: Bool = false { + @Sendable didSet { + Task { [internalIsScanResultChanged] in + await MainActor.run { + self.isScanResultChanged = internalIsScanResultChanged + } + } + } + } + + private var internalIsScanResultChangedForTesting: Bool = false { + @Sendable didSet { + Task { [internalIsScanResultChangedForTesting] in + await MainActor.run { + self.isScanResultChangedForTesting = internalIsScanResultChangedForTesting + } + } + } + } + + enum NFCOperation { + case read + case readForTesting + case write + } + + var nfcSession: NFCNDEFReaderSession? + var nfcOperation = NFCOperation.read + private var userNdefMessage: NFCNDEFMessage? + private var isLock = false + + @MainActor override init() { + } +} + +extension NFCManager: NFCManagerProtocol { + func startReading() async { + internalScanResult = nil + internalIsScanResultChanged = false + nfcOperation = .read + startSesstion() + } + + func startReadingForTesting() async { + internalScanResult = nil + internalIsScanResultChangedForTesting = false + nfcOperation = .readForTesting + startSesstion() + } + + func startWriting(ndefMessage: NFCNDEFMessage, isLock: Bool) async { + nfcOperation = .write + userNdefMessage = ndefMessage + self.isLock = isLock + startSesstion() + } + + private func startSesstion() { + nfcSession = NFCNDEFReaderSession(delegate: self, queue: nil, invalidateAfterFirstRead: false) + nfcSession?.begin() + } +} + +extension NFCManager: NFCNDEFReaderSessionDelegate { + func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) { + } + + func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCNDEFTag]) { + guard let tag = tags.first else { return } + + session.connect(to: tag) { error in + if let error = error { + session.invalidate(errorMessage: "Connection error: \(error.localizedDescription)") + return + } + + tag.queryNDEFStatus { status, capacity, error in + if let error = error { + session.invalidate(errorMessage: "Checking NDEF status error: \(error.localizedDescription)") + return + } + + switch status { + case .notSupported: + session.invalidate(errorMessage: String.tagIsNotNdefFormatted) + case .readOnly: + switch self.nfcOperation { + case .read: + self.read(session: session, tag: tag, status: status) + case .readForTesting: + self.read(session: session, tag: tag, status: status, test: true) + case .write: + session.invalidate(errorMessage: String.tagIsNotWritable) + } + case .readWrite: + switch self.nfcOperation { + case .read: + self.read(session: session, tag: tag, status: status) + case .readForTesting: + self.read(session: session, tag: tag, status: status, test: true) + case .write: + if capacity < self.userNdefMessage!.length { + let errorMessage = "Tag capacity is too small. Minimum size requirement is \(self.userNdefMessage!.length) bytes." + session.invalidate(errorMessage: errorMessage) + return + } + + self.write(session: session, tag: tag) + } + + @unknown default: + session.invalidate(errorMessage: String.unknownNdefStatus) + } + } + } + } + + private func read( + session: NFCNDEFReaderSession, + tag: NFCNDEFTag, + status: NFCNDEFStatus, + test: Bool = false + ) { + tag.readNDEF { [weak self] message, error in + if let error { + session.invalidate(errorMessage: "Reading error: \(error.localizedDescription)") + if test { + self?.internalIsScanResultChangedForTesting = true + } else { + self?.internalIsScanResultChanged = true + } + return + } + + guard let message else { + session.invalidate(errorMessage: String.noRecrodsFound) + self?.internalScanResult = .failure(ScanResultError.failed(String.tagNotValid)) + + if test { + self?.internalIsScanResultChangedForTesting = true + } else { + self?.internalIsScanResultChanged = true + } + return + } + + let isReadOnly = status == .readOnly + self?.setResultExtractedFrom(message: message, isReadOnly: isReadOnly, test: test) + + if test { + self?.internalIsScanResultChangedForTesting = true + } else { + self?.internalIsScanResultChanged = true + } + + session.invalidate() + } + } + + private func write(session: NFCNDEFReaderSession, tag: NFCNDEFTag) { + guard let userNdefMessage = self.userNdefMessage else { return } + + write( + session: session, + tag: tag, + ndefMessage: userNdefMessage, + isLock: isLock + ) { error in + guard error == nil else { return } + print(">>> Write: \(userNdefMessage)") + } + } + + private func write( + session: NFCNDEFReaderSession, + tag: NFCNDEFTag, + ndefMessage: NFCNDEFMessage, + isLock: Bool = false, + completion: @escaping ((Error?) -> Void) + ) { + tag.writeNDEF(ndefMessage) { error in + if let error = error { + session.invalidate(errorMessage: "Writing error: \(error.localizedDescription)") + completion(error) + } else { + if isLock { + tag.writeLock { error in + if let error = error { + session.invalidate(errorMessage: "Writing lock error: \(error.localizedDescription)") + completion(error) + } else { + session.alertMessage = String.writingSucceeded + session.invalidate() + completion(nil) + } + } + } else { + session.alertMessage = String.writingSucceeded + session.invalidate() + completion(nil) + } + } + } + } + + private func setResultExtractedFrom(message: NFCNDEFMessage, isReadOnly: Bool, test: Bool) { + let itemTagInfo = Utility.extractItemTagInfoFrom(message: message, test: test) + + if itemTagInfo.success { + let itemTagData = ItemTagData( + itemTagId: itemTagInfo.id, + itemTagType: ItemTagType(string: itemTagInfo.type), + isReadOnly: isReadOnly, + scannedAt: Date.now + ) + internalScanResult = .success(itemTagData) + } else { + internalScanResult = .failure(ScanResultError.failed(itemTagInfo.message)) + } + } + + func readerSessionDidBecomeActive(_ session: NFCNDEFReaderSession) {} + + func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) { + print( "readerSession error: \(error.localizedDescription)") + } +} diff --git a/NativeAppTemplate/NativeAppTemplate.entitlements b/NativeAppTemplate/NativeAppTemplate.entitlements index 0c67376..3b79e0d 100644 --- a/NativeAppTemplate/NativeAppTemplate.entitlements +++ b/NativeAppTemplate/NativeAppTemplate.entitlements @@ -1,5 +1,14 @@ - + + com.apple.developer.associated-domains + + applinks:api.nativeapptemplate.com + + com.apple.developer.nfc.readersession.formats + + TAG + + diff --git a/NativeAppTemplate/Networking/Adapters/EntityAdapter.swift b/NativeAppTemplate/Networking/Adapters/EntityAdapter.swift index ebc91bd..55594c4 100644 --- a/NativeAppTemplate/Networking/Adapters/EntityAdapter.swift +++ b/NativeAppTemplate/Networking/Adapters/EntityAdapter.swift @@ -38,7 +38,8 @@ enum EntityType { case shop case shopkeeper case shopkeeperSignIn - + case itemTag + init?(from string: String) { switch string { case "shop": @@ -47,6 +48,8 @@ enum EntityType { self = .shopkeeper case "shopkeeper_sign_in": self = .shopkeeperSignIn + case "item_tag": + self = .itemTag default: return nil } diff --git a/NativeAppTemplate/Networking/Adapters/EntityAdapters/ItemTagAdapter.swift b/NativeAppTemplate/Networking/Adapters/EntityAdapters/ItemTagAdapter.swift new file mode 100644 index 0000000..571447b --- /dev/null +++ b/NativeAppTemplate/Networking/Adapters/EntityAdapters/ItemTagAdapter.swift @@ -0,0 +1,47 @@ +// +// ItemTagAdapter.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/01. +// + +import struct Foundation.URL + +struct ItemTagAdapter: EntityAdapter { + static func process(resource: JSONAPIResource, relationships: [EntityRelationship] = [], cacheUpdate: DataCacheUpdate = DataCacheUpdate()) throws -> ItemTag { + guard resource.entityType == .itemTag else { throw EntityAdapterError.invalidResourceTypeForAdapter } + + guard let shopId = resource.attributes["shop_id"] as? String, + let queueNumber = resource.attributes["queue_number"] as? String, + let state = resource.attributes["state"] as? String, + let scanState = resource.attributes["scan_state"] as? String, + let createdAtString = resource.attributes["created_at"] as? String, + let shopName = resource.attributes["shop_name"] as? String + else { + throw EntityAdapterError.invalidOrMissingAttributes + } + + let createdAt = createdAtString.iso8601! + + let customerReadAtString = resource.attributes["customer_read_at"] as? String + let customerReadAt = customerReadAtString?.iso8601 + + let completedAtString = resource.attributes["completed_at"] as? String + let completedAt = completedAtString?.iso8601 + + let alreadyCompleted = resource.attributes["already_completed"] as? Bool + + return ItemTag( + id: resource.id, + shopId: shopId, + queueNumber: queueNumber, + state: ItemTagState(string: state), + scanState: ScanState(string: scanState), + createdAt: createdAt, + customerReadAt: customerReadAt, + completedAt: completedAt, + shopName: shopName, + alreadyCompleted: alreadyCompleted + ) + } +} diff --git a/NativeAppTemplate/Networking/Adapters/EntityAdapters/ShopAdapter.swift b/NativeAppTemplate/Networking/Adapters/EntityAdapters/ShopAdapter.swift index deb139a..b32f41b 100644 --- a/NativeAppTemplate/Networking/Adapters/EntityAdapters/ShopAdapter.swift +++ b/NativeAppTemplate/Networking/Adapters/EntityAdapters/ShopAdapter.swift @@ -12,7 +12,8 @@ struct ShopAdapter: EntityAdapter { guard resource.entityType == .shop else { throw EntityAdapterError.invalidResourceTypeForAdapter } guard let name = resource.attributes["name"] as? String, - let timeZone = resource.attributes["time_zone"] as? String + let timeZone = resource.attributes["time_zone"] as? String, + let displayShopServerPath = resource.attributes["display_shop_server_path"] as? String else { throw EntityAdapterError.invalidOrMissingAttributes } @@ -21,7 +22,11 @@ struct ShopAdapter: EntityAdapter { id: resource.id, name: name, description: resource.attributes["description"] as? String ?? "", - timeZone: timeZone + timeZone: timeZone, + itemTagsCount: resource.attributes["item_tags_count"] as? Int ?? 0, + scannedItemTagsCount: resource.attributes["scanned_item_tags_count"] as? Int ?? 0, + completedItemTagsCount: resource.attributes["completed_item_tags_count"] as? Int ?? 0, + displayShopServerPath: displayShopServerPath ) } } diff --git a/NativeAppTemplate/Networking/Requests/ItemTagsRequest.swift b/NativeAppTemplate/Networking/Requests/ItemTagsRequest.swift new file mode 100644 index 0000000..cd3ca3a --- /dev/null +++ b/NativeAppTemplate/Networking/Requests/ItemTagsRequest.swift @@ -0,0 +1,185 @@ +// +// ItemTagsRequest.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/01. +// + +import Foundation +import SwiftyJSON + +struct GetItemTagsRequest: Request { + typealias Response = [ItemTag] + + // MARK: - Properties + var method: HTTPMethod { .GET } + var path: String { "/shopkeeper/shops/\(shopId)/item_tags" } + var additionalHeaders: [String: String] = [:] + var body: Data? { nil } + + let shopId: String + + // MARK: - Internal + func handle(response: Data) throws -> Response { + let json = try JSON(data: response) + let doc = JSONAPIDocument(json) + let itemTags = try doc.data.map { try ItemTagAdapter.process(resource: $0) } + return itemTags + } +} + +struct GetItemTagDetailRequest: Request { + typealias Response = ItemTag + + // MARK: - Properties + var method: HTTPMethod { .GET } + var path: String { "/shopkeeper/item_tags/\(id)" } + var additionalHeaders: [String: String] = [:] + var body: Data? { nil } + + // MARK: - Parameters + let id: String + + // MARK: - Internal + func handle(response: Data) throws -> Response { + let json = try JSON(data: response) + let doc = JSONAPIDocument(json) + let itemTags = try doc.data.map { try ItemTagAdapter.process(resource: $0) } + + guard let itemTag = itemTags.first, + itemTags.count == 1 else { + throw NativeAppTemplateAPIError.processingError(nil) + } + + return itemTag + } +} + +struct MakeItemTagRequest: Request { + typealias Response = ItemTag + + // MARK: - Properties + var method: HTTPMethod { .POST } + var path: String { "/shopkeeper/shops/\(shopId)/item_tags" } + var additionalHeaders: [String: String] = [:] + var body: Data? { + let json = itemTag.toJson() + return try? JSONSerialization.data(withJSONObject: json) + } + + let shopId: String + + // MARK: - Parameters + let itemTag: ItemTag + + // MARK: - Internal + func handle(response: Data) throws -> ItemTag { + let json = try JSON(data: response) + let doc = JSONAPIDocument(json) + let itemTags = try doc.data.map { try ItemTagAdapter.process(resource: $0) } + guard let itemTag = itemTags.first, + itemTags.count == 1 else { + throw NativeAppTemplateAPIError.responseHasIncorrectNumberOfElements + } + + return itemTag + } +} + +struct UpdateItemTagRequest: Request { + typealias Response = ItemTag + + // MARK: - Properties + var method: HTTPMethod { .PATCH } + var path: String { "/shopkeeper/item_tags/\(id)" } + var additionalHeaders: [String: String] = [:] + var body: Data? { + let json = itemTag.toJson() + return try? JSONSerialization.data(withJSONObject: json) + } + var id: String + + // MARK: - Parameters + let itemTag: ItemTag + + // MARK: - Internal + func handle(response: Data) throws -> Response { + let json = try JSON(data: response) + let doc = JSONAPIDocument(json) + let itemTags = try doc.data.map { try ItemTagAdapter.process(resource: $0) } + guard let theItemTag = itemTags.first, + itemTags.count == 1 else { + throw NativeAppTemplateAPIError.responseHasIncorrectNumberOfElements + } + + return theItemTag + } +} + +struct DestroyItemTagRequest: Request { + typealias Response = Void + + // MARK: - Properties + var method: HTTPMethod { .DELETE } + var path: String { "/shopkeeper/item_tags/\(id)" } + var additionalHeaders: [String: String] = [:] + var body: Data? { nil } + + // MARK: - Parameters + let id: String + + // MARK: - Internal + func handle(response: Data) throws { } +} + +struct CompleteItemTagRequest: Request { + typealias Response = ItemTag + + // MARK: - Properties + var method: HTTPMethod { .PATCH } + var path: String { "/shopkeeper/item_tags/\(id)/complete" } + var additionalHeaders: [String: String] = [:] + var body: Data? { nil } + + // MARK: - Parameters + let id: String + + // MARK: - Internal + func handle(response: Data) throws -> Response { + let json = try JSON(data: response) + let doc = JSONAPIDocument(json) + let itemTags = try doc.data.map { try ItemTagAdapter.process(resource: $0) } + guard let itemTag = itemTags.first, + itemTags.count == 1 else { + throw NativeAppTemplateAPIError.responseHasIncorrectNumberOfElements + } + + return itemTag + } +} + +struct ResetItemTagRequest: Request { + typealias Response = ItemTag + + // MARK: - Properties + var method: HTTPMethod { .PATCH } + var path: String { "/shopkeeper/item_tags/\(id)/reset" } + var additionalHeaders: [String: String] = [:] + var body: Data? { nil } + + // MARK: - Parameters + let id: String + + // MARK: - Internal + func handle(response: Data) throws -> Response { + let json = try JSON(data: response) + let doc = JSONAPIDocument(json) + let itemTags = try doc.data.map { try ItemTagAdapter.process(resource: $0) } + guard let theItemTag = itemTags.first, + itemTags.count == 1 else { + throw NativeAppTemplateAPIError.responseHasIncorrectNumberOfElements + } + + return theItemTag + } +} diff --git a/NativeAppTemplate/Networking/Requests/PermissionsRequest.swift b/NativeAppTemplate/Networking/Requests/PermissionsRequest.swift index a0c7586..2815f51 100644 --- a/NativeAppTemplate/Networking/Requests/PermissionsRequest.swift +++ b/NativeAppTemplate/Networking/Requests/PermissionsRequest.swift @@ -33,6 +33,7 @@ struct PermissionsResponse { var iosAppVersion: Int var shouldUpdatePrivacy: Bool var shouldUpdateTerms: Bool + var maximumQueueNumberLength: Int var shopLimitCount: Int } @@ -61,7 +62,11 @@ struct PermissionsRequest: Request { guard let shouldUpdateTerms = doc.meta["should_update_terms"] as? Bool else { throw NativeAppTemplateAPIError.responseMissingRequiredMeta(field: "should_update_terms") } - + + guard let maximumQueueNumberLength = doc.meta["maximum_queue_number_length"] as? Int else { + throw NativeAppTemplateAPIError.responseMissingRequiredMeta(field: "maximum_queue_number_length") + } + guard let shopLimitCount = doc.meta["shop_limit_count"] as? Int else { throw NativeAppTemplateAPIError.responseMissingRequiredMeta(field: "shop_limit_count") } @@ -70,6 +75,7 @@ struct PermissionsRequest: Request { iosAppVersion: iosAppVersion, shouldUpdatePrivacy: shouldUpdatePrivacy, shouldUpdateTerms: shouldUpdateTerms, + maximumQueueNumberLength: maximumQueueNumberLength, shopLimitCount: shopLimitCount ) diff --git a/NativeAppTemplate/Networking/Requests/ShopsRequest.swift b/NativeAppTemplate/Networking/Requests/ShopsRequest.swift index b1df8c6..281d542 100644 --- a/NativeAppTemplate/Networking/Requests/ShopsRequest.swift +++ b/NativeAppTemplate/Networking/Requests/ShopsRequest.swift @@ -136,3 +136,19 @@ struct DestroyShopRequest: Request { // MARK: - Internal func handle(response: Data) throws { } } + +struct ResetShopRequest: Request { + typealias Response = Void + + // MARK: - Properties + var method: HTTPMethod { .DELETE } + var path: String { "/shopkeeper/shops/\(id)/reset" } + var additionalHeaders: [String: String] = [:] + var body: Data? { nil } + + // MARK: - Parameters + let id: String + + // MARK: - Internal + func handle(response: Data) throws { } +} diff --git a/NativeAppTemplate/Networking/Services/ItemTagsService.swift b/NativeAppTemplate/Networking/Services/ItemTagsService.swift new file mode 100644 index 0000000..6d8860e --- /dev/null +++ b/NativeAppTemplate/Networking/Services/ItemTagsService.swift @@ -0,0 +1,47 @@ +// +// ItemTagsService.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/01. +// + +import class Foundation.URLSession + +struct ItemTagsService: Service { + let networkClient: NativeAppTemplateAPI + let session = URLSession(configuration: .default) +} + +extension ItemTagsService { + // MARK: - Internal + func allItemTags(shopId: String) async throws -> GetItemTagsRequest.Response { + let request = GetItemTagsRequest(shopId: shopId) + return try await makeRequest(request: request) + } + + func itemTagDetail(id: String) async throws -> GetItemTagDetailRequest.Response { + try await makeRequest(request: GetItemTagDetailRequest(id: id)) + } + + func makeItemTag(shopId: String, itemTag: ItemTag) async throws -> MakeItemTagRequest.Response { + let request = MakeItemTagRequest(shopId: shopId, itemTag: itemTag) + return try await makeRequest(request: request) + } + + func updateItemTag(id: String, itemTag: ItemTag) async throws -> UpdateItemTagRequest.Response { + let request = UpdateItemTagRequest(id: id, itemTag: itemTag) + return try await makeRequest(request: request) + } + + func destroyItemTag(id: String) async throws -> DestroyItemTagRequest.Response { + try await makeRequest(request: DestroyItemTagRequest(id: id)) + } + + func completeItemTag(id: String) async throws -> CompleteItemTagRequest.Response { + try await makeRequest(request: CompleteItemTagRequest(id: id)) + } + + func resetItemTag(id: String) async throws -> ResetItemTagRequest.Response { + try await makeRequest(request: ResetItemTagRequest(id: id)) + } +} diff --git a/NativeAppTemplate/Networking/Services/ShopsService.swift b/NativeAppTemplate/Networking/Services/ShopsService.swift index 9873e47..7695b86 100644 --- a/NativeAppTemplate/Networking/Services/ShopsService.swift +++ b/NativeAppTemplate/Networking/Services/ShopsService.swift @@ -56,4 +56,8 @@ extension ShopsService { let request = MakeShopRequest(shop: shop) return try await makeRequest(request: request) } + + func resetShop(id: String) async throws -> ResetShopRequest.Response { + try await makeRequest(request: ResetShopRequest(id: id)) + } } diff --git a/NativeAppTemplate/Sessions/SessionController.swift b/NativeAppTemplate/Sessions/SessionController.swift index d7083f2..9e79fd1 100644 --- a/NativeAppTemplate/Sessions/SessionController.swift +++ b/NativeAppTemplate/Sessions/SessionController.swift @@ -37,10 +37,15 @@ import Observation private(set) var permissionState: PermissionState = .notLoaded private(set) var didFetchPermissions = false var shouldPopToRootView = false - + var didBackgroundTagReading = false + + var completeScanResult = CompleteScanResult() + var showTagInfoScanResult = ShowTagInfoScanResult() + var shouldUpdateApp = false var shouldUpdatePrivacy = false var shouldUpdateTerms = false + var maximumQueueNumberLength = 0 var shopLimitCount = 0 var shopkeeper: Shopkeeper? { @@ -184,7 +189,8 @@ import Observation shouldUpdateApp = Int(Bundle.main.appBuild)! < prmissionsResponse.iosAppVersion shouldUpdatePrivacy = prmissionsResponse.shouldUpdatePrivacy shouldUpdateTerms = prmissionsResponse.shouldUpdateTerms - + maximumQueueNumberLength = prmissionsResponse.maximumQueueNumberLength + shopLimitCount = prmissionsResponse.shopLimitCount didFetchPermissions = true diff --git a/NativeAppTemplate/UI/App Root/AppTabView.swift b/NativeAppTemplate/UI/App Root/AppTabView.swift index f896f95..b94799d 100644 --- a/NativeAppTemplate/UI/App Root/AppTabView.swift +++ b/NativeAppTemplate/UI/App Root/AppTabView.swift @@ -9,6 +9,7 @@ import SwiftUI struct AppTabView< ShopListView: View, + ScanView: View, SettingsView: View > { @@ -18,13 +19,16 @@ struct AppTabView< @State var navigationPathShops = NavigationPath() @State var navigationPathStats = NavigationPath() private let shopListView: () -> ShopListView + private let scanView: () -> ScanView private let settingsView: () -> SettingsView init( shopListView: @escaping () -> ShopListView, + scanView: @escaping () -> ScanView, settingsView: @escaping () -> SettingsView ) { self.shopListView = shopListView + self.scanView = scanView self.settingsView = settingsView } } @@ -60,7 +64,15 @@ extension AppTabView: View { imageName: "storefront.fill", tab: .shops ) - + + tab( + content: scanView, + navigationPath: nil, + text: .scan, + imageName: "platter.filled.bottom.iphone", + tab: .scan + ) + tab( content: settingsView, navigationPath: nil, @@ -89,6 +101,7 @@ struct AppTabView_Previews: PreviewProvider { static var previews: some View { AppTabView( shopListView: { Text(verbatim: "SHOPS") }, + scanView: { Text(verbatim: "SCAN") }, settingsView: { Text(verbatim: "SETTINGS") } ).environment(TabViewModel()) } diff --git a/NativeAppTemplate/UI/App Root/MainView.swift b/NativeAppTemplate/UI/App Root/MainView.swift index 09a65b4..aa91bd1 100644 --- a/NativeAppTemplate/UI/App Root/MainView.swift +++ b/NativeAppTemplate/UI/App Root/MainView.swift @@ -33,7 +33,9 @@ struct MainView: View { @Environment(DataManager.self) private var dataManager @Environment(SessionController.self) private var sessionController @State var isShowingForceAppUpdatesAlert = false + @State var itemTagId: String? @State var isResetting = false + @State var isShowingResetConfirmationDialog = false @State private var isShowingAcceptPrivacySheet = false @State private var arePrivacyAccepted = false @State private var isShowingAcceptTermsSheet = false @@ -46,6 +48,7 @@ struct MainView: View { contentView .background(Color.backgroundColor) .overlay(MessageBarView(messageBus: messageBus), alignment: .bottom) + .onContinueUserActivity(NSUserActivityTypeBrowsingWeb, perform: handleBackgroundTagReading) .onChange(of: sessionController.shouldUpdatePrivacy) { if sessionController.shouldUpdatePrivacy { isShowingAcceptPrivacySheet = true @@ -56,6 +59,19 @@ struct MainView: View { isShowingAcceptTermsSheet = true } } + .confirmationDialog( + String.itemTagAlreadyCompleted, + isPresented: $isShowingResetConfirmationDialog + ) { + Button(String.reset, role: .destructive) { + resetTag(itemTagId: itemTagId!) + } + Button(String.cancel, role: .cancel) { + isShowingResetConfirmationDialog = false + } + } message: { + Text(String.areYouSure) + } } } } @@ -86,6 +102,7 @@ private extension MainView { if dataManager.isRebuildingRepositories { AppTabView( shopListView: LoadingView.init, + scanView: LoadingView.init, settingsView: LoadingView.init ) .environment(tabViewModel) @@ -93,12 +110,14 @@ private extension MainView { if sessionController.shouldUpdateApp { AppTabView( shopListView: NeedAppUpdatesView.init, + scanView: NeedAppUpdatesView.init, settingsView: NeedAppUpdatesView.init ) .environment(tabViewModel) } else { AppTabView( shopListView: shopListView, + scanView: scanView, settingsView: settingsView ) .environment(tabViewModel) @@ -119,6 +138,7 @@ private extension MainView { case .offline: AppTabView( shopListView: OfflineView.init, + scanView: OfflineView.init, settingsView: OfflineView.init ) .environment(tabViewModel) @@ -129,14 +149,92 @@ private extension MainView { func shopListView() -> ShopListView { .init( - shopRepository: dataManager.shopRepository + shopRepository: dataManager.shopRepository, + itemTagRepository: dataManager.itemTagRepository ) } + func scanView() -> ScanView { + .init( + itemTagRepository: dataManager.itemTagRepository + ) + } + func settingsView() -> SettingsView { .init(accountPasswordRepository: dataManager.accountPasswordRepository) } + + func handleBackgroundTagReading(_ userActivity: NSUserActivity) { + guard sessionController.isLoggedIn else { + messageBus.post(message: Message(level: .error, message: String.pleaseSignIn, autoDismiss: false)) + return + } + + let ndefMessage = userActivity.ndefMessagePayload + guard !ndefMessage.records.isEmpty, + ndefMessage.records[0].typeNameFormat != .empty else { + return + } + + let itemTagInfo = Utility.extractItemTagInfoFrom(message: ndefMessage) + + if itemTagInfo.success { + itemTagId = itemTagInfo.id + completeTag(itemTagId: itemTagId!) + } else { + messageBus.post(message: Message(level: .error, message: itemTagInfo.message, autoDismiss: false)) + tabViewModel.selectedTab = .scan + } + } + + func completeTag(itemTagId: String) { + Task { @MainActor in + do { + let itemTag = try await dataManager.itemTagRepository.complete(id: itemTagId) + + sessionController.completeScanResult = CompleteScanResult( + itemTag: itemTag, + type: .completed + ) + + if itemTag.alreadyCompleted! { + isShowingResetConfirmationDialog = true + } + } catch { + sessionController.completeScanResult = CompleteScanResult( + type: .failed, + message: error.localizedDescription + ) + } + + sessionController.didBackgroundTagReading = true + tabViewModel.selectedTab = .scan + } + } + private func resetTag(itemTagId: String) { + Task { @MainActor in + isResetting = true + + do { + let itemTag = try await dataManager.itemTagRepository.reset(id: itemTagId) + sessionController.completeScanResult = CompleteScanResult( + itemTag: itemTag, + type: .reset + ) + } catch { + sessionController.completeScanResult = CompleteScanResult( + type: .failed, + message: error.localizedDescription + ) + } + + isResetting = false + sessionController.didBackgroundTagReading = true + tabViewModel.selectedTab = .scan + } + } + func logout() { Task { try await sessionController.logout() diff --git a/NativeAppTemplate/UI/Scan/CompleteScanResultView.swift b/NativeAppTemplate/UI/Scan/CompleteScanResultView.swift new file mode 100644 index 0000000..df83e88 --- /dev/null +++ b/NativeAppTemplate/UI/Scan/CompleteScanResultView.swift @@ -0,0 +1,86 @@ +// +// CompleteScanResultView.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/04. +// + +import SwiftUI + +struct CompleteScanResultView: View { + @Environment(\.dismiss) private var dismiss + @Environment(MessageBus.self) private var messageBus + var completeScanResult: CompleteScanResult + + init( + completeScanResult: CompleteScanResult + ) { + self.completeScanResult = completeScanResult + } + + var body: some View { + contentView + } +} + +// MARK: - private +private extension CompleteScanResultView { + var contentView: some View { + + @ViewBuilder var contentView: some View { + switch completeScanResult.type { + case .completed, .reset: + succeededView + case .failed: + failedView + case .idled: + idledView + } + } + + return contentView + } + + @ViewBuilder var succeededView: some View { + if let itemTag = completeScanResult.itemTag { + GroupBox(label: Label(String("Result"), systemImage: "checkmark.circle") ) { + Text(String(itemTag.queueNumber)) + .font(.uiTitle1) + + if itemTag.state == .completed { + CompletedTag() + } else { + IdlingTag() + } + + if completeScanResult.type == .reset { + Text(completeScanResult.type.displayString) + } + + HStack(alignment: .firstTextBaseline) { + Text(completeScanResult.scannedAt.cardTimeAgoInWordsDateString) + .font(.uiBodyCustom) + .foregroundStyle(.successSecondaryForeground) + Text(verbatim: "complete scanned") + .font(.uiFootnote) + .foregroundStyle(.successSecondaryForeground) + } + .padding(.top, 8) + } + .backgroundStyle(.successBackground) + } + } + + @ViewBuilder var failedView: some View { + GroupBox(label: Label(String("Error"), systemImage: "exclamationmark.triangle") ) { + Text(completeScanResult.message) + } + .backgroundStyle(.failureBackground) + } + + @ViewBuilder var idledView: some View { + GroupBox(label: Label(String("Result"), systemImage: "checkmark.circle") ) { + } + .backgroundStyle(.successBackground) + } +} diff --git a/NativeAppTemplate/UI/Scan/ScanView.swift b/NativeAppTemplate/UI/Scan/ScanView.swift new file mode 100644 index 0000000..abc9d6b --- /dev/null +++ b/NativeAppTemplate/UI/Scan/ScanView.swift @@ -0,0 +1,294 @@ +// +// ScanView.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/04. +// + +import SwiftUI +import CoreNFC + +enum ScanType: String { + case completeScan + case test + + var displayString: String { + switch self { + case .completeScan: + return "Complete Scan" + case .test: + return "Test" + } + } +} + +// MARK: - CaseIterable +extension ScanType: CaseIterable { + var index: Self.AllCases.Index { + get { + Self.allCases.firstIndex(of: self)! + } + set { + self = Self.allCases[newValue] + } + } + + var count: Int { + Self.allCases.count + } +} + +// MARK: - Identifiable +extension ScanType: Identifiable { + var id: Self { self } +} + +struct ScanView: View { + @Environment(MessageBus.self) private var messageBus + @Environment(SessionController.self) private var sessionController + @StateObject private var nfcManager = appSingletons.nfcManager + @State private var scanType: ScanType = .completeScan + @State private var isShowingResetConfirmationDialog = false + @State private var isFetching = false + @State private var isResetting = false + private let itemTagRepository: ItemTagRepository + + init( + itemTagRepository: ItemTagRepository + ) { + self.itemTagRepository = itemTagRepository + } + + var body: some View { + contentView + .onChange(of: sessionController.didBackgroundTagReading) { + Task { @MainActor in + if sessionController.didBackgroundTagReading { + sessionController.didBackgroundTagReading = false + scanType = .completeScan + } + } + } + } +} + +// MARK: - private +private extension ScanView { + var contentView: some View { + @ViewBuilder var contentView: some View { + if isFetching { + LoadingView() + } else { + scanView + .onChange(of: nfcManager.isScanResultChanged) { + guard nfcManager.isScanResultChanged else { return } + guard nfcManager.scanResult != nil else { return } + + switch nfcManager.scanResult { + case .success(let itemTagData): + completeTag(itemTagId: itemTagData.itemTagId) + case .failure(let error): + sessionController.completeScanResult = CompleteScanResult( + type: .failed, + message: error.localizedDescription + ) + default: + break + } + } + .onChange(of: nfcManager.isScanResultChangedForTesting) { + guard nfcManager.isScanResultChangedForTesting else { return } + guard nfcManager.scanResult != nil else { return } + + switch nfcManager.scanResult { + case .success(let itemTagData): + fetchItemTagDetail(itemTagData: itemTagData) + case .failure(let error): + sessionController.showTagInfoScanResult = ShowTagInfoScanResult( + type: .failed, + message: error.localizedDescription + ) + default: + break + } + } + } + } + + return contentView + } + + var scanView: some View { + ScrollView { + VStack(spacing: 64) { + switch scanType { + case .completeScan: + if !isShowingResetConfirmationDialog { + GroupBox(label: Label(String.completeScan, systemImage: "flag.checkered") ) { + MainButtonView(title: String.scan, type: .coloredPrimary(withArrow: false)) { + guard NFCNDEFReaderSession.readingAvailable else { + messageBus.post( + message: Message( + level: .error, + message: String.thisDeviceDoesNotSupportTagScanning, + autoDismiss: false + ) + ) + return + } + + sessionController.completeScanResult = CompleteScanResult() + + Task { + await nfcManager.startReading() + } + } + .padding() + + Text(String.completeScanHelp) + .font(.uiFootnote) + .foregroundStyle(.coloredPrimaryFootnoteText) + } + .foregroundStyle(.coloredPrimaryForeground) + .backgroundStyle(.coloredPrimaryBackground) + } + + CompleteScanResultView( + completeScanResult: sessionController.completeScanResult + ) + case .test: + GroupBox(label: Label(String.showTagInfoScan, systemImage: "info.circle") ) { + MainButtonView(title: String.scan, type: .coloredSecondary(withArrow: false)) { + guard NFCNDEFReaderSession.readingAvailable else { + messageBus.post( + message: Message( + level: .error, + message: String.thisDeviceDoesNotSupportTagScanning, + autoDismiss: false + ) + ) + return + } + + sessionController.showTagInfoScanResult = ShowTagInfoScanResult() + + Task { + await nfcManager.startReadingForTesting() + } + } + .padding() + + Text(String.showTagInfoScanHelp) + .font(.uiFootnote) + .foregroundStyle(.coloredSecondaryFootnoteText) + } + .foregroundStyle(.coloredSecondaryForeground) + .backgroundStyle(.coloredSecondaryBackground) + + ShowTagInfoScanResultView( + showTagInfoScanResult: sessionController.showTagInfoScanResult + ) + } + + Spacer() + } + } + .toolbar { + ToolbarItem(placement: .principal) { + Picker(String("ScanType"), selection: $scanType) { + Text(String.completeScan).tag(ScanType.completeScan) + Text(String.showTagInfoScan).tag(ScanType.test) + } + .pickerStyle(SegmentedPickerStyle()) + } + } + .toolbarBackground(.black, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .padding() + .confirmationDialog( + String.itemTagAlreadyCompleted, + isPresented: $isShowingResetConfirmationDialog + ) { + Button(String.reset, role: .destructive) { + if let itemTagId = sessionController.completeScanResult.itemTag?.id { + resetTag(itemTagId: itemTagId) + } + } + Button(String.cancel, role: .cancel) { + isShowingResetConfirmationDialog = false + } + } message: { + Text(String.areYouSure) + } + .accessibility(identifier: "scanView") + .scrollContentBackground(.hidden) + } + + func completeTag(itemTagId: String) { + Task { @MainActor in + do { + let itemTag = try await itemTagRepository.complete(id: itemTagId) + + sessionController.completeScanResult = CompleteScanResult( + itemTag: itemTag, + type: .completed + ) + + if itemTag.alreadyCompleted! { + isShowingResetConfirmationDialog = true + } + } catch { + sessionController.completeScanResult = CompleteScanResult( + type: .failed, + message: error.localizedDescription + ) + } + } + } + + private func resetTag(itemTagId: String) { + Task { @MainActor in + isResetting = true + + do { + let itemTag = try await itemTagRepository.reset(id: itemTagId) + sessionController.completeScanResult = CompleteScanResult( + itemTag: itemTag, + type: .reset + ) + } catch { + sessionController.completeScanResult = CompleteScanResult( + type: .failed, + message: error.localizedDescription + ) + } + + isResetting = false + } + } + + private func fetchItemTagDetail(itemTagData: ItemTagData) { + Task { + isFetching = true + + do { + let itemTag = try await itemTagRepository.fetchDetail(id: itemTagData.itemTagId) + + sessionController.showTagInfoScanResult = ShowTagInfoScanResult( + itemTag: itemTag, + itemTagType: itemTagData.itemTagType, + isReadOnly: itemTagData.isReadOnly, + type: .succeeded, + scannedAt: itemTagData.scannedAt + ) + } catch { + sessionController.showTagInfoScanResult = ShowTagInfoScanResult( + type: .failed, + message: error.localizedDescription + ) + } + + isFetching = false + } + } +} diff --git a/NativeAppTemplate/UI/Scan/ShowTagInfoScanResultView.swift b/NativeAppTemplate/UI/Scan/ShowTagInfoScanResultView.swift new file mode 100644 index 0000000..bc1d0d6 --- /dev/null +++ b/NativeAppTemplate/UI/Scan/ShowTagInfoScanResultView.swift @@ -0,0 +1,172 @@ +// +// ShowTagInfoDetailView.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/04. +// + +import SwiftUI + +struct ShowTagInfoScanResultView: View { + @Environment(\.dismiss) private var dismiss + @Environment(MessageBus.self) private var messageBus + var showTagInfoScanResult: ShowTagInfoScanResult + + init( + showTagInfoScanResult: ShowTagInfoScanResult + ) { + self.showTagInfoScanResult = showTagInfoScanResult + } + + var body: some View { + contentView + } +} + +// MARK: - private +private extension ShowTagInfoScanResultView { + var contentView: some View { + + @ViewBuilder var contentView: some View { + switch showTagInfoScanResult.type { + case .succeeded: + succeededView + case .failed: + failedView + case .idled: + idledView + } + } + + return contentView + } + + @ViewBuilder var succeededView: some View { + GroupBox(label: Label(String.tagInfo, systemImage: "rectangle") ) { + VStack { + if let itemTag = showTagInfoScanResult.itemTag { + let scannedAt = showTagInfoScanResult.scannedAt + let itemTagType = showTagInfoScanResult.itemTagType + let isReadOnly = showTagInfoScanResult.isReadOnly + let displayReadOnly = isReadOnly ? String.readOnly : String.writable + + let imageSize = 10.0 + + Text(String(itemTag.queueNumber)) + .font(.uiTitle1) + .foregroundStyle(itemTagType == .server ? .red : .blue) + HStack(alignment: .firstTextBaseline) { + Text(String(scannedAt.cardTimeAgoInWordsDateString)) + .font(.uiBodyCustom) + .foregroundStyle(.coloredSecondaryFootnoteText) + Text(verbatim: "show tag info scanned") + .font(.uiFootnote) + .foregroundStyle(.coloredSecondaryFootnoteText) + } + + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 12, verticalSpacing: 8) { + GridRow { + Image(systemName: "storefront") + .frame(width: imageSize, height: imageSize) + .foregroundStyle(.coloredSecondaryFootnoteText) + Text(itemTag.shopName) + .font(.uiLabelBold) + Text(" ") + .font(.uiFootnote) + .foregroundStyle(.coloredSecondaryFootnoteText) + } + GridRow { + Image(systemName: "info.circle") + .frame(width: imageSize, height: imageSize) + .foregroundStyle(.coloredSecondaryFootnoteText) + Text(itemTagType.displayString) + .font(.uiLabelBold) + .foregroundStyle(itemTagType == .server ? .red : .blue) + Text(verbatim: "tag type") + .font(.uiFootnote) + .foregroundStyle(.coloredSecondaryFootnoteText) + } + GridRow { + Image(systemName: "flag.checkered") + .frame(width: imageSize, height: imageSize) + .foregroundStyle(.coloredSecondaryFootnoteText) + if itemTag.state == .completed { + CompletedTag() + } else { + IdlingTag() + } + Text(verbatim: "tag status") + .font(.uiFootnote) + .foregroundStyle(.coloredSecondaryFootnoteText) + } + + if itemTag.scanState == ScanState.scanned && itemTag.customerReadAt != nil { + GridRow { + Image(systemName: "person.2") + .frame(width: imageSize, height: imageSize) + .foregroundStyle(.coloredSecondaryFootnoteText) + Text(itemTag.customerReadAt!.cardTimeString) + .font(.uiLabelBold) + Text(verbatim: "scanned by a customer") + .font(.uiFootnote) + .foregroundStyle(.coloredSecondaryFootnoteText) + } + } + + if itemTag.state == ItemTagState.completed && itemTag.completedAt != nil { + GridRow { + Image(systemName: "flag.checkered.circle") + .frame(width: imageSize, height: imageSize) + .foregroundStyle(.coloredSecondaryFootnoteText) + Text(itemTag.completedAt!.cardTimeString) + .font(.uiLabelBold) + Text(verbatim: "completed") + .font(.uiFootnote) + .foregroundStyle(.coloredSecondaryFootnoteText) + } + } + + GridRow { + Image(systemName: "rectangle") + .frame(width: imageSize, height: imageSize) + .foregroundStyle(.coloredSecondaryFootnoteText) + Text(displayReadOnly) + .font(.uiLabelBold) + Text(verbatim: "NFC tag") + .font(.uiFootnote) + .foregroundStyle(.coloredSecondaryFootnoteText) + } + GridRow { + Image(systemName: "clock") + .frame(width: imageSize, height: imageSize) + .foregroundStyle(.coloredSecondaryFootnoteText) + Text(itemTag.createdAt.cardDateString) + .font(.uiLabelBold) + Text(verbatim: "created") + .font(.uiFootnote) + .foregroundStyle(.coloredSecondaryFootnoteText) + } + } + } + } + } + .foregroundStyle(.coloredSecondaryForeground) + .backgroundStyle(.coloredSecondaryBackground) + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + } + + @ViewBuilder var failedView: some View { + GroupBox(label: Label(String("Error"), systemImage: "exclamationmark.triangle") ) { + Text(showTagInfoScanResult.message) + .padding(.top) + } + .backgroundStyle(.failureBackground) + } + + @ViewBuilder var idledView: some View { + GroupBox(label: Label(String.tagInfo, systemImage: "rectangle") ) { + } + .foregroundStyle(.coloredSecondaryForeground) + .backgroundStyle(.coloredSecondaryBackground) + } +} diff --git a/NativeAppTemplate/UI/Settings/SettingsView.swift b/NativeAppTemplate/UI/Settings/SettingsView.swift index 1007e78..c64c6ff 100644 --- a/NativeAppTemplate/UI/Settings/SettingsView.swift +++ b/NativeAppTemplate/UI/Settings/SettingsView.swift @@ -1,30 +1,9 @@ -// Copyright (c) 2019 Razeware LLC // -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: +// SettingsView.swift +// NativeAppTemplate // -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. +// Created by Daisuke Adachi on 2023/02/25. // -// Notwithstanding the foregoing, you may not use, copy, modify, merge, publish, -// distribute, sublicense, create a derivative work, and/or sell copies of the -// Software in any work that is designed, intended, or marketed for pedagogical or -// instructional purposes related to programming, coding, application development, -// or information technology. Permission for such use, copying, modification, -// merger, publication, distribution, sublicensing, creation of derivative works, -// or sale is expressly withheld. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. import SwiftUI import MessageUI @@ -67,9 +46,17 @@ struct SettingsView: View { Section(header: Text(String.information)) { Link(destination: URL(string: String.supportWebsiteUrl)!) { - Label(String.supportWebsite, systemImage: "info") + Label(String.supportWebsite, systemImage: "globe") } + Link(destination: URL(string: String.howToUseUrl)!) { + Label(String.howToUse, systemImage: "info") + } + + Link(destination: URL(string: String.faqsUrl)!) { + Label(String.faqs, systemImage: "questionmark") + } + Link(destination: URL(string: String.discussionsUrl)!) { Label(String.discussions, systemImage: "bubble.left.and.bubble.right") } @@ -102,6 +89,16 @@ struct SettingsView: View { } .listRowBackground(Color.clear) } + +#if DEBUG + if sessionController.isLoggedIn { + Section { + Text(verbatim: sessionController.client.accountId) + } header: { + Text(verbatim: "Account ID") + } + } +#endif } } .navigationTitle(String.settings) diff --git a/NativeAppTemplate/UI/Shared/MainButtonView.swift b/NativeAppTemplate/UI/Shared/MainButtonView.swift index a7693aa..3708053 100644 --- a/NativeAppTemplate/UI/Shared/MainButtonView.swift +++ b/NativeAppTemplate/UI/Shared/MainButtonView.swift @@ -32,6 +32,10 @@ enum MainButtonType { case primary(withArrow: Bool) case secondary(withArrow: Bool) case destructive(withArrow: Bool) + case coloredPrimary(withArrow: Bool) + case coloredSecondary(withArrow: Bool) + case server(withArrow: Bool) + case customer(withArrow: Bool) var color: Color { switch self { @@ -39,8 +43,16 @@ enum MainButtonType { return .primaryButtonForeground case .secondary: return .secondaryButtonForeground + case .coloredPrimary: + return .coloredPrimaryButtonForeground + case .coloredSecondary: + return .coloredSecondaryButtonForeground case .destructive: return .destructiveButtonForeground + case .server: + return .serverForeground + case .customer: + return .customerForeground } } @@ -49,7 +61,11 @@ enum MainButtonType { case .primary(let hasArrow), .secondary(let hasArrow), - .destructive(let hasArrow): + .coloredPrimary(let hasArrow), + .coloredSecondary(let hasArrow), + .destructive(let hasArrow), + .server(let hasArrow), + .customer(let hasArrow): return hasArrow } } @@ -80,6 +96,10 @@ struct MainButtonView: View { .font(.uiButtonLabelLarge) .foregroundStyle(type.color) .padding(16) +// If commenting out below and select max large font size on settings accessibility, you will not be enable to tap Scan button on Scan tab. +// .background(GeometryReader { proxy in +// Color.clear.preference(key: SizeKey.self, value: proxy.size) +// }) Spacer() } diff --git a/NativeAppTemplate/UI/Shared/Tags/CompletedTag.swift b/NativeAppTemplate/UI/Shared/Tags/CompletedTag.swift new file mode 100644 index 0000000..cffc64d --- /dev/null +++ b/NativeAppTemplate/UI/Shared/Tags/CompletedTag.swift @@ -0,0 +1,34 @@ +// +// CompletedTag.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/04. +// + +import SwiftUI + +struct CompletedTag: View { + var body: some View { + TagView( + text: "completed", + textColor: .completedTagForeground, + backgroundColor: .completedTagBackground, + borderColor: .completedTagBorder + ) + } +} + +struct CompletedTag_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 12) { + completedTag.colorScheme(.light) + completedTag.colorScheme(.dark) + } + } + + static var completedTag: some View { + CompletedTag() + .padding() + .background(Color.backgroundColor) + } +} diff --git a/NativeAppTemplate/UI/Shared/Tags/CustomerScannedTag.swift b/NativeAppTemplate/UI/Shared/Tags/CustomerScannedTag.swift new file mode 100644 index 0000000..e4d6813 --- /dev/null +++ b/NativeAppTemplate/UI/Shared/Tags/CustomerScannedTag.swift @@ -0,0 +1,34 @@ +// +// CustomerScannedTag.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/04. +// + +import SwiftUI + +struct CustomerScannedTag: View { + var body: some View { + TagView( + text: "customer scanned", + textColor: .customerScannedTagForeground, + backgroundColor: .customerScannedTagBackground, + borderColor: .customerScannedTagBorder + ) + } +} + +struct CustomerScannedTag_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 12) { + customerScannedTag.colorScheme(.light) + customerScannedTag.colorScheme(.dark) + } + } + + static var customerScannedTag: some View { + CustomerScannedTag() + .padding() + .background(Color.backgroundColor) + } +} diff --git a/NativeAppTemplate/UI/Shared/Tags/IdlingTagView.swift b/NativeAppTemplate/UI/Shared/Tags/IdlingTagView.swift new file mode 100644 index 0000000..527fa53 --- /dev/null +++ b/NativeAppTemplate/UI/Shared/Tags/IdlingTagView.swift @@ -0,0 +1,34 @@ +// +// IdlingTag.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/04. +// + +import SwiftUI + +struct IdlingTag: View { + var body: some View { + TagView( + text: "idling", + textColor: .idlingTagForeground, + backgroundColor: .idlingTagBackground, + borderColor: .idlingTagBorder + ) + } +} + +struct IdlingTag_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 12) { + idlingTag.colorScheme(.light) + idlingTag.colorScheme(.dark) + } + } + + static var idlingTag: some View { + IdlingTag() + .padding() + .background(Color.backgroundColor) + } +} diff --git a/NativeAppTemplate/UI/Shared/Tags/TagView.swift b/NativeAppTemplate/UI/Shared/Tags/TagView.swift new file mode 100644 index 0000000..e312251 --- /dev/null +++ b/NativeAppTemplate/UI/Shared/Tags/TagView.swift @@ -0,0 +1,76 @@ +// +// TagView.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2023/01/21. +// + +import SwiftUI + +struct TagView: View { + private static let defaultIconHeight: CGFloat = 12.0 + + private struct SizeKey: PreferenceKey { + static func reduce(value: inout CGSize?, nextValue: () -> CGSize?) { + value = value ?? nextValue() + } + } + + @State private var height: CGFloat? + + let text: String + let textColor: Color + let backgroundColor: Color + let borderColor: Color + var image: Image? + + var body: some View { + HStack(spacing: 4) { + image? + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundStyle(textColor) + .frame(height: Self.defaultIconHeight) + + Text(text.uppercased()) + .foregroundStyle(textColor) + .font(.uiUppercaseTag) + .kerning(0.5) + .background( + GeometryReader { proxy in + Color.clear.preference(key: SizeKey.self, value: proxy.size) + } + ) + } + .padding([.vertical], 4) + .padding([.horizontal], 8) + .background(backgroundColor) + .cornerRadius(4) // This is a bit hacky. + .onPreferenceChange(SizeKey.self) { size in + Task { @MainActor in + height = size?.height + } + } + } +} + +struct TagView_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 18) { + TagView( + text: "this is a tag", + textColor: .white, + backgroundColor: .red, + borderColor: .yellow + ) + + TagView( + text: "with an image", + textColor: .white, + backgroundColor: .red, + borderColor: .yellow, + image: Image(systemName: "checkmark") + ) + } + } +} diff --git a/NativeAppTemplate/UI/Shop Detail/ShopDetailCardView.swift b/NativeAppTemplate/UI/Shop Detail/ShopDetailCardView.swift new file mode 100644 index 0000000..12989b6 --- /dev/null +++ b/NativeAppTemplate/UI/Shop Detail/ShopDetailCardView.swift @@ -0,0 +1,61 @@ +// +// ShopDetailCardView.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/04. +// + +import SwiftUI + +struct ShopDetailCardView: View { + let itemTag: ItemTag + + init( + itemTag: ItemTag + ) { + self.itemTag = itemTag + } + + var body: some View { + content + } + + var content: some View { + HStack { + Text(String(itemTag.queueNumber)) + .font(.uiTitle4) + + Spacer() + + VStack(alignment: .trailing) { + if itemTag.scanState == ScanState.scanned { + CustomerScannedTag() + + if let customerReadAt = itemTag.customerReadAt { + Text(customerReadAt.cardTimeString) + .font(.uiFootnote) + .foregroundStyle(.contentText) + } + } + } + + Spacer() + + VStack(alignment: .trailing) { + if itemTag.state == .completed { + CompletedTag() + + if let completedAt = itemTag.completedAt { + Text(completedAt.cardTimeString) + .font(.uiFootnote) + .foregroundStyle(.contentText) + } + } else { + IdlingTag() + } + } + .frame(minWidth: 82, alignment: .trailing) + } + .frame(minHeight: 48) + } +} diff --git a/NativeAppTemplate/UI/Shop Detail/ShopDetailView.swift b/NativeAppTemplate/UI/Shop Detail/ShopDetailView.swift index 0f8bde2..79b3d13 100644 --- a/NativeAppTemplate/UI/Shop Detail/ShopDetailView.swift +++ b/NativeAppTemplate/UI/Shop Detail/ShopDetailView.swift @@ -8,6 +8,20 @@ import SwiftUI import TipKit +struct ReadInstructionsTip: Tip { + var title: Text { + Text(String.readInstructions) + } + + var message: Text? { + Text(String.haveFun) + } + + var image: Image? { + Image(systemName: "info.circle") + } +} + struct ShopDetailView: View { @Environment(\.dismiss) private var dismiss @Environment(\.mainTab) private var mainTab @@ -15,21 +29,27 @@ struct ShopDetailView: View { @Environment(MessageBus.self) private var messageBus @Environment(SessionController.self) 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: ShopRepository + private let itemTagRepository: ItemTagRepository private var shopId: String private var shop: Binding { Binding { shopRepository.findBy(id: shopId) - } set: { _ in + } set: { _ in } } init( shopRepository: ShopRepository, + itemTagRepository: ItemTagRepository, shopId: String ) { self.shopRepository = shopRepository + self.itemTagRepository = itemTagRepository self.shopId = shopId } } @@ -52,7 +72,7 @@ private extension ShopDetailView { var contentView: some View { @ViewBuilder var contentView: some View { - if isFetching { + if isFetching || isResetting || isCompleting { LoadingView() } else { shopDetailView @@ -62,15 +82,87 @@ private extension ShopDetailView { return contentView } + var header: some View { + ScrollView(.horizontal) { + VStack(alignment: .leading, spacing: 0) { + let tip = ReadInstructionsTip() + TipView(tip, arrowEdge: .bottom) + .tint(.alarm) + + Text("\(String.instructions):") + .foregroundStyle(.contentText) + HStack(alignment: .firstTextBaseline) { + Text(verbatim: "1.") + .font(.uiCaption) + .foregroundStyle(.contentText) + HStack { + let openServerNumberTagsWebpage = "\(String.open) [\(String.serverNumberTagsWebpage)](\(shop.wrappedValue.displayShopServerUrl))." + Text(.init(openServerNumberTagsWebpage)) + .font(.uiCaption) + .foregroundStyle(.contentText) + } + } + HStack(alignment: .firstTextBaseline) { + Text(verbatim: "2.") + .font(.uiCaption) + .foregroundStyle(.contentText) + Text("\(String.swipeNumberTagBelow) \(String.tapDisplayedButton)") + .font(.uiCaption) + .foregroundStyle(.contentText) + } + HStack(alignment: .firstTextBaseline) { + Text(verbatim: "3.") + .font(.uiCaption) + .foregroundStyle(.contentText) + Text(String.serverNumberTagsWebpageWillBeUpdated) + .font(.uiCaption) + .foregroundStyle(.contentText) + } + Link(String.learnMore, destination: URL(string: String.howToUseUrl)!) + } + } + } + + var cardsView: some View { + ForEach(itemTagRepository.itemTags, id: \.id) { itemTag in + ShopDetailCardView(itemTag: itemTag) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if itemTag.state == ItemTagState.idled { + Button { completeTag(itemTagId: itemTag.id) } label: { + Label(String.complete, systemImage: "bolt.fill") + .labelStyle(.titleOnly) + } + .tint(.blue) + } else { + Button(role: .destructive) { resetTag(itemTagId: itemTag.id) } label: { + Label(String.reset, systemImage: "trash") + .labelStyle(.titleOnly) + } + .tint(.red) + } + } + .listRowBackground(Color.cardBackground) + } + } + var shopDetailView: some View { VStack { - Text(shop.wrappedValue.name) - .font(.uiTitle1) - .foregroundStyle(.titleText) + header .padding(.top) - Text(shop.wrappedValue.description) - .foregroundStyle(.contentText) - .padding(.top, 4) + .padding(.horizontal, 8) + List { + Section { + cardsView + } header: { + EmptyView() + .id(ScrollToTopID(mainTab: mainTab, detail: true)) + } + } + .scrollContentBackground(.hidden) + .accessibility(identifier: "shopDetailView") + .refreshable { + reload() + } } .navigationTitle(shop.wrappedValue.name) .toolbar { @@ -78,6 +170,7 @@ private extension ShopDetailView { NavigationLink( destination: ShopSettingsView( shopRepository: shopRepository, + itemTagRepository: itemTagRepository, shopId: shop.wrappedValue.id ) ) { @@ -97,11 +190,12 @@ private extension ShopDetailView { } private func fetchShopDetail() { - Task { + Task { @MainActor in isFetching = true do { _ = try await shopRepository.fetchDetail(id: shopId) + _ = try await itemTagRepository.fetchAll(shopId: shopId) isFetching = false } catch { messageBus.post( @@ -116,4 +210,40 @@ private extension ShopDetailView { } } } + + 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 List/ItemTag Detail/ItemTagDetailView.swift b/NativeAppTemplate/UI/Shop List/ItemTag Detail/ItemTagDetailView.swift new file mode 100644 index 0000000..7716a91 --- /dev/null +++ b/NativeAppTemplate/UI/Shop List/ItemTag Detail/ItemTagDetailView.swift @@ -0,0 +1,307 @@ +// +// ItemTagDetailView.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/04. +// + +import SwiftUI +import Photos +import CoreNFC + +struct ItemTagDetailView: View { + @Environment(\.dismiss) private var dismiss + @Environment(MessageBus.self) private var messageBus + @Environment(SessionController.self) private var sessionController + private var itemTagRepository: ItemTagRepository + @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() + + private var shop: Shop + private var itemTagId: String + + private var itemTag: Binding { + Binding { + itemTagRepository.findBy(id: itemTagId) + } set: { _ in + } + } + + init( + itemTagRepository: ItemTagRepository, + shop: Shop, + itemTagId: String + ) { + self.itemTagRepository = itemTagRepository + self.shop = shop + self.itemTagId = itemTagId + } + + var body: some View { + contentView + .task { + reload() + } + } +} + +// MARK: - private +private extension ItemTagDetailView { + var contentView: some View { + + @ViewBuilder var contentView: some View { + if isFetching || isDeleting || isGeneratingQrCode { + LoadingView() + } else { + itemTagDetailView + } + } + + return contentView + } + + private var itemTagDetailView: some View { + ScrollView { + VStack(alignment: .center) { + VStack(alignment: .center, spacing: 0) { + Text(verbatim: "Write Info to Tag / Save Customer QR code") + .font(.title2) + .padding(.top, 8) + + Text(shop.name) + .font(.title3) + .padding(.top, 16) + + Text(String(itemTag.wrappedValue.queueNumber)) + .font(.largeTitle) + .bold() + .padding(.top, 8) + .foregroundStyle(.lightestAccent) + } + + GroupBox(label: Label(String("Lock"), systemImage: "lock") ) { + Toggle(isOn: $isLocked) { + Text(verbatim: "Lock") + } + .dynamicTypeSize(...DynamicTypeSize.large) + .frame(width: 96) + .tint(.lockForeground) + + if isLocked { + Text(String.youCannotUndoAfterLockingTag) + .font(.uiFootnote) + .foregroundStyle(.alarm) + } + } + .foregroundStyle(.lockForeground) + .backgroundStyle(.lockBackground) + + 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) + } + } + .padding() + } + .foregroundStyle(.serverForeground) + .backgroundStyle(.serverBackground) + + 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) + } + } + .padding() + + if let customerTagQrCodeImage = 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)) + } + } + } + } label: { + Text(String.saveToPhotoAlbum) + } + } else { + generateCustomerQrCodeView + } + } + .padding(.top, 24) + .foregroundStyle(.customerForeground) + .backgroundStyle(.customerBackground) + } + } + .sheet( + isPresented: $isShowingEditSheet, + onDismiss: { + reload() + }, + content: { + ItemTagEditView(itemTagRepository: itemTagRepository, itemTagId: itemTagId) + } + ) + .confirmationDialog( + String.buttonDeleteTag, + isPresented: $isShowingDeleteConfirmationDialog + ) { + Button(String.buttonDeleteTag, role: .destructive) { + destroyItemTag() + } + Button(String.cancel, role: .cancel) { + isShowingDeleteConfirmationDialog = false + } + } message: { + Text(String.areYouSure) + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + isShowingEditSheet.toggle() + } label: { + Text(String.edit) + } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button { + isShowingDeleteConfirmationDialog.toggle() + } label: { + Image(systemName: "trash") + } + } + } + } + + 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() + } 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 List/ItemTag Detail/ItemTagEditView.swift b/NativeAppTemplate/UI/Shop List/ItemTag Detail/ItemTagEditView.swift new file mode 100644 index 0000000..8f88eee --- /dev/null +++ b/NativeAppTemplate/UI/Shop List/ItemTag Detail/ItemTagEditView.swift @@ -0,0 +1,168 @@ +// +// ItemTagEditView.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/04. +// + +import SwiftUI + +struct ItemTagEditView: View { + @Environment(\.dismiss) private var dismiss + @Environment(MessageBus.self) private var messageBus + @Environment(SessionController.self) private var sessionController + private var itemTagRepository: ItemTagRepository + @State private var queueNumber = "" + @State private var isFetching = true + @State private var isUpdating = false + private var itemTagId: String + + private var itemTag: Binding { + Binding { + itemTagRepository.findBy(id: itemTagId) + } set: { _ in + } + } + + init( + itemTagRepository: ItemTagRepository, + itemTagId: String + ) { + self.itemTagRepository = itemTagRepository + self.itemTagId = itemTagId + } + + 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() + } + } +} + +// MARK: - private +private extension ItemTagEditView { + var contentView: some View { + + @ViewBuilder var contentView: some View { + if isFetching || isUpdating { + LoadingView() + } else { + itemTagEditView + } + } + + return contentView + } + + var itemTagEditView: some View { + NavigationStack { + Form { + Section { + TextField(String("A001"), text: $queueNumber) + .keyboardType(.asciiCapable) + .onChange(of: queueNumber) { + queueNumber = String(queueNumber.prefix(sessionController.maximumQueueNumberLength)) + } + } header: { + Text(String.tagNumber) + } footer: { + VStack(alignment: .leading) { + Text("Tag Number must be a 2-\(sessionController.maximumQueueNumberLength) alphanumeric characters.") + .font(.uiFootnote) + Text(String.zeroPadding) + .font(.uiFootnote) + Text(String.tagNumberIsInvalid) + .font(.uiFootnote) + .foregroundStyle(hasInvalidDataQueueNumber ? .red : .clear) + } + } + } + .navigationTitle(String.editTag) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + updateItemTag() + } label: { + Text(String.save) + } + .disabled(hasInvalidData) + } + ToolbarItem(placement: .navigationBarLeading) { + Button { + dismiss() + } label: { + Text(String.cancel) + } + } + } + } + } + + 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 List/ItemTag List/ItemTagCreateView.swift b/NativeAppTemplate/UI/Shop List/ItemTag List/ItemTagCreateView.swift new file mode 100644 index 0000000..3f968d4 --- /dev/null +++ b/NativeAppTemplate/UI/Shop List/ItemTag List/ItemTagCreateView.swift @@ -0,0 +1,136 @@ +// +// ItemTagCreateView.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/04. +// + +import SwiftUI + +struct ItemTagCreateView: View { + @Environment(\.dismiss) private var dismiss + @Environment(MessageBus.self) private var messageBus + @Environment(SessionController.self) private var sessionController + private var itemTagRepository: ItemTagRepository + @State private var queueNumber = "" + @State private var isCreating = false + private var shopId: String + + init( + itemTagRepository: ItemTagRepository, + 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 + } + + var body: some View { + contentView + } +} + +// MARK: - private +private extension ItemTagCreateView { + var contentView: some View { + + @ViewBuilder var contentView: some View { + if isCreating { + LoadingView() + } else { + itemTagCreateView + } + } + + return contentView + } + + var itemTagCreateView: some View { + NavigationStack { + Form { + Section { + TextField(String("A001"), text: $queueNumber) + .keyboardType(.asciiCapable) + .onChange(of: queueNumber) { + queueNumber = String(queueNumber.prefix(sessionController.maximumQueueNumberLength)) + } + } header: { + Text(String.tagNumber) + } footer: { + VStack(alignment: .leading) { + Text("Tag Number must be a 2-\(sessionController.maximumQueueNumberLength) alphanumeric characters.") + .font(.uiFootnote) + Text(String.zeroPadding) + .font(.uiFootnote) + Text(String.tagNumberIsInvalid) + .font(.uiFootnote) + .foregroundStyle(hasInvalidDataQueueNumber ? .red : .clear) + } + } + } + .navigationTitle(String.addTag) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + createItemTag() + } label: { + Text(String.save) + } + .disabled(hasInvalidData) + } + ToolbarItem(placement: .navigationBarLeading) { + Button { + dismiss() + } label: { + Text(String.cancel) + } + } + } + } + } + + 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 List/ItemTag List/ItemTagListCardView.swift b/NativeAppTemplate/UI/Shop List/ItemTag List/ItemTagListCardView.swift new file mode 100644 index 0000000..0978b0e --- /dev/null +++ b/NativeAppTemplate/UI/Shop List/ItemTag List/ItemTagListCardView.swift @@ -0,0 +1,17 @@ +// +// ItemTagListCardView.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/04. +// + +import SwiftUI + +struct ItemTagListCardView: View { + let itemTag: ItemTag + + var body: some View { + Text(String(itemTag.queueNumber)) + .font(.uiTitle4) + } +} diff --git a/NativeAppTemplate/UI/Shop List/ItemTag List/ItemTagListView.swift b/NativeAppTemplate/UI/Shop List/ItemTag List/ItemTagListView.swift new file mode 100644 index 0000000..ff4ee5b --- /dev/null +++ b/NativeAppTemplate/UI/Shop List/ItemTag List/ItemTagListView.swift @@ -0,0 +1,153 @@ +// +// ItemTagListView.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/04. +// + +import SwiftUI + +struct ItemTagListView: View { + @Environment(MessageBus.self) private var messageBus + @Environment(SessionController.self) private var sessionController + private var itemTagRepository: ItemTagRepository + @State private var isShowingCreateSheet = false + @State private var isDeleting = false + @State private var isShowingDeleteConfirmationDialog = false + private let shop: Shop + + init( + itemTagRepository: ItemTagRepository, + shop: Shop + ) { + self.itemTagRepository = itemTagRepository + self.shop = shop + } + + var body: some View { + contentView + .task { + reload() + } + } +} + +// MARK: - private +private extension ItemTagListView { + var contentView: some View { + @ViewBuilder var contentView: some View { + if isDeleting { + LoadingView() + } else { + switch itemTagRepository.state { + case .initial, .loading: + LoadingView() + case .hasData: + itemTagListView + case .failed: + reloadView + } + } + } + + return contentView + } + + var itemTagListView: some View { + VStack { + Text(shop.name) + .font(.uiTitle1) + .foregroundStyle(.titleText) + .padding(.top, 24) + .multilineTextAlignment(.center) + + if itemTagRepository.isEmpty { + noResultsView + } else { + List(itemTagRepository.itemTags) { itemTag in + NavigationLink( + destination: ItemTagDetailView(itemTagRepository: itemTagRepository, shop: shop, itemTagId: itemTag.id) + ) { + ItemTagListCardView( + itemTag: itemTag + ) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { destroyItemTag(itemTagId: itemTag.id) } label: { + Label(String.delete, systemImage: "trash") + .labelStyle(.titleOnly) + } + .tint(.red) + } + } + .listRowBackground(Color.cardBackground) + } + .refreshable { + reload() + } + } + } + .navigationTitle(String.shopSettingsManageNumberTagsLabel) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + isShowingCreateSheet.toggle() + } label: { + Image(systemName: "plus") + } + } + } + .sheet(isPresented: $isShowingCreateSheet, + onDismiss: { + reload() + }, content: { + ItemTagCreateView(itemTagRepository: itemTagRepository, shopId: shop.id) + } + ) + } + + 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") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 96) + .padding() + + Text(String.addTagDescription) + .foregroundStyle(.contentText) + .padding() + + MainButtonView(title: String.addTag, type: .primary(withArrow: false)) { + isShowingCreateSheet.toggle() + } + .padding() + + Spacer() + } + .padding() + } + + var reloadView: some View { + ErrorView(buttonAction: reload) + } +} diff --git a/NativeAppTemplate/UI/Shop List/ShopListCardView.swift b/NativeAppTemplate/UI/Shop List/ShopListCardView.swift index b5be6d8..ffec7d4 100644 --- a/NativeAppTemplate/UI/Shop List/ShopListCardView.swift +++ b/NativeAppTemplate/UI/Shop List/ShopListCardView.swift @@ -6,6 +6,7 @@ // import SwiftUI + struct ShopListCardView: View { let shop: Shop @@ -15,6 +16,45 @@ struct ShopListCardView: View { .font(.uiTitle4) .foregroundStyle(.accent) + let statImageSize = 12.0 + + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 12, verticalSpacing: 4) { + GridRow { + Image(systemName: "person.2") + .frame(width: statImageSize, height: statImageSize) + .foregroundStyle(.secondaryText) + Text(String(shop.scannedItemTagsCount)) + .font(.uiLabelBold) + .gridColumnAlignment(.trailing) + Text(verbatim: "tags scanned by customers") + .font(.uiFootnote) + .foregroundStyle(.contentText) + } + + GridRow { + Image(systemName: "flag.checkered") + .frame(width: statImageSize, height: statImageSize) + .foregroundStyle(.secondaryText) + Text(String(shop.completedItemTagsCount)) + .font(.uiLabelBold) + Text(verbatim: "completed tags") + .font(.uiFootnote) + .foregroundStyle(.contentText) + } + + GridRow { + Image(systemName: "rectangle.stack") + .frame(width: statImageSize, height: statImageSize) + .foregroundStyle(.secondaryText) + Text(String(shop.itemTagsCount)) + .font(.uiLabelBold) + Text(verbatim: "all tags") + .font(.uiFootnote) + .foregroundStyle(.contentText) + } + } + .padding(.top) + Text(shop.description) .font(.uiCaption) .foregroundStyle(.contentText) diff --git a/NativeAppTemplate/UI/Shop List/ShopListView.swift b/NativeAppTemplate/UI/Shop List/ShopListView.swift index a3cf67f..ff57aa8 100644 --- a/NativeAppTemplate/UI/Shop List/ShopListView.swift +++ b/NativeAppTemplate/UI/Shop List/ShopListView.swift @@ -27,18 +27,36 @@ // THE SOFTWARE. import SwiftUI +import TipKit + +struct TapShopBelowTip: Tip { + var title: Text { + Text(String.tapShopBelow) + } + + var message: Text? { + Text(String.haveFun) + } + + var image: Image? { + Image(systemName: "info.circle") + } +} struct ShopListView: View { @Environment(\.mainTab) private var mainTab @Environment(TabViewModel.self) private var tabViewModel @Environment(SessionController.self) private var sessionController private var shopRepository: ShopRepository + private var itemTagRepository: ItemTagRepository @State private var isShowingCreateSheet = false init( - shopRepository: ShopRepository + shopRepository: ShopRepository, + itemTagRepository: ItemTagRepository ) { self.shopRepository = shopRepository + self.itemTagRepository = itemTagRepository } } @@ -56,7 +74,7 @@ extension ShopListView { reload() } } - // Avoid showing deleted shop. + // Avoid showing deleted shop. .onChange(of: sessionController.shouldPopToRootView) { Task { try await Task.sleep(nanoseconds: 2_000_000_000) @@ -107,6 +125,10 @@ private extension ShopListView { Section { cardsView } header: { + let tip = TapShopBelowTip() + TipView(tip, arrowEdge: .bottom) + .tint(.alarm) + EmptyView() .id(ScrollToTopID(mainTab: mainTab, detail: false)) } footer: { @@ -123,10 +145,11 @@ private extension ShopListView { .navigationDestination(for: Shop.self) { shop in ShopDetailView( shopRepository: shopRepository, + itemTagRepository: itemTagRepository, shopId: shop.id ) } - .accessibility(identifier: "shopListView") + .accessibility(identifier: "shopListView") .refreshable { reload() } @@ -183,7 +206,7 @@ private extension ShopListView { .aspectRatio(contentMode: .fit) .frame(width: 96) .padding() - + HStack(alignment: .firstTextBaseline) { Text(String(leftInShopSlots)) .font(.uiTitle3) @@ -191,7 +214,7 @@ private extension ShopListView { .foregroundStyle(.contentText) } .padding() - + Spacer() } } diff --git a/NativeAppTemplate/UI/Shop Settings/NumberTagsWebpageListView.swift b/NativeAppTemplate/UI/Shop Settings/NumberTagsWebpageListView.swift new file mode 100644 index 0000000..d27e520 --- /dev/null +++ b/NativeAppTemplate/UI/Shop Settings/NumberTagsWebpageListView.swift @@ -0,0 +1,82 @@ +// +// NumberTagsWebpageList.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/04. +// + +import SwiftUI +import UniformTypeIdentifiers + +enum NumberTagsWebpageListType: String, Identifiable, CaseIterable, Codable, Hashable { + case server + + var id: Self { self } + + var displayString: String { + switch self { + case .server: + return String.serverNumberTagsWebpage + } + } +} + +struct NumberTagsWebpageListView: View { + @Environment(MessageBus.self) private var messageBus + private var shop: Shop + + init( + shop: Shop + ) { + self.shop = shop + } +} + +// MARK: - View +extension NumberTagsWebpageListView { + var body: some View { + contentView + } +} + +// MARK: - private +private extension NumberTagsWebpageListView { + var contentView: some View { + + @ViewBuilder var contentView: some View { + numberTagsWebpageListView + } + + return contentView + } + + var numberTagsWebpageListView: some View { + VStack { + Text(shop.name) + .font(.uiTitle1) + .foregroundStyle(.titleText) + .padding(.top, 24) + List(NumberTagsWebpageListType.allCases) { numberTagsWebpageListType in + switch numberTagsWebpageListType { + case .server: + Section { + Link(numberTagsWebpageListType.displayString, destination: shop.displayShopServerUrl) + } header: { + Label(String("Server"), systemImage: "storefront") + } footer: { + Button(String.copyWebpageUrl) { + copyWebpageUrl(shop.displayShopServerUrl.absoluteString) + } + } + .listRowBackground(Color.cardBackground) + } + } + } + .navigationTitle(String.shopSettingsNumberTagsWebpageLabel) + } + + func copyWebpageUrl(_ url: String) { + UIPasteboard.general.setValue(url, forPasteboardType: UTType.plainText.identifier) + messageBus.post(message: Message(level: .success, message: .webpageUrlCopied)) + } +} diff --git a/NativeAppTemplate/UI/Shop Settings/ShopSettingsView.swift b/NativeAppTemplate/UI/Shop Settings/ShopSettingsView.swift index b190606..89ddd28 100644 --- a/NativeAppTemplate/UI/Shop Settings/ShopSettingsView.swift +++ b/NativeAppTemplate/UI/Shop Settings/ShopSettingsView.swift @@ -12,9 +12,12 @@ struct ShopSettingsView: View { @Environment(MessageBus.self) private var messageBus @Environment(SessionController.self) private var sessionController @State private var isFetching = true + @State private var isResetting = false @State private var isDeleting = false + @State private var isShowingResetConfirmationDialog = false @State private var isShowingDeleteConfirmationDialog = false private let shopRepository: ShopRepository + private let itemTagRepository: ItemTagRepository private var shopId: String private var shop: Binding { @@ -26,9 +29,11 @@ struct ShopSettingsView: View { init( shopRepository: ShopRepository, + itemTagRepository: ItemTagRepository, shopId: String ) { self.shopRepository = shopRepository + self.itemTagRepository = itemTagRepository self.shopId = shopId } } @@ -48,7 +53,7 @@ private extension ShopSettingsView { var contentView: some View { @ViewBuilder var contentView: some View { - if isFetching || isDeleting { + if isFetching || isResetting || isDeleting { LoadingView() } else { shopSettingsView @@ -76,6 +81,39 @@ private extension ShopSettingsView { } Section { + NavigationLink { + ItemTagListView( + itemTagRepository: itemTagRepository, + shop: shop.wrappedValue + ) + } label: { + Label(String.shopSettingsManageNumberTagsLabel, systemImage: "rectangle.stack") + } + .listRowBackground(Color.cardBackground) + } + + Section { + NavigationLink { + NumberTagsWebpageListView(shop: shop.wrappedValue) + } label: { + Label(String.shopSettingsNumberTagsWebpageLabel, systemImage: "globe") + } + } + .listRowBackground(Color.cardBackground) + + Section { + VStack(spacing: 8) { + MainButtonView(title: String.resetNumberTags, type: .destructive(withArrow: false)) { + isShowingResetConfirmationDialog = true + } + .listRowBackground(Color.clear) + Text(String.resetNumberTagsDescription) + .font(.uiFootnote) + .foregroundStyle(.contentText) + .listRowBackground(Color.clear) + } + .listRowBackground(Color.clear) + MainButtonView(title: String.deleteShop, type: .destructive(withArrow: false)) { isShowingDeleteConfirmationDialog = true } @@ -90,6 +128,19 @@ private extension ShopSettingsView { } } .navigationTitle(String.shopSettingsLabel) + .confirmationDialog( + String.resetNumberTags, + isPresented: $isShowingResetConfirmationDialog + ) { + Button(String.resetNumberTags, role: .destructive) { + resetShop() + } + Button(String.cancel, role: .cancel) { + isShowingResetConfirmationDialog = false + } + } message: { + Text(String.areYouSure) + } .confirmationDialog( String.deleteShop, isPresented: $isShowingDeleteConfirmationDialog @@ -112,7 +163,7 @@ private extension ShopSettingsView { private func fetchShopDetail() { Task { @MainActor in isFetching = true - + do { _ = try await shopRepository.fetchDetail(id: shopId) isFetching = false @@ -124,10 +175,25 @@ private extension ShopSettingsView { } } + private func resetShop () { + Task { @MainActor in + isResetting = true + + do { + try await shopRepository.reset(id: shop.id) + messageBus.post(message: Message(level: .success, message: .shopReset)) + } catch { + messageBus.post(message: Message(level: .error, message: "\(String.shopResetError) \(error.localizedDescription)", autoDismiss: false)) + } + + dismiss() + } + } + private func destroyShop () { Task { @MainActor in isDeleting = true - + do { try await shopRepository.destroy(id: shop.id) messageBus.post(message: Message(level: .success, message: .shopDeleted)) diff --git a/NativeAppTemplate/Utilities/ImageSaver.swift b/NativeAppTemplate/Utilities/ImageSaver.swift new file mode 100644 index 0000000..9f0eadf --- /dev/null +++ b/NativeAppTemplate/Utilities/ImageSaver.swift @@ -0,0 +1,22 @@ +// +// ImageSaver.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/04. +// + +import UIKit + +class ImageSaver: NSObject { + private var completion: (_ error: Error?) -> Void = { _ in } + + func save(image: UIImage, completion: @escaping (_ error: Error?) -> Void) { + self.completion = completion + UIImageWriteToSavedPhotosAlbum(image, self, #selector(image(_:didFinishSavingWithError:contextInfo:)), nil) + } + + @objc + private func image(_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer) { + completion(error) + } +} diff --git a/NativeAppTemplate/Utilities/QRCodeGenerator.swift b/NativeAppTemplate/Utilities/QRCodeGenerator.swift new file mode 100644 index 0000000..72cdef1 --- /dev/null +++ b/NativeAppTemplate/Utilities/QRCodeGenerator.swift @@ -0,0 +1,48 @@ +// +// QRCodeGenerator.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/04. +// + +import SwiftUI + +struct QRCodeGenerator { + func generate(inputText: String, scale: CGFloat = 2, centerImage: UIImage?) -> UIImage? { + guard let qrFilter = CIFilter(name: "CIQRCodeGenerator") + else { return nil } + + let inputData = inputText.data(using: .utf8) + qrFilter.setValue(inputData, forKey: "inputMessage") + qrFilter.setValue("H", forKey: "inputCorrectionLevel") + + guard let ciImage = qrFilter.outputImage + else { return nil } + + let sizeTransform = CGAffineTransform(scaleX: scale, y: scale) + let scaledCiImage = ciImage.transformed(by: sizeTransform) + + let context = CIContext() + guard let cgImage = context.createCGImage(scaledCiImage, from: scaledCiImage.extent) + else { return nil } + + if let centerImage = centerImage { + return UIImage(cgImage: cgImage).composited(withSmallCenterImage: centerImage) + } else { + return UIImage(cgImage: cgImage) + } + } + + func generateWithCenterText(inputText: String, scale: CGFloat = 2, centerText: String) -> UIImage? { + if let centerImage = centerText.image( + withAttributes: [ + .font: UIFont.systemFont(ofSize: 40.0), + .backgroundColor: UIColor.white + ] + ) { + return generate(inputText: inputText, scale: scale, centerImage: centerImage) + } else { + return generate(inputText: inputText, scale: scale, centerImage: nil) + } + } +} diff --git a/NativeAppTemplate/Utilities/Utility.swift b/NativeAppTemplate/Utilities/Utility.swift index f3e2756..59d0f7d 100644 --- a/NativeAppTemplate/Utilities/Utility.swift +++ b/NativeAppTemplate/Utilities/Utility.swift @@ -9,6 +9,19 @@ import Foundation import CoreNFC enum Utility { + static func scanUrl(itemTagId: String, itemTagType: String) -> URL { + let path = itemTagType == "server" ? String.scanPath : String.scanPathCustomer + let pathURL = NativeAppTemplateEnvironment.prod.baseURL.appendingPathComponent(path) + var urlComponent = URLComponents(url: pathURL, resolvingAgainstBaseURL: true) + + urlComponent?.queryItems = [ + URLQueryItem(name: "item_tag_id", value: itemTagId), + URLQueryItem(name: "type", value: itemTagType) + ] + + return (urlComponent?.url)! + } + static func currentTimeZone() -> String { let defaultTimeZone = String.defaultTimeZone let timeZoneHourFormatted = currentTimeZoneHourFormatted() @@ -38,6 +51,61 @@ enum Utility { return defaultTimeZone } + static func extractItemTagInfoFrom(message: NFCNDEFMessage, test: Bool = false) -> ItemTagInfoFromNdefMessage { + var itemTagInfo = ItemTagInfoFromNdefMessage() + + let urls: [URLComponents] = message.records.compactMap { (payload: NFCNDEFPayload) -> URLComponents? in + // Search for URL record with matching domain host and scheme. + if let url = payload.wellKnownTypeURIPayload() { + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + if components?.host == String.domain && components?.scheme == String.scheme { + return components + } + } + return nil + } + + guard urls.count == 1, + let items = urls.first?.queryItems else { + return itemTagInfo + } + + for item in items { + switch item.name { + case "item_tag_id": + if let itemTagId = item.value { + itemTagInfo.id = itemTagId + } + print("item_tag_id: \(String(describing: itemTagInfo.id))") + case "type": + if let type = item.value { + itemTagInfo.type = type + } + print("type: \(String(describing: itemTagInfo.type))") + default: + break + } + } + + if test { + if itemTagInfo.id.isEmpty || itemTagInfo.type.isEmpty { + } else if itemTagInfo.type != ItemTagType.customer.rawValue && itemTagInfo.type != ItemTagType.server.rawValue { + } else { + itemTagInfo.success = true + } + } else { + if itemTagInfo.id.isEmpty || itemTagInfo.type.isEmpty { + } else if itemTagInfo.type == ItemTagType.customer.rawValue { + itemTagInfo.message = .scanServerTag + } else if itemTagInfo.type != ItemTagType.server.rawValue { + } else { + itemTagInfo.success = true + } + } + + return itemTagInfo + } + static var deviceModel: String { var utsnameInstance = utsname() uname(&utsnameInstance) diff --git a/NativeAppTemplateTests/Networking/Adapters/ItemTagAdapterTest.swift b/NativeAppTemplateTests/Networking/Adapters/ItemTagAdapterTest.swift new file mode 100644 index 0000000..54ad646 --- /dev/null +++ b/NativeAppTemplateTests/Networking/Adapters/ItemTagAdapterTest.swift @@ -0,0 +1,69 @@ +// +// ItemTagAdapterTest.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/01. +// + +import Testing +import SwiftyJSON +@testable import NativeAppTemplate + +struct ItemTagAdapterTest { + let sampleResource: JSON = [ + "id": "5712F2DF-DFC7-A3AA-66BC-191203654A1A", + "type": "item_tag", + "attributes": [ + "shop_id": "88705252-2FD2-4414-9E85-E6888033294A", + "queue_number": "A001", + "state": "idled", + "scan_state": "unscanned", + "created_at": "2020-01-01T12:00:00.000Z", + "shop_name": "Shop1", + "customer_read_at": "2020-01-02T12:00:00.000Z", + "completed_at": "2020-01-04T12:00:00.000Z", + "already_completed": false + ] + ] + + func makeJsonAPIResource(for dict: JSON) throws -> JSONAPIResource { + let json: JSON = [ + "data": [ + dict + ] + ] + + let document = JSONAPIDocument(json) + return document.data.first! + } + + @Test func validResourceProcessedCorrectly() async throws { + let resource = try makeJsonAPIResource(for: sampleResource) + let itemTag = try ItemTagAdapter.process(resource: resource) + #expect("5712F2DF-DFC7-A3AA-66BC-191203654A1A" == itemTag.id) + } + + @Test func inInvalidTypeThrows() throws { + var sample = sampleResource + sample["type"] = "invalid" + + let resource = try makeJsonAPIResource(for: sample) + + #expect { try ItemTagAdapter.process(resource: resource) } throws: { error in + let entityAdapterError = error as? EntityAdapterError + return EntityAdapterError.invalidResourceTypeForAdapter == entityAdapterError + } + } + + @Test func missingnAccountIdThrows() throws { + var sample = sampleResource + sample["attributes"].dictionaryObject?.removeValue(forKey: "shop_id") + + let resource = try makeJsonAPIResource(for: sample) + + #expect { try ItemTagAdapter.process(resource: resource) } throws: { error in + let entityAdapterError = error as? EntityAdapterError + return EntityAdapterError.invalidOrMissingAttributes == entityAdapterError + } + } +} diff --git a/NativeAppTemplateTests/Networking/Adapters/ShopAdapterTest.swift b/NativeAppTemplateTests/Networking/Adapters/ShopAdapterTest.swift index 62a69ba..4a1070b 100644 --- a/NativeAppTemplateTests/Networking/Adapters/ShopAdapterTest.swift +++ b/NativeAppTemplateTests/Networking/Adapters/ShopAdapterTest.swift @@ -16,7 +16,11 @@ struct ShopAdapterTest { "attributes": [ "name": "Shop1", "description": "This is a Shop1", - "time_zone": "Tokyo" + "time_zone": "Tokyo", + "display_shop_server_path": "https://api.nativeapptemplate.com/display/shops/1ed7ea32-65d5-4e64-97a0-0e00b6cee8c3?type=server", // swiftlint:disable:this line_length + "item_tags_count": 10, + "scanned_item_tags_count": 1, + "completed_item_tags_count": 2 ], "relationships": [ "account": [ diff --git a/SampleCode.xcconfig b/SampleCode.xcconfig new file mode 100644 index 0000000..92c5a54 --- /dev/null +++ b/SampleCode.xcconfig @@ -0,0 +1,13 @@ +// +// See the LICENSE.txt file for this sample’s licensing information. +// +// SampleCode.xcconfig +// + +// The `SAMPLE_CODE_DISAMBIGUATOR` configuration is to make it easier to build +// and run a sample code project. Once you set your project's development team, +// you'll have a unique bundle identifier. This is because the bundle identifier +// is derived based on the 'SAMPLE_CODE_DISAMBIGUATOR' value. Do not use this +// approach in your own projects—it's only useful for sample code projects because +// they are frequently downloaded and don't have a development team set. +SAMPLE_CODE_DISAMBIGUATOR=${DEVELOPMENT_TEAM}