diff --git a/.gitignore b/.gitignore index 77f0536..40df552 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,5 @@ Carthage/Build secrets.*.xcconfig !secrets.template.xcconfig +# Claude +.claude/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0ef3d58 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,106 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build, Test, and Development Commands + +### Building the Project +```bash +# Build for Debug +xcodebuild -project NativeAppTemplate.xcodeproj \ + -scheme "NativeAppTemplate" \ + -configuration Debug \ + -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.2' \ + build + +# Build for Release +xcodebuild -project NativeAppTemplate.xcodeproj \ + -scheme "NativeAppTemplate" \ + -configuration Release \ + -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.2' \ + build +``` + +### Running Tests +```bash +# Run all tests +xcodebuild -project NativeAppTemplate.xcodeproj \ + -scheme "NativeAppTemplate" \ + -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.2' \ + test +``` + +### Linting +```bash +# Run SwiftLint (must be installed via: brew install swiftlint) +cd NativeAppTemplate && swiftlint + +# Run SwiftLint with strict mode (as in CI) +cd NativeAppTemplate && swiftlint --strict +``` + +## Architecture Overview + +### MVVM with Observable Pattern +The app uses iOS 17's `@Observable` macro for state management with clean separation between: +- **Views**: SwiftUI views (99% SwiftUI, UIKit only for mail view) +- **ViewModels**: Observable state containers that bridge views and data +- **Models**: Domain objects and data structures +- **Repositories**: Data access layer implementing CRUD operations + +### Core Components + +**Data Flow Architecture**: +1. **SessionController** (`/Sessions/SessionController.swift`): Manages authentication state and user sessions +2. **DataManager** (`/Data/DataManager.swift`): Central orchestrator that manages all repositories +3. **Repositories**: Each entity has its own repository (e.g., `ShopRepository`, `ItemTagRepository`) +4. **Services**: Network layer abstraction with protocol-based design +5. **MessageBus** (`/MessageBus.swift`): Event communication system for decoupled components + +**Networking Architecture**: +- JSON API format with custom adapters (`/Networking/Adapters/`) +- Service layer pattern (`/Networking/Services/`) +- Request/Response models (`/Networking/Requests/`, `/Networking/Responses/`) +- Error handling with typed errors (`/Models/AppError.swift`) + +### Key Features Implementation + +**NFC Support**: +- Tag reading/writing capabilities (`/NFC/`) +- Background tag reading support +- Application-specific tag data format + +**Offline Support**: +- Network monitoring (`/Networking/NetworkMonitor.swift`) +- Keychain storage for secure data persistence + +**Configuration**: +- Central configuration in `Constants.swift` +- API endpoints, UI strings, and app constants +- Environment-specific settings (scheme, domain, port) + +### Project Structure +``` +NativeAppTemplate/ +├── App.swift # App entry point +├── MainView.swift # Root view +├── Data/ # Data layer (repositories, ViewModels) +├── Models/ # Domain models +├── Networking/ # API layer +├── UI/ # SwiftUI views by feature +├── Sessions/ # Authentication +├── Persistence/ # Keychain storage +├── Utilities/ # Helpers and extensions +└── NFC/ # NFC functionality +``` + +### Dependencies (Swift Package Manager) +- KeychainAccess (4.2.2) - Secure credential storage +- SwiftyJSON (5.0.2) - JSON parsing +- Swift Collections (1.1.4) - Additional data structures + +### Testing +Uses Swift Testing framework with `@Test` attribute. Tests are organized by component type (models, adapters, networking). \ No newline at end of file diff --git a/NativeAppTemplate.xcodeproj/project.pbxproj b/NativeAppTemplate.xcodeproj/project.pbxproj index fa8f915..3863d46 100644 --- a/NativeAppTemplate.xcodeproj/project.pbxproj +++ b/NativeAppTemplate.xcodeproj/project.pbxproj @@ -32,7 +32,6 @@ 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 */; }; @@ -56,7 +55,6 @@ 017203A325A96F7B008FD63B /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017203A225A96F7A008FD63B /* Constants.swift */; }; 017203B325A96FD6008FD63B /* UIApplication+DismissKeyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017203A825A96FBF008FD63B /* UIApplication+DismissKeyboard.swift */; }; 017203B625A96FD6008FD63B /* View+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017203AB25A96FBF008FD63B /* View+Extensions.swift */; }; - 017203CA25A97090008FD63B /* SessionController+States.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017203C825A9708A008FD63B /* SessionController+States.swift */; }; 017203CB25A97090008FD63B /* SessionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017203C725A9708A008FD63B /* SessionController.swift */; }; 017203EB25AA6606008FD63B /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017203EA25AA6601008FD63B /* Logger.swift */; }; 0172040025AA6775008FD63B /* LoginRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017203F525AA675E008FD63B /* LoginRepository.swift */; }; @@ -119,6 +117,13 @@ 018D4EFF2B6350F500CBA736 /* Inter-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 018D4EFE2B6350F500CBA736 /* Inter-Bold.ttf */; }; 018E21CB2B36367F00FFD1F6 /* MeRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 018E21CA2B36367F00FFD1F6 /* MeRequest.swift */; }; 018E21CD2B36377800FFD1F6 /* MeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 018E21CC2B36377800FFD1F6 /* MeService.swift */; }; + 0199CD242E07510200109DC6 /* AccountPasswordRepositoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0199CD212E07510200109DC6 /* AccountPasswordRepositoryProtocol.swift */; }; + 0199CD252E07510200109DC6 /* ItemTagRepositoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0199CD222E07510200109DC6 /* ItemTagRepositoryProtocol.swift */; }; + 0199CD262E07510200109DC6 /* ShopRepositoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0199CD232E07510200109DC6 /* ShopRepositoryProtocol.swift */; }; + 0199CD2A2E07512100109DC6 /* OnboardingRepositoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0199CD282E07512100109DC6 /* OnboardingRepositoryProtocol.swift */; }; + 0199CD2B2E07512100109DC6 /* SignUpRepositoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0199CD292E07512100109DC6 /* SignUpRepositoryProtocol.swift */; }; + 0199CD2C2E07512100109DC6 /* LoginRepositoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0199CD272E07512100109DC6 /* LoginRepositoryProtocol.swift */; }; + 0199CD3E2E075CBB00109DC6 /* SessionControllerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0199CD3D2E075CBB00109DC6 /* SessionControllerProtocol.swift */; }; 01B37C7629B0960700BF5B2D /* ForgotPasswordView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B37C7529B0960700BF5B2D /* ForgotPasswordView.swift */; }; 01B526542AF4E36400655131 /* MainTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B526532AF4E36400655131 /* MainTab.swift */; }; 01B526562AF4E82A00655131 /* ScrollToTopID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B526552AF4E82A00655131 /* ScrollToTopID.swift */; }; @@ -205,7 +210,6 @@ 017203A825A96FBF008FD63B /* UIApplication+DismissKeyboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+DismissKeyboard.swift"; sourceTree = ""; }; 017203AB25A96FBF008FD63B /* View+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = ""; }; 017203C725A9708A008FD63B /* SessionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionController.swift; sourceTree = ""; }; - 017203C825A9708A008FD63B /* SessionController+States.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionController+States.swift"; sourceTree = ""; }; 017203EA25AA6601008FD63B /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; 017203F525AA675E008FD63B /* LoginRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginRepository.swift; sourceTree = ""; }; 0172045725AA82B4008FD63B /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; @@ -267,6 +271,13 @@ 018D4EFE2B6350F500CBA736 /* Inter-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Inter-Bold.ttf"; sourceTree = ""; }; 018E21CA2B36367F00FFD1F6 /* MeRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeRequest.swift; sourceTree = ""; }; 018E21CC2B36377800FFD1F6 /* MeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeService.swift; sourceTree = ""; }; + 0199CD212E07510200109DC6 /* AccountPasswordRepositoryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPasswordRepositoryProtocol.swift; sourceTree = ""; }; + 0199CD222E07510200109DC6 /* ItemTagRepositoryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTagRepositoryProtocol.swift; sourceTree = ""; }; + 0199CD232E07510200109DC6 /* ShopRepositoryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopRepositoryProtocol.swift; sourceTree = ""; }; + 0199CD272E07512100109DC6 /* LoginRepositoryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginRepositoryProtocol.swift; sourceTree = ""; }; + 0199CD282E07512100109DC6 /* OnboardingRepositoryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingRepositoryProtocol.swift; sourceTree = ""; }; + 0199CD292E07512100109DC6 /* SignUpRepositoryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpRepositoryProtocol.swift; sourceTree = ""; }; + 0199CD3D2E075CBB00109DC6 /* SessionControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionControllerProtocol.swift; sourceTree = ""; }; 01B37C7529B0960700BF5B2D /* ForgotPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForgotPasswordView.swift; sourceTree = ""; }; 01B526532AF4E36400655131 /* MainTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTab.swift; sourceTree = ""; }; 01B526552AF4E82A00655131 /* ScrollToTopID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollToTopID.swift; sourceTree = ""; }; @@ -565,9 +576,9 @@ 017203C525A9708A008FD63B /* Sessions */ = { isa = PBXGroup; children = ( - 01E0A62125BD4A7800298D35 /* Shopkeeper+Backdoor.swift */, 017203C725A9708A008FD63B /* SessionController.swift */, - 017203C825A9708A008FD63B /* SessionController+States.swift */, + 0199CD3D2E075CBB00109DC6 /* SessionControllerProtocol.swift */, + 01E0A62125BD4A7800298D35 /* Shopkeeper+Backdoor.swift */, ); path = Sessions; sourceTree = ""; @@ -584,10 +595,13 @@ isa = PBXGroup; children = ( 017203F525AA675E008FD63B /* LoginRepository.swift */, + 0199CD272E07512100109DC6 /* LoginRepositoryProtocol.swift */, 017278062D7D4F5800CE424F /* OnboardingRepository.swift */, + 0199CD282E07512100109DC6 /* OnboardingRepositoryProtocol.swift */, 0172052E25AC41A7008FD63B /* SessionRequest.swift */, 0172051925AAF6BF008FD63B /* SessionsService.swift */, 011DDC20287669EA00C6C21F /* SignUpRepository.swift */, + 0199CD292E07512100109DC6 /* SignUpRepositoryProtocol.swift */, 011DDC2428766CEC00C6C21F /* SignUpRequest.swift */, 011DDC2228766C5D00C6C21F /* SignUpService.swift */, ); @@ -733,8 +747,11 @@ isa = PBXGroup; children = ( 0106414129A9F51700B46FED /* AccountPasswordRepository.swift */, + 0199CD212E07510200109DC6 /* AccountPasswordRepositoryProtocol.swift */, 017278742D7D8FAC00CE424F /* ItemTagRepository.swift */, + 0199CD222E07510200109DC6 /* ItemTagRepositoryProtocol.swift */, 017204A925AA8449008FD63B /* ShopRepository.swift */, + 0199CD232E07510200109DC6 /* ShopRepositoryProtocol.swift */, ); path = Repositories; sourceTree = ""; @@ -824,7 +841,7 @@ attributes = { BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 1620; - LastUpgradeCheck = 1600; + LastUpgradeCheck = 1640; TargetAttributes = { 011F6DEC259EF16400BED22E = { CreatedOnToolsVersion = 12.3; @@ -874,7 +891,6 @@ 01D19B412D4DE33500BDEAB7 /* Resources */ = { isa = PBXResourcesBuildPhase; files = ( - 0135E8E42D7E4478004AD8FA /* SampleCode.xcconfig in Resources */, ); }; /* End PBXResourcesBuildPhase section */ @@ -896,6 +912,8 @@ " echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"", "fi", "", + "", + "", ); }; /* End PBXShellScriptBuildPhase section */ @@ -928,8 +946,12 @@ 01B37C7629B0960700BF5B2D /* ForgotPasswordView.swift in Sources */, 01E0A5B725BD0FCD00298D35 /* OfflineView.swift in Sources */, 0110A15F2AC816F5003EDCBA /* SendConfirmation.swift in Sources */, + 0199CD2A2E07512100109DC6 /* OnboardingRepositoryProtocol.swift in Sources */, + 0199CD2B2E07512100109DC6 /* SignUpRepositoryProtocol.swift in Sources */, + 0199CD2C2E07512100109DC6 /* LoginRepositoryProtocol.swift in Sources */, 0172033D25A9642E008FD63B /* NativeAppTemplateEnvironment.swift in Sources */, 0172787F2D7D933000CE424F /* ShopDetailCardView.swift in Sources */, + 0199CD3E2E075CBB00109DC6 /* SessionControllerProtocol.swift in Sources */, 01EE363E29A6DCEB009BCD9D /* ShopkeeperEditView.swift in Sources */, 0182D38225B296B9001E881D /* ShopkeeperAdapter.swift in Sources */, 01BE4F1D29CA6F8C002008BE /* TimeZoneData.swift in Sources */, @@ -950,7 +972,6 @@ 0172051A25AAF6C0008FD63B /* SessionsService.swift in Sources */, 017204D125AA8479008FD63B /* DataState.swift in Sources */, 012643372B3554AD00D4E9BD /* AcceptTermsView.swift in Sources */, - 017203CA25A97090008FD63B /* SessionController+States.swift in Sources */, 0172033E25A9642E008FD63B /* Parameters.swift in Sources */, 0150A36629B14BB300907F96 /* SendResetPassword.swift in Sources */, 017204B625AA8467008FD63B /* DataManager.swift in Sources */, @@ -976,6 +997,9 @@ 017204D925AA847E008FD63B /* ShopRepository.swift in Sources */, 017278612D7D83E700CE424F /* ItemTagData.swift in Sources */, 017278622D7D83E700CE424F /* ItemTag.swift in Sources */, + 0199CD242E07510200109DC6 /* AccountPasswordRepositoryProtocol.swift in Sources */, + 0199CD252E07510200109DC6 /* ItemTagRepositoryProtocol.swift in Sources */, + 0199CD262E07510200109DC6 /* ShopRepositoryProtocol.swift in Sources */, 017278632D7D83E700CE424F /* ItemTagState.swift in Sources */, 017278642D7D83E700CE424F /* ItemTagInfoFromNdefMessage.swift in Sources */, 017278652D7D83E700CE424F /* ItemTagType.swift in Sources */, @@ -1085,6 +1109,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = NNYDL5U3V3; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -1153,6 +1178,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = NNYDL5U3V3; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -1189,7 +1215,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_ASSET_PATHS = "\"NativeAppTemplate/Preview Content\""; - DEVELOPMENT_TEAM = NNYDL5U3V3; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; INFOPLIST_FILE = NativeAppTemplate/Info.plist; @@ -1226,7 +1251,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_ASSET_PATHS = "\"NativeAppTemplate/Preview Content\""; - DEVELOPMENT_TEAM = NNYDL5U3V3; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; INFOPLIST_FILE = NativeAppTemplate/Info.plist; @@ -1289,6 +1313,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = NNYDL5U3V3; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -1324,7 +1349,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_ASSET_PATHS = "\"NativeAppTemplate/Preview Content\""; - DEVELOPMENT_TEAM = NNYDL5U3V3; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; INFOPLIST_FILE = NativeAppTemplate/Info.plist; @@ -1355,7 +1379,6 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = NNYDL5U3V3; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.2; @@ -1377,7 +1400,6 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = NNYDL5U3V3; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.2; @@ -1398,7 +1420,6 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = NNYDL5U3V3; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.2; diff --git a/NativeAppTemplate.xcodeproj/xcshareddata/xcschemes/NativeAppTemplate.xcscheme b/NativeAppTemplate.xcodeproj/xcshareddata/xcschemes/NativeAppTemplate.xcscheme index 0b8b2cd..9752f72 100644 --- a/NativeAppTemplate.xcodeproj/xcshareddata/xcschemes/NativeAppTemplate.xcscheme +++ b/NativeAppTemplate.xcodeproj/xcshareddata/xcschemes/NativeAppTemplate.xcscheme @@ -1,6 +1,6 @@ ItemTag + func reload(shopId: String) + func fetchAll(shopId: String) async throws -> [ItemTag] + func fetchDetail(id: String) async throws -> ItemTag + func create(shopId: String, itemTag: ItemTag) async throws -> ItemTag + func update(id: String, itemTag: ItemTag) async throws -> ItemTag + func destroy(id: String) async throws + func complete(id: String) async throws -> ItemTag + func reset(id: String) async throws -> ItemTag +} diff --git a/NativeAppTemplate/Data/Repositories/ShopRepository.swift b/NativeAppTemplate/Data/Repositories/ShopRepository.swift index 155ebaa..be45ec8 100644 --- a/NativeAppTemplate/Data/Repositories/ShopRepository.swift +++ b/NativeAppTemplate/Data/Repositories/ShopRepository.swift @@ -7,7 +7,7 @@ import SwiftUI -@MainActor @Observable class ShopRepository { +@MainActor @Observable class ShopRepository: ShopRepositoryProtocol { let shopsService: ShopsService var shops: [Shop] = [] @@ -15,7 +15,7 @@ import SwiftUI private(set) var limitCount = 0 private(set) var createdShopsCount = 0 - init( + required init( shopsService: ShopsService ) { self.shopsService = shopsService diff --git a/NativeAppTemplate/Data/Repositories/ShopRepositoryProtocol.swift b/NativeAppTemplate/Data/Repositories/ShopRepositoryProtocol.swift new file mode 100644 index 0000000..a8225ef --- /dev/null +++ b/NativeAppTemplate/Data/Repositories/ShopRepositoryProtocol.swift @@ -0,0 +1,26 @@ +// +// ShopRepositoryProtocol.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2022/06/28. +// + +import SwiftUI + +@MainActor protocol ShopRepositoryProtocol: AnyObject, Observable, Sendable { + var shops: [Shop] { get } + var isEmpty: Bool { get } + var state: DataState { get } + var limitCount: Int { get } + var createdShopsCount: Int { get } + + init(shopsService: ShopsService) + + func findBy(id: String) -> Shop + func reload() + func fetchDetail(id: String) async throws -> Shop + func create(shop: Shop) async throws -> Shop + func update(id: String, shop: Shop) async throws -> Shop + func destroy(id: String) async throws + func reset(id: String) async throws +} diff --git a/NativeAppTemplate/Login/LoginRepository.swift b/NativeAppTemplate/Login/LoginRepository.swift index be8c695..56d9ece 100644 --- a/NativeAppTemplate/Login/LoginRepository.swift +++ b/NativeAppTemplate/Login/LoginRepository.swift @@ -7,7 +7,7 @@ import Foundation -@MainActor @Observable public class LoginRepository { +@MainActor @Observable public class LoginRepository: LoginRepositoryProtocol { // MARK: - Properties private var _currentShopkeeper: Shopkeeper? @@ -25,7 +25,7 @@ import Foundation return _currentShopkeeper } - @MainActor func login(email: String, password: String) async throws -> Shopkeeper { + public func login(email: String, password: String) async throws -> Shopkeeper { do { let sessionsService = SessionsService(networkClient: NativeAppTemplateAPI(authToken: "", client: "", expiry: "", uid: "", accountId: "")) let shopkeeper = try await sessionsService.makeSession(email: email, password: password) @@ -40,7 +40,7 @@ import Foundation return currentShopkeeper! } - @MainActor func logout(networkClient: NativeAppTemplateAPI) async throws { + public func logout(networkClient: NativeAppTemplateAPI) async throws { do { let sessionsService = SessionsService(networkClient: networkClient) try await sessionsService.destroySession() diff --git a/NativeAppTemplate/Login/LoginRepositoryProtocol.swift b/NativeAppTemplate/Login/LoginRepositoryProtocol.swift new file mode 100644 index 0000000..3cf1f70 --- /dev/null +++ b/NativeAppTemplate/Login/LoginRepositoryProtocol.swift @@ -0,0 +1,16 @@ +// +// LoginRepositoryProtocol.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2021/01/11. +// + +import Foundation + +@MainActor public protocol LoginRepositoryProtocol: AnyObject, Observable, Sendable { + var currentShopkeeper: Shopkeeper? { get } + + func login(email: String, password: String) async throws -> Shopkeeper + func logout(networkClient: NativeAppTemplateAPI) async throws + func updateShopkeeper(shopkeeper: Shopkeeper?) throws +} diff --git a/NativeAppTemplate/Login/OnboardingRepository.swift b/NativeAppTemplate/Login/OnboardingRepository.swift index 2d901d4..5fba9e9 100644 --- a/NativeAppTemplate/Login/OnboardingRepository.swift +++ b/NativeAppTemplate/Login/OnboardingRepository.swift @@ -8,7 +8,7 @@ import Foundation import OrderedCollections -@MainActor @Observable class OnboardingRepository { +@MainActor @Observable class OnboardingRepository: OnboardingRepositoryProtocol { var onboardings: [Onboarding] = [] let onboardingsDictionary: OrderedDictionary = [ 1: false, diff --git a/NativeAppTemplate/Login/OnboardingRepositoryProtocol.swift b/NativeAppTemplate/Login/OnboardingRepositoryProtocol.swift new file mode 100644 index 0000000..aa19d40 --- /dev/null +++ b/NativeAppTemplate/Login/OnboardingRepositoryProtocol.swift @@ -0,0 +1,16 @@ +// +// OnboardingRepositoryProtocol.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/03/04. +// + +import Foundation +import OrderedCollections + +@MainActor protocol OnboardingRepositoryProtocol: AnyObject, Observable, Sendable { + var onboardings: [Onboarding] { get set } + var onboardingsDictionary: OrderedDictionary { get } + + func reload() +} diff --git a/NativeAppTemplate/Login/SignUpRepository.swift b/NativeAppTemplate/Login/SignUpRepository.swift index c76be48..08ff807 100644 --- a/NativeAppTemplate/Login/SignUpRepository.swift +++ b/NativeAppTemplate/Login/SignUpRepository.swift @@ -7,7 +7,7 @@ import Foundation -@MainActor class SignUpRepository { +@MainActor class SignUpRepository: SignUpRepositoryProtocol { func signUp(signUp: SignUp) async throws -> Shopkeeper { var shopkeeper: Shopkeeper diff --git a/NativeAppTemplate/Login/SignUpRepositoryProtocol.swift b/NativeAppTemplate/Login/SignUpRepositoryProtocol.swift new file mode 100644 index 0000000..b864c38 --- /dev/null +++ b/NativeAppTemplate/Login/SignUpRepositoryProtocol.swift @@ -0,0 +1,16 @@ +// +// SignUpRepositoryProtocol.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2022/07/07. +// + +import Foundation + +@MainActor protocol SignUpRepositoryProtocol: AnyObject, Sendable { + func signUp(signUp: SignUp) async throws -> Shopkeeper + func update(id: String, signUp: SignUp, networkClient: NativeAppTemplateAPI) async throws -> Shopkeeper + func destroy(networkClient: NativeAppTemplateAPI) async throws + func sendResetPasswordInstruction(sendResetPassword: SendResetPassword) async throws + func sendConfirmationInstruction(sendConfirmation: SendConfirmation) async throws +} diff --git a/NativeAppTemplate/Login/SignUpService.swift b/NativeAppTemplate/Login/SignUpService.swift index db305fc..5d766e6 100644 --- a/NativeAppTemplate/Login/SignUpService.swift +++ b/NativeAppTemplate/Login/SignUpService.swift @@ -9,7 +9,7 @@ import Foundation import SwiftyJSON struct SignUpsService { - var networkClient: NativeAppTemplateAPI + var networkClient = NativeAppTemplateAPI() var session = URLSession(configuration: .default) } diff --git a/NativeAppTemplate/Networking/Network/NativeAppTemplateAPI.swift b/NativeAppTemplate/Networking/Network/NativeAppTemplateAPI.swift index c5a8a05..f79c067 100644 --- a/NativeAppTemplate/Networking/Network/NativeAppTemplateAPI.swift +++ b/NativeAppTemplate/Networking/Network/NativeAppTemplateAPI.swift @@ -60,9 +60,10 @@ extension NativeAppTemplateAPIError: LocalizedError { } } } - -struct NativeAppTemplateAPI: Equatable { - static func == (lhs: NativeAppTemplateAPI, rhs: NativeAppTemplateAPI) -> Bool { + +@MainActor +public struct NativeAppTemplateAPI: Equatable { + nonisolated public static func == (lhs: NativeAppTemplateAPI, rhs: NativeAppTemplateAPI) -> Bool { lhs.environment == rhs.environment && lhs.session == rhs.session && lhs.authToken == rhs.authToken && @@ -85,8 +86,12 @@ struct NativeAppTemplateAPI: Equatable { let contentTypeHeader: HTTPHeader = ("Content-Type", "application/vnd.api+json; charset=utf-8") var additionalHeaders: HTTPHeaders = [:] + nonisolated init() { + self.init(authToken: "", client: "", expiry: "", uid: "", accountId: "") + } + // MARK: - Initializers - init( + nonisolated init( session: URLSession = .init(configuration: .default), environment: NativeAppTemplateEnvironment = .prod, authToken: String, diff --git a/NativeAppTemplate/Networking/Services/AccountPasswordService.swift b/NativeAppTemplate/Networking/Services/AccountPasswordService.swift index 560d498..5737bae 100644 --- a/NativeAppTemplate/Networking/Services/AccountPasswordService.swift +++ b/NativeAppTemplate/Networking/Services/AccountPasswordService.swift @@ -8,7 +8,7 @@ import class Foundation.URLSession struct AccountPasswordService: Service { - let networkClient: NativeAppTemplateAPI + var networkClient = NativeAppTemplateAPI() let session = URLSession(configuration: .default) } diff --git a/NativeAppTemplate/Networking/Services/ItemTagsService.swift b/NativeAppTemplate/Networking/Services/ItemTagsService.swift index 6d8860e..080d325 100644 --- a/NativeAppTemplate/Networking/Services/ItemTagsService.swift +++ b/NativeAppTemplate/Networking/Services/ItemTagsService.swift @@ -8,7 +8,7 @@ import class Foundation.URLSession struct ItemTagsService: Service { - let networkClient: NativeAppTemplateAPI + var networkClient = NativeAppTemplateAPI() let session = URLSession(configuration: .default) } diff --git a/NativeAppTemplate/Networking/Services/MeService.swift b/NativeAppTemplate/Networking/Services/MeService.swift index 4628dbc..66be3a7 100644 --- a/NativeAppTemplate/Networking/Services/MeService.swift +++ b/NativeAppTemplate/Networking/Services/MeService.swift @@ -8,7 +8,7 @@ import class Foundation.URLSession struct MeService: Service { - let networkClient: NativeAppTemplateAPI + var networkClient = NativeAppTemplateAPI() let session = URLSession(configuration: .default) } diff --git a/NativeAppTemplate/Networking/Services/PermissionsService.swift b/NativeAppTemplate/Networking/Services/PermissionsService.swift index d7d9721..a4bd802 100644 --- a/NativeAppTemplate/Networking/Services/PermissionsService.swift +++ b/NativeAppTemplate/Networking/Services/PermissionsService.swift @@ -29,7 +29,7 @@ import class Foundation.URLSession struct PermissionsService: Service { - let networkClient: NativeAppTemplateAPI + var networkClient = NativeAppTemplateAPI() let session = URLSession(configuration: .default) } diff --git a/NativeAppTemplate/Networking/Services/ShopsService.swift b/NativeAppTemplate/Networking/Services/ShopsService.swift index 7695b86..d146f62 100644 --- a/NativeAppTemplate/Networking/Services/ShopsService.swift +++ b/NativeAppTemplate/Networking/Services/ShopsService.swift @@ -29,7 +29,7 @@ import class Foundation.URLSession struct ShopsService: Service { - let networkClient: NativeAppTemplateAPI + var networkClient = NativeAppTemplateAPI() let session = URLSession(configuration: .default) } diff --git a/NativeAppTemplate/Sessions/SessionController+States.swift b/NativeAppTemplate/Sessions/SessionController+States.swift deleted file mode 100644 index b82e4a8..0000000 --- a/NativeAppTemplate/Sessions/SessionController+States.swift +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) 2020 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: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// 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 struct Foundation.Date - -extension SessionController { - enum UserState: Sendable { - case loggedIn - case loggingIn - case notLoggedIn - } - - enum SessionState: Sendable { - case unknown - case online - case offline - } - - enum PermissionState: Equatable, Sendable { - case notLoaded - case loading - case loaded - case error - } -} diff --git a/NativeAppTemplate/Sessions/SessionController.swift b/NativeAppTemplate/Sessions/SessionController.swift index 9e79fd1..d8d89ac 100644 --- a/NativeAppTemplate/Sessions/SessionController.swift +++ b/NativeAppTemplate/Sessions/SessionController.swift @@ -1,36 +1,15 @@ -// 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: +// SessionController.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/12/23. // -// 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 Foundation import Network import Observation -@MainActor @Observable class SessionController { +@MainActor @Observable class SessionController: SessionControllerProtocol { // Managing the state of the current session private(set) var sessionState: SessionState = .unknown private(set) var userState: UserState = .notLoggedIn @@ -70,7 +49,7 @@ import Observation private(set) var client: NativeAppTemplateAPI - private(set) var loginRepository: LoginRepository + private(set) var loginRepository: LoginRepositoryProtocol private let connectionMonitor = NWPathMonitor() private(set) var permissionsService: PermissionsService private(set) var meService: MeService @@ -85,8 +64,8 @@ import Observation } // MARK: - Initializers - init(loginRepository: LoginRepository) { - self.loginRepository = LoginRepository() + init(loginRepository: LoginRepositoryProtocol) { + self.loginRepository = loginRepository let shopkeeper = Shopkeeper.backdoor ?? loginRepository.currentShopkeeper let token = shopkeeper?.token ?? "" diff --git a/NativeAppTemplate/Sessions/SessionControllerProtocol.swift b/NativeAppTemplate/Sessions/SessionControllerProtocol.swift new file mode 100644 index 0000000..d700b0e --- /dev/null +++ b/NativeAppTemplate/Sessions/SessionControllerProtocol.swift @@ -0,0 +1,63 @@ +// +// SessionControllerProtocol.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/04/25. +// + +import Foundation + +public enum UserState: Sendable { + case loggedIn + case loggingIn + case notLoggedIn +} + +public enum SessionState: Sendable { + case unknown + case online + case offline +} + +public enum PermissionState: Equatable, Sendable { + case notLoaded + case loading + case loaded + case error +} + +@MainActor +protocol SessionControllerProtocol: AnyObject, Observable, Sendable { + // MARK: - Properties + var sessionState: SessionState { get } + var userState: UserState { get } + var permissionState: PermissionState { get } + var didFetchPermissions: Bool { get } + + var shouldPopToRootView: Bool { get set } + var didBackgroundTagReading: Bool { get set } + + var completeScanResult: CompleteScanResult { get set } + var showTagInfoScanResult: ShowTagInfoScanResult { get set } + + var shouldUpdateApp: Bool { get set } + var shouldUpdatePrivacy: Bool { get set } + var shouldUpdateTerms: Bool { get set } + var maximumQueueNumberLength: Int { get set } + var shopLimitCount: Int { get set } + + var shopkeeper: Shopkeeper? { get set } + var hasPermissions: Bool { get } + + var isLoggedIn: Bool { get } + var client: NativeAppTemplateAPI { get } + + // MARK: - Methods + func login(email: String, password: String) async throws + func logout() async throws + func fetchPermissionsIfNeeded() + func fetchPermissions() + func updateShopkeeper(shopkeeper: Shopkeeper?) throws + func updateConfirmedPrivacyVersion() async throws + func updateConfirmedTermsVersion() async throws +} diff --git a/NativeAppTemplate/UI/App Root/AcceptPrivacyView.swift b/NativeAppTemplate/UI/App Root/AcceptPrivacyView.swift index d5b4e7f..0180484 100644 --- a/NativeAppTemplate/UI/App Root/AcceptPrivacyView.swift +++ b/NativeAppTemplate/UI/App Root/AcceptPrivacyView.swift @@ -10,7 +10,7 @@ import SwiftUI struct AcceptPrivacyView: View { @Environment(\.dismiss) private var dismiss @Environment(MessageBus.self) private var messageBus - @Environment(SessionController.self) private var sessionController + @Environment(\.sessionController) private var sessionController @Binding var arePrivacyAccepted: Bool @State private var isUpdating = false diff --git a/NativeAppTemplate/UI/App Root/AcceptTermsView.swift b/NativeAppTemplate/UI/App Root/AcceptTermsView.swift index e9b2bcf..6c1933a 100644 --- a/NativeAppTemplate/UI/App Root/AcceptTermsView.swift +++ b/NativeAppTemplate/UI/App Root/AcceptTermsView.swift @@ -10,7 +10,7 @@ import SwiftUI struct AcceptTermsView: View { @Environment(\.dismiss) private var dismiss @Environment(MessageBus.self) private var messageBus - @Environment(SessionController.self) private var sessionController + @Environment(\.sessionController) private var sessionController @Binding var areTermsAccepted: Bool @State private var isUpdating = false diff --git a/NativeAppTemplate/UI/App Root/AppTabView.swift b/NativeAppTemplate/UI/App Root/AppTabView.swift index b94799d..404de8b 100644 --- a/NativeAppTemplate/UI/App Root/AppTabView.swift +++ b/NativeAppTemplate/UI/App Root/AppTabView.swift @@ -13,7 +13,7 @@ struct AppTabView< SettingsView: View > { - @Environment(SessionController.self) private var sessionController + @Environment(\.sessionController) private var sessionController @Environment(DataManager.self) private var dataManager @Environment(TabViewModel.self) private var model @State var navigationPathShops = NavigationPath() diff --git a/NativeAppTemplate/UI/App Root/ForgotPasswordView.swift b/NativeAppTemplate/UI/App Root/ForgotPasswordView.swift index 2d2af4c..6abc844 100644 --- a/NativeAppTemplate/UI/App Root/ForgotPasswordView.swift +++ b/NativeAppTemplate/UI/App Root/ForgotPasswordView.swift @@ -12,10 +12,10 @@ struct ForgotPasswordView: View { @Environment(MessageBus.self) private var messageBus @State var email: String = "" @State private var isSendingResetPasswordInstructions = false - let signUpRepository: SignUpRepository + let signUpRepository: SignUpRepositoryProtocol init( - signUpRepository: SignUpRepository + signUpRepository: SignUpRepositoryProtocol ) { self.signUpRepository = signUpRepository } diff --git a/NativeAppTemplate/UI/App Root/MainView.swift b/NativeAppTemplate/UI/App Root/MainView.swift index aa91bd1..e851df4 100644 --- a/NativeAppTemplate/UI/App Root/MainView.swift +++ b/NativeAppTemplate/UI/App Root/MainView.swift @@ -31,7 +31,7 @@ import SwiftUI struct MainView: View { @Environment(MessageBus.self) private var messageBus @Environment(DataManager.self) private var dataManager - @Environment(SessionController.self) private var sessionController + @Environment(\.sessionController) private var sessionController @State var isShowingForceAppUpdatesAlert = false @State var itemTagId: String? @State var isResetting = false diff --git a/NativeAppTemplate/UI/App Root/OnboardingView.swift b/NativeAppTemplate/UI/App Root/OnboardingView.swift index 65aef22..2969fd6 100644 --- a/NativeAppTemplate/UI/App Root/OnboardingView.swift +++ b/NativeAppTemplate/UI/App Root/OnboardingView.swift @@ -9,7 +9,7 @@ import SwiftUI struct OnboardingView: View { let isAppStorePromotion = false - @State private var onboardingRepository = OnboardingRepository() + @State private var onboardingRepository: OnboardingRepositoryProtocol = OnboardingRepository() var body: some View { NavigationStack { diff --git a/NativeAppTemplate/UI/App Root/PermissionsLoadingView.swift b/NativeAppTemplate/UI/App Root/PermissionsLoadingView.swift index 2bdf2af..704e105 100644 --- a/NativeAppTemplate/UI/App Root/PermissionsLoadingView.swift +++ b/NativeAppTemplate/UI/App Root/PermissionsLoadingView.swift @@ -29,7 +29,7 @@ import SwiftUI struct PermissionsLoadingView: View { - @Environment(SessionController.self) private var sessionController + @Environment(\.sessionController) private var sessionController @State private var isShowingLogoutAlert = false var body: some View { diff --git a/NativeAppTemplate/UI/App Root/ResendConfirmationInstructionsView.swift b/NativeAppTemplate/UI/App Root/ResendConfirmationInstructionsView.swift index 9350aaa..8a1aab3 100644 --- a/NativeAppTemplate/UI/App Root/ResendConfirmationInstructionsView.swift +++ b/NativeAppTemplate/UI/App Root/ResendConfirmationInstructionsView.swift @@ -12,10 +12,10 @@ struct ResendConfirmationInstructionsView: View { @Environment(MessageBus.self) private var messageBus @State var email: String = "" @State private var isSendingConfirmationInstructions = false - let signUpRepository: SignUpRepository + let signUpRepository: SignUpRepositoryProtocol init( - signUpRepository: SignUpRepository + signUpRepository: SignUpRepositoryProtocol ) { self.signUpRepository = signUpRepository } diff --git a/NativeAppTemplate/UI/App Root/SignInEmailAndPasswordView.swift b/NativeAppTemplate/UI/App Root/SignInEmailAndPasswordView.swift index 7f0f252..aa21460 100644 --- a/NativeAppTemplate/UI/App Root/SignInEmailAndPasswordView.swift +++ b/NativeAppTemplate/UI/App Root/SignInEmailAndPasswordView.swift @@ -10,8 +10,8 @@ import SwiftUI struct SignInEmailAndPasswordView: View { @Environment(MessageBus.self) private var messageBus - @Environment(SessionController.self) private var sessionController - let signUpRepository: SignUpRepository + @Environment(\.sessionController) private var sessionController + let signUpRepository: SignUpRepositoryProtocol @State var email: String = "" @State var password: String = "" diff --git a/NativeAppTemplate/UI/App Root/SignUpOrSignInView.swift b/NativeAppTemplate/UI/App Root/SignUpOrSignInView.swift index 7c5db08..4937cbb 100644 --- a/NativeAppTemplate/UI/App Root/SignUpOrSignInView.swift +++ b/NativeAppTemplate/UI/App Root/SignUpOrSignInView.swift @@ -37,7 +37,7 @@ private extension SignUpOrSignInView { .padding(.horizontal, 24) VStack { - NavigationLink(destination: SignUpView(signUpRepository: SignUpRepository())) { + NavigationLink(destination: SignUpView(signUpRepository: SignUpRepository() as SignUpRepositoryProtocol)) { MainButtonImageView(title: String.signUpForAnAccount, type: .primary(withArrow: false)) .padding(.top, 8) .padding(.horizontal, 24) @@ -46,7 +46,7 @@ private extension SignUpOrSignInView { Text(verbatim: "or") .padding(.top, 8) - NavigationLink(destination: SignInEmailAndPasswordView(signUpRepository: SignUpRepository())) { + NavigationLink(destination: SignInEmailAndPasswordView(signUpRepository: SignUpRepository() as SignUpRepositoryProtocol)) { Text(String.signInToYourAccount) .font(.uiLabel) } diff --git a/NativeAppTemplate/UI/App Root/SignUpView.swift b/NativeAppTemplate/UI/App Root/SignUpView.swift index 4bf70e2..6ed65cf 100644 --- a/NativeAppTemplate/UI/App Root/SignUpView.swift +++ b/NativeAppTemplate/UI/App Root/SignUpView.swift @@ -9,9 +9,9 @@ import SwiftUI struct SignUpView: View { @Environment(\.dismiss) private var dismiss - @Environment(SessionController.self) private var sessionController + @Environment(\.sessionController) private var sessionController @Environment(MessageBus.self) private var messageBus - private var signUpRepository: SignUpRepository + private var signUpRepository: SignUpRepositoryProtocol @State private var errorMessage: String = "" @State private var isCreating = false @@ -22,7 +22,7 @@ struct SignUpView: View { @State private var selectedTimeZone: String init( - signUpRepository: SignUpRepository + signUpRepository: SignUpRepositoryProtocol ) { self.signUpRepository = signUpRepository _selectedTimeZone = State(initialValue: Utility.currentTimeZone()) diff --git a/NativeAppTemplate/UI/Scan/ScanView.swift b/NativeAppTemplate/UI/Scan/ScanView.swift index abc9d6b..ec486f6 100644 --- a/NativeAppTemplate/UI/Scan/ScanView.swift +++ b/NativeAppTemplate/UI/Scan/ScanView.swift @@ -45,16 +45,16 @@ extension ScanType: Identifiable { struct ScanView: View { @Environment(MessageBus.self) private var messageBus - @Environment(SessionController.self) private var sessionController + @Environment(\.sessionController) 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 + private let itemTagRepository: ItemTagRepositoryProtocol init( - itemTagRepository: ItemTagRepository + itemTagRepository: ItemTagRepositoryProtocol ) { self.itemTagRepository = itemTagRepository } diff --git a/NativeAppTemplate/UI/Settings/PasswordEditView.swift b/NativeAppTemplate/UI/Settings/PasswordEditView.swift index 2978361..d4db1b2 100644 --- a/NativeAppTemplate/UI/Settings/PasswordEditView.swift +++ b/NativeAppTemplate/UI/Settings/PasswordEditView.swift @@ -14,10 +14,10 @@ struct PasswordEditView: View { @State private var currentPassword: String = "" @State private var password: String = "" @State private var passwordConfirmation: String = "" - private var accountPasswordRepository: AccountPasswordRepository + private var accountPasswordRepository: AccountPasswordRepositoryProtocol init( - accountPasswordRepository: AccountPasswordRepository + accountPasswordRepository: AccountPasswordRepositoryProtocol ) { self.accountPasswordRepository = accountPasswordRepository } diff --git a/NativeAppTemplate/UI/Settings/SettingsView.swift b/NativeAppTemplate/UI/Settings/SettingsView.swift index c64c6ff..85279fa 100644 --- a/NativeAppTemplate/UI/Settings/SettingsView.swift +++ b/NativeAppTemplate/UI/Settings/SettingsView.swift @@ -10,16 +10,16 @@ import MessageUI struct SettingsView: View { @Environment(MessageBus.self) private var messageBus - @Environment(SessionController.self) private var sessionController + @Environment(\.sessionController) private var sessionController @Environment(TabViewModel.self) private var tabViewModel @State var isShowingMailView = false @State var alertNoMail = false @State var result: Result? - private var signUpRepository = SignUpRepository() - private var accountPasswordRepository: AccountPasswordRepository + private var signUpRepository: SignUpRepositoryProtocol = SignUpRepository() + private var accountPasswordRepository: AccountPasswordRepositoryProtocol init( - accountPasswordRepository: AccountPasswordRepository + accountPasswordRepository: AccountPasswordRepositoryProtocol ) { self.accountPasswordRepository = accountPasswordRepository } diff --git a/NativeAppTemplate/UI/Settings/ShopkeeperEditView.swift b/NativeAppTemplate/UI/Settings/ShopkeeperEditView.swift index 7a5e072..25ddca1 100644 --- a/NativeAppTemplate/UI/Settings/ShopkeeperEditView.swift +++ b/NativeAppTemplate/UI/Settings/ShopkeeperEditView.swift @@ -11,7 +11,7 @@ struct ShopkeeperEditView: View { @Environment(\.dismiss) private var dismiss @Environment(\.openURL) var openURL @Environment(MessageBus.self) private var messageBus - @Environment(SessionController.self) private var sessionController + @Environment(\.sessionController) private var sessionController @Environment(TabViewModel.self) private var tabViewModel @State private var isUpdating = false @State private var isDeleting = false @@ -19,11 +19,11 @@ struct ShopkeeperEditView: View { @State private var email: String = "" @State private var selectedTimeZone: String @State private var isShowingDeleteConfirmationDialog = false - private var signUpRepository: SignUpRepository + private var signUpRepository: SignUpRepositoryProtocol private var shopkeeper: Shopkeeper init( - signUpRepository: SignUpRepository, + signUpRepository: SignUpRepositoryProtocol, shopkeeper: Shopkeeper ) { self.signUpRepository = signUpRepository diff --git a/NativeAppTemplate/UI/Shop Detail/ShopDetailView.swift b/NativeAppTemplate/UI/Shop Detail/ShopDetailView.swift index 79b3d13..9e8c4b9 100644 --- a/NativeAppTemplate/UI/Shop Detail/ShopDetailView.swift +++ b/NativeAppTemplate/UI/Shop Detail/ShopDetailView.swift @@ -27,13 +27,13 @@ struct ShopDetailView: View { @Environment(\.mainTab) private var mainTab @Environment(TabViewModel.self) private var tabViewModel @Environment(MessageBus.self) private var messageBus - @Environment(SessionController.self) private var sessionController + @Environment(\.sessionController) private var sessionController @State private var isFetching = true @State private var isResetting = false @State private var isCompleting = false @State private var itemTags: [ItemTag]? - private let shopRepository: ShopRepository - private let itemTagRepository: ItemTagRepository + private let shopRepository: ShopRepositoryProtocol + private let itemTagRepository: ItemTagRepositoryProtocol private var shopId: String private var shop: Binding { @@ -44,8 +44,8 @@ struct ShopDetailView: View { } init( - shopRepository: ShopRepository, - itemTagRepository: ItemTagRepository, + shopRepository: ShopRepositoryProtocol, + itemTagRepository: ItemTagRepositoryProtocol, shopId: String ) { self.shopRepository = shopRepository diff --git a/NativeAppTemplate/UI/Shop List/ItemTag Detail/ItemTagDetailView.swift b/NativeAppTemplate/UI/Shop List/ItemTag Detail/ItemTagDetailView.swift index 7716a91..fcae284 100644 --- a/NativeAppTemplate/UI/Shop List/ItemTag Detail/ItemTagDetailView.swift +++ b/NativeAppTemplate/UI/Shop List/ItemTag Detail/ItemTagDetailView.swift @@ -12,8 +12,8 @@ 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 + @Environment(\.sessionController) private var sessionController + private var itemTagRepository: ItemTagRepositoryProtocol @StateObject private var nfcManager = appSingletons.nfcManager @State private var isLocked = false @State private var isShowingEditSheet = false @@ -36,7 +36,7 @@ struct ItemTagDetailView: View { } init( - itemTagRepository: ItemTagRepository, + itemTagRepository: ItemTagRepositoryProtocol, shop: Shop, itemTagId: String ) { diff --git a/NativeAppTemplate/UI/Shop List/ItemTag Detail/ItemTagEditView.swift b/NativeAppTemplate/UI/Shop List/ItemTag Detail/ItemTagEditView.swift index 8f88eee..429429e 100644 --- a/NativeAppTemplate/UI/Shop List/ItemTag Detail/ItemTagEditView.swift +++ b/NativeAppTemplate/UI/Shop List/ItemTag Detail/ItemTagEditView.swift @@ -10,8 +10,8 @@ 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 + @Environment(\.sessionController) private var sessionController + private var itemTagRepository: ItemTagRepositoryProtocol @State private var queueNumber = "" @State private var isFetching = true @State private var isUpdating = false @@ -25,7 +25,7 @@ struct ItemTagEditView: View { } init( - itemTagRepository: ItemTagRepository, + itemTagRepository: ItemTagRepositoryProtocol, itemTagId: String ) { self.itemTagRepository = itemTagRepository diff --git a/NativeAppTemplate/UI/Shop List/ItemTag List/ItemTagCreateView.swift b/NativeAppTemplate/UI/Shop List/ItemTag List/ItemTagCreateView.swift index 3f968d4..11ce3a1 100644 --- a/NativeAppTemplate/UI/Shop List/ItemTag List/ItemTagCreateView.swift +++ b/NativeAppTemplate/UI/Shop List/ItemTag List/ItemTagCreateView.swift @@ -10,14 +10,14 @@ 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 + @Environment(\.sessionController) private var sessionController + private var itemTagRepository: ItemTagRepositoryProtocol @State private var queueNumber = "" @State private var isCreating = false private var shopId: String init( - itemTagRepository: ItemTagRepository, + itemTagRepository: ItemTagRepositoryProtocol, shopId: String ) { self.itemTagRepository = itemTagRepository diff --git a/NativeAppTemplate/UI/Shop List/ItemTag List/ItemTagListView.swift b/NativeAppTemplate/UI/Shop List/ItemTag List/ItemTagListView.swift index ff4ee5b..f068444 100644 --- a/NativeAppTemplate/UI/Shop List/ItemTag List/ItemTagListView.swift +++ b/NativeAppTemplate/UI/Shop List/ItemTag List/ItemTagListView.swift @@ -9,15 +9,15 @@ import SwiftUI struct ItemTagListView: View { @Environment(MessageBus.self) private var messageBus - @Environment(SessionController.self) private var sessionController - private var itemTagRepository: ItemTagRepository + @Environment(\.sessionController) private var sessionController + private var itemTagRepository: ItemTagRepositoryProtocol @State private var isShowingCreateSheet = false @State private var isDeleting = false @State private var isShowingDeleteConfirmationDialog = false private let shop: Shop init( - itemTagRepository: ItemTagRepository, + itemTagRepository: ItemTagRepositoryProtocol, shop: Shop ) { self.itemTagRepository = itemTagRepository diff --git a/NativeAppTemplate/UI/Shop List/ShopCreateView.swift b/NativeAppTemplate/UI/Shop List/ShopCreateView.swift index d7b901c..3928c3c 100644 --- a/NativeAppTemplate/UI/Shop List/ShopCreateView.swift +++ b/NativeAppTemplate/UI/Shop List/ShopCreateView.swift @@ -9,16 +9,16 @@ import SwiftUI struct ShopCreateView: View { @Environment(\.dismiss) private var dismiss - @Environment(SessionController.self) private var sessionController + @Environment(\.sessionController) private var sessionController @Environment(MessageBus.self) private var messageBus - private var shopRepository: ShopRepository + private var shopRepository: ShopRepositoryProtocol @State private var name = "" @State private var description = "" @State private var selectedTimeZone: String @State private var isCreating = false init( - shopRepository: ShopRepository + shopRepository: ShopRepositoryProtocol ) { self.shopRepository = shopRepository _selectedTimeZone = State(initialValue: Utility.currentTimeZone()) diff --git a/NativeAppTemplate/UI/Shop List/ShopListView.swift b/NativeAppTemplate/UI/Shop List/ShopListView.swift index ff57aa8..cde5dbf 100644 --- a/NativeAppTemplate/UI/Shop List/ShopListView.swift +++ b/NativeAppTemplate/UI/Shop List/ShopListView.swift @@ -46,14 +46,14 @@ struct TapShopBelowTip: Tip { 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 + @Environment(\.sessionController) private var sessionController + private var shopRepository: ShopRepositoryProtocol + private var itemTagRepository: ItemTagRepositoryProtocol @State private var isShowingCreateSheet = false init( - shopRepository: ShopRepository, - itemTagRepository: ItemTagRepository + shopRepository: ShopRepositoryProtocol, + itemTagRepository: ItemTagRepositoryProtocol ) { self.shopRepository = shopRepository self.itemTagRepository = itemTagRepository diff --git a/NativeAppTemplate/UI/Shop Settings/ShopBasicSettingsView.swift b/NativeAppTemplate/UI/Shop Settings/ShopBasicSettingsView.swift index 27967fe..1e4190f 100644 --- a/NativeAppTemplate/UI/Shop Settings/ShopBasicSettingsView.swift +++ b/NativeAppTemplate/UI/Shop Settings/ShopBasicSettingsView.swift @@ -10,8 +10,8 @@ import SwiftUI struct ShopBasicSettingsView: View { @Environment(\.dismiss) private var dismiss @Environment(MessageBus.self) private var messageBus - @Environment(SessionController.self) private var sessionController - private var shopRepository: ShopRepository + @Environment(\.sessionController) private var sessionController + private var shopRepository: ShopRepositoryProtocol @State private var isFetching = true @State private var isUpdating = false @State private var name = "" @@ -27,7 +27,7 @@ struct ShopBasicSettingsView: View { } init( - shopRepository: ShopRepository, + shopRepository: ShopRepositoryProtocol, shopId: String ) { self.shopRepository = shopRepository diff --git a/NativeAppTemplate/UI/Shop Settings/ShopSettingsView.swift b/NativeAppTemplate/UI/Shop Settings/ShopSettingsView.swift index 89ddd28..d402c80 100644 --- a/NativeAppTemplate/UI/Shop Settings/ShopSettingsView.swift +++ b/NativeAppTemplate/UI/Shop Settings/ShopSettingsView.swift @@ -10,14 +10,14 @@ import SwiftUI struct ShopSettingsView: View { @Environment(\.dismiss) private var dismiss @Environment(MessageBus.self) private var messageBus - @Environment(SessionController.self) private var sessionController + @Environment(\.sessionController) private var sessionController @State private var isFetching = true @State private var isResetting = false @State private var isDeleting = false @State private var isShowingResetConfirmationDialog = false @State private var isShowingDeleteConfirmationDialog = false - private let shopRepository: ShopRepository - private let itemTagRepository: ItemTagRepository + private let shopRepository: ShopRepositoryProtocol + private let itemTagRepository: ItemTagRepositoryProtocol private var shopId: String private var shop: Binding { @@ -28,8 +28,8 @@ struct ShopSettingsView: View { } init( - shopRepository: ShopRepository, - itemTagRepository: ItemTagRepository, + shopRepository: ShopRepositoryProtocol, + itemTagRepository: ItemTagRepositoryProtocol, shopId: String ) { self.shopRepository = shopRepository diff --git a/NativeAppTemplateTests/Demo/Data/Repositories/DemoAccountPasswordRepository.swift b/NativeAppTemplateTests/Demo/Data/Repositories/DemoAccountPasswordRepository.swift new file mode 100644 index 0000000..0cec48c --- /dev/null +++ b/NativeAppTemplateTests/Demo/Data/Repositories/DemoAccountPasswordRepository.swift @@ -0,0 +1,53 @@ +// +// DemoAccountPasswordRepository.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/06/16. +// + +@testable import NativeAppTemplate +import Foundation + +@MainActor +final class DemoAccountPasswordRepository: AccountPasswordRepositoryProtocol { + var lastUpdatePassword: UpdatePassword? + var shouldThrowError = false + var errorMessage = "Invalid current password" + + required init(accountPasswordService: AccountPasswordService) { + } + + func update(updatePassword: UpdatePassword) async throws { + lastUpdatePassword = updatePassword + + if shouldThrowError { + throw NativeAppTemplateAPIError.requestFailed(nil, 422, errorMessage) + } + + // Simulate validation + if updatePassword.currentPassword.isEmpty { + throw NativeAppTemplateAPIError.requestFailed(nil, 422, "Current password is required") + } + + if updatePassword.password.isEmpty { + throw NativeAppTemplateAPIError.requestFailed(nil, 422, "New password is required") + } + + if updatePassword.password != updatePassword.passwordConfirmation { + throw NativeAppTemplateAPIError.requestFailed(nil, 422, "Password confirmation does not match") + } + + if updatePassword.password.count < 8 { + throw NativeAppTemplateAPIError.requestFailed(nil, 422, "Password must be at least 8 characters long") + } + + // Success case - password updated + } + + // MARK: - Test Helpers + func resetState() { + lastUpdatePassword = nil + shouldThrowError = false + errorMessage = "Invalid current password" + } +} diff --git a/NativeAppTemplateTests/Demo/Data/Repositories/DemoAccountPasswordRepositoryTest.swift b/NativeAppTemplateTests/Demo/Data/Repositories/DemoAccountPasswordRepositoryTest.swift new file mode 100644 index 0000000..9e998a9 --- /dev/null +++ b/NativeAppTemplateTests/Demo/Data/Repositories/DemoAccountPasswordRepositoryTest.swift @@ -0,0 +1,113 @@ +// +// DemoAccountPasswordRepositoryTest.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/06/16. +// + +import Testing +@testable import NativeAppTemplate + +@Suite +struct DemoAccountPasswordRepositoryTest { + @MainActor + struct Tests { + let repository = DemoAccountPasswordRepository(accountPasswordService: AccountPasswordService()) + + @Test + func updatePasswordSuccess() async throws { + repository.resetState() + + let updatePassword = UpdatePassword( + currentPassword: "currentPassword123", + password: "newPassword123", + passwordConfirmation: "newPassword123" + ) + + await #expect(throws: Never.self) { + try await repository.update(updatePassword: updatePassword) + } + + #expect(repository.lastUpdatePassword?.currentPassword == "currentPassword123") + #expect(repository.lastUpdatePassword?.password == "newPassword123") + #expect(repository.lastUpdatePassword?.passwordConfirmation == "newPassword123") + } + + @Test + func updatePasswordWithEmptyCurrentPassword() async throws { + repository.resetState() + + let updatePassword = UpdatePassword( + currentPassword: "", + password: "newPassword123", + passwordConfirmation: "newPassword123" + ) + + await #expect(throws: NativeAppTemplateAPIError.self) { + try await repository.update(updatePassword: updatePassword) + } + } + + @Test + func updatePasswordWithEmptyNewPassword() async throws { + repository.resetState() + + let updatePassword = UpdatePassword( + currentPassword: "currentPassword123", + password: "", + passwordConfirmation: "" + ) + + await #expect(throws: NativeAppTemplateAPIError.self) { + try await repository.update(updatePassword: updatePassword) + } + } + + @Test + func updatePasswordWithMismatchedConfirmation() async throws { + repository.resetState() + + let updatePassword = UpdatePassword( + currentPassword: "currentPassword123", + password: "newPassword123", + passwordConfirmation: "differentPassword123" + ) + + await #expect(throws: NativeAppTemplateAPIError.self) { + try await repository.update(updatePassword: updatePassword) + } + } + + @Test + func updatePasswordWithShortPassword() async throws { + repository.resetState() + + let updatePassword = UpdatePassword( + currentPassword: "currentPassword123", + password: "short", + passwordConfirmation: "short" + ) + + await #expect(throws: NativeAppTemplateAPIError.self) { + try await repository.update(updatePassword: updatePassword) + } + } + + @Test + func updatePasswordWithForcedError() async throws { + repository.resetState() + repository.shouldThrowError = true + repository.errorMessage = "Custom error message" + + let updatePassword = UpdatePassword( + currentPassword: "currentPassword123", + password: "newPassword123", + passwordConfirmation: "newPassword123" + ) + + await #expect(throws: NativeAppTemplateAPIError.self) { + try await repository.update(updatePassword: updatePassword) + } + } + } +} diff --git a/NativeAppTemplateTests/Demo/Data/Repositories/DemoItemTagRepository.swift b/NativeAppTemplateTests/Demo/Data/Repositories/DemoItemTagRepository.swift new file mode 100644 index 0000000..21a879e --- /dev/null +++ b/NativeAppTemplateTests/Demo/Data/Repositories/DemoItemTagRepository.swift @@ -0,0 +1,116 @@ +// +// DemoItemTagRepository.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/05/17. +// + +import Testing +@testable import NativeAppTemplate +import Foundation + +@MainActor +final class DemoItemTagRepository: ItemTagRepositoryProtocol { + var itemTags: [ItemTag] = [] + var state: DataState = .initial + var isEmpty: Bool { itemTags.isEmpty } + + required init(itemTagsService: ItemTagsService) { + } + + func findBy(id: String) -> ItemTag { + itemTags.first { $0.id == id }! + } + + func reload(shopId: String) { + state = .loading + + let allItemTags = fetchAll() + itemTags = allItemTags.filter { $0.shopId == shopId } + + state = .hasData + } + + func fetchAll(shopId: String) async throws -> [ItemTag] { + let allItemTags = fetchAll() + let itemTags = allItemTags.filter { $0.shopId == shopId } + + return itemTags + } + + func fetchDetail(id: String) async throws -> ItemTag { + return itemTags.first { $0.id == id }! + } + + func create(shopId: String, itemTag: ItemTag) async throws -> ItemTag { + itemTags.append(itemTag) + return itemTag + } + + func update(id: String, itemTag: ItemTag) async throws -> ItemTag { + let index = itemTags.firstIndex { $0.id == id }! + itemTags[index] = itemTag + + return itemTag + } + + func destroy(id: String) async throws { + itemTags.removeAll { $0.id == id } + } + + func complete(id: String) async throws -> ItemTag { + var itemTag = itemTags.first { $0.id == id }! + itemTag.state = .completed + itemTag.completedAt = .now + + let index = itemTags.firstIndex { $0.id == id }! + itemTags[index] = itemTag + + return itemTag + } + + func reset(id: String) async throws -> ItemTag { + var itemTag = itemTags.first { $0.id == id }! + itemTag.state = .idled + itemTag.scanState = .unscanned + itemTag.completedAt = nil + itemTag.customerReadAt = nil + + let index = itemTags.firstIndex { $0.id == id }! + itemTags[index] = itemTag + + return itemTag + } + + private func fetchAll() -> [ItemTag] { + return [ + mockItemTag(id: "1", shopId: "1", queueNumber: "A001"), + mockItemTag(id: "2", shopId: "1", queueNumber: "A002"), + mockItemTag(id: "3", shopId: "1", queueNumber: "A003"), + mockItemTag(id: "4", shopId: "2", queueNumber: "A001"), + mockItemTag(id: "5", shopId: "2", queueNumber: "A002"), + mockItemTag(id: "6", shopId: "2", queueNumber: "A003"), + mockItemTag(id: "7", shopId: "2", queueNumber: "A004") + ] + } + + // MARK: - Helpers + private func mockItemTag( + id: String = UUID().uuidString, + shopId: String = UUID().uuidString, + queueNumber: String = "Mock ItemTag" + ) -> ItemTag { + ItemTag( + id: id, + shopId: shopId, + queueNumber: queueNumber, + state: .idled, + scanState: .unscanned, + createdAt: .now, + customerReadAt: nil, + completedAt: nil, + shopName: "Mock ItemTag", + alreadyCompleted: false + ) + } +} diff --git a/NativeAppTemplateTests/Demo/Data/Repositories/DemoItemTagRepositoryTest.swift b/NativeAppTemplateTests/Demo/Data/Repositories/DemoItemTagRepositoryTest.swift new file mode 100644 index 0000000..92ef522 --- /dev/null +++ b/NativeAppTemplateTests/Demo/Data/Repositories/DemoItemTagRepositoryTest.swift @@ -0,0 +1,121 @@ +// +// DemoItemTagRepositoryTest.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/05/17. +// + +import Testing +@testable import NativeAppTemplate + +@Suite +struct DemoItemTagRepositoryTest { + @MainActor + struct Tests { + let repository = DemoItemTagRepository(itemTagsService: ItemTagsService()) + + @Test + func findBy() { + repository.reload(shopId: "1") + + let itemTags = repository.findBy(id: "1") + #expect(itemTags.queueNumber == "A001") + } + + @Test + func reload() { + repository.reload(shopId: "1") + + #expect(repository.itemTags.count == 3) + #expect(repository.state == .hasData) + } + + @Test + func fetchAll() async throws { + let itemTags = try await repository.fetchAll(shopId: "1") + + #expect(itemTags.count == 3) + } + + @Test + func fetchDetail() async throws { + repository.reload(shopId: "1") + + let itemTag = try await repository.fetchDetail(id: "1") + #expect(itemTag.queueNumber == "A001") + } + + @Test + func create() async throws { + let shopId = "1" + repository.reload(shopId: shopId) + + let newQueueNumber = "A099" + let newItemTag = ItemTag( + shopId: shopId, + queueNumber: newQueueNumber, + state: .idled, + scanState: .unscanned, + createdAt: .now, + customerReadAt: nil, + completedAt: nil, + shopName: "Mock ItemTag", + alreadyCompleted: false + ) + + let createdItemTag = try await repository.create(shopId: shopId, itemTag: newItemTag) + #expect(createdItemTag.queueNumber == newQueueNumber) + #expect(repository.itemTags.count == 4) + } + + @Test + func update() async throws { + repository.reload(shopId: "1") + + var itemTag = repository.findBy(id: "1") + let newQueueNumber = "B001" + itemTag.queueNumber = newQueueNumber + let updatedItemTag = try await repository.update(id: "1", itemTag: itemTag) + #expect(updatedItemTag.queueNumber == newQueueNumber) + } + + @Test + func destroy() async throws { + repository.reload(shopId: "1") + + try await repository.destroy(id: "1") + #expect(!repository.itemTags.contains { $0.id == "1" }) + } + + @Test + func complete() async throws { + repository.reload(shopId: "1") + + var itemTag = repository.findBy(id: "1") + itemTag.completedAt = nil + _ = try await repository.update(id: "1", itemTag: itemTag) + + let completedItemTag = try await repository.complete(id: "1") + #expect(completedItemTag.state == ItemTagState.completed) + #expect(completedItemTag.completedAt != nil) + } + + @Test + func reset() async throws { + repository.reload(shopId: "1") + + var itemTag = repository.findBy(id: "1") + itemTag.state = .completed + itemTag.scanState = .scanned + itemTag.completedAt = .now + itemTag.customerReadAt = .now + _ = try await repository.update(id: "1", itemTag: itemTag) + + let resetItemTag = try await repository.reset(id: "1") + #expect(resetItemTag.state == .idled) + #expect(resetItemTag.scanState == .unscanned) + #expect(resetItemTag.completedAt == nil) + #expect(resetItemTag.customerReadAt == nil) + } + } +} diff --git a/NativeAppTemplateTests/Demo/Data/Repositories/DemoShopRepository.swift b/NativeAppTemplateTests/Demo/Data/Repositories/DemoShopRepository.swift new file mode 100644 index 0000000..408e2db --- /dev/null +++ b/NativeAppTemplateTests/Demo/Data/Repositories/DemoShopRepository.swift @@ -0,0 +1,76 @@ +// +// DemoShopRepository.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/05/11. +// + +@testable import NativeAppTemplate +import Foundation + +@MainActor +final class DemoShopRepository: ShopRepositoryProtocol { + var shops: [Shop] = [] + var state: DataState = .initial + var limitCount: Int = 10 + var createdShopsCount: Int = 0 + var isEmpty: Bool { shops.isEmpty } + + required init(shopsService: ShopsService) { + } + + func findBy(id: String) -> Shop { + shops.first { $0.id == id }! + } + + func reload() { + state = .loading + shops = [ + mockShop(id: "1", name: "Shop 1"), + mockShop(id: "2", name: "Shop 2"), + mockShop(id: "3", name: "Shop 3"), + mockShop(id: "4", name: "Shop 4"), + mockShop(id: "5", name: "Shop 5") + ] + createdShopsCount = shops.count + state = .hasData + } + + func fetchDetail(id: String) async throws -> Shop { + return shops.first { $0.id == id }! + } + + func create(shop: Shop) async throws -> Shop { + shops.append(shop) + createdShopsCount += 1 + return shop + } + + func update(id: String, shop: Shop) async throws -> Shop { + let index = shops.firstIndex { $0.id == id }! + shops[index] = shop + + return shop + } + + func destroy(id: String) async throws { + shops.removeAll { $0.id == id } + } + + func reset(id: String) async throws { + } + + // MARK: - Helpers + private func mockShop(id: String = UUID().uuidString, name: String = "Mock Shop") -> Shop { + Shop( + id: id, + name: name, + description: "This is a mock shop for testing", + timeZone: "Tokyo", + itemTagsCount: 10, + scannedItemTagsCount: 5, + completedItemTagsCount: 3, + displayShopServerPath: "https://api.nativeapptemplate.com/display/shops/\(id)?type=server" + ) + } +} diff --git a/NativeAppTemplateTests/Demo/Data/Repositories/DemoShopRepositoryTest.swift b/NativeAppTemplateTests/Demo/Data/Repositories/DemoShopRepositoryTest.swift new file mode 100644 index 0000000..6435bfd --- /dev/null +++ b/NativeAppTemplateTests/Demo/Data/Repositories/DemoShopRepositoryTest.swift @@ -0,0 +1,90 @@ +// +// DemoShopRepositoryTests.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/05/11. +// + +import Testing +@testable import NativeAppTemplate + +@Suite +struct DemoShopRepositoryTest { + @MainActor + struct Tests { + let repository = DemoShopRepository(shopsService: ShopsService()) + + @Test + func findBy() { + repository.reload() + + let shop = repository.findBy(id: "1") + #expect(shop.name == "Shop 1") + } + + @Test + func reload() { + repository.reload() + + #expect(repository.shops.count == 5) + #expect(repository.state == .hasData) + } + + @Test + func fetchDetail() async throws { + repository.reload() + + let shop = try await repository.fetchDetail(id: "1") + #expect(shop.name == "Shop 1") + } + + @Test + func create() async throws { + repository.reload() + + let newName = "New Shop" + let newShop = Shop( + id: "99", + name: newName, + description: "A new shop", + timeZone: "Tokyo", + itemTagsCount: 0, + scannedItemTagsCount: 0, + completedItemTagsCount: 0, + displayShopServerPath: "https://api.nativeapptemplate.com/display/shops/99?type=server" + ) + + let createdShop = try await repository.create(shop: newShop) + #expect(createdShop.name == newName) + #expect(repository.shops.count == 6) + } + + @Test + func update() async throws { + repository.reload() + + var shop = repository.findBy(id: "1") + let newName = "New Shop" + shop.name = newName + let updatedShop = try await repository.update(id: "1", shop: shop) + #expect(updatedShop.name == newName) + } + + @Test + func destroy() async throws { + repository.reload() + + try await repository.destroy(id: "1") + #expect(!repository.shops.contains { $0.id == "1" }) + } + + @Test + func reset() async throws { + repository.reload() + + await #expect(throws: Never.self) { + try await repository.reset(id: "1") + } + } + } +}