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
4 changes: 2 additions & 2 deletions SampoomManagement.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = B9PUAVBBKX;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
Expand Down Expand Up @@ -317,7 +317,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = B9PUAVBBKX;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
Expand Down
6 changes: 6 additions & 0 deletions SampoomManagement/App/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,23 @@
//

import SwiftUI
import Toast

struct RootView: View {
let dependencies: AppDependencies

@StateObject private var loginViewModel: LoginViewModel
@StateObject private var signUpViewModel: SignUpViewModel
@ObservedObject private var authViewModel: AuthViewModel
@ObservedObject private var globalMessageHandler: GlobalMessageHandler
@State private var showSignUp: Bool = false

init(dependencies: AppDependencies) {
self.dependencies = dependencies
_loginViewModel = StateObject(wrappedValue: dependencies.makeLoginViewModel())
_signUpViewModel = StateObject(wrappedValue: dependencies.makeSignUpViewModel())
self.authViewModel = dependencies.authViewModel
self.globalMessageHandler = dependencies.globalMessageHandler
}

var body: some View {
Expand Down Expand Up @@ -70,6 +73,9 @@ struct RootView: View {
}
}
}

// Toast 컨테이너 (앱 최상단에 배치)
ToastContainer(globalMessageHandler: globalMessageHandler)
}
.onChange(of: authViewModel.shouldNavigateToLogin) { _, shouldNavigate in
if shouldNavigate {
Expand Down
21 changes: 17 additions & 4 deletions SampoomManagement/Core/DI/AppDependencies.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import SwiftUI
class AppDependencies {
// MARK: - Core
let networkManager: NetworkManager
let globalMessageHandler: GlobalMessageHandler

// MARK: - Auth
let authPreferences: AuthPreferences
Expand Down Expand Up @@ -65,6 +66,9 @@ class AppDependencies {
let cancelOrderUseCase: CancelOrderUseCase

init() {
// Global Message Handler
globalMessageHandler = GlobalMessageHandler.shared

// Auth Preferences
authPreferences = AuthPreferences()

Expand Down Expand Up @@ -126,7 +130,7 @@ class AppDependencies {

// Order
orderAPI = OrderAPI(networkManager: networkManager)
orderRepository = OrderRepositoryImpl(api: orderAPI)
orderRepository = OrderRepositoryImpl(api: orderAPI, preferences: authPreferences)
getOrderUseCase = GetOrderUseCase(repository: orderRepository)
createOrderUseCase = CreateOrderUseCase(repository: orderRepository)
getOrderDetailUseCase = GetOrderDetailUseCase(repository: orderRepository)
Expand Down Expand Up @@ -158,7 +162,11 @@ class AppDependencies {
}

func makePartDetailViewModel() -> PartDetailViewModel {
return PartDetailViewModel(addOutboundUseCase: addOutboundUseCase, addCartUseCase: addCartUseCase)
return PartDetailViewModel(
addOutboundUseCase: addOutboundUseCase,
addCartUseCase: addCartUseCase,
globalMessageHandler: globalMessageHandler
)
}

func makeSearchViewModel() -> SearchViewModel {
Expand All @@ -182,19 +190,24 @@ class AppDependencies {
updateCartQuantityUseCase: updateCartQuantityUseCase,
deleteCartUseCase: deleteCartUseCase,
deleteAllCartUseCase: deleteAllCartUseCase,
createOrderUseCase: createOrderUseCase
createOrderUseCase: createOrderUseCase,
globalMessageHandler: globalMessageHandler
)
}

func makeOrderListViewModel() -> OrderListViewModel {
return OrderListViewModel(getOrderUseCase: getOrderUseCase)
return OrderListViewModel(
getOrderUseCase: getOrderUseCase,
globalMessageHandler: globalMessageHandler
)
}

func makeOrderDetailViewModel(orderId: Int) -> OrderDetailViewModel {
return OrderDetailViewModel(
getOrderDetailUseCase: getOrderDetailUseCase,
cancelOrderUseCase: cancelOrderUseCase,
receiveOrderUseCase: receiveOrderUseCase,
globalMessageHandler: globalMessageHandler,
orderId: orderId
)
}
Expand Down
8 changes: 7 additions & 1 deletion SampoomManagement/Core/Network/APIResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// Created by 채상윤 on 9/29/25.
//

import Foundation
@preconcurrency import Foundation

struct APIResponse<T: Codable>: Codable {
let status: Int
Expand All @@ -16,3 +16,9 @@ struct APIResponse<T: Codable>: Codable {

struct EmptyResponse: Codable {
}

/// API 에러 응답 (안드로이드와 동일한 구조)
struct ApiErrorResponse: Codable {
let code: Int?
let message: String?
}
26 changes: 24 additions & 2 deletions SampoomManagement/Core/Network/NetworkError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ enum NetworkError: Error, LocalizedError {
case decodingError(Error)
case invalidURL
case noData
case serverError(Int)
case serverError(Int, message: String?)
case invalidParameters
case unauthorized

Expand All @@ -21,19 +21,41 @@ enum NetworkError: Error, LocalizedError {
case .networkError(let error):
return "네트워크 오류: \(error.localizedDescription)"
case .decodingError(let error):
// 디코딩 에러의 실제 메시지 추출 시도
if let decodingError = error as? DecodingError {
return decodingErrorMessage(decodingError)
}
return "데이터 파싱 오류: \(error.localizedDescription)"
case .invalidURL:
return "잘못된 URL"
case .noData:
return "데이터가 없습니다"
case .serverError(let code):
case .serverError(let code, let message):
if let message = message, !message.isEmpty {
return message
}
return "서버 오류: \(code)"
case .invalidParameters:
return "잘못된 매개변수입니다"
case .unauthorized:
return "인증이 필요합니다"
}
}

private func decodingErrorMessage(_ error: DecodingError) -> String {
switch error {
case .dataCorrupted(let context):
return "데이터 형식 오류: \(context.debugDescription)"
case .keyNotFound(let key, _):
return "필수 데이터 누락: \(key.stringValue)"
case .typeMismatch(let type, _):
return "데이터 형식 불일치: \(type)"
case .valueNotFound(let type, _):
return "필수 값 누락: \(type)"
@unknown default:
return "데이터 파싱 오류"
}
}
}

enum AuthError: Error, LocalizedError {
Expand Down
143 changes: 133 additions & 10 deletions SampoomManagement/Core/Network/NetworkManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// Created by 채상윤 on 9/29/25.
//

import Foundation
@preconcurrency import Foundation
import Alamofire

class NetworkManager {
Expand All @@ -18,6 +18,27 @@ class NetworkManager {
self.session = Session(configuration: configuration, interceptor: authRequestInterceptor)
}

// 디코딩을 main actor 컨텍스트에서 수행
// Swift 6 strict concurrency: 타입이 actor와 격리되지 않았음을 보장하기 위해
// @MainActor 함수에서 직접 디코딩
@MainActor private func decodeApiErrorResponse(from data: Data) -> ApiErrorResponse? {
let decoder = JSONDecoder()
// @MainActor 함수 내에서는 Codable 디코딩이 actor 격리 문제 없이 수행됨
return try? decoder.decode(ApiErrorResponse.self, from: data)
}

@MainActor private func decodeApiResponse<T: Codable>(_ type: T.Type, from data: Data) throws -> APIResponse<T> {
let decoder = JSONDecoder()
// @MainActor 함수 내에서는 Codable 디코딩이 actor 격리 문제 없이 수행됨
return try decoder.decode(APIResponse<T>.self, from: data)
}

@MainActor private func decodeEmptyApiResponse(from data: Data) -> APIResponse<EmptyResponse>? {
let decoder = JSONDecoder()
// @MainActor 함수 내에서는 Codable 디코딩이 actor 격리 문제 없이 수행됨
return try? decoder.decode(APIResponse<EmptyResponse>.self, from: data)
}

func request<T: Codable>(
endpoint: String,
method: HTTPMethod = .get,
Expand All @@ -36,17 +57,118 @@ class NetworkManager {
)

dataRequest.responseData { response in
// HTTP 상태 코드가 에러 범위(4xx, 5xx)인 경우 응답 body를 파싱 시도
if let httpResponse = response.response,
httpResponse.statusCode >= 400,
let data = response.data {

Task { @MainActor in
// 1. ApiErrorResponse 형식으로 파싱 시도 (안드로이드와 동일)
if let errorResponse = self.decodeApiErrorResponse(from: data) {
let errorCode = errorResponse.code ?? httpResponse.statusCode
continuation.resume(throwing: NetworkError.serverError(errorCode, message: errorResponse.message))
return
}

// 2. APIResponse 형식으로 파싱 시도 (기존 방식)
if let apiResponse = self.decodeEmptyApiResponse(from: data) {
continuation.resume(throwing: NetworkError.serverError(httpResponse.statusCode, message: apiResponse.message))
return
}

// 3. 파싱 실패 시 기본 에러
continuation.resume(throwing: NetworkError.serverError(httpResponse.statusCode, message: nil))
}
return
}

switch response.result {
case .success(let data):
do {
print("NetworkManager - Raw response data: \(String(data: data, encoding: .utf8) ?? "Unable to decode")")
let decoder = JSONDecoder()
let apiResponse = try decoder.decode(APIResponse<T>.self, from: data)
print("NetworkManager - Decoded response: \(apiResponse)")
continuation.resume(returning: apiResponse)
} catch {
print("NetworkManager - Decoding error: \(error)")
continuation.resume(throwing: NetworkError.decodingError(error))
Task { @MainActor in
do {
print("NetworkManager - Raw response data: \(String(data: data, encoding: .utf8) ?? "Unable to decode")")
let apiResponse = try self.decodeApiResponse(T.self, from: data)
print("NetworkManager - Decoded response: \(apiResponse)")
continuation.resume(returning: apiResponse)
} catch {
print("NetworkManager - Decoding error: \(error)")
continuation.resume(throwing: NetworkError.decodingError(error))
}
}
case .failure(let error):
print("NetworkManager - Network error: \(error)")
continuation.resume(throwing: NetworkError.networkError(error))
}
}
}
}, onCancel: {
})
}

func request<T: Codable, E: Encodable>(
endpoint: String,
method: HTTPMethod = .get,
body: E? = nil,
responseType: T.Type
) async throws -> APIResponse<T> {
let url = baseURL + endpoint

return try await withTaskCancellationHandler(operation: {
try await withCheckedThrowingContinuation { continuation in
let dataRequest: DataRequest
if let body = body {
dataRequest = session.request(
url,
method: method,
parameters: body,
encoder: JSONParameterEncoder.default
)
} else {
dataRequest = session.request(
url,
method: method,
encoding: method == .get ? URLEncoding.default : JSONEncoding.default
)
}

dataRequest.responseData { response in
// HTTP 상태 코드가 에러 범위(4xx, 5xx)인 경우 응답 body를 파싱 시도
if let httpResponse = response.response,
httpResponse.statusCode >= 400,
let data = response.data {

Task { @MainActor in
// 1. ApiErrorResponse 형식으로 파싱 시도 (안드로이드와 동일)
if let errorResponse = self.decodeApiErrorResponse(from: data) {
let errorCode = errorResponse.code ?? httpResponse.statusCode
continuation.resume(throwing: NetworkError.serverError(errorCode, message: errorResponse.message))
return
}

// 2. APIResponse 형식으로 파싱 시도 (기존 방식)
if let apiResponse = self.decodeEmptyApiResponse(from: data) {
continuation.resume(throwing: NetworkError.serverError(httpResponse.statusCode, message: apiResponse.message))
return
}

// 3. 파싱 실패 시 기본 에러
continuation.resume(throwing: NetworkError.serverError(httpResponse.statusCode, message: nil))
}
return
}

switch response.result {
case .success(let data):
Task { @MainActor in
do {
print("NetworkManager - Raw response data: \(String(data: data, encoding: .utf8) ?? "Unable to decode")")
let apiResponse = try self.decodeApiResponse(T.self, from: data)
print("NetworkManager - Decoded response: \(apiResponse)")
continuation.resume(returning: apiResponse)
} catch {
print("NetworkManager - Decoding error: \(error)")
continuation.resume(throwing: NetworkError.decodingError(error))
}
}
case .failure(let error):
print("NetworkManager - Network error: \(error)")
Expand All @@ -58,3 +180,4 @@ class NetworkManager {
})
}
}

Loading