diff --git a/.gitignore b/.gitignore index cc5cc875..8a2926e2 100644 --- a/.gitignore +++ b/.gitignore @@ -117,3 +117,12 @@ BaseConfig.xcconfig .claude Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Onboarding/.DS_Store + +# --- Tuist (generated artifacts only; legacy Atcha-iOS.xcodeproj stays tracked) --- +/Atcha.xcworkspace/ +Projects/**/*.xcodeproj/ +Projects/**/Derived/ +Tuist/.build/ +.tuist/ +graph.dot +graph.png diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift new file mode 100644 index 00000000..5062c469 --- /dev/null +++ b/Projects/App/Project.swift @@ -0,0 +1,84 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let appTarget = Target.target( + name: "AtchaV2", + destinations: Atcha.destinations, + product: .app, + bundleId: Atcha.v2BundleID, + deploymentTargets: Atcha.v2Deployment, + infoPlist: .extendingDefault(with: [ + "CFBundleDisplayName": "앗차", + "UILaunchScreen": [:], + "NSAlarmKitUsageDescription": "막차 시간에 맞춰 알람을 울리기 위해 권한이 필요합니다.", + "UIApplicationSceneManifest": [ + "UIApplicationSupportsMultipleScenes": false, + "UISceneConfigurations": [ + "UIWindowSceneSessionRoleApplication": [ + [ + "UISceneConfigurationName": "Default", + "UISceneDelegateClassName": "$(PRODUCT_MODULE_NAME).SceneDelegate", + ], + ], + ], + ], + "UISupportedInterfaceOrientations": ["UIInterfaceOrientationPortrait"], + "ITSAppUsesNonExemptEncryption": false, + ]), + sources: ["Sources/**"], + resources: ["Resources/**"], + dependencies: [ + .project(target: "HomeFeature", path: "../Feature/Home"), + .project(target: "HomeFeatureInterface", path: "../Feature/Home"), + .project(target: "Domain", path: "../Domain"), + .project(target: "AtchaData", path: "../Data"), + .project(target: "CoreNetwork", path: "../Core/Network"), + .project(target: "CoreCoordinator", path: "../Core/Coordinator"), + .project(target: "DesignSystem", path: "../DesignSystem"), + .external(name: "FirebaseCore"), + .external(name: "FirebaseCrashlytics"), + .external(name: "FirebaseMessaging"), + ], + settings: .atchaV2(base: [ + // Firebase static libraries under XcodeProj-based integration need + // -ObjC so their ObjC categories are loaded (Xcode's native SPM + // integration adds the equivalent implicitly). + "OTHER_LDFLAGS": ["$(inherited)", "-ObjC"], + ]) +) + +let project = Project( + name: "AtchaV2", + options: .options( + automaticSchemesOptions: .disabled, + defaultKnownRegions: Atcha.knownRegions, + developmentRegion: Atcha.developmentRegion + ), + settings: .atchaV2(), + targets: [appTarget], + schemes: [ + .scheme( + name: "AtchaV2", + shared: true, + buildAction: .buildAction(targets: ["AtchaV2"]), + runAction: .runAction(configuration: "Debug", executable: "AtchaV2"), + archiveAction: .archiveAction(configuration: "Debug"), + profileAction: .profileAction(configuration: "Debug", executable: "AtchaV2"), + analyzeAction: .analyzeAction(configuration: "Debug") + ), + .scheme( + name: "AtchaV2-Stage", + shared: true, + buildAction: .buildAction(targets: ["AtchaV2"]), + runAction: .runAction(configuration: "Stage", executable: "AtchaV2"), + archiveAction: .archiveAction(configuration: "Stage") + ), + .scheme( + name: "AtchaV2-Live", + shared: true, + buildAction: .buildAction(targets: ["AtchaV2"]), + runAction: .runAction(configuration: "Release", executable: "AtchaV2"), + archiveAction: .archiveAction(configuration: "Release") + ), + ] +) diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..13613e3e --- /dev/null +++ b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/App/Resources/Assets.xcassets/Contents.json b/Projects/App/Resources/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Projects/App/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/App/Sources/AppCoordinator.swift b/Projects/App/Sources/AppCoordinator.swift new file mode 100644 index 00000000..a2ede259 --- /dev/null +++ b/Projects/App/Sources/AppCoordinator.swift @@ -0,0 +1,28 @@ +import CoreCoordinator +import UIKit + +final class AppCoordinator: Coordinator, CoordinatorFinishDelegate { + var childCoordinators: [any Coordinator] = [] + weak var finishDelegate: (any CoordinatorFinishDelegate)? + + // Window root — the app coordinator owns its navigation controller. + private let navigationController: UINavigationController + private let container: AppDIContainer + + init(navigationController: UINavigationController, container: AppDIContainer) { + self.navigationController = navigationController + self.container = container + } + + func start() { + let homeCoordinator = container.makeHomeDIContainer() + .makeHomeCoordinator(navigationController: navigationController) + homeCoordinator.finishDelegate = self + addChild(homeCoordinator) + homeCoordinator.start() + } + + func coordinatorDidFinish(_ coordinator: any Coordinator) { + removeChild(coordinator) + } +} diff --git a/Projects/App/Sources/AppDIContainer.swift b/Projects/App/Sources/AppDIContainer.swift new file mode 100644 index 00000000..ed118a22 --- /dev/null +++ b/Projects/App/Sources/AppDIContainer.swift @@ -0,0 +1,23 @@ +import AtchaData +import CoreNetwork +import Domain +import HomeFeature +import HomeFeatureInterface + +/// Composition root — the only place that sees concrete Data/Network types. +/// Presentation modules depend on Domain protocols only. +final class AppDIContainer { + private let networkClient: any NetworkClient + + init() { + self.networkClient = URLSessionNetworkClient( + baseURL: AppEnvironment.current.apiBaseURL + ) + } + + func makeHomeDIContainer() -> any HomeCoordinatorBuildable { + let repository: any HomeRepository = HomeRepositoryImpl(networkClient: networkClient) + let fetchHome: any FetchHomeUseCase = DefaultFetchHomeUseCase(repository: repository) + return HomeDIContainer(fetchHomeUseCase: fetchHome) + } +} diff --git a/Projects/App/Sources/AppDelegate.swift b/Projects/App/Sources/AppDelegate.swift new file mode 100644 index 00000000..f72936b6 --- /dev/null +++ b/Projects/App/Sources/AppDelegate.swift @@ -0,0 +1,30 @@ +import FirebaseCore +import UIKit + +@main +final class AppDelegate: UIResponder, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + configureFirebaseIfAvailable() + return true + } + + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + UISceneConfiguration(name: "Default", sessionRole: connectingSceneSession.role) + } + + private func configureFirebaseIfAvailable() { + // A GoogleService-Info.plist for com.atcha.iOS.v2 is not provisioned + // yet; configure() without it crashes, so guard on the resource. + guard Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist") != nil else { + return + } + FirebaseApp.configure() + } +} diff --git a/Projects/App/Sources/AppEnvironment.swift b/Projects/App/Sources/AppEnvironment.swift new file mode 100644 index 00000000..334bf511 --- /dev/null +++ b/Projects/App/Sources/AppEnvironment.swift @@ -0,0 +1,29 @@ +import Foundation + +/// Build-configuration-driven environment. Compilation conditions come from +/// build settings (Debug=DEV, Stage=STAGE, Release=LIVE) — no dependency on +/// the gitignored xcconfigs at runtime. +enum AppEnvironment { + case dev + case stage + case live + + static var current: AppEnvironment { + #if LIVE + .live + #elseif STAGE + .stage + #else + .dev + #endif + } + + // Placeholder URLs — replace with the real per-environment hosts. + var apiBaseURL: URL { + switch self { + case .dev: URL(string: "https://dev-api.atcha.example")! + case .stage: URL(string: "https://stage-api.atcha.example")! + case .live: URL(string: "https://api.atcha.example")! + } + } +} diff --git a/Projects/App/Sources/SceneDelegate.swift b/Projects/App/Sources/SceneDelegate.swift new file mode 100644 index 00000000..997b9019 --- /dev/null +++ b/Projects/App/Sources/SceneDelegate.swift @@ -0,0 +1,26 @@ +import UIKit + +final class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + private var appCoordinator: AppCoordinator? + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let windowScene = scene as? UIWindowScene else { return } + let navigationController = UINavigationController() + let coordinator = AppCoordinator( + navigationController: navigationController, + container: AppDIContainer() + ) + + let window = UIWindow(windowScene: windowScene) + window.rootViewController = navigationController + window.makeKeyAndVisible() + self.window = window + appCoordinator = coordinator + coordinator.start() + } +} diff --git a/Projects/Core/Coordinator/Project.swift b/Projects/Core/Coordinator/Project.swift new file mode 100644 index 00000000..0603d942 --- /dev/null +++ b/Projects/Core/Coordinator/Project.swift @@ -0,0 +1,8 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.layer( + name: "CoreCoordinator", + bundleSuffix: "core.coordinator", + isolation: .mainActor +) diff --git a/Projects/Core/Coordinator/Sources/Coordinator.swift b/Projects/Core/Coordinator/Sources/Coordinator.swift new file mode 100644 index 00000000..547fe87b --- /dev/null +++ b/Projects/Core/Coordinator/Sources/Coordinator.swift @@ -0,0 +1,35 @@ +/// Base navigation-flow contract. +/// +/// Memory rules every conformer must follow: +/// - Store `finishDelegate` weak; the parent outlives the child. +/// - Store any `UINavigationController` reference weak unless the coordinator +/// is the window root (AppCoordinator) and therefore owns it. +/// - Parents remove finished children in `coordinatorDidFinish`. +@MainActor +public protocol Coordinator: AnyObject { + var childCoordinators: [any Coordinator] { get set } + var finishDelegate: (any CoordinatorFinishDelegate)? { get set } + func start() +} + +@MainActor +public protocol CoordinatorFinishDelegate: AnyObject { + func coordinatorDidFinish(_ coordinator: any Coordinator) +} + +public extension Coordinator { + func addChild(_ coordinator: any Coordinator) { + childCoordinators.append(coordinator) + } + + func removeChild(_ coordinator: any Coordinator) { + childCoordinators.removeAll { $0 === coordinator } + } + + /// Call when this flow is done: releases children and notifies the parent, + /// which removes this coordinator in `coordinatorDidFinish`. + func finish() { + childCoordinators.removeAll() + finishDelegate?.coordinatorDidFinish(self) + } +} diff --git a/Projects/Core/Coordinator/Tests/CoordinatorTests.swift b/Projects/Core/Coordinator/Tests/CoordinatorTests.swift new file mode 100644 index 00000000..570b7de2 --- /dev/null +++ b/Projects/Core/Coordinator/Tests/CoordinatorTests.swift @@ -0,0 +1,35 @@ +@testable import CoreCoordinator +import Testing + +@MainActor +private final class TestCoordinator: Coordinator { + var childCoordinators: [any Coordinator] = [] + weak var finishDelegate: (any CoordinatorFinishDelegate)? + func start() {} +} + +@MainActor +private final class ParentCoordinator: Coordinator, CoordinatorFinishDelegate { + var childCoordinators: [any Coordinator] = [] + weak var finishDelegate: (any CoordinatorFinishDelegate)? + func start() {} + + func coordinatorDidFinish(_ coordinator: any Coordinator) { + removeChild(coordinator) + } +} + +@MainActor +struct CoordinatorTests { + @Test + func finish_notifiesParentAndParentRemovesChild() { + let parent = ParentCoordinator() + let child = TestCoordinator() + child.finishDelegate = parent + parent.addChild(child) + #expect(parent.childCoordinators.count == 1) + + child.finish() + #expect(parent.childCoordinators.isEmpty) + } +} diff --git a/Projects/Core/Network/Project.swift b/Projects/Core/Network/Project.swift new file mode 100644 index 00000000..a97bdbba --- /dev/null +++ b/Projects/Core/Network/Project.swift @@ -0,0 +1,4 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.layer(name: "CoreNetwork", bundleSuffix: "core.network") diff --git a/Projects/Core/Network/Sources/Endpoint.swift b/Projects/Core/Network/Sources/Endpoint.swift new file mode 100644 index 00000000..445eb7e8 --- /dev/null +++ b/Projects/Core/Network/Sources/Endpoint.swift @@ -0,0 +1,23 @@ +import Foundation + +public enum HTTPMethod: String, Sendable { + case get = "GET" + case post = "POST" + case put = "PUT" + case patch = "PATCH" + case delete = "DELETE" +} + +public protocol Endpoint: Sendable { + var path: String { get } + var method: HTTPMethod { get } + var headers: [String: String] { get } + var queryItems: [URLQueryItem] { get } + var body: Data? { get } +} + +public extension Endpoint { + var headers: [String: String] { [:] } + var queryItems: [URLQueryItem] { [] } + var body: Data? { nil } +} diff --git a/Projects/Core/Network/Sources/NetworkClient.swift b/Projects/Core/Network/Sources/NetworkClient.swift new file mode 100644 index 00000000..f22ad481 --- /dev/null +++ b/Projects/Core/Network/Sources/NetworkClient.swift @@ -0,0 +1,17 @@ +import Foundation + +public protocol NetworkClient: Sendable { + func data(for endpoint: any Endpoint) async throws -> Data + func request( + _ endpoint: any Endpoint, + as type: Response.Type + ) async throws -> Response +} + +public extension NetworkClient { + func request( + _ endpoint: any Endpoint + ) async throws -> Response { + try await request(endpoint, as: Response.self) + } +} diff --git a/Projects/Core/Network/Sources/NetworkError.swift b/Projects/Core/Network/Sources/NetworkError.swift new file mode 100644 index 00000000..e55e7a9c --- /dev/null +++ b/Projects/Core/Network/Sources/NetworkError.swift @@ -0,0 +1,9 @@ +import Foundation + +public enum NetworkError: Error, Sendable { + case invalidURL + case transport(underlying: any Error) + case invalidResponse + case unacceptableStatus(code: Int, data: Data) + case decoding(underlying: any Error) +} diff --git a/Projects/Core/Network/Sources/URLSessionNetworkClient.swift b/Projects/Core/Network/Sources/URLSessionNetworkClient.swift new file mode 100644 index 00000000..e6846d8b --- /dev/null +++ b/Projects/Core/Network/Sources/URLSessionNetworkClient.swift @@ -0,0 +1,72 @@ +import Foundation + +public struct URLSessionNetworkClient: NetworkClient { + private let baseURL: URL + private let session: URLSession + // JSONDecoder is not Sendable — hand out a fresh instance per decode + // through a @Sendable factory instead of sharing one. + private let makeDecoder: @Sendable () -> JSONDecoder + + public init( + baseURL: URL, + session: URLSession = .shared, + makeDecoder: @escaping @Sendable () -> JSONDecoder = { JSONDecoder() } + ) { + self.baseURL = baseURL + self.session = session + self.makeDecoder = makeDecoder + } + + public func data(for endpoint: any Endpoint) async throws -> Data { + let request = try urlRequest(for: endpoint) + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + throw NetworkError.transport(underlying: error) + } + guard let http = response as? HTTPURLResponse else { + throw NetworkError.invalidResponse + } + guard (200 ..< 300).contains(http.statusCode) else { + throw NetworkError.unacceptableStatus(code: http.statusCode, data: data) + } + return data + } + + public func request( + _ endpoint: any Endpoint, + as _: Response.Type + ) async throws -> Response { + let data = try await data(for: endpoint) + do { + return try makeDecoder().decode(Response.self, from: data) + } catch { + throw NetworkError.decoding(underlying: error) + } + } + + // internal, not private — exercised directly by unit tests. + func urlRequest(for endpoint: any Endpoint) throws -> URLRequest { + guard var components = URLComponents( + url: baseURL.appendingPathComponent(endpoint.path), + resolvingAgainstBaseURL: false + ) else { + throw NetworkError.invalidURL + } + if !endpoint.queryItems.isEmpty { + components.queryItems = endpoint.queryItems + } + guard let url = components.url else { + throw NetworkError.invalidURL + } + var request = URLRequest(url: url) + request.httpMethod = endpoint.method.rawValue + request.httpBody = endpoint.body + for (field, value) in endpoint.headers { + request.setValue(value, forHTTPHeaderField: field) + } + return request + } +} diff --git a/Projects/Core/Network/Tests/URLSessionNetworkClientTests.swift b/Projects/Core/Network/Tests/URLSessionNetworkClientTests.swift new file mode 100644 index 00000000..f3410d21 --- /dev/null +++ b/Projects/Core/Network/Tests/URLSessionNetworkClientTests.swift @@ -0,0 +1,19 @@ +@testable import CoreNetwork +import Foundation +import Testing + +private struct PingEndpoint: Endpoint { + let path = "/ping" + let method: HTTPMethod = .get + var queryItems: [URLQueryItem] { [URLQueryItem(name: "q", value: "1")] } +} + +struct URLSessionNetworkClientTests { + @Test + func urlRequest_composesPathMethodAndQuery() throws { + let client = URLSessionNetworkClient(baseURL: URL(string: "https://api.example.com")!) + let request = try client.urlRequest(for: PingEndpoint()) + #expect(request.url?.absoluteString == "https://api.example.com/ping?q=1") + #expect(request.httpMethod == "GET") + } +} diff --git a/Projects/Data/Project.swift b/Projects/Data/Project.swift new file mode 100644 index 00000000..c1f0483d --- /dev/null +++ b/Projects/Data/Project.swift @@ -0,0 +1,13 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +// Module is named AtchaData, not Data — a module literally named "Data" +// shadows Foundation.Data in qualified lookups. +let project = Project.layer( + name: "AtchaData", + bundleSuffix: "data", + dependencies: [ + .project(target: "Domain", path: "../Domain"), + .project(target: "CoreNetwork", path: "../Core/Network"), + ] +) diff --git a/Projects/Data/Sources/DTO/HomeSummaryRequestDTO.swift b/Projects/Data/Sources/DTO/HomeSummaryRequestDTO.swift new file mode 100644 index 00000000..1b7aab4e --- /dev/null +++ b/Projects/Data/Sources/DTO/HomeSummaryRequestDTO.swift @@ -0,0 +1,11 @@ +public struct HomeSummaryRequestDTO: Encodable, Sendable { + public let userID: String + + public init(userID: String) { + self.userID = userID + } + + enum CodingKeys: String, CodingKey { + case userID = "user_id" + } +} diff --git a/Projects/Data/Sources/DTO/HomeSummaryResponseDTO.swift b/Projects/Data/Sources/DTO/HomeSummaryResponseDTO.swift new file mode 100644 index 00000000..31d58c6a --- /dev/null +++ b/Projects/Data/Sources/DTO/HomeSummaryResponseDTO.swift @@ -0,0 +1,11 @@ +import Domain + +public struct HomeSummaryResponseDTO: Decodable, Sendable { + public let id: String + public let title: String + public let subtitle: String? + + public func toEntity() -> HomeSummary { + HomeSummary(id: id, title: title, subtitle: subtitle ?? "") + } +} diff --git a/Projects/Data/Sources/Network/HomeEndpoint.swift b/Projects/Data/Sources/Network/HomeEndpoint.swift new file mode 100644 index 00000000..bcfb1ccc --- /dev/null +++ b/Projects/Data/Sources/Network/HomeEndpoint.swift @@ -0,0 +1,25 @@ +import CoreNetwork +import Foundation + +enum HomeEndpoint: Endpoint { + case summary(HomeSummaryRequestDTO) + + var path: String { + switch self { + case .summary: "/v2/home/summary" + } + } + + var method: HTTPMethod { + switch self { + case .summary: .get + } + } + + var queryItems: [URLQueryItem] { + switch self { + case let .summary(request): + [URLQueryItem(name: "user_id", value: request.userID)] + } + } +} diff --git a/Projects/Data/Sources/Repositories/HomeRepositoryImpl.swift b/Projects/Data/Sources/Repositories/HomeRepositoryImpl.swift new file mode 100644 index 00000000..18a0c5bd --- /dev/null +++ b/Projects/Data/Sources/Repositories/HomeRepositoryImpl.swift @@ -0,0 +1,17 @@ +import CoreNetwork +import Domain + +public struct HomeRepositoryImpl: HomeRepository { + private let networkClient: any NetworkClient + + public init(networkClient: any NetworkClient) { + self.networkClient = networkClient + } + + public func fetchHomeSummary() async throws -> HomeSummary { + let dto: HomeSummaryResponseDTO = try await networkClient.request( + HomeEndpoint.summary(HomeSummaryRequestDTO(userID: "me")) + ) + return dto.toEntity() + } +} diff --git a/Projects/Data/Tests/HomeSummaryResponseDTOTests.swift b/Projects/Data/Tests/HomeSummaryResponseDTOTests.swift new file mode 100644 index 00000000..76952263 --- /dev/null +++ b/Projects/Data/Tests/HomeSummaryResponseDTOTests.swift @@ -0,0 +1,13 @@ +@testable import AtchaData +import Domain +import Foundation +import Testing + +struct HomeSummaryResponseDTOTests { + @Test + func toEntity_mapsFieldsAndDefaultsNilSubtitle() throws { + let json = Data(#"{"id":"1","title":"막차까지 42분","subtitle":null}"#.utf8) + let dto = try JSONDecoder().decode(HomeSummaryResponseDTO.self, from: json) + #expect(dto.toEntity() == HomeSummary(id: "1", title: "막차까지 42분", subtitle: "")) + } +} diff --git a/Projects/DesignSystem/Project.swift b/Projects/DesignSystem/Project.swift new file mode 100644 index 00000000..3ebb1903 --- /dev/null +++ b/Projects/DesignSystem/Project.swift @@ -0,0 +1,9 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.layer( + name: "DesignSystem", + bundleSuffix: "designsystem", + isolation: .mainActor, + resources: ["Resources/**"] +) diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/dsAccent.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/dsAccent.colorset/Contents.json new file mode 100644 index 00000000..bcd18ece --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/dsAccent.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.910", + "green" : "0.310", + "red" : "0.290" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.450", + "red" : "0.420" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/dsBackground.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/dsBackground.colorset/Contents.json new file mode 100644 index 00000000..311224ae --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/dsBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.075", + "green" : "0.067", + "red" : "0.067" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/dsTextPrimary.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/dsTextPrimary.colorset/Contents.json new file mode 100644 index 00000000..8bd1c76f --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/dsTextPrimary.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.050", + "green" : "0.050", + "red" : "0.050" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.950", + "green" : "0.950", + "red" : "0.950" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/DesignSystem/Sources/Components/DSButton.swift b/Projects/DesignSystem/Sources/Components/DSButton.swift new file mode 100644 index 00000000..fc396c21 --- /dev/null +++ b/Projects/DesignSystem/Sources/Components/DSButton.swift @@ -0,0 +1,28 @@ +import UIKit + +public final class DSButton: UIButton { + public enum Style { + case primary + case secondary + } + + public init(title: String, style: Style = .primary) { + super.init(frame: .zero) + var configuration: UIButton.Configuration = (style == .primary) ? .filled() : .gray() + configuration.title = title + configuration.cornerStyle = .large + if style == .primary { + configuration.baseBackgroundColor = DSColor.accent + } + configuration.contentInsets = .init( + top: DSSpacing.sm, leading: DSSpacing.md, + bottom: DSSpacing.sm, trailing: DSSpacing.md + ) + self.configuration = configuration + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } +} diff --git a/Projects/DesignSystem/Sources/Tokens/DSColor.swift b/Projects/DesignSystem/Sources/Tokens/DSColor.swift new file mode 100644 index 00000000..1eea6830 --- /dev/null +++ b/Projects/DesignSystem/Sources/Tokens/DSColor.swift @@ -0,0 +1,11 @@ +import UIKit + +public enum DSColor { + public static var accent: UIColor { asset("dsAccent", fallback: .systemIndigo) } + public static var background: UIColor { asset("dsBackground", fallback: .systemBackground) } + public static var textPrimary: UIColor { asset("dsTextPrimary", fallback: .label) } + + private static func asset(_ name: String, fallback: UIColor) -> UIColor { + UIColor(named: name, in: .module, compatibleWith: nil) ?? fallback + } +} diff --git a/Projects/DesignSystem/Sources/Tokens/DSFont.swift b/Projects/DesignSystem/Sources/Tokens/DSFont.swift new file mode 100644 index 00000000..0ea04801 --- /dev/null +++ b/Projects/DesignSystem/Sources/Tokens/DSFont.swift @@ -0,0 +1,15 @@ +import UIKit + +public enum DSFont { + public static func title(_ size: CGFloat = 22) -> UIFont { + .systemFont(ofSize: size, weight: .bold) + } + + public static func body(_ size: CGFloat = 16) -> UIFont { + .systemFont(ofSize: size, weight: .regular) + } + + public static func caption(_ size: CGFloat = 12) -> UIFont { + .systemFont(ofSize: size, weight: .medium) + } +} diff --git a/Projects/DesignSystem/Sources/Tokens/DSSpacing.swift b/Projects/DesignSystem/Sources/Tokens/DSSpacing.swift new file mode 100644 index 00000000..448e17f9 --- /dev/null +++ b/Projects/DesignSystem/Sources/Tokens/DSSpacing.swift @@ -0,0 +1,9 @@ +import Foundation + +public enum DSSpacing { + public static let xs: CGFloat = 4 + public static let sm: CGFloat = 8 + public static let md: CGFloat = 16 + public static let lg: CGFloat = 24 + public static let xl: CGFloat = 32 +} diff --git a/Projects/DesignSystem/Tests/DesignTokenTests.swift b/Projects/DesignSystem/Tests/DesignTokenTests.swift new file mode 100644 index 00000000..00e91c15 --- /dev/null +++ b/Projects/DesignSystem/Tests/DesignTokenTests.swift @@ -0,0 +1,15 @@ +@testable import DesignSystem +import Testing + +// Token-only assertions — resource-bundle lookup in hostless unit tests is +// unreliable for static frameworks, so no color asset access here. +@MainActor +struct DesignTokenTests { + @Test + func spacingScaleIsAscending() { + #expect(DSSpacing.xs < DSSpacing.sm) + #expect(DSSpacing.sm < DSSpacing.md) + #expect(DSSpacing.md < DSSpacing.lg) + #expect(DSSpacing.lg < DSSpacing.xl) + } +} diff --git a/Projects/Domain/Project.swift b/Projects/Domain/Project.swift new file mode 100644 index 00000000..33ccb112 --- /dev/null +++ b/Projects/Domain/Project.swift @@ -0,0 +1,4 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.layer(name: "Domain") diff --git a/Projects/Domain/Sources/Entities/HomeSummary.swift b/Projects/Domain/Sources/Entities/HomeSummary.swift new file mode 100644 index 00000000..42fd12c2 --- /dev/null +++ b/Projects/Domain/Sources/Entities/HomeSummary.swift @@ -0,0 +1,11 @@ +public struct HomeSummary: Equatable, Sendable { + public let id: String + public let title: String + public let subtitle: String + + public init(id: String, title: String, subtitle: String) { + self.id = id + self.title = title + self.subtitle = subtitle + } +} diff --git a/Projects/Domain/Sources/Interfaces/HomeRepository.swift b/Projects/Domain/Sources/Interfaces/HomeRepository.swift new file mode 100644 index 00000000..c342cadc --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/HomeRepository.swift @@ -0,0 +1,3 @@ +public protocol HomeRepository: Sendable { + func fetchHomeSummary() async throws -> HomeSummary +} diff --git a/Projects/Domain/Sources/UseCases/FetchHomeUseCase.swift b/Projects/Domain/Sources/UseCases/FetchHomeUseCase.swift new file mode 100644 index 00000000..7772edf0 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/FetchHomeUseCase.swift @@ -0,0 +1,15 @@ +public protocol FetchHomeUseCase: Sendable { + func execute() async throws -> HomeSummary +} + +public struct DefaultFetchHomeUseCase: FetchHomeUseCase { + private let repository: any HomeRepository + + public init(repository: any HomeRepository) { + self.repository = repository + } + + public func execute() async throws -> HomeSummary { + try await repository.fetchHomeSummary() + } +} diff --git a/Projects/Domain/Tests/DefaultFetchHomeUseCaseTests.swift b/Projects/Domain/Tests/DefaultFetchHomeUseCaseTests.swift new file mode 100644 index 00000000..bbffb01f --- /dev/null +++ b/Projects/Domain/Tests/DefaultFetchHomeUseCaseTests.swift @@ -0,0 +1,17 @@ +@testable import Domain +import Testing + +private struct StubHomeRepository: HomeRepository { + let summary: HomeSummary + func fetchHomeSummary() async throws -> HomeSummary { summary } +} + +struct DefaultFetchHomeUseCaseTests { + @Test + func execute_returnsRepositoryEntity() async throws { + let expected = HomeSummary(id: "1", title: "t", subtitle: "s") + let sut = DefaultFetchHomeUseCase(repository: StubHomeRepository(summary: expected)) + let result = try await sut.execute() + #expect(result == expected) + } +} diff --git a/Projects/Feature/Home/Example/ExampleApp.swift b/Projects/Feature/Home/Example/ExampleApp.swift new file mode 100644 index 00000000..a497ffd9 --- /dev/null +++ b/Projects/Feature/Home/Example/ExampleApp.swift @@ -0,0 +1,54 @@ +import CoreCoordinator +import Domain +import HomeFeature +import HomeFeatureInterface +import UIKit + +@main +final class AppDelegate: UIResponder, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + true + } + + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + UISceneConfiguration(name: "Default", sessionRole: connectingSceneSession.role) + } +} + +final class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + private var homeCoordinator: (any Coordinator)? + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let windowScene = scene as? UIWindowScene else { return } + let navigationController = UINavigationController() + let container = HomeDIContainer(fetchHomeUseCase: PreviewFetchHomeUseCase()) + let coordinator = container.makeHomeCoordinator(navigationController: navigationController) + + let window = UIWindow(windowScene: windowScene) + window.rootViewController = navigationController + window.makeKeyAndVisible() + self.window = window + homeCoordinator = coordinator + coordinator.start() + } +} + +// Example apps wire stub use cases — no Data/network dependency. +struct PreviewFetchHomeUseCase: FetchHomeUseCase { + func execute() async throws -> HomeSummary { + try? await Task.sleep(for: .seconds(1)) + return HomeSummary(id: "preview", title: "막차까지 42분", subtitle: "Example 앱의 스텁 데이터입니다") + } +} diff --git a/Projects/Feature/Home/Interface/HomeCoordinatorBuildable.swift b/Projects/Feature/Home/Interface/HomeCoordinatorBuildable.swift new file mode 100644 index 00000000..850afeba --- /dev/null +++ b/Projects/Feature/Home/Interface/HomeCoordinatorBuildable.swift @@ -0,0 +1,10 @@ +import CoreCoordinator +import UIKit + +/// Entry point other modules use to start the Home flow. +/// The concrete builder is the feature's DIContainer; wiring happens at the +/// App composition root. +@MainActor +public protocol HomeCoordinatorBuildable { + func makeHomeCoordinator(navigationController: UINavigationController) -> any Coordinator +} diff --git a/Projects/Feature/Home/Project.swift b/Projects/Feature/Home/Project.swift new file mode 100644 index 00000000..d2012857 --- /dev/null +++ b/Projects/Feature/Home/Project.swift @@ -0,0 +1,23 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.feature( + name: "Home", + dependencies: [ + .project(target: "Domain", path: "../../Domain"), + .project(target: "DesignSystem", path: "../../DesignSystem"), + .project(target: "CoreCoordinator", path: "../../Core/Coordinator"), + .external(name: "SnapKit"), + ], + interfaceDependencies: [ + .project(target: "Domain", path: "../../Domain"), + .project(target: "CoreCoordinator", path: "../../Core/Coordinator"), + ], + testDependencies: [ + .project(target: "Domain", path: "../../Domain"), + ], + exampleDependencies: [ + .project(target: "Domain", path: "../../Domain"), + .project(target: "CoreCoordinator", path: "../../Core/Coordinator"), + ] +) diff --git a/Projects/Feature/Home/Sources/HomeCoordinator.swift b/Projects/Feature/Home/Sources/HomeCoordinator.swift new file mode 100644 index 00000000..f6979a1e --- /dev/null +++ b/Projects/Feature/Home/Sources/HomeCoordinator.swift @@ -0,0 +1,21 @@ +import CoreCoordinator +import UIKit + +final class HomeCoordinator: Coordinator { + var childCoordinators: [any Coordinator] = [] + weak var finishDelegate: (any CoordinatorFinishDelegate)? + + // The App (window root) owns the navigation controller. + private weak var navigationController: UINavigationController? + private let container: HomeDIContainer + + init(navigationController: UINavigationController, container: HomeDIContainer) { + self.navigationController = navigationController + self.container = container + } + + func start() { + let viewController = container.makeHomeViewController() + navigationController?.pushViewController(viewController, animated: false) + } +} diff --git a/Projects/Feature/Home/Sources/HomeDIContainer.swift b/Projects/Feature/Home/Sources/HomeDIContainer.swift new file mode 100644 index 00000000..cbc37735 --- /dev/null +++ b/Projects/Feature/Home/Sources/HomeDIContainer.swift @@ -0,0 +1,23 @@ +import CoreCoordinator +import Domain +import HomeFeatureInterface +import UIKit + +/// Assembles the Home feature's screens. Coordinators own flow only; +/// screen/ViewModel assembly stays here so adding screens never bloats +/// coordinator initializers. +public final class HomeDIContainer: HomeCoordinatorBuildable { + private let fetchHomeUseCase: any FetchHomeUseCase + + public init(fetchHomeUseCase: any FetchHomeUseCase) { + self.fetchHomeUseCase = fetchHomeUseCase + } + + public func makeHomeCoordinator(navigationController: UINavigationController) -> any Coordinator { + HomeCoordinator(navigationController: navigationController, container: self) + } + + func makeHomeViewController() -> UIViewController { + HomeViewController(viewModel: HomeViewModel(fetchHomeUseCase: fetchHomeUseCase)) + } +} diff --git a/Projects/Feature/Home/Sources/HomeViewController.swift b/Projects/Feature/Home/Sources/HomeViewController.swift new file mode 100644 index 00000000..f0b52adb --- /dev/null +++ b/Projects/Feature/Home/Sources/HomeViewController.swift @@ -0,0 +1,101 @@ +import DesignSystem +import SnapKit +import UIKit + +final class HomeViewController: UIViewController { + // VC strongly owns the VM; the VM's closures capture the VC weakly. + private let viewModel: HomeViewModel + + private let titleLabel: UILabel = { + let label = UILabel() + label.font = DSFont.title() + label.textColor = DSColor.textPrimary + label.textAlignment = .center + return label + }() + + private let subtitleLabel: UILabel = { + let label = UILabel() + label.font = DSFont.body() + label.textColor = DSColor.textPrimary + label.textAlignment = .center + label.numberOfLines = 0 + return label + }() + + private let refreshButton = DSButton(title: "새로고침") + private let activityIndicator = UIActivityIndicatorView(style: .medium) + + init(viewModel: HomeViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func viewDidLoad() { + super.viewDidLoad() + configureUI() + bind() + viewModel.viewDidLoad() + } + + private func configureUI() { + view.backgroundColor = DSColor.background + navigationItem.title = "홈" + + [titleLabel, subtitleLabel, refreshButton, activityIndicator] + .forEach(view.addSubview) + + titleLabel.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(DSSpacing.md) + } + subtitleLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(DSSpacing.sm) + make.leading.trailing.equalToSuperview().inset(DSSpacing.md) + } + refreshButton.snp.makeConstraints { make in + make.top.equalTo(subtitleLabel.snp.bottom).offset(DSSpacing.lg) + make.centerX.equalToSuperview() + } + activityIndicator.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.bottom.equalTo(titleLabel.snp.top).offset(-DSSpacing.lg) + } + + refreshButton.addAction( + UIAction { [weak self] _ in self?.viewModel.refresh() }, + for: .touchUpInside + ) + } + + private func bind() { + viewModel.onStateChange = { [weak self] state in + self?.render(state) + } + render(viewModel.state) + } + + private func render(_ state: HomeViewModel.State) { + switch state { + case .idle: + break + case .loading: + activityIndicator.startAnimating() + titleLabel.text = nil + subtitleLabel.text = nil + case let .loaded(viewData): + activityIndicator.stopAnimating() + titleLabel.text = viewData.titleText + subtitleLabel.text = viewData.subtitleText + case let .failed(message): + activityIndicator.stopAnimating() + titleLabel.text = "앗차!" + subtitleLabel.text = message + } + } +} diff --git a/Projects/Feature/Home/Sources/HomeViewData.swift b/Projects/Feature/Home/Sources/HomeViewData.swift new file mode 100644 index 00000000..cd51a6bc --- /dev/null +++ b/Projects/Feature/Home/Sources/HomeViewData.swift @@ -0,0 +1,12 @@ +import Domain + +/// Presentation model — views never see the Entity directly. +struct HomeViewData: Equatable { + let titleText: String + let subtitleText: String + + init(entity: HomeSummary) { + self.titleText = entity.title + self.subtitleText = entity.subtitle + } +} diff --git a/Projects/Feature/Home/Sources/HomeViewModel.swift b/Projects/Feature/Home/Sources/HomeViewModel.swift new file mode 100644 index 00000000..6042fe92 --- /dev/null +++ b/Projects/Feature/Home/Sources/HomeViewModel.swift @@ -0,0 +1,56 @@ +import Domain +import Foundation + +// Convention: every ViewModel in the codebase is @MainActor. +@MainActor +final class HomeViewModel { + enum State: Equatable { + case idle + case loading + case loaded(HomeViewData) + case failed(message: String) + } + + /// Set by the ViewController; always invoked on the main actor. + var onStateChange: ((State) -> Void)? + + private(set) var state: State = .idle { + didSet { onStateChange?(state) } + } + + private let fetchHomeUseCase: any FetchHomeUseCase + private var loadTask: Task? + + init(fetchHomeUseCase: any FetchHomeUseCase) { + self.fetchHomeUseCase = fetchHomeUseCase + } + + deinit { + loadTask?.cancel() + } + + func viewDidLoad() { + load() + } + + func refresh() { + load() + } + + private func load() { + loadTask?.cancel() + state = .loading + // [weak self]: the in-flight task must not keep the ViewModel alive. + loadTask = Task { [weak self] in + guard let useCase = self?.fetchHomeUseCase else { return } + do { + let summary = try await useCase.execute() + guard !Task.isCancelled else { return } + self?.state = .loaded(HomeViewData(entity: summary)) + } catch { + guard !Task.isCancelled else { return } + self?.state = .failed(message: "홈 정보를 불러오지 못했습니다.") + } + } + } +} diff --git a/Projects/Feature/Home/Tests/HomeViewModelTests.swift b/Projects/Feature/Home/Tests/HomeViewModelTests.swift new file mode 100644 index 00000000..42d0cf55 --- /dev/null +++ b/Projects/Feature/Home/Tests/HomeViewModelTests.swift @@ -0,0 +1,34 @@ +import Domain +@testable import HomeFeature +import Testing + +private struct StubFetchHomeUseCase: FetchHomeUseCase { + let summary: HomeSummary + func execute() async throws -> HomeSummary { summary } +} + +@MainActor +struct HomeViewModelTests { + @Test + func viewDidLoad_success_transitionsLoadingToLoaded() async { + let summary = HomeSummary(id: "1", title: "막차까지 42분", subtitle: "지금 출발하면 여유있어요") + let sut = HomeViewModel(fetchHomeUseCase: StubFetchHomeUseCase(summary: summary)) + + var states: [HomeViewModel.State] = [] + await confirmation("reaches .loaded") { loaded in + sut.onStateChange = { state in + states.append(state) + if case .loaded = state { loaded() } + } + sut.viewDidLoad() + + // Drain until the async load lands (stub resolves immediately). + while !states.contains(where: { if case .loaded = $0 { true } else { false } }) { + await Task.yield() + } + } + + #expect(states.first == .loading) + #expect(states.last == .loaded(HomeViewData(entity: summary))) + } +} diff --git a/Projects/Legacy/Project.swift b/Projects/Legacy/Project.swift new file mode 100644 index 00000000..0be20c70 --- /dev/null +++ b/Projects/Legacy/Project.swift @@ -0,0 +1,157 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +// Wraps the legacy app (sources referenced in place from //Atcha-iOS) as one +// Tuist target. Values mirror the committed Atcha-iOS.xcodeproj; scheme names +// (Atcha-Dev/Stage/Live) are preserved for fastlane/CI compatibility. + +let infoPlistKeys: SettingsDictionary = [ + "GENERATE_INFOPLIST_FILE": "YES", + "INFOPLIST_KEY_CFBundleDisplayName": "앗차", + "INFOPLIST_KEY_LSApplicationCategoryType": "public.app-category.navigation", + "INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription": "정확한 막차 경로를 제공하기 위해 권한이 필요합니다", + "INFOPLIST_KEY_NSLocationWhenInUseUsageDescription": "정확한 막차 경로를 제공하기 위해 권한이 필요합니다", + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents": "YES", + "INFOPLIST_KEY_UILaunchStoryboardName": "LaunchScreen", + "INFOPLIST_KEY_UIMainStoryboardFile": "Main", + "INFOPLIST_KEY_UISupportedInterfaceOrientations": "UIInterfaceOrientationPortrait", +] + +let legacyBase: SettingsDictionary = infoPlistKeys.merging([ + "ASSETCATALOG_COMPILER_APPICON_NAME": "AppIcon", + "ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS": "YES", + "ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME": "AccentColor", + "CURRENT_PROJECT_VERSION": "14", + "MARKETING_VERSION": "1.9.7", + "SWIFT_VERSION": "5.0", + "TARGETED_DEVICE_FAMILY": "1", + "SWIFT_EMIT_LOC_STRINGS": "YES", + "SUPPORTS_MACCATALYST": "NO", + "SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD": "NO", + "SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD": "NO", + "LD_RUNPATH_SEARCH_PATHS": ["$(inherited)", "@executable_path/Frameworks"], + // Vendored frameworks live inside the legacy source folder. + "FRAMEWORK_SEARCH_PATHS": ["$(inherited)", "$(SRCROOT)/../../Atcha-iOS"], + // Firebase/Kakao static libs under XcodeProj-based integration need -ObjC + // (native Xcode SPM added the equivalent implicitly). + "OTHER_LDFLAGS": ["$(inherited)", "-ObjC"], +]) { _, new in new } + +let debugSigning: SettingsDictionary = [ + "CODE_SIGN_STYLE": "Automatic", + "CODE_SIGN_IDENTITY": "Apple Development", + "DEVELOPMENT_TEAM": "23SCTLK482", +] + +let distributionSigning: SettingsDictionary = [ + "CODE_SIGN_STYLE": "Manual", + "CODE_SIGN_IDENTITY": "Apple Development", + "CODE_SIGN_IDENTITY[sdk=iphoneos*]": "iPhone Distribution", + "DEVELOPMENT_TEAM": "", + "DEVELOPMENT_TEAM[sdk=iphoneos*]": "23SCTLK482", + "PROVISIONING_PROFILE_SPECIFIER": "", + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]": "match AppStore com.atcha.iOS", +] + +let legacyTarget = Target.target( + name: "Atcha-iOS", + destinations: [.iPhone], + product: .app, + productName: "Atcha-iOS", + bundleId: "com.atcha.iOS", + deploymentTargets: Atcha.legacyDeployment, + infoPlist: .file(path: "../../Atcha-iOS/Info.plist"), + sources: [ + .glob("../../Atcha-iOS/**/*.swift", excluding: [ + "../../Atcha-iOS/TMapSDK.framework/**", + "../../Atcha-iOS/VSMSDK.xcframework/**", + // Dead file on disk — the committed pbxproj never compiled it and + // it redeclares AppDIContainer. + "../../Atcha-iOS/App/DIContainer/DIContainer.swift", + ]), + ], + resources: [ + "../../Atcha-iOS/DesignSource/Assets.xcassets", + "../../Atcha-iOS/DesignSource/AtchaColor/Colors.xcassets", + "../../Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets", + "../../Atcha-iOS/DesignSource/AtchaFont/*.otf", + "../../Atcha-iOS/DesignSource/AtchaLottie/*.json", + "../../Atcha-iOS/Base.lproj/**", + "../../Atcha-iOS/GoogleService-Info.plist", + "../../Atcha-iOS/silent.mp3", + "../../Atcha-iOS/siren.mp3", + // NOTE: the old project also copied the four *.xcconfig files into the + // app bundle (secrets!). Deliberately not reproduced — AppConfig.swift + // reads Info.plist keys only. + ], + entitlements: "../../Atcha-iOS/Atcha-iOS.entitlements", + dependencies: [ + .framework(path: "../../Atcha-iOS/TMapSDK.framework"), + .xcframework(path: "../../Atcha-iOS/VSMSDK.xcframework"), + .external(name: "SnapKit"), + // KakaoSDK is the umbrella product — it already contains + // KakaoSDKAuth/KakaoSDKCommon (listing them too duplicates the links). + .external(name: "KakaoSDK"), + .external(name: "FirebaseAuth"), + .external(name: "FirebaseCore"), + .external(name: "FirebaseCrashlytics"), + .external(name: "FirebaseMessaging"), + .external(name: "Lottie"), + .external(name: "AmplitudeSwift"), + ], + settings: .settings( + base: legacyBase, + configurations: [ + .debug(name: "Debug", settings: debugSigning, xcconfig: "../../DevConfig.xcconfig"), + .release(name: "Stage", settings: distributionSigning, xcconfig: "../../StageConfig.xcconfig"), + .release(name: "Release", settings: distributionSigning, xcconfig: "../../LiveConfig.xcconfig"), + ], + defaultSettings: .recommended + ) +) + +let project = Project( + name: "AtchaLegacy", + options: .options( + automaticSchemesOptions: .disabled, + defaultKnownRegions: ["ko", "Base"], + developmentRegion: "ko", + disableBundleAccessors: true, + disableSynthesizedResourceAccessors: true + ), + settings: .settings(configurations: [ + .debug(name: "Debug", xcconfig: "../../DevConfig.xcconfig"), + .release(name: "Stage", xcconfig: "../../StageConfig.xcconfig"), + .release(name: "Release", xcconfig: "../../LiveConfig.xcconfig"), + ]), + targets: [legacyTarget], + schemes: [ + .scheme( + name: "Atcha-Dev", + shared: true, + buildAction: .buildAction(targets: ["Atcha-iOS"]), + runAction: .runAction(configuration: "Debug", executable: "Atcha-iOS"), + archiveAction: .archiveAction(configuration: "Debug"), + profileAction: .profileAction(configuration: "Debug", executable: "Atcha-iOS"), + analyzeAction: .analyzeAction(configuration: "Debug") + ), + .scheme( + name: "Atcha-Stage", + shared: true, + buildAction: .buildAction(targets: ["Atcha-iOS"]), + runAction: .runAction(configuration: "Stage", executable: "Atcha-iOS"), + archiveAction: .archiveAction(configuration: "Stage"), + profileAction: .profileAction(configuration: "Stage", executable: "Atcha-iOS"), + analyzeAction: .analyzeAction(configuration: "Stage") + ), + .scheme( + name: "Atcha-Live", + shared: true, + buildAction: .buildAction(targets: ["Atcha-iOS"]), + runAction: .runAction(configuration: "Release", executable: "Atcha-iOS"), + archiveAction: .archiveAction(configuration: "Release"), + profileAction: .profileAction(configuration: "Release", executable: "Atcha-iOS"), + analyzeAction: .analyzeAction(configuration: "Release") + ), + ] +) diff --git a/Scripts/bootstrap.sh b/Scripts/bootstrap.sh new file mode 100755 index 00000000..3b725f63 --- /dev/null +++ b/Scripts/bootstrap.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# Bootstraps the Tuist workspace. +# +# The four xcconfigs are gitignored (CI decodes the real ones from secrets into +# the repo root). Tuist fails generation when a referenced xcconfig is missing, +# so create empty stand-ins locally; empty files reproduce today's local +# behavior (settings not injected, build succeeds). +set -eu +cd "$(dirname "$0")/.." + +for f in BaseConfig.xcconfig DevConfig.xcconfig StageConfig.xcconfig LiveConfig.xcconfig; do + if [ ! -f "$f" ]; then + printf '// local stand-in — real values are injected by CI (gitignored)\n' > "$f" + fi +done + +tuist install +tuist generate --no-open diff --git a/Tuist.swift b/Tuist.swift new file mode 100644 index 00000000..8c404712 --- /dev/null +++ b/Tuist.swift @@ -0,0 +1,3 @@ +import ProjectDescription + +let tuist = Tuist(project: .tuist()) diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved new file mode 100644 index 00000000..fc41a82b --- /dev/null +++ b/Tuist/Package.resolved @@ -0,0 +1,195 @@ +{ + "originHash" : "0b05e39d91c67dfb05d736da41f4a037d02edbb517fedfd41f0ecb99ae4db844", + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, + { + "identity" : "alamofire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/Alamofire.git", + "state" : { + "revision" : "7595cbcf59809f9977c5f6378500de2ad73b7ddb", + "version" : "5.12.0" + } + }, + { + "identity" : "amplitude-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/amplitude/Amplitude-Swift", + "state" : { + "branch" : "main", + "revision" : "63a8a83dabeb8ef10096034f4763165ef827f76b" + } + }, + { + "identity" : "amplitudecore-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/amplitude/AmplitudeCore-Swift.git", + "state" : { + "revision" : "bbad108b92f332fdaa76b67bce3f3ae88015ad0b", + "version" : "1.4.10" + } + }, + { + "identity" : "analytics-connector-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/amplitude/analytics-connector-ios.git", + "state" : { + "revision" : "982b4c787285d213653bd2a504d6a86b52227cea", + "version" : "1.3.2" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902", + "version" : "11.3.1" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "fdc352fabaf5916e7faa1f96ad02b1957e93e5a5", + "version" : "11.15.0" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "a2d0f1f1666de591eb1a811f40b1706f5c63a2ed", + "version" : "2.3.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "45ce435e9406d3c674dd249a042b932bee006f60", + "version" : "11.15.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "ba3358d3c3dbae8ef230b58a46b97ad65e84e974", + "version" : "10.1.1" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "9f183ae842be978784f2963a343682e0c46d8fb3", + "version" : "8.1.2" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "c756a29784521063b6a1202907e2cc47f41b667c", + "version" : "4.5.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "kakao-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kakao/kakao-ios-sdk", + "state" : { + "revision" : "2a68ca01e2d7900a1559b31d0d59843837f130f2", + "version" : "2.28.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "lottie-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/airbnb/lottie-ios.git", + "state" : { + "revision" : "f4db77d7feacba0c2360b84a40c38a6ce8ff399d", + "version" : "4.6.1" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "3851d94a41890dea16dc3db34caf60e585cb4163", + "version" : "2.30910.1" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837", + "version" : "2.4.1" + } + }, + { + "identity" : "snapkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SnapKit/SnapKit.git", + "state" : { + "revision" : "2842e6e84e82eb9a8dac0100ca90d9444b0307f4", + "version" : "5.7.1" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "55d7a1cc5666b85c13464aea1c4b4a90feccb4c8", + "version" : "1.38.1" + } + } + ], + "version" : 3 +} diff --git a/Tuist/Package.swift b/Tuist/Package.swift new file mode 100644 index 00000000..9a819e9c --- /dev/null +++ b/Tuist/Package.swift @@ -0,0 +1,32 @@ +// swift-tools-version: 6.0 +import PackageDescription + +#if TUIST +import struct ProjectDescription.PackageSettings + +let packageSettings = PackageSettings( + // Default product type is .staticFramework — keep everything static and + // link Firebase/Kakao/etc. only at app targets to avoid duplicate symbols. + productTypes: [:], + baseSettings: .settings( + // External targets must know all three configurations, otherwise Xcode + // falls back to a default config when building Stage (tuist#4597). + configurations: [ + .debug(name: "Debug"), + .release(name: "Stage"), + .release(name: "Release"), + ] + ) +) +#endif + +let package = Package( + name: "AtchaDependencies", + dependencies: [ + .package(url: "https://github.com/SnapKit/SnapKit.git", from: "5.7.1"), + .package(url: "https://github.com/kakao/kakao-ios-sdk", from: "2.24.4"), + .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "11.14.0"), + .package(url: "https://github.com/airbnb/lottie-ios.git", from: "4.5.2"), + .package(url: "https://github.com/amplitude/Amplitude-Swift", branch: "main"), + ] +) diff --git a/Tuist/ProjectDescriptionHelpers/Atcha.swift b/Tuist/ProjectDescriptionHelpers/Atcha.swift new file mode 100644 index 00000000..b4da37fc --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/Atcha.swift @@ -0,0 +1,11 @@ +import ProjectDescription + +public enum Atcha { + public static let teamID = "23SCTLK482" + public static let v2BundleID = "com.atcha.iOS.v2" + public static let destinations: Destinations = [.iPhone] + public static let v2Deployment: DeploymentTargets = .iOS("26.0") + public static let legacyDeployment: DeploymentTargets = .iOS("16.1") + public static let knownRegions = ["ko", "Base"] + public static let developmentRegion = "ko" +} diff --git a/Tuist/ProjectDescriptionHelpers/Project+Feature.swift b/Tuist/ProjectDescriptionHelpers/Project+Feature.swift new file mode 100644 index 00000000..fbf2558b --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/Project+Feature.swift @@ -0,0 +1,99 @@ +import ProjectDescription + +public extension Project { + /// Micro-feature: emits {name}Feature, {name}FeatureInterface, + /// {name}FeatureTests, {name}FeatureExample. + /// + /// Conventions baked into every feature: + /// - Every ViewModel is `@MainActor` (module default isolation is MainActor too). + /// - Presentation depends on Domain, never on AtchaData. + /// - Assembly lives in the feature's DIContainer; Coordinators own flow only. + /// - Example apps wire stub use cases (no Data/network dependency). + /// Stubs are intentionally duplicated between Tests and Example (no + /// Testing target) — revisit if a stub is needed in 3+ places. + static func feature( + name: String, + dependencies: [TargetDependency] = [], + interfaceDependencies: [TargetDependency] = [], + testDependencies: [TargetDependency] = [], + exampleDependencies: [TargetDependency] = [], + resources: ResourceFileElements? = nil + ) -> Project { + let featureName = "\(name)Feature" + let bundleBase = "\(Atcha.v2BundleID).feature.\(name.lowercased())" + + let sceneManifest: Plist.Value = [ + "UIApplicationSupportsMultipleScenes": false, + "UISceneConfigurations": [ + "UIWindowSceneSessionRoleApplication": [ + [ + "UISceneConfigurationName": "Default", + "UISceneDelegateClassName": "$(PRODUCT_MODULE_NAME).SceneDelegate", + ], + ], + ], + ] + + let interface = Target.target( + name: "\(featureName)Interface", + destinations: Atcha.destinations, + product: .staticFramework, + bundleId: "\(bundleBase).interface", + deploymentTargets: Atcha.v2Deployment, + infoPlist: .default, + sources: ["Interface/**"], + dependencies: interfaceDependencies, + settings: .atchaV2() + ) + + let sources = Target.target( + name: featureName, + destinations: Atcha.destinations, + product: .staticFramework, + bundleId: bundleBase, + deploymentTargets: Atcha.v2Deployment, + infoPlist: .default, + sources: ["Sources/**"], + resources: resources, + dependencies: [.target(name: "\(featureName)Interface")] + dependencies, + settings: .atchaV2() + ) + + let tests = Target.target( + name: "\(featureName)Tests", + destinations: Atcha.destinations, + product: .unitTests, + bundleId: "\(bundleBase).tests", + deploymentTargets: Atcha.v2Deployment, + infoPlist: .default, + sources: ["Tests/**"], + dependencies: [.target(name: featureName)] + testDependencies, + settings: .atchaV2() + ) + + let example = Target.target( + name: "\(featureName)Example", + destinations: Atcha.destinations, + product: .app, + bundleId: "\(bundleBase).example", + deploymentTargets: Atcha.v2Deployment, + infoPlist: .extendingDefault(with: [ + "UILaunchScreen": [:], + "UIApplicationSceneManifest": sceneManifest, + ]), + sources: ["Example/**"], + dependencies: [.target(name: featureName)] + exampleDependencies, + settings: .atchaV2() + ) + + return Project( + name: featureName, + options: .options( + defaultKnownRegions: Atcha.knownRegions, + developmentRegion: Atcha.developmentRegion + ), + settings: .atchaV2(), + targets: [interface, sources, tests, example] + ) + } +} diff --git a/Tuist/ProjectDescriptionHelpers/Project+Layer.swift b/Tuist/ProjectDescriptionHelpers/Project+Layer.swift new file mode 100644 index 00000000..49762d98 --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/Project+Layer.swift @@ -0,0 +1,50 @@ +import ProjectDescription + +public extension Project { + /// Horizontal / Clean Architecture layer module: framework + unit tests. + static func layer( + name: String, + bundleSuffix: String? = nil, + isolation: AtchaIsolation = .nonisolated, + dependencies: [TargetDependency] = [], + testDependencies: [TargetDependency] = [], + resources: ResourceFileElements? = nil + ) -> Project { + let suffix = bundleSuffix ?? name.lowercased() + + let framework = Target.target( + name: name, + destinations: Atcha.destinations, + product: .staticFramework, + bundleId: "\(Atcha.v2BundleID).\(suffix)", + deploymentTargets: Atcha.v2Deployment, + infoPlist: .default, + sources: ["Sources/**"], + resources: resources, + dependencies: dependencies, + settings: .atchaV2(isolation: isolation) + ) + + let tests = Target.target( + name: "\(name)Tests", + destinations: Atcha.destinations, + product: .unitTests, + bundleId: "\(Atcha.v2BundleID).\(suffix).tests", + deploymentTargets: Atcha.v2Deployment, + infoPlist: .default, + sources: ["Tests/**"], + dependencies: [.target(name: name)] + testDependencies, + settings: .atchaV2(isolation: isolation) + ) + + return Project( + name: name, + options: .options( + defaultKnownRegions: Atcha.knownRegions, + developmentRegion: Atcha.developmentRegion + ), + settings: .atchaV2(isolation: isolation), + targets: [framework, tests] + ) + } +} diff --git a/Tuist/ProjectDescriptionHelpers/Settings+Atcha.swift b/Tuist/ProjectDescriptionHelpers/Settings+Atcha.swift new file mode 100644 index 00000000..21ca558f --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/Settings+Atcha.swift @@ -0,0 +1,47 @@ +import ProjectDescription + +/// Default actor isolation for a module. UI-facing modules (App, Features, +/// DesignSystem, CoreCoordinator) use .mainActor; Domain/AtchaData/CoreNetwork +/// stay .nonisolated so async domain/network code needs no annotations. +public enum AtchaIsolation: String { + case mainActor = "MainActor" + case nonisolated = "nonisolated" +} + +public extension Settings { + /// Swift 6 settings + the canonical Debug/Stage/Release configuration + /// triple. Per-configuration compilation conditions drive AppEnvironment: + /// Debug=DEV, Stage=STAGE, Release=LIVE. + static func atchaV2( + base extra: SettingsDictionary = [:], + isolation: AtchaIsolation = .mainActor + ) -> Settings { + let base: SettingsDictionary = [ + "SWIFT_VERSION": "6.0", + "SWIFT_APPROACHABLE_CONCURRENCY": "YES", + "SWIFT_DEFAULT_ACTOR_ISOLATION": .string(isolation.rawValue), + "CODE_SIGN_STYLE": "Automatic", + "DEVELOPMENT_TEAM": .string(Atcha.teamID), + "TARGETED_DEVICE_FAMILY": "1", + ].merging(extra) { _, custom in custom } + + return .settings( + base: base, + configurations: [ + .debug( + name: "Debug", + settings: ["SWIFT_ACTIVE_COMPILATION_CONDITIONS": "$(inherited) DEV"] + ), + .release( + name: "Stage", + settings: ["SWIFT_ACTIVE_COMPILATION_CONDITIONS": "$(inherited) STAGE"] + ), + .release( + name: "Release", + settings: ["SWIFT_ACTIVE_COMPILATION_CONDITIONS": "$(inherited) LIVE"] + ), + ], + defaultSettings: .recommended + ) + } +} diff --git a/Workspace.swift b/Workspace.swift new file mode 100644 index 00000000..b65b0e5c --- /dev/null +++ b/Workspace.swift @@ -0,0 +1,15 @@ +import ProjectDescription + +let workspace = Workspace( + name: "Atcha", + projects: [ + "Projects/App", + "Projects/Feature/Home", + "Projects/Domain", + "Projects/Data", + "Projects/Core/Network", + "Projects/Core/Coordinator", + "Projects/DesignSystem", + "Projects/Legacy", + ] +) diff --git a/mise.toml b/mise.toml new file mode 100644 index 00000000..2eed2c5b --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +tuist = "4.202.0"