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
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
84 changes: 84 additions & 0 deletions Projects/App/Project.swift
Original file line number Diff line number Diff line change
@@ -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")
),
]
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
6 changes: 6 additions & 0 deletions Projects/App/Resources/Assets.xcassets/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
28 changes: 28 additions & 0 deletions Projects/App/Sources/AppCoordinator.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
23 changes: 23 additions & 0 deletions Projects/App/Sources/AppDIContainer.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
30 changes: 30 additions & 0 deletions Projects/App/Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
29 changes: 29 additions & 0 deletions Projects/App/Sources/AppEnvironment.swift
Original file line number Diff line number Diff line change
@@ -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")!
}
}
}
26 changes: 26 additions & 0 deletions Projects/App/Sources/SceneDelegate.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
8 changes: 8 additions & 0 deletions Projects/Core/Coordinator/Project.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import ProjectDescription
import ProjectDescriptionHelpers

let project = Project.layer(
name: "CoreCoordinator",
bundleSuffix: "core.coordinator",
isolation: .mainActor
)
35 changes: 35 additions & 0 deletions Projects/Core/Coordinator/Sources/Coordinator.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
35 changes: 35 additions & 0 deletions Projects/Core/Coordinator/Tests/CoordinatorTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 4 additions & 0 deletions Projects/Core/Network/Project.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import ProjectDescription
import ProjectDescriptionHelpers

let project = Project.layer(name: "CoreNetwork", bundleSuffix: "core.network")
23 changes: 23 additions & 0 deletions Projects/Core/Network/Sources/Endpoint.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
17 changes: 17 additions & 0 deletions Projects/Core/Network/Sources/NetworkClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Foundation

public protocol NetworkClient: Sendable {
func data(for endpoint: any Endpoint) async throws -> Data
func request<Response: Decodable & Sendable>(
_ endpoint: any Endpoint,
as type: Response.Type
) async throws -> Response
}

public extension NetworkClient {
func request<Response: Decodable & Sendable>(
_ endpoint: any Endpoint
) async throws -> Response {
try await request(endpoint, as: Response.self)
}
}
9 changes: 9 additions & 0 deletions Projects/Core/Network/Sources/NetworkError.swift
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading