Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 30 additions & 30 deletions MovieBooking/App/Reducer/AppReducer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@ struct AppReducer {
enum State {
case splash(SplashFeature.State)
case auth(AuthCoordinator.State)
case mainTab(MainTabReducer.State)


case mainTab(MainTabFeature.State)

init() {
self = .splash(.init())
Expand All @@ -35,20 +33,20 @@ struct AppReducer {
case presentMain
}


@CasePathable
enum ScopeAction {
case splash(SplashFeature.Action)
case auth(AuthCoordinator.Action)
case mainTab(MainTabReducer.Action)
case mainTab(MainTabFeature.Action)
}


@Dependency(\.continuousClock) var clock

public var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .view(let viewAction):
case .view(let viewAction):
return handleViewAction(&state, action: viewAction)

case .scope(let scopeAction):
Expand All @@ -62,7 +60,7 @@ struct AppReducer {
AuthCoordinator()
}
.ifCaseLet(\.mainTab, action: \.scope.mainTab) {
MainTabReducer()
MainTabFeature()
}
}
}
Expand All @@ -73,15 +71,14 @@ extension AppReducer {
action: View
) -> Effect<Action> {
switch action {
// MARK: - 둜그인 ν™”λ©΄μœΌλ‘œ
case .presentAuth:
state = .auth(.init())
return .none
// MARK: - 둜그인 ν™”λ©΄μœΌλ‘œ
case .presentAuth:
state = .auth(.init())
return .none

case .presentMain:
case .presentMain:
state = .mainTab(.init())
return .none

return .send(.scope(.mainTab(.scope(.movieList(.fetchMovie)))))
}
}

Expand All @@ -92,23 +89,26 @@ extension AppReducer {
) -> Effect<Action> {
switch action {
case .splash(.navigation(.presentLogin)):
return .run { send in
try await clock.sleep(for: .seconds(1))
await send(.view(.presentAuth))
}

case .splash(.navigation(.presentMain)):
return .run { send in
try await clock.sleep(for: .seconds(1))
await send(.view(.presentMain))
}


case .auth(.navigation(.presentMain)):
return .send(.view(.presentMain), animation: .easeIn)
return .run { send in
try await clock.sleep(for: .seconds(1))
await send(.view(.presentAuth))
}

case .splash(.navigation(.presentMain)):
return .run { send in
try await clock.sleep(for: .seconds(1))
await send(.view(.presentMain))
}

case .auth(.navigation(.presentMain)):
return .send(.view(.presentMain), animation: .easeIn)

case .mainTab(.navigation(.backToLogin)):
return .run { send in
await send(.view(.presentAuth), animation: .easeIn)
}
default: return .none

default:
return .none
}
}
}
32 changes: 15 additions & 17 deletions MovieBooking/App/View/AppView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,25 @@ import SwiftUI
import ComposableArchitecture

struct AppView: View {
@Perception.Bindable var store: StoreOf<AppReducer>
var store: StoreOf<AppReducer>

var body: some View {
WithPerceptionTracking {
SwitchStore(store) { state in
switch state {
case .splash:
if let store = store.scope(state: \.splash, action: \.scope.splash) {
SplashView(store: store)
}
SwitchStore(store) { state in
switch state {
case .splash:
if let store = store.scope(state: \.splash, action: \.scope.splash) {
SplashView(store: store)
}

case .auth:
if let store = store.scope(state: \.auth, action: \.scope.auth) {
AuthCoordinatorView(store: store)
}
case .auth:
if let store = store.scope(state: \.auth, action: \.scope.auth) {
AuthCoordinatorView(store: store)
}

case .mainTab:
if let store = store.scope(state: \.mainTab, action: \.scope.mainTab) {
MainTabView(store: store)
}
}
case .mainTab:
if let store = store.scope(state: \.mainTab, action: \.scope.mainTab) {
MainTabView(store: store)
}
}
}
}
Expand Down
1 change: 1 addition & 0 deletions MovieBooking/DesignSystem/Color/Colors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ public extension ShapeStyle where Self == Color {
static var indigo500: Color { .init(hex: "6C4EFF") }
static var statusError: Color { .init(hex: "D32F2F")}
static var gray500: Color { .init(hex: "9E9E9E")}
static var lavenderPurple: Color { .init(hex: "C4B5FD")}
}
43 changes: 23 additions & 20 deletions MovieBooking/Domain/Entity/Error/Common/CommonError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,39 +7,42 @@

import Foundation

/// 곡톡 μ—λŸ¬ (λ„€νŠΈμ›Œν¬ λ“± μ „μ—­μ—μ„œ κ³΅μœ λ˜λŠ” κ²½μš°μ—λ§Œ μ‚¬μš©)
/// 곡톡 μ—λŸ¬ (정말 κ³΅ν†΅μœΌλ‘œ μ‚¬μš©λ˜λŠ” μ—λŸ¬λ§Œ)
///
/// ⚠️ 주의: νŠΉμ • 도메인에 μ†ν•˜λŠ” μ—λŸ¬λŠ” 각 도메인 μ—λŸ¬λ₯Ό μ‚¬μš©ν•˜μ„Έμš”
/// - λ„€νŠΈμ›Œν¬ μ—λŸ¬ β†’ NetworkError
/// - 데이터 μ—λŸ¬ β†’ DataError
/// - μœ νš¨μ„± 검사 μ—λŸ¬ β†’ ValidationError
/// - μ‹œμŠ€ν…œ μ—λŸ¬ β†’ SystemError
public enum CommonError: Error, Equatable {
case networkUnavailable
case requestTimeout
case serverError(statusCode: Int)
case rateLimited(retryAfter: TimeInterval?)
/// μ•Œ 수 μ—†λŠ” μ—λŸ¬ (μ΅œν›„μ˜ μˆ˜λ‹¨)
case unknown(message: String? = nil)

/// μ·¨μ†Œλœ μž‘μ—…
case cancelled

/// μ§€μ›λ˜μ§€ μ•ŠλŠ” κΈ°λŠ₯
case unsupported(feature: String? = nil)
}

// MARK: - μ‚¬μš©μž λ©”μ‹œμ§€ (UI friendly)
extension CommonError: LocalizedError {
public var errorDescription: String? {
switch self {
case .networkUnavailable:
return "λ„€νŠΈμ›Œν¬ 연결을 ν™•μΈν•΄μ£Όμ„Έμš”."

case .requestTimeout:
return "μš”μ²­ μ‹œκ°„μ΄ μ΄ˆκ³Όλ˜μ—ˆμŠ΅λ‹ˆλ‹€. λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”."

case .serverError(let statusCode):
return "μ„œλ²„ 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€(μ½”λ“œ: \(statusCode)). μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”."

case .rateLimited(let retryAfter):
if let seconds = retryAfter {
return "μš”μ²­μ΄ λ„ˆλ¬΄ λ§ŽμŠ΅λ‹ˆλ‹€. \(Int(seconds))초 ν›„ λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”."
}
return "μš”μ²­μ΄ λ„ˆλ¬΄ λ§ŽμŠ΅λ‹ˆλ‹€. μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”."

case .unknown(let message):
if let message = message {
return "μ•Œ 수 μ—†λŠ” 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€: \(message)"
}
return "μ•Œ 수 μ—†λŠ” 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€. μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”."

case .cancelled:
return "μž‘μ—…μ΄ μ·¨μ†Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€."

case .unsupported(let feature):
if let feature = feature {
return "\(feature) κΈ°λŠ₯은 μ§€μ›λ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."
}
return "μ§€μ›λ˜μ§€ μ•ŠλŠ” κΈ°λŠ₯μž…λ‹ˆλ‹€."
}
}
}
42 changes: 42 additions & 0 deletions MovieBooking/Domain/Entity/Error/Data/DataError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//
// DataError.swift
// MovieBooking
//
// Created by Wonji Suh on 10/17/25.
//

import Foundation

/// 데이터 κ΄€λ ¨ μ—λŸ¬
public enum DataError: Error, Equatable {
case notFound(resource: String? = nil)
case dataCorrupted
case serializationFailed
case decodingFailed
case encodingFailed
}

// MARK: - μ‚¬μš©μž λ©”μ‹œμ§€ (UI friendly)
extension DataError: LocalizedError {
public var errorDescription: String? {
switch self {
case .notFound(let resource):
if let resource = resource {
return "\(resource)을(λ₯Ό) 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."
}
return "μš”μ²­ν•œ 데이터λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."

case .dataCorrupted:
return "데이터가 μ†μƒλ˜μ—ˆμŠ΅λ‹ˆλ‹€. λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”."

case .serializationFailed:
return "데이터 λ³€ν™˜ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€."

case .decodingFailed:
return "데이터 처리 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€."

case .encodingFailed:
return "데이터 μ €μž₯ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€."
}
}
}
51 changes: 51 additions & 0 deletions MovieBooking/Domain/Entity/Error/System/SystemError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//
// SystemError.swift
// MovieBooking
//
// Created by Wonji Suh on 10/17/25.
//

import Foundation

/// μ‹œμŠ€ν…œ κ΄€λ ¨ μ—λŸ¬
public enum SystemError: Error, Equatable {
case dependencyUnavailable(service: String? = nil)
case configurationError
case insufficientPermissions(permission: String? = nil)
case resourceExhausted(resource: String? = nil)
case serviceUnavailable(service: String? = nil)
}

// MARK: - μ‚¬μš©μž λ©”μ‹œμ§€ (UI friendly)
extension SystemError: LocalizedError {
public var errorDescription: String? {
switch self {
case .dependencyUnavailable(let service):
if let service = service {
return "\(service) μ„œλΉ„μŠ€λ₯Ό μ‚¬μš©ν•  수 μ—†μŠ΅λ‹ˆλ‹€. μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”."
}
return "μ„œλΉ„μŠ€λ₯Ό μ‚¬μš©ν•  수 μ—†μŠ΅λ‹ˆλ‹€. μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”."

case .configurationError:
return "μ•± 섀정에 λ¬Έμ œκ°€ μžˆμŠ΅λ‹ˆλ‹€. 앱을 μž¬μ‹œμž‘ν•΄μ£Όμ„Έμš”."

case .insufficientPermissions(let permission):
if let permission = permission {
return "\(permission) κΆŒν•œμ΄ ν•„μš”ν•©λ‹ˆλ‹€. μ„€μ •μ—μ„œ κΆŒν•œμ„ ν—ˆμš©ν•΄μ£Όμ„Έμš”."
}
return "κΆŒν•œμ΄ ν•„μš”ν•©λ‹ˆλ‹€. μ„€μ •μ—μ„œ κΆŒν•œμ„ ν—ˆμš©ν•΄μ£Όμ„Έμš”."

case .resourceExhausted(let resource):
if let resource = resource {
return "\(resource) λ¦¬μ†ŒμŠ€κ°€ λΆ€μ‘±ν•©λ‹ˆλ‹€. μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”."
}
return "μ‹œμŠ€ν…œ λ¦¬μ†ŒμŠ€κ°€ λΆ€μ‘±ν•©λ‹ˆλ‹€. μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”."

case .serviceUnavailable(let service):
if let service = service {
return "\(service) μ„œλΉ„μŠ€κ°€ μΌμ‹œμ μœΌλ‘œ μ‚¬μš©ν•  수 μ—†μŠ΅λ‹ˆλ‹€."
}
return "μ„œλΉ„μŠ€κ°€ μΌμ‹œμ μœΌλ‘œ μ‚¬μš©ν•  수 μ—†μŠ΅λ‹ˆλ‹€."
}
}
}
74 changes: 74 additions & 0 deletions MovieBooking/Domain/Entity/Error/Validation/ValidationError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//
// ValidationError.swift
// MovieBooking
//
// Created by Wonji Suh on 10/17/25.
//

import Foundation

/// μœ νš¨μ„± 검사 κ΄€λ ¨ μ—λŸ¬
public enum ValidationError: Error, Equatable {
case validationFailed(fields: [String])
case invalidInput(field: String, reason: String? = nil)
case requiredFieldMissing(field: String)
case invalidFormat(field: String, expectedFormat: String? = nil)
case outOfRange(field: String, min: Any? = nil, max: Any? = nil)

// Equatable κ΅¬ν˜„μ„ μœ„ν•΄ Any λŒ€μ‹  String으둜 처리
public static func == (lhs: ValidationError, rhs: ValidationError) -> Bool {
switch (lhs, rhs) {
case (.validationFailed(let lFields), .validationFailed(let rFields)):
return lFields == rFields
case (.invalidInput(let lField, let lReason), .invalidInput(let rField, let rReason)):
return lField == rField && lReason == rReason
case (.requiredFieldMissing(let lField), .requiredFieldMissing(let rField)):
return lField == rField
case (.invalidFormat(let lField, let lFormat), .invalidFormat(let rField, let rFormat)):
return lField == rField && lFormat == rFormat
case (.outOfRange(let lField, _, _), .outOfRange(let rField, _, _)):
return lField == rField // min, maxλŠ” λΉ„κ΅μ—μ„œ μ œμ™Έ
default:
return false
}
}
}

// MARK: - μ‚¬μš©μž λ©”μ‹œμ§€ (UI friendly)
extension ValidationError: LocalizedError {
public var errorDescription: String? {
switch self {
case .validationFailed(let fields):
if fields.isEmpty {
return "μž…λ ₯값이 μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."
}
return "\(fields.joined(separator: ", ")) ν•­λͺ©μ„ ν™•μΈν•΄μ£Όμ„Έμš”."

case .invalidInput(let field, let reason):
if let reason = reason {
return "\(field): \(reason)"
}
return "\(field) ν•­λͺ©μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."

case .requiredFieldMissing(let field):
return "\(field)은(λŠ”) ν•„μˆ˜ μž…λ ₯ ν•­λͺ©μž…λ‹ˆλ‹€."

case .invalidFormat(let field, let expectedFormat):
if let format = expectedFormat {
return "\(field)의 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. (\(format) ν˜•μ‹μœΌλ‘œ μž…λ ₯ν•΄μ£Όμ„Έμš”)"
}
return "\(field)의 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."

case .outOfRange(let field, let min, let max):
var message = "\(field)의 값이 λ²”μœ„λ₯Ό λ²—μ–΄λ‚¬μŠ΅λ‹ˆλ‹€."
if let min = min, let max = max {
message += " (\(min) ~ \(max) μ‚¬μ΄μ˜ 값을 μž…λ ₯ν•΄μ£Όμ„Έμš”)"
} else if let min = min {
message += " (\(min) μ΄μƒμ˜ 값을 μž…λ ₯ν•΄μ£Όμ„Έμš”)"
} else if let max = max {
message += " (\(max) μ΄ν•˜μ˜ 값을 μž…λ ₯ν•΄μ£Όμ„Έμš”)"
}
return message
}
}
}
Loading
Loading