diff --git a/.hermes/skills/devlog-architecture-harness/SKILL.md b/.hermes/skills/devlog-architecture-harness/SKILL.md index b3f63570..2b65ef74 100644 --- a/.hermes/skills/devlog-architecture-harness/SKILL.md +++ b/.hermes/skills/devlog-architecture-harness/SKILL.md @@ -77,7 +77,7 @@ Ask the user before editing when: - WidgetCore would depend on Domain, Data, Infra, Persistence, Presentation, or App. - Presentation would depend on Data, Infra, Persistence, or App. - Data would gain concrete SDK or storage implementation details. -- The Presentation `Store` flow or reducer responsibility would change. +- The Presentation `StorePattern` flow or reducer responsibility would change. - A compile fix requires relaxing the intended architecture. - The change is outside the requested issue or PR scope. @@ -88,7 +88,7 @@ When the boundary is clear: - Keep the diff limited to the requested task. - Preserve existing logic unless the user explicitly approved logic changes. - Prefer existing DevLog naming and layer patterns. -- Preserve the existing Presentation `Store` pattern: `@MainActor`, `State`, `Action`, `SideEffect`, and `send -> reduce -> run`. +- Preserve the existing Presentation `StorePattern`: `@MainActor`, `State`, `Action`, `SideEffect`, and `send -> reduce -> run`. - Do not introduce unrelated cleanup. - Do not change lockfiles unless dependency resolution is part of the task. @@ -96,8 +96,7 @@ When the boundary is clear: For Swift/iOS code changes: -- Use Xcode Local MCP for the build. -- If Xcode Local MCP is unavailable, say so and ask before using another path unless the user already approved a fallback. +- Follow the repository `AGENTS.md` Verification section. - Inspect the final diff for architecture-scope drift. For docs-only or harness-only changes: diff --git a/.hermes/skills/devlog-architecture-harness/references/devlog-architecture-flow.md b/.hermes/skills/devlog-architecture-harness/references/devlog-architecture-flow.md index fd2246a4..17542331 100644 --- a/.hermes/skills/devlog-architecture-harness/references/devlog-architecture-flow.md +++ b/.hermes/skills/devlog-architecture-harness/references/devlog-architecture-flow.md @@ -123,12 +123,12 @@ flowchart TD | `DevLogWidgetCore` | widget data contracts and pure snapshot logic | Core | Adding Domain, Data, Infra, Persistence, Presentation, or App dependency | | `DevLogWidgetExtension` | WidgetKit rendering and timeline plumbing | WidgetCore | Calling app/domain services directly | -## Presentation Store flow +## Presentation StorePattern flow ```mermaid flowchart LR View["SwiftUI View"] - ViewModel["ViewModel / Store"] + ViewModel["ViewModel / StorePattern"] Send["send(Action)"] Reduce["reduce(with:)"] State["State update"] diff --git a/.hermes/skills/devlog-architecture-harness/references/devlog-workflow-rules.md b/.hermes/skills/devlog-architecture-harness/references/devlog-workflow-rules.md index e988638c..5ab98de6 100644 --- a/.hermes/skills/devlog-architecture-harness/references/devlog-workflow-rules.md +++ b/.hermes/skills/devlog-architecture-harness/references/devlog-workflow-rules.md @@ -10,6 +10,10 @@ This reference holds DevLog-specific working rules that should live with the pro ## Verification +- Follow `AGENTS.md` for the canonical lint and build verification policy. +- Run Homebrew SwiftLint (`swiftlint`) on changed Swift files. +- Lint production Swift files with the applicable source `.swiftlint.yml` config. +- Lint test Swift files with `.swiftlint-tests.yml` or the module `Tests/.swiftlint.yml` that inherits from it. Do not use the root production config for tests. - Prefer Xcode Local MCP for iOS project code changes. - If Xcode Local MCP is unavailable or fails because of session transport, state that explicitly before using a fallback. - This repository is workspace-based. Prefer workspace/scheme context over standalone project builds when dependencies cross module projects. @@ -61,9 +65,9 @@ This reference holds DevLog-specific working rules that should live with the pro - For example, keep the app-facing Domain query separate from an Infra-facing Data query when that avoids Domain coupling in service protocols. - Firebase-specific error detection belongs in Infra; Data should handle domain-level errors after mapping. -## Presentation Store +## Presentation StorePattern -- Preserve the existing `Store` shape: `@MainActor`, `State`, `Action`, `SideEffect`, `send -> reduce -> run`. +- Preserve the existing `StorePattern` shape: `@MainActor`, `State`, `Action`, `SideEffect`, `send -> reduce -> run`. - Reducers compute state and return side effects. - I/O belongs in `run` or injected services. - Do not leave reducer-era helper methods behind after moving work into `run`. diff --git a/AGENTS.md b/AGENTS.md index 7b28bc98..f7ddad26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,6 @@ These instructions apply only to the repository root. ## Naming and Swift style -- Set variables named after a type using the type's full name in camel case. - In Swift, do not write explicit type annotations unless required. - Use `opfic` in new Swift file headers. - Prefer `<` and `<=` over `>` and `>=` when writing comparisons, if the condition can be expressed clearly that way. @@ -37,7 +36,7 @@ Treat this repository as a Tuist-generated, workspace-based modular iOS app. The 4. Classify the change as mechanical, architectural, or ambiguous. 5. For ambiguous architecture changes, stop and ask the user before editing. 6. Keep the diff limited to the requested architecture scope. -7. After Swift/iOS code changes, verify with Xcode Local MCP. +7. After Swift/iOS code changes, follow the Verification section. 8. Report the changed files, architecture decision, and verification result. ### Current layer map @@ -53,10 +52,10 @@ Treat this repository as a Tuist-generated, workspace-based modular iOS app. The - `Widget/DevLogWidgetExtension`: WidgetKit UI, widget providers, entries, timelines, and extension resources. It should consume WidgetCore outputs rather than app/domain services directly. - `Firebase/functions`: TypeScript Cloud Functions. Deploy updated functions one by one separately. -### Store flow +### StorePattern flow -- Preserve the existing Presentation `Store` pattern. -- `Store` is `@MainActor` and uses `State`, `Action`, `SideEffect`, and the `send -> reduce -> run` flow. +- Preserve the existing Presentation `StorePattern`. +- `StorePattern` is `@MainActor` and uses `State`, `Action`, `SideEffect`, and the `send -> reduce -> run` flow. - Reducers should compute state and return side effects. - I/O belongs in `run` or injected services, not in reducer state computation. - Ask before changing reducer, side-effect, or ViewModel responsibility boundaries. @@ -86,7 +85,10 @@ These may proceed after inspection when they do not change architecture meaning: ## Verification -- If iOS project code changes, test build with Xcode Local MCP. +- If Swift files change, run Homebrew SwiftLint (`swiftlint`) on the changed Swift files. +- For production Swift files, use the applicable source `.swiftlint.yml` config. +- For test Swift files, use `.swiftlint-tests.yml` or the module `Tests/.swiftlint.yml` that inherits from it. Do not lint tests with the root production config. +- If iOS project code changes, verify with Xcode Local MCP when it is available. - If Xcode Local MCP is unavailable, state that explicitly before using a fallback. - Do not claim architecture work is complete without checking the diff scope. - Do not spend time on unrelated generated project or lockfile churn. Keep generated workspace/project and `Package.resolved` changes out of source control unless they are part of an explicitly approved dependency-lock policy. @@ -98,10 +100,3 @@ These may proceed after inspection when they do not change architecture meaning: - Treat `AGENTS.md` and `.hermes/skills/devlog-architecture-harness` as the canonical DevLog AI working rules. - If global memory conflicts with this repository, follow this repository. - For PR, commit, Xcode project, CI, widget, Store, localization, or release workflow details, read `.hermes/skills/devlog-architecture-harness/references/devlog-workflow-rules.md`. - -## Sub-agent use - -- Consider sub-agent use on every non-trivial task. -- Use sub-agents only for independent research, verification, or disjoint implementation work. -- Do not use sub-agents for sequential steps, overlapping file edits, or tightly coupled refactors. -- The main agent remains responsible for planning, integration, final verification, and user communication. diff --git a/Application/DevLogApp/Sources/App/DevLogApp.swift b/Application/DevLogApp/Sources/App/DevLogApp.swift index 1a2dca6c..0e68fd0b 100644 --- a/Application/DevLogApp/Sources/App/DevLogApp.swift +++ b/Application/DevLogApp/Sources/App/DevLogApp.swift @@ -29,6 +29,7 @@ struct DevLogApp: App { networkConnectivityUseCase: container.resolve(ObserveNetworkConnectivityUseCase.self), systemThemeUseCase: container.resolve(ObserveSystemThemeUseCase.self), trackAnalyticsEventUseCase: container.resolve(TrackAnalyticsEventUseCase.self), + signInUseCase: container.resolve(SignInUseCase.self), widgetURLTab: { MainTab(widgetURL: $0) }, windowEvent: windowEvent, pushNotificationTodoIdPublisher: PushNotificationRoute.shared.observe(), diff --git a/Application/DevLogCore/Sources/ActivityKind.swift b/Application/DevLogCore/Sources/ActivityKind.swift index 823cec07..27e939b1 100644 --- a/Application/DevLogCore/Sources/ActivityKind.swift +++ b/Application/DevLogCore/Sources/ActivityKind.swift @@ -7,7 +7,7 @@ import Foundation -public enum ActivityKind: String, Hashable { +public enum ActivityKind: String, Hashable, Sendable { case created case completed case deleted diff --git a/Application/DevLogCore/Sources/Logger.swift b/Application/DevLogCore/Sources/Logger.swift index 553677c2..b34e192a 100644 --- a/Application/DevLogCore/Sources/Logger.swift +++ b/Application/DevLogCore/Sources/Logger.swift @@ -8,7 +8,7 @@ import Foundation import os.log -public final class Logger { +public final class Logger: Sendable { private let subsystem: String private let category: String private let osLog: OSLog diff --git a/Application/DevLogCore/Sources/TodayDisplayOptions.swift b/Application/DevLogCore/Sources/TodayDisplayOptions.swift index a27ceb39..ed063c6a 100644 --- a/Application/DevLogCore/Sources/TodayDisplayOptions.swift +++ b/Application/DevLogCore/Sources/TodayDisplayOptions.swift @@ -7,14 +7,14 @@ import Foundation -public struct TodayDisplayOptions: Equatable { - public enum DueDateVisibility: String, CaseIterable, Equatable { +public struct TodayDisplayOptions: Equatable, Sendable { + public enum DueDateVisibility: String, CaseIterable, Equatable, Sendable { case all case withDueDateOnly case withoutDueDateOnly } - public enum FocusVisibility: String, CaseIterable, Equatable { + public enum FocusVisibility: String, CaseIterable, Equatable, Sendable { case all case focusedOnly } diff --git a/Application/DevLogData/Sources/Protocol/AuthService.swift b/Application/DevLogData/Sources/Protocol/AuthService.swift index f6c667b0..f8b1cacf 100644 --- a/Application/DevLogData/Sources/Protocol/AuthService.swift +++ b/Application/DevLogData/Sources/Protocol/AuthService.swift @@ -8,7 +8,7 @@ import Combine import Foundation -public protocol AuthService { +public protocol AuthService: Sendable { var uid: String? { get } var providerIDs: [String] { get } var currentUserEmail: String? { get } diff --git a/Application/DevLogData/Sources/Protocol/AuthenticationService.swift b/Application/DevLogData/Sources/Protocol/AuthenticationService.swift index c2c783b8..6a9a23b1 100644 --- a/Application/DevLogData/Sources/Protocol/AuthenticationService.swift +++ b/Application/DevLogData/Sources/Protocol/AuthenticationService.swift @@ -7,7 +7,7 @@ import Foundation -public protocol AuthenticationService { +public protocol AuthenticationService: Sendable { func signIn() async throws -> AuthDataResponse func signOut(_ uid: String) async throws func deleteAuth(_ uid: String) async throws diff --git a/Application/DevLogData/Sources/Protocol/UserService.swift b/Application/DevLogData/Sources/Protocol/UserService.swift index 3a82c536..67174c5d 100644 --- a/Application/DevLogData/Sources/Protocol/UserService.swift +++ b/Application/DevLogData/Sources/Protocol/UserService.swift @@ -7,7 +7,7 @@ import Foundation -public protocol UserService { +public protocol UserService: Sendable { func upsertUser(_ response: AuthDataResponse) async throws func fetchUserProfile() async throws -> UserProfileResponse func upsertStatusMessage(_ message: String) async throws diff --git a/Application/DevLogData/Sources/Protocol/WidgetSnapshotPreferenceStore.swift b/Application/DevLogData/Sources/Protocol/WidgetSnapshotPreferenceStore.swift index 160957c3..8c0a4ae2 100644 --- a/Application/DevLogData/Sources/Protocol/WidgetSnapshotPreferenceStore.swift +++ b/Application/DevLogData/Sources/Protocol/WidgetSnapshotPreferenceStore.swift @@ -8,7 +8,7 @@ import Foundation import DevLogCore -public protocol WidgetSnapshotPreferenceStore { +public protocol WidgetSnapshotPreferenceStore: Sendable { func heatmapActivityTypes() -> [String] func setHeatmapActivityTypes(_ activityTypes: [String]) func selectedActivityKinds() -> Set diff --git a/Application/DevLogData/Sources/Protocol/WidgetSnapshotUpdater.swift b/Application/DevLogData/Sources/Protocol/WidgetSnapshotUpdater.swift index 1748bd34..3fa25bc7 100644 --- a/Application/DevLogData/Sources/Protocol/WidgetSnapshotUpdater.swift +++ b/Application/DevLogData/Sources/Protocol/WidgetSnapshotUpdater.swift @@ -8,7 +8,7 @@ import Foundation import DevLogCore -public protocol WidgetSnapshotUpdater { +public protocol WidgetSnapshotUpdater: Sendable { func updateTodaySnapshot( todos: [WidgetTodoSnapshot], now: Date diff --git a/Application/DevLogDomain/Sources/Protocol/AuthenticationRepository.swift b/Application/DevLogDomain/Sources/Protocol/AuthenticationRepository.swift index 5652583a..9cbd628a 100644 --- a/Application/DevLogDomain/Sources/Protocol/AuthenticationRepository.swift +++ b/Application/DevLogDomain/Sources/Protocol/AuthenticationRepository.swift @@ -7,7 +7,7 @@ import Foundation -public protocol AuthenticationRepository { +public protocol AuthenticationRepository: Sendable { func signIn(_ provider: AuthProvider) async throws func signOut() async throws func restore() -> Bool diff --git a/Application/DevLogDomain/Sources/UseCase/Auth/SignIn/SignInUseCase.swift b/Application/DevLogDomain/Sources/UseCase/Auth/SignIn/SignInUseCase.swift index 46122e3c..926e656e 100644 --- a/Application/DevLogDomain/Sources/UseCase/Auth/SignIn/SignInUseCase.swift +++ b/Application/DevLogDomain/Sources/UseCase/Auth/SignIn/SignInUseCase.swift @@ -5,6 +5,6 @@ // Created by 최윤진 on 11/2/25. // -public protocol SignInUseCase { +public protocol SignInUseCase: Sendable { func execute(_ provider: AuthProvider) async throws } diff --git a/Application/DevLogInfra/Sources/Common/FirebaseDependency.swift b/Application/DevLogInfra/Sources/Common/FirebaseDependency.swift new file mode 100644 index 00000000..25013ca1 --- /dev/null +++ b/Application/DevLogInfra/Sources/Common/FirebaseDependency.swift @@ -0,0 +1,47 @@ +// +// FirebaseDependency.swift +// DevLogInfra +// +// Created by opfic on 6/5/26. +// + +import FirebaseAuth +import FirebaseFirestore +import FirebaseFunctions +import FirebaseMessaging + +struct FirebaseDependency: @unchecked Sendable { + private let value: Value + + init(value: Value) { + self.value = value + } +} + +extension FirebaseDependency where Value == Firestore { + func document(_ path: String) -> DocumentReference { + value.document(path) + } +} + +extension FirebaseDependency where Value == Functions { + func httpsCallable(_ name: some RawRepresentable) -> HTTPSCallable { + value.httpsCallable(name) + } +} + +extension FirebaseDependency where Value == Messaging { + func token() async throws -> String { + try await value.token() + } + + func deleteToken() async throws { + try await value.deleteToken() + } +} + +extension FirebaseDependency where Value == AuthStateDidChangeListenerHandle { + func removeAuthStateDidChangeListener() { + Auth.auth().removeStateDidChangeListener(value) + } +} diff --git a/Application/DevLogInfra/Sources/Common/TopViewControllerProvider.swift b/Application/DevLogInfra/Sources/Common/TopViewControllerProvider.swift index e6d8ee41..95ae7423 100644 --- a/Application/DevLogInfra/Sources/Common/TopViewControllerProvider.swift +++ b/Application/DevLogInfra/Sources/Common/TopViewControllerProvider.swift @@ -8,7 +8,7 @@ import UIKit import DevLogData -final class TopViewControllerProvider { +final class TopViewControllerProvider: Sendable { @MainActor func topViewController() -> UIViewController? { guard let keyWindow = keyWindow() else { diff --git a/Application/DevLogInfra/Sources/Service/AuthServiceImpl.swift b/Application/DevLogInfra/Sources/Service/AuthServiceImpl.swift index 36fc819f..00836e3e 100644 --- a/Application/DevLogInfra/Sources/Service/AuthServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/AuthServiceImpl.swift @@ -13,12 +13,10 @@ import DevLogCore import DevLogData final class AuthServiceImpl: AuthService { - private let store = Firestore.firestore() - private let messaging = Messaging.messaging() + private let store = FirebaseDependency(value: Firestore.firestore()) + private let messaging = FirebaseDependency(value: Messaging.messaging()) private let logger = Logger(category: "AuthServiceImpl") - private let subject = CurrentValueSubject(Auth.auth().currentUser != nil) - private var handler: AuthStateDidChangeListenerHandle? - private var isCompletingSignIn = false + private let authStatePublisher: AuthStatePublisher var uid: String? { Auth.auth().currentUser?.uid @@ -37,45 +35,26 @@ final class AuthServiceImpl: AuthService { } init() { - handler = Auth.auth().addStateDidChangeListener { [weak self] _, user in - guard let self else { return } - let signedIn = user != nil - self.logger.info("Firebase auth state changed. signedIn: \(signedIn)") - - if signedIn && self.isCompletingSignIn { - self.logger.info("Delaying signed-in publication until user bootstrap finishes") - return - } - - self.subject.send(signedIn) - } - } - - deinit { - guard let handler else { return } - Auth.auth().removeStateDidChangeListener(handler) + authStatePublisher = AuthStatePublisher(logger: logger) } func observeSignedIn() -> AnyPublisher { - subject.eraseToAnyPublisher() + authStatePublisher.observeSignedIn() } func beginSignIn() { logger.info("Beginning sign-in bootstrap") - isCompletingSignIn = true - subject.send(false) + authStatePublisher.beginSignIn() } func completeSignIn() { logger.info("Completing sign-in bootstrap") - isCompletingSignIn = false - subject.send(Auth.auth().currentUser != nil) + authStatePublisher.completeSignIn() } func cancelSignIn() { logger.info("Cancelling sign-in bootstrap") - isCompletingSignIn = false - subject.send(Auth.auth().currentUser != nil) + authStatePublisher.cancelSignIn() } func getProviderID() async throws -> String? { @@ -134,3 +113,68 @@ final class AuthServiceImpl: AuthService { } } + +private final class AuthStatePublisher: @unchecked Sendable { + private let logger: Logger + private let subject: CurrentValueSubject + private let lock = NSLock() + private var handler: FirebaseDependency? + private var isCompletingSignIn = false + + init(logger: Logger) { + self.logger = logger + self.subject = CurrentValueSubject(Auth.auth().currentUser != nil) + self.handler = FirebaseDependency( + value: Auth.auth().addStateDidChangeListener { [weak self] _, user in + self?.handleAuthStateChange(user) + } + ) + } + + deinit { + guard let handler else { return } + handler.removeAuthStateDidChangeListener() + } + + func observeSignedIn() -> AnyPublisher { + lock.lock() + defer { lock.unlock() } + return subject.eraseToAnyPublisher() + } + + func beginSignIn() { + lock.lock() + isCompletingSignIn = true + subject.send(false) + lock.unlock() + } + + func completeSignIn() { + lock.lock() + isCompletingSignIn = false + subject.send(Auth.auth().currentUser != nil) + lock.unlock() + } + + func cancelSignIn() { + lock.lock() + isCompletingSignIn = false + subject.send(Auth.auth().currentUser != nil) + lock.unlock() + } + + private func handleAuthStateChange(_ user: User?) { + lock.lock() + defer { lock.unlock() } + + let signedIn = user != nil + logger.info("Firebase auth state changed. signedIn: \(signedIn)") + + if signedIn && isCompletingSignIn { + logger.info("Delaying signed-in publication until user bootstrap finishes") + return + } + + subject.send(signedIn) + } +} diff --git a/Application/DevLogInfra/Sources/Service/FirebaseAnalyticsServiceImpl.swift b/Application/DevLogInfra/Sources/Service/FirebaseAnalyticsServiceImpl.swift index 6f8eff72..02708602 100644 --- a/Application/DevLogInfra/Sources/Service/FirebaseAnalyticsServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/FirebaseAnalyticsServiceImpl.swift @@ -20,7 +20,7 @@ final class FirebaseAnalyticsServiceImpl: AnalyticsService { Analytics.logEvent( AnalyticsEventScreenView, parameters: [ - AnalyticsParameterScreenName: name, + AnalyticsParameterScreenName: name ] ) } diff --git a/Application/DevLogInfra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift b/Application/DevLogInfra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift index 2a7e7270..e95d640d 100644 --- a/Application/DevLogInfra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift @@ -23,13 +23,11 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { case revokeAppleAccessToken } - private var appleSignInDelegate: AppleSignInDelegate? - private var appleSignInContinuation: CheckedContinuation? - private let store = Firestore.firestore() - private let functions = Functions.functions(region: "asia-northeast3") - private let messaging = Messaging.messaging() + private let session = AppleSignInSession() + private let store = FirebaseDependency(value: Firestore.firestore()) + private let functions = FirebaseDependency(value: Functions.functions(region: "asia-northeast3")) + private let messaging = FirebaseDependency(value: Messaging.messaging()) private var user: User? { Auth.auth().currentUser } - private let providerID = AuthProviderID.apple private let logger = Logger(category: "AppleAuthService") func signIn() async throws -> AuthDataResponse { @@ -81,9 +79,9 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { try await changeRequest.commitChanges() // FirebaseAuth 계정에 Apple ID 연결 - if !result.user.providerData.contains(where: { $0.providerID == providerID.rawValue }) { + if !result.user.providerData.contains(where: { $0.providerID == AuthProviderID.apple.rawValue }) { let appleCredential = OAuthProvider.credential( - providerID: providerID, + providerID: AuthProviderID.apple, idToken: idTokenString, rawNonce: nonce ) @@ -151,7 +149,7 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { } let appleCredential = OAuthProvider.credential( - providerID: providerID, + providerID: AuthProviderID.apple, idToken: idTokenString, rawNonce: nonce ) @@ -187,7 +185,7 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { } logger.info("Starting Firebase Apple provider unlink. uid: \(uid)") - _ = try await user?.unlink(fromProvider: providerID.rawValue) + _ = try await user?.unlink(fromProvider: AuthProviderID.apple.rawValue) } catch { logger.error("Failed to unlink Apple account", error: error) throw error @@ -197,7 +195,7 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { // Apple 인증 메서드 @MainActor func authenticateWithAppleAsync() async throws -> AppleAuthResponse { - guard appleSignInDelegate == nil, appleSignInContinuation == nil else { + guard session.canStartSignIn else { throw SocialLoginError.authenticationAlreadyInProgress } @@ -213,13 +211,11 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { let controller = ASAuthorizationController(authorizationRequests: [request]) let authorization = try await withCheckedThrowingContinuation { continuation in - let delegate = AppleSignInDelegate { [weak self] result in + let delegate = session.start(continuation: continuation) { [weak self] result in self?.completeAppleSignIn(with: result) } - self.appleSignInDelegate = delegate - self.appleSignInContinuation = continuation - controller.delegate = self.appleSignInDelegate - controller.presentationContextProvider = self.appleSignInDelegate + controller.delegate = delegate + controller.presentationContextProvider = delegate controller.performRequests() } @@ -241,17 +237,7 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { @MainActor private func completeAppleSignIn(with result: Result) { - guard let continuation = appleSignInContinuation else { return } - - appleSignInContinuation = nil - appleSignInDelegate = nil - - switch result { - case .success(let authorization): - continuation.resume(returning: authorization) - case .failure(let error): - continuation.resume(throwing: error) - } + session.complete(with: result) } // Apple CustomToken 발급 메서드 @@ -313,3 +299,41 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { _ = try await revokeFunction.call(["token": token]) } } + +private final class AppleSignInSession: @unchecked Sendable { + @MainActor + private var delegate: AppleSignInDelegate? + @MainActor + private var continuation: CheckedContinuation? + + @MainActor + var canStartSignIn: Bool { + delegate == nil && continuation == nil + } + + @MainActor + func start( + continuation: CheckedContinuation, + completion: @escaping @MainActor (Result) -> Void + ) -> AppleSignInDelegate { + let delegate = AppleSignInDelegate(finish: completion) + self.delegate = delegate + self.continuation = continuation + return delegate + } + + @MainActor + func complete(with result: Result) { + guard let continuation else { return } + + self.continuation = nil + self.delegate = nil + + switch result { + case .success(let authorization): + continuation.resume(returning: authorization) + case .failure(let error): + continuation.resume(throwing: error) + } + } +} diff --git a/Application/DevLogInfra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift b/Application/DevLogInfra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift index 7c69973b..de71578c 100644 --- a/Application/DevLogInfra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift @@ -26,11 +26,10 @@ final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService { static let acceptHeader = "application/vnd.github.v3+json" } - private let store = Firestore.firestore() - private let functions = Functions.functions(region: "asia-northeast3") - private let messaging = Messaging.messaging() + private let store = FirebaseDependency(value: Firestore.firestore()) + private let functions = FirebaseDependency(value: Functions.functions(region: "asia-northeast3")) + private let messaging = FirebaseDependency(value: Messaging.messaging()) private var user: User? { Auth.auth().currentUser } - private let providerID = AuthProviderID.gitHub private let provider = TopViewControllerProvider() private let logger = Logger(category: "GithubAuthService") private let gitHubApiClient = NXAPIClient( @@ -161,7 +160,7 @@ final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService { try await tokensRef.updateData(["githubAccessToken": FieldValue.delete()]) logger.info("Starting Firebase GitHub provider unlink. uid: \(uid)") - _ = try await user?.unlink(fromProvider: providerID.rawValue) + _ = try await user?.unlink(fromProvider: AuthProviderID.gitHub.rawValue) } catch { logger.error("Failed to unlink GitHub account", error: error) throw error diff --git a/Application/DevLogInfra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift b/Application/DevLogInfra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift index b575c04c..5be6c436 100644 --- a/Application/DevLogInfra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift @@ -14,8 +14,8 @@ import DevLogCore import DevLogData final class GoogleAuthenticationServiceImpl: AuthenticationService { - private let store = Firestore.firestore() - private let messaging = Messaging.messaging() + private let store = FirebaseDependency(value: Firestore.firestore()) + private let messaging = FirebaseDependency(value: Messaging.messaging()) private var user: User? { Auth.auth().currentUser } private let provider = TopViewControllerProvider() private let logger = Logger(category: "GoogleAuthService") diff --git a/Application/DevLogInfra/Sources/Service/UserServiceImpl.swift b/Application/DevLogInfra/Sources/Service/UserServiceImpl.swift index cdedbb61..f112c62e 100644 --- a/Application/DevLogInfra/Sources/Service/UserServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/UserServiceImpl.swift @@ -11,7 +11,7 @@ import DevLogCore import DevLogData final class UserServiceImpl: UserService { - private let store = Firestore.firestore() + private let store = FirebaseDependency(value: Firestore.firestore()) private let logger = Logger(category: "UserServiceImpl") // 유저를 Firestore에 저장 및 업데이트 diff --git a/Application/DevLogPersistence/Sources/Widget/UserDefaultsDependency.swift b/Application/DevLogPersistence/Sources/Widget/UserDefaultsDependency.swift new file mode 100644 index 00000000..b1526771 --- /dev/null +++ b/Application/DevLogPersistence/Sources/Widget/UserDefaultsDependency.swift @@ -0,0 +1,32 @@ +// +// UserDefaultsDependency.swift +// DevLogPersistence +// +// Created by opfic on 6/5/26. +// + +import Foundation + +struct UserDefaultsDependency: @unchecked Sendable { + private let value: UserDefaults + + init(value: UserDefaults) { + self.value = value + } + + func stringArray(forKey key: String) -> [String]? { + value.stringArray(forKey: key) + } + + func string(forKey key: String) -> String? { + value.string(forKey: key) + } + + func set(_ value: Any?, forKey key: String) { + self.value.set(value, forKey: key) + } + + func removeObject(forKey key: String) { + value.removeObject(forKey: key) + } +} diff --git a/Application/DevLogPersistence/Sources/Widget/WidgetSnapshotPreferenceStoreImpl.swift b/Application/DevLogPersistence/Sources/Widget/WidgetSnapshotPreferenceStoreImpl.swift index e17594b9..82a8cc1a 100644 --- a/Application/DevLogPersistence/Sources/Widget/WidgetSnapshotPreferenceStoreImpl.swift +++ b/Application/DevLogPersistence/Sources/Widget/WidgetSnapshotPreferenceStoreImpl.swift @@ -16,10 +16,10 @@ final class WidgetSnapshotPreferenceStoreImpl: WidgetSnapshotPreferenceStore { case todayFocusVisibility = "Today.focusVisibility" } - private let userDefaults: UserDefaults + private let userDefaults: UserDefaultsDependency init(userDefaults: UserDefaults = .standard) { - self.userDefaults = userDefaults + self.userDefaults = UserDefaultsDependency(value: userDefaults) } func heatmapActivityTypes() -> [String] { diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeViewModel.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeViewModel.swift index cda4cf3e..476a6129 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeViewModel.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeViewModel.swift @@ -11,7 +11,7 @@ import DevLogCore import DevLogDomain @Observable -final class HomeViewModel: Store { +final class HomeViewModel: StorePattern { struct State: Equatable { var preferences: [TodoCategoryItem] = [] var recentTodos: [RecentTodoItem] = [] diff --git a/Application/DevLogPresentation/Sources/Home/TodoDetailViewModel.swift b/Application/DevLogPresentation/Sources/Home/TodoDetailViewModel.swift index dc068268..dc206b2c 100644 --- a/Application/DevLogPresentation/Sources/Home/TodoDetailViewModel.swift +++ b/Application/DevLogPresentation/Sources/Home/TodoDetailViewModel.swift @@ -9,7 +9,7 @@ import Foundation import DevLogDomain @Observable -final class TodoDetailViewModel: Store { +final class TodoDetailViewModel: StorePattern { struct State: Equatable { var todo: Todo? var selectedTodoId: TodoIdItem? diff --git a/Application/DevLogPresentation/Sources/Home/TodoEditorViewModel.swift b/Application/DevLogPresentation/Sources/Home/TodoEditorViewModel.swift index 55089578..0919804f 100644 --- a/Application/DevLogPresentation/Sources/Home/TodoEditorViewModel.swift +++ b/Application/DevLogPresentation/Sources/Home/TodoEditorViewModel.swift @@ -10,7 +10,7 @@ import OrderedCollections import DevLogDomain @Observable -final class TodoEditorViewModel: Store { +final class TodoEditorViewModel: StorePattern { struct State: Equatable { var isCompleted: Bool = false var completedAt: Date? diff --git a/Application/DevLogPresentation/Sources/Home/TodoListViewModel.swift b/Application/DevLogPresentation/Sources/Home/TodoListViewModel.swift index e0da16a6..0ff89534 100644 --- a/Application/DevLogPresentation/Sources/Home/TodoListViewModel.swift +++ b/Application/DevLogPresentation/Sources/Home/TodoListViewModel.swift @@ -10,7 +10,7 @@ import DevLogCore import DevLogDomain @Observable -final class TodoListViewModel: Store { +final class TodoListViewModel: StorePattern { struct State: Equatable { var todos: [TodoListItem] = [] var searchText: String = "" diff --git a/Application/DevLogPresentation/Sources/Home/TodoManageViewModel.swift b/Application/DevLogPresentation/Sources/Home/TodoManageViewModel.swift index 72c30bc5..7dedcbeb 100644 --- a/Application/DevLogPresentation/Sources/Home/TodoManageViewModel.swift +++ b/Application/DevLogPresentation/Sources/Home/TodoManageViewModel.swift @@ -9,7 +9,7 @@ import SwiftUI import DevLogDomain @Observable -final class TodoManageViewModel: Store { +final class TodoManageViewModel: StorePattern { struct State: Equatable { var preferences: [TodoCategoryItem] var category: TodoCategoryItem? diff --git a/Application/DevLogPresentation/Sources/Login/LoginFeature.swift b/Application/DevLogPresentation/Sources/Login/LoginFeature.swift new file mode 100644 index 00000000..4d0fcde8 --- /dev/null +++ b/Application/DevLogPresentation/Sources/Login/LoginFeature.swift @@ -0,0 +1,125 @@ +// +// LoginFeature.swift +// DevLogPresentation +// +// Created by opfic on 6/5/26. +// + +import ComposableArchitecture +import DevLogDomain +import Foundation + +@Reducer +struct LoginFeature { + @ObservableState + struct State: Equatable { + var isLoading = false + var showAlert = false + var alertType: AlertType? + var alertTitle = "" + var alertMessage = "" + } + + enum Action { + case setAlert(Bool, AlertType? = nil) + case tapSignInButton(AuthProvider) + case signInSucceeded + case signInFailed(AlertType) + case signInCancelled + } + + enum AlertType: Equatable { + case emailUnavailable + case error + } + + @Dependency(\.signInUseCase) var signInUseCase + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .setAlert(let isPresented, let alertType): + setAlert(&state, isPresented: isPresented, alertType: alertType) + case .tapSignInButton(let provider): + state.isLoading = true + return .run { [signInUseCase] send in + do { + try await signInUseCase.execute(provider) + await send(.signInSucceeded) + } catch { + if error.isSocialLoginCancelled { + await send(.signInCancelled) + return + } + await send(.signInFailed(alertType(for: error))) + } + } + case .signInSucceeded, .signInCancelled: + state.isLoading = false + case .signInFailed(let alertType): + state.isLoading = false + setAlert(&state, isPresented: true, alertType: alertType) + } + return .none + } + } +} + +struct SignInUseCaseDependency: Sendable { + var execute: @Sendable (AuthProvider) async throws -> Void + + init(execute: @escaping @Sendable (AuthProvider) async throws -> Void) { + self.execute = execute + } +} + +extension SignInUseCaseDependency: DependencyKey { + static let liveValue = Self { _ in + preconditionFailure("SignInUseCaseDependency must be provided.") + } + + static let testValue = liveValue + + static func live(_ signInUseCase: SignInUseCase) -> SignInUseCaseDependency { + Self { + try await signInUseCase.execute($0) + } + } +} + +extension DependencyValues { + var signInUseCase: SignInUseCaseDependency { + get { self[SignInUseCaseDependency.self] } + set { self[SignInUseCaseDependency.self] = newValue } + } +} + +private extension LoginFeature { + func setAlert( + _ state: inout State, + isPresented: Bool, + alertType: AlertType? + ) { + switch alertType { + case .emailUnavailable: + state.alertTitle = String(localized: "login_alert_email_unavailable_title") + state.alertMessage = String(localized: "login_alert_email_unavailable_message") + case .error: + state.alertTitle = String(localized: "common_error_title") + state.alertMessage = String(localized: "common_error_message") + case .none: + state.alertTitle = "" + state.alertMessage = "" + } + state.showAlert = isPresented + state.alertType = alertType + } + + func alertType(for error: Error) -> AlertType { + if case AuthError.emailNotFound = error { + return .emailUnavailable + } + + return .error + } +} diff --git a/Application/DevLogPresentation/Sources/Login/LoginView.swift b/Application/DevLogPresentation/Sources/Login/LoginView.swift index b302eada..55998367 100644 --- a/Application/DevLogPresentation/Sources/Login/LoginView.swift +++ b/Application/DevLogPresentation/Sources/Login/LoginView.swift @@ -6,12 +6,12 @@ // import SwiftUI -import DevLogDomain +import ComposableArchitecture struct LoginView: View { @Environment(\.colorScheme) var colorScheme @Environment(\.sceneWidth) var sceneWidth - @State var viewModel: LoginViewModel + let store: StoreOf var body: some View { ZStack { @@ -24,15 +24,15 @@ struct LoginView: View { Spacer() VStack(spacing: 20) { LoginButton(logo: Image("Google"), text: String(localized: "login_google_sign_in")) { - viewModel.send(.tapSignInButton(.google)) + store.send(.tapSignInButton(.google)) } LoginButton(logo: Image("Github"), text: String(localized: "login_github_sign_in")) { - viewModel.send(.tapSignInButton(.github)) + store.send(.tapSignInButton(.github)) } LoginButton(logo: Image("Apple"), text: String(localized: "login_apple_sign_in")) { - viewModel.send(.tapSignInButton(.apple)) + store.send(.tapSignInButton(.apple)) } } .padding(.bottom, 30) @@ -42,17 +42,17 @@ struct LoginView: View { .multilineTextAlignment(.center) .padding(.vertical) } - if viewModel.state.isLoading { + if store.isLoading { LoadingView() } } - .alert(viewModel.state.alertTitle, isPresented: Binding( - get: { viewModel.state.showAlert }, - set: { viewModel.send(.setAlert($0)) } + .alert(store.alertTitle, isPresented: Binding( + get: { store.showAlert }, + set: { store.send(.setAlert($0)) } )) { Button(String(localized: "common_close"), role: .cancel) { } } message: { - Text(viewModel.state.alertMessage) + Text(store.alertMessage) } } } diff --git a/Application/DevLogPresentation/Sources/Login/LoginViewModel.swift b/Application/DevLogPresentation/Sources/Login/LoginViewModel.swift deleted file mode 100644 index dffd2361..00000000 --- a/Application/DevLogPresentation/Sources/Login/LoginViewModel.swift +++ /dev/null @@ -1,121 +0,0 @@ -// -// LoginViewModel.swift -// DevLogPresentation -// -// Created by 최윤진 on 11/14/25. -// - -import Foundation -import DevLogDomain - -@Observable -final class LoginViewModel: Store { - struct State: Equatable { - var isLoading = false - var showAlert: Bool = false - var alertType: AlertType? - var alertTitle: String = "" - var alertMessage: String = "" - } - - enum Action { - case setAlert(Bool, AlertType? = nil) - case tapSignInButton(AuthProvider) - case setLoading(Bool) - } - - enum SideEffect { - case signIn(AuthProvider) - } - - enum AlertType { - case emailUnavailable - case error - } - - private let signInUseCase: SignInUseCase - private let loadingState = LoadingState() - - private(set) var state = State() - - init( - signInUseCase: SignInUseCase - ) { - self.signInUseCase = signInUseCase - } - - func reduce(with action: Action) -> [SideEffect] { - var state = self.state - var effects: [SideEffect] = [] - - switch action { - case .setAlert(let isPresented, let alertType): - setAlert(&state, isPresented: isPresented, alertType: alertType) - case .tapSignInButton(let authProvider): - effects = [.signIn(authProvider)] - case .setLoading(let value): - state.isLoading = value - } - - if self.state != state { self.state = state } - return effects - } - - func run(_ effect: SideEffect) { - switch effect { - case .signIn(let authProvider): - beginLoading(.immediate) - Task { - do { - defer { endLoading(.immediate) } - try await self.signInUseCase.execute(authProvider) - } catch { - if error.isSocialLoginCancelled { return } - send(.setAlert(true, alertType(for: error))) - } - } - } - } -} - -private extension LoginViewModel { - func setAlert( - _ state: inout State, - isPresented: Bool, - alertType: AlertType?, - ) { - switch alertType { - case .emailUnavailable: - state.alertTitle = String(localized: "login_alert_email_unavailable_title") - state.alertMessage = String(localized: "login_alert_email_unavailable_message") - case .error: - state.alertTitle = String(localized: "common_error_title") - state.alertMessage = String(localized: "common_error_message") - case .none: - state.alertTitle = "" - state.alertMessage = "" - } - state.showAlert = isPresented - state.alertType = alertType - } - - func alertType(for error: Error) -> AlertType { - if case AuthError.emailNotFound = error { - return .emailUnavailable - } - - return .error - } - - func beginLoading(_ mode: LoadingState.Mode) { - loadingState.begin(mode: mode) { [weak self] isLoading in - self?.send(.setLoading(isLoading)) - } - } - - func endLoading(_ mode: LoadingState.Mode) { - loadingState.end(mode: mode) { [weak self] isLoading in - self?.send(.setLoading(isLoading)) - } - } -} diff --git a/Application/DevLogPresentation/Sources/Main/MainViewModel.swift b/Application/DevLogPresentation/Sources/Main/MainViewModel.swift index 4679faaf..0347af30 100644 --- a/Application/DevLogPresentation/Sources/Main/MainViewModel.swift +++ b/Application/DevLogPresentation/Sources/Main/MainViewModel.swift @@ -12,7 +12,7 @@ import DevLogDomain import DevLogCore @Observable -final class MainViewModel: Store { +final class MainViewModel: StorePattern { struct State: Equatable { var unreadPushCount = 0 var showAlert = false diff --git a/Application/DevLogPresentation/Sources/Profile/ProfileViewModel.swift b/Application/DevLogPresentation/Sources/Profile/ProfileViewModel.swift index af46da00..3b49073c 100644 --- a/Application/DevLogPresentation/Sources/Profile/ProfileViewModel.swift +++ b/Application/DevLogPresentation/Sources/Profile/ProfileViewModel.swift @@ -11,7 +11,7 @@ import DevLogCore import DevLogDomain @Observable -final class ProfileViewModel: Store { +final class ProfileViewModel: StorePattern { struct State: Equatable { var name: String = "" var email: String = "" diff --git a/Application/DevLogPresentation/Sources/Protocol/Store.swift b/Application/DevLogPresentation/Sources/Protocol/StorePattern.swift similarity index 86% rename from Application/DevLogPresentation/Sources/Protocol/Store.swift rename to Application/DevLogPresentation/Sources/Protocol/StorePattern.swift index b99e183a..33893c29 100644 --- a/Application/DevLogPresentation/Sources/Protocol/Store.swift +++ b/Application/DevLogPresentation/Sources/Protocol/StorePattern.swift @@ -1,5 +1,5 @@ // -// Store.swift +// StorePattern.swift // DevLogPresentation // // Created by 최윤진 on 11/22/25. @@ -9,7 +9,7 @@ import Foundation import DevLogDomain @MainActor -public protocol Store: AnyObject { +public protocol StorePattern: AnyObject { associatedtype State associatedtype Action associatedtype SideEffect @@ -20,7 +20,7 @@ public protocol Store: AnyObject { func run(_ effect: SideEffect) } -extension Store { +extension StorePattern { func send(_ action: Action) { let sideEffects = reduce(with: action) sideEffects.forEach(run) diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewModel.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewModel.swift index 1a5e15da..a1604314 100644 --- a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewModel.swift +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewModel.swift @@ -11,7 +11,7 @@ import DevLogCore import DevLogDomain @Observable -final class PushNotificationListViewModel: Store { +final class PushNotificationListViewModel: StorePattern { struct State: Equatable { var notifications: [PushNotificationItem] = [] var showAlert: Bool = false diff --git a/Application/DevLogPresentation/Sources/Root/RootView.swift b/Application/DevLogPresentation/Sources/Root/RootView.swift index 3fe332cd..530a8ce7 100644 --- a/Application/DevLogPresentation/Sources/Root/RootView.swift +++ b/Application/DevLogPresentation/Sources/Root/RootView.swift @@ -7,6 +7,7 @@ import SwiftUI import Combine +import ComposableArchitecture import DevLogCore import DevLogDomain @@ -15,6 +16,7 @@ public struct RootView: View { @State var viewModel: RootViewModel @State private var selectedRoute: Route? @State private var selectedMainTab = MainTab.home + private let loginStore: StoreOf private let widgetURLTab: (URL) -> MainTab? private let windowEvent: TodoEditorWindowEvent private let pushNotificationTodoIdPublisher: AnyPublisher @@ -25,6 +27,7 @@ public struct RootView: View { networkConnectivityUseCase: ObserveNetworkConnectivityUseCase, systemThemeUseCase: ObserveSystemThemeUseCase, trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase, + signInUseCase: SignInUseCase, widgetURLTab: @escaping (URL) -> MainTab?, windowEvent: TodoEditorWindowEvent, pushNotificationTodoIdPublisher: AnyPublisher, @@ -36,6 +39,13 @@ public struct RootView: View { systemThemeUseCase: systemThemeUseCase, trackAnalyticsEventUseCase: trackAnalyticsEventUseCase )) + self.loginStore = Store( + initialState: LoginFeature.State() + ) { + LoginFeature() + } withDependencies: { + $0.signInUseCase = .live(signInUseCase) + } self.widgetURLTab = widgetURLTab self.windowEvent = windowEvent self.pushNotificationTodoIdPublisher = pushNotificationTodoIdPublisher @@ -53,9 +63,7 @@ public struct RootView: View { selectedTab: $selectedMainTab ) } else { - LoginView(viewModel: LoginViewModel( - signInUseCase: container.resolve(SignInUseCase.self)) - ) + LoginView(store: loginStore) } } } diff --git a/Application/DevLogPresentation/Sources/Root/RootViewModel.swift b/Application/DevLogPresentation/Sources/Root/RootViewModel.swift index 3c63f0da..28bacbff 100644 --- a/Application/DevLogPresentation/Sources/Root/RootViewModel.swift +++ b/Application/DevLogPresentation/Sources/Root/RootViewModel.swift @@ -12,7 +12,7 @@ import DevLogCore import DevLogDomain @Observable -final class RootViewModel: Store { +final class RootViewModel: StorePattern { struct State: Equatable { var showAlert: Bool = false var alertTitle: String = "" diff --git a/Application/DevLogPresentation/Sources/Search/SearchViewModel.swift b/Application/DevLogPresentation/Sources/Search/SearchViewModel.swift index 31532af3..dc14f08e 100644 --- a/Application/DevLogPresentation/Sources/Search/SearchViewModel.swift +++ b/Application/DevLogPresentation/Sources/Search/SearchViewModel.swift @@ -11,7 +11,7 @@ import DevLogCore import DevLogDomain @Observable -final class SearchViewModel: Store { +final class SearchViewModel: StorePattern { struct State: Equatable { var isLoading: Bool = false var isSearching: Bool = false diff --git a/Application/DevLogPresentation/Sources/Settings/AccountViewModel.swift b/Application/DevLogPresentation/Sources/Settings/AccountViewModel.swift index 75729a19..140897fa 100644 --- a/Application/DevLogPresentation/Sources/Settings/AccountViewModel.swift +++ b/Application/DevLogPresentation/Sources/Settings/AccountViewModel.swift @@ -9,7 +9,7 @@ import Foundation import DevLogDomain @Observable -final class AccountViewModel: Store { +final class AccountViewModel: StorePattern { struct State: Equatable { var currentProvider: AuthProvider? var connectedProviders: [AuthProvider] = [] diff --git a/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsViewModel.swift b/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsViewModel.swift index c73731e3..32572124 100644 --- a/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsViewModel.swift +++ b/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsViewModel.swift @@ -9,7 +9,7 @@ import Foundation import DevLogDomain @Observable -final class PushNotificationSettingsViewModel: Store { +final class PushNotificationSettingsViewModel: StorePattern { struct State: Equatable { var pushNotificationEnable: Bool = false var viewPushNotificationTime: Date = .init() diff --git a/Application/DevLogPresentation/Sources/Settings/SettingsViewModel.swift b/Application/DevLogPresentation/Sources/Settings/SettingsViewModel.swift index 6098c251..33bb1295 100644 --- a/Application/DevLogPresentation/Sources/Settings/SettingsViewModel.swift +++ b/Application/DevLogPresentation/Sources/Settings/SettingsViewModel.swift @@ -11,7 +11,7 @@ import DevLogCore import DevLogDomain @Observable -final class SettingsViewModel: Store { +final class SettingsViewModel: StorePattern { struct State: Equatable { var theme: SystemTheme = .automatic var dirSize: Int64 = 0 diff --git a/Application/DevLogPresentation/Sources/Today/TodayViewModel.swift b/Application/DevLogPresentation/Sources/Today/TodayViewModel.swift index ccaef572..6aeb8572 100644 --- a/Application/DevLogPresentation/Sources/Today/TodayViewModel.swift +++ b/Application/DevLogPresentation/Sources/Today/TodayViewModel.swift @@ -10,7 +10,7 @@ import DevLogCore import DevLogDomain @Observable -final class TodayViewModel: Store { +final class TodayViewModel: StorePattern { // TodayView 상단에서 사용자가 선택하는 요약 탭 범위. enum SectionScope: Hashable, CaseIterable { case all diff --git a/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift b/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift new file mode 100644 index 00000000..324c135f --- /dev/null +++ b/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift @@ -0,0 +1,203 @@ +// +// LoginFeatureTests.swift +// DevLogPresentationTests +// +// Created by opfic on 6/5/26. +// + +import Testing +import ComposableArchitecture +import Foundation +import DevLogDomain +@testable import DevLogPresentation + +@MainActor +struct LoginFeatureTests { + @Test("로그인 버튼을 누르면 선택한 인증 제공자로 로그인 유스케이스가 호출된다") + func 로그인_버튼을_누르면_선택한_인증_제공자로_로그인_유스케이스가_호출된다() async { + let spy = SignInUseCaseSpy() + let driver = LoginTestDriver(useCase: spy) + + driver.tapSignInButton(.github) + + await waitUntil { + spy.calledProviders == [.github] + } + + #expect(spy.calledProviders == [.github]) + } + + @Test("로그인 요청 중에는 로딩 상태가 켜지고 요청이 끝나면 꺼진다") + func 로그인_요청_중에는_로딩_상태가_켜지고_요청이_끝나면_꺼진다() async { + let spy = SignInUseCaseSpy() + spy.shouldSuspend = true + let driver = LoginTestDriver(useCase: spy) + + driver.tapSignInButton(.google) + + await waitUntil { + driver.isLoading + } + + #expect(driver.isLoading) + + spy.resume() + + await waitUntil { + !driver.isLoading + } + + #expect(!driver.isLoading) + } + + @Test("로그인 실패 후에도 로딩 상태가 꺼진다") + func 로그인_실패_후에도_로딩_상태가_꺼진다() async { + let spy = SignInUseCaseSpy() + spy.shouldSuspend = true + spy.error = AuthError.unsupportedProvider + let driver = LoginTestDriver(useCase: spy) + + driver.tapSignInButton(.apple) + + await waitUntil { + driver.isLoading + } + + #expect(driver.isLoading) + + spy.resume() + + await waitUntil { + !driver.isLoading && driver.showAlert + } + + #expect(!driver.isLoading) + } + + @Test("이메일을 가져오지 못하면 이메일 없음 알림을 표시한다") + func 이메일을_가져오지_못하면_이메일_없음_알림을_표시한다() async { + let spy = SignInUseCaseSpy() + spy.error = AuthError.emailNotFound + let driver = LoginTestDriver(useCase: spy) + + driver.tapSignInButton(.google) + + await waitUntil { + driver.showAlert + } + + #expect(driver.alertKind == .emailUnavailable) + #expect(driver.alertTitle == String(localized: "login_alert_email_unavailable_title")) + #expect(driver.alertMessage == String(localized: "login_alert_email_unavailable_message")) + } + + @Test("일반 로그인 에러가 발생하면 공통 에러 알림을 표시한다") + func 일반_로그인_에러가_발생하면_공통_에러_알림을_표시한다() async { + let spy = SignInUseCaseSpy() + spy.error = AuthError.unsupportedProvider + let driver = LoginTestDriver(useCase: spy) + + driver.tapSignInButton(.apple) + + await waitUntil { + driver.showAlert + } + + #expect(driver.alertKind == .error) + #expect(driver.alertTitle == String(localized: "common_error_title")) + #expect(driver.alertMessage == String(localized: "common_error_message")) + } + + @Test("소셜 로그인 취소 에러가 발생하면 알림을 표시하지 않는다") + func 소셜_로그인_취소_에러가_발생하면_알림을_표시하지_않는다() async { + let spy = SignInUseCaseSpy() + spy.error = NSError(domain: "com.google.GIDSignIn", code: -5) + let driver = LoginTestDriver(useCase: spy) + + driver.tapSignInButton(.google) + + await waitUntil { + spy.calledProviders == [.google] && !driver.isLoading + } + + #expect(!driver.showAlert) + #expect(driver.alertKind == nil) + #expect(driver.alertTitle.isEmpty) + #expect(driver.alertMessage.isEmpty) + } + + @Test("알림을 닫으면 알림 상태와 문구가 초기화된다") + func 알림을_닫으면_알림_상태와_문구가_초기화된다() async { + let spy = SignInUseCaseSpy() + spy.error = AuthError.emailNotFound + let driver = LoginTestDriver(useCase: spy) + + driver.tapSignInButton(.google) + + await waitUntil { + driver.showAlert + } + + driver.setAlert(false) + + #expect(!driver.showAlert) + #expect(driver.alertKind == nil) + #expect(driver.alertTitle.isEmpty) + #expect(driver.alertMessage.isEmpty) + } +} + +private enum LoginAlertKind: Equatable { + case emailUnavailable + case error +} + +@MainActor +private struct LoginTestDriver { + private let feature: StoreOf + + var isLoading: Bool { + feature.state.isLoading + } + + var showAlert: Bool { + feature.state.showAlert + } + + var alertKind: LoginAlertKind? { + switch feature.state.alertType { + case .emailUnavailable: + return .emailUnavailable + case .error: + return .error + case .none: + return nil + } + } + + var alertTitle: String { + feature.state.alertTitle + } + + var alertMessage: String { + feature.state.alertMessage + } + + init(useCase: SignInUseCase) { + feature = ComposableArchitecture.Store( + initialState: LoginFeature.State() + ) { + LoginFeature() + } withDependencies: { + $0.signInUseCase = .live(useCase) + } + } + + func tapSignInButton(_ provider: AuthProvider) { + feature.send(.tapSignInButton(provider)) + } + + func setAlert(_ isPresented: Bool) { + feature.send(.setAlert(isPresented)) + } +} diff --git a/Application/DevLogPresentation/Tests/Support/TestSupport.swift b/Application/DevLogPresentation/Tests/Support/TestSupport.swift index 970ab039..4d6bf319 100644 --- a/Application/DevLogPresentation/Tests/Support/TestSupport.swift +++ b/Application/DevLogPresentation/Tests/Support/TestSupport.swift @@ -50,6 +50,42 @@ final class FetchPushNotificationsUseCaseSpy: FetchPushNotificationsUseCase { } } +final class SignInUseCaseSpy: SignInUseCase { + var error: Error? + var shouldSuspend = false + private(set) var calledProviders: [AuthProvider] = [] + private var continuation: CheckedContinuation? + private var shouldResume = false + + func execute(_ provider: AuthProvider) async throws { + calledProviders.append(provider) + + if shouldSuspend { + await withCheckedContinuation { continuation in + if shouldResume { + shouldResume = false + continuation.resume() + } else { + self.continuation = continuation + } + } + } + + if let error { + throw error + } + } + + func resume() { + guard let continuation else { + shouldResume = true + return + } + self.continuation = nil + continuation.resume() + } +} + final class DeletePushNotificationUseCaseSpy: DeletePushNotificationUseCase { private(set) var calledNotificationIds: [String] = [] diff --git a/README.md b/README.md index 0942f268..464d0bd4 100644 --- a/README.md +++ b/README.md @@ -67,11 +67,11 @@ MVVM을 기반으로 하되, ViewModel 상태 관리에는 MVI 형태의 단방 - Store Protocol + StorePattern Protocol - Store 프로토콜 + StorePattern 프로토콜 diff --git a/Widget/DevLogWidgetCore/Sources/Common/UserDefaultsDependency.swift b/Widget/DevLogWidgetCore/Sources/Common/UserDefaultsDependency.swift new file mode 100644 index 00000000..9de0492b --- /dev/null +++ b/Widget/DevLogWidgetCore/Sources/Common/UserDefaultsDependency.swift @@ -0,0 +1,28 @@ +// +// UserDefaultsDependency.swift +// DevLogWidgetCore +// +// Created by opfic on 6/5/26. +// + +import Foundation + +struct UserDefaultsDependency: @unchecked Sendable { + private let value: UserDefaults + + init(value: UserDefaults) { + self.value = value + } + + func data(forKey key: String) -> Data? { + value.data(forKey: key) + } + + func set(_ value: Any?, forKey key: String) { + self.value.set(value, forKey: key) + } + + func removeObject(forKey key: String) { + value.removeObject(forKey: key) + } +} diff --git a/Widget/DevLogWidgetCore/Sources/Common/WidgetSharedDefaultsStore.swift b/Widget/DevLogWidgetCore/Sources/Common/WidgetSharedDefaultsStore.swift index 72b63765..89f20d74 100644 --- a/Widget/DevLogWidgetCore/Sources/Common/WidgetSharedDefaultsStore.swift +++ b/Widget/DevLogWidgetCore/Sources/Common/WidgetSharedDefaultsStore.swift @@ -7,11 +7,11 @@ import Foundation -public final class WidgetSharedDefaultsStore { - private let userDefaults: UserDefaults +public final class WidgetSharedDefaultsStore: Sendable { + private let userDefaults: UserDefaultsDependency public init(userDefaults: UserDefaults = UserDefaults(suiteName: WidgetAppGroup.identifier) ?? .standard) { - self.userDefaults = userDefaults + self.userDefaults = UserDefaultsDependency(value: userDefaults) } func data(forKey key: String) -> Data? { diff --git a/Widget/DevLogWidgetCore/Sources/Common/WidgetSnapshotStore.swift b/Widget/DevLogWidgetCore/Sources/Common/WidgetSnapshotStore.swift index 727e2bc8..9801befd 100644 --- a/Widget/DevLogWidgetCore/Sources/Common/WidgetSnapshotStore.swift +++ b/Widget/DevLogWidgetCore/Sources/Common/WidgetSnapshotStore.swift @@ -7,7 +7,7 @@ import Foundation -public final class WidgetSnapshotStore { +public final class WidgetSnapshotStore: Sendable { private let store: WidgetSharedDefaultsStore private let encoder = JSONEncoder() private let decoder = JSONDecoder() diff --git a/Widget/DevLogWidgetCore/Sources/Heatmap/HeatmapWidgetSnapshotFactory.swift b/Widget/DevLogWidgetCore/Sources/Heatmap/HeatmapWidgetSnapshotFactory.swift index 5db55edf..eda3c1e1 100644 --- a/Widget/DevLogWidgetCore/Sources/Heatmap/HeatmapWidgetSnapshotFactory.swift +++ b/Widget/DevLogWidgetCore/Sources/Heatmap/HeatmapWidgetSnapshotFactory.swift @@ -8,8 +8,8 @@ import Foundation import DevLogCore -public struct HeatmapWidgetSnapshotFactory { - fileprivate struct DailyCounts { +public struct HeatmapWidgetSnapshotFactory: Sendable { + fileprivate struct DailyCounts: Sendable { var createdCount = 0 var completedCount = 0 var deletedCount = 0 diff --git a/Widget/DevLogWidgetCore/Sources/Today/TodayWidgetSnapshotFactory.swift b/Widget/DevLogWidgetCore/Sources/Today/TodayWidgetSnapshotFactory.swift index efa0a212..351543f8 100644 --- a/Widget/DevLogWidgetCore/Sources/Today/TodayWidgetSnapshotFactory.swift +++ b/Widget/DevLogWidgetCore/Sources/Today/TodayWidgetSnapshotFactory.swift @@ -8,8 +8,8 @@ import Foundation import DevLogCore -public struct TodayWidgetSnapshotFactory { - private enum SectionCategory: String, CaseIterable { +public struct TodayWidgetSnapshotFactory: Sendable { + private enum SectionCategory: String, CaseIterable, Sendable { case focused case overdue case dueSoon @@ -17,7 +17,7 @@ public struct TodayWidgetSnapshotFactory { case unscheduled } - private struct SectionCollection { + private struct SectionCollection: Sendable { var focused = [TodayWidgetTodoItem]() var overdue = [TodayWidgetTodoItem]() var dueSoon = [TodayWidgetTodoItem]() @@ -40,7 +40,7 @@ public struct TodayWidgetSnapshotFactory { } } - private struct TodayWidgetTodoItem { + private struct TodayWidgetTodoItem: Sendable { let id: String let number: Int let title: String