diff --git "a/.github/ISSUE_TEMPLATE/\354\235\264\354\212\210-\354\203\235\354\204\261-\355\205\234\355\224\214\353\246\277.md" "b/.github/ISSUE_TEMPLATE/\354\235\264\354\212\210-\354\203\235\354\204\261-\355\205\234\355\224\214\353\246\277.md"
new file mode 100644
index 00000000..b97423c9
--- /dev/null
+++ "b/.github/ISSUE_TEMPLATE/\354\235\264\354\212\210-\354\203\235\354\204\261-\355\205\234\355\224\214\353\246\277.md"
@@ -0,0 +1,16 @@
+---
+name: 이슈 생성 템플릿
+about: 이슈 생성 템플릿입니다.
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+## 작업사항
+
+## Todo
+- [ ] todo
+- [ ] todo
+
+## ETC
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..595752f3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,72 @@
+### macOS ###
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+### Xcode ###
+# Xcode
+#
+# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
+
+## User settings
+xcuserdata/
+
+## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
+*.xcscmblueprint
+*.xccheckout
+
+## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
+build/
+DerivedData/
+*.moved-aside
+*.pbxuser
+!default.pbxuser
+*.mode1v3
+!default.mode1v3
+*.mode2v3
+!default.mode2v3
+*.perspectivev3
+!default.perspectivev3
+
+### Xcode Patch ###
+*.xcodeproj/*
+!*.xcodeproj/project.pbxproj
+!*.xcodeproj/xcshareddata/
+!*.xcworkspace/contents.xcworkspacedata
+/*.gcno
+
+### Projects ###
+*.xcodeproj
+*.xcworkspace
+
+### Tuist derived files ###
+graph.dot
+Derived/
+
+### Tuist managed dependencies ###
+Tuist/.build
+
+## XCConfig ##
+*.xcconfig
+
+*.plist
\ No newline at end of file
diff --git a/.mise.toml b/.mise.toml
new file mode 100644
index 00000000..51e7e5aa
--- /dev/null
+++ b/.mise.toml
@@ -0,0 +1,2 @@
+[tools]
+tuist = "4.20.0"
diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000..9e6bbab5
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,16 @@
+generate:
+ tuist install
+ tuist generate
+
+clean:
+ tuist clean
+ rm -rf **/**/**/*.xcodeproj
+ rm -rf **/**/*.xcodeproj
+ rm -rf **/*.xcodeproj
+ rm -rf *.xcworkspace
+
+graph:
+ tuist graph --skip-external-dependencies
+
+module:
+ swift Scripts/GenerateModule.swift
diff --git a/Plugins/ConfigurationPlugin/Plugin.swift b/Plugins/ConfigurationPlugin/Plugin.swift
new file mode 100644
index 00000000..1335135c
--- /dev/null
+++ b/Plugins/ConfigurationPlugin/Plugin.swift
@@ -0,0 +1,10 @@
+//
+// Plugin.swift
+// DependencyPlugin
+//
+// Created by 임현규 on 7/19/24.
+//
+
+import ProjectDescription
+
+let plugin = Plugin(name: "ConfiguratipnPlugin")
diff --git a/Plugins/ConfigurationPlugin/ProjectDescriptionHelpers/Configuration+Extensions.swift b/Plugins/ConfigurationPlugin/ProjectDescriptionHelpers/Configuration+Extensions.swift
new file mode 100644
index 00000000..b74862d9
--- /dev/null
+++ b/Plugins/ConfigurationPlugin/ProjectDescriptionHelpers/Configuration+Extensions.swift
@@ -0,0 +1,14 @@
+//
+// Configuration+Extensions.swift
+// ConfiguratipnPlugin
+//
+// Created by 임현규 on 7/19/24.
+//
+
+import ProjectDescription
+
+public extension ConfigurationName {
+ static var dev: ConfigurationName { configuration(ProjectDeployTarget.dev.rawValue) }
+ static var prod: ConfigurationName { configuration(ProjectDeployTarget.prod.rawValue) }
+ static var test: ConfigurationName { configuration(ProjectDeployTarget.test.rawValue) }
+}
diff --git a/Plugins/ConfigurationPlugin/ProjectDescriptionHelpers/ProjectDeployTarget.swift b/Plugins/ConfigurationPlugin/ProjectDescriptionHelpers/ProjectDeployTarget.swift
new file mode 100644
index 00000000..fa3bc052
--- /dev/null
+++ b/Plugins/ConfigurationPlugin/ProjectDescriptionHelpers/ProjectDeployTarget.swift
@@ -0,0 +1,18 @@
+//
+// ProjectDeployTarget.swift
+// ProjectDescriptionHelpers
+//
+// Created by 임현규 on 7/19/24.
+//
+
+import ProjectDescription
+
+public enum ProjectDeployTarget: String {
+ case dev = "DEV"
+ case prod = "PROD"
+ case test = "TEST"
+
+ public var configurationName: ConfigurationName {
+ ConfigurationName.configuration(self.rawValue)
+ }
+}
diff --git a/Plugins/DependencyPlugin/Plugin.swift b/Plugins/DependencyPlugin/Plugin.swift
new file mode 100644
index 00000000..f2823c87
--- /dev/null
+++ b/Plugins/DependencyPlugin/Plugin.swift
@@ -0,0 +1,10 @@
+//
+// Plugin.swift
+// DependencyPlugin
+//
+// Created by 임현규 on 7/17/24.
+//
+
+import ProjectDescription
+
+let plugin = Plugin(name: "DependencyPlugin")
diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Modules.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Modules.swift
new file mode 100644
index 00000000..ad0ef009
--- /dev/null
+++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Modules.swift
@@ -0,0 +1,86 @@
+//
+// Modules.swift
+// DependencyPlugin
+//
+// Created by 임현규 on 7/17/24.
+//
+
+import ProjectDescription
+
+public enum ModulePath {
+ case feature(Feature)
+ case domain(Domain)
+ case core(Core)
+ case shared(Shared)
+}
+
+// MARK: - AppModule
+
+public extension ModulePath {
+ enum App: String, CaseIterable {
+ case iOS
+ public static let name: String = "App"
+ }
+}
+
+
+// MARK: - FeatureModule
+public extension ModulePath {
+ enum Feature: String, CaseIterable {
+ case TabBar
+ case Report
+ case BottleArrival
+ case GeneralSignUp
+ case BaseWebView
+ case ProfileSetup
+ case Onboarding
+ case MyPage
+ case BottleStorage
+ case SandBeach
+ case Login
+
+ public static let name: String = "Feature"
+ }
+}
+
+// MARK: - DomainModule
+
+public extension ModulePath {
+ enum Domain: String, CaseIterable {
+ case Report
+ case WebView
+ case Bottle
+ case Profile
+ case Auth
+
+ public static let name: String = "Domain"
+ }
+}
+
+// MARK: - CoreModule
+
+public extension ModulePath {
+ enum Core: String, CaseIterable {
+ case Toast
+ case KeyChainStore
+ case WebView
+ case Util
+ case Logger
+ case Network
+
+ public static let name: String = "Core"
+ }
+}
+
+// MARK: - SharedModule
+
+public extension ModulePath {
+ enum Shared: String, CaseIterable {
+ case DesignSystemThirdPartyLib
+ case ThirdPartyLib
+ case DesignSystem
+ case Util
+
+ public static let name: String = "Shared"
+ }
+}
diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Path+Modules.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Path+Modules.swift
new file mode 100644
index 00000000..0bf2fd28
--- /dev/null
+++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Path+Modules.swift
@@ -0,0 +1,64 @@
+//
+// Path+Modules.swift
+// DependencyPlugin
+//
+// Created by 임현규 on 7/17/24.
+//
+
+import ProjectDescription
+
+// MARK: - ProjectDescription.Path + App
+
+public extension ProjectDescription.Path {
+ static var app: Self {
+ return .relativeToRoot("Projects/\(ModulePath.App.name)")
+ }
+}
+
+// MARK: - ProjectDescription.Path + Feature
+
+public extension ProjectDescription.Path {
+ static var feature: Self {
+ return .relativeToRoot("Projects/\(ModulePath.Feature.name)")
+ }
+
+ static func feature(implementation module: ModulePath.Feature) -> Self {
+ return .relativeToRoot("Projects/\(ModulePath.Feature.name)/\(module.rawValue)")
+ }
+}
+
+// MARK: - ProjectDescription.Path + Domain
+
+public extension ProjectDescription.Path {
+ static var domain: Self {
+ return .relativeToRoot("Projects/\(ModulePath.Domain.name)")
+ }
+
+ static func domain(implementation module: ModulePath.Domain) -> Self {
+ return .relativeToRoot("Projects/\(ModulePath.Domain.name)/\(module.rawValue)")
+ }
+}
+
+// MARK: - ProjectDescription.Path + Core
+
+public extension ProjectDescription.Path {
+ static var core: Self {
+ return .relativeToRoot("Projects/\(ModulePath.Core.name)")
+ }
+
+ static func core(implementation module: ModulePath.Core) -> Self {
+ return .relativeToRoot("Projects/\(ModulePath.Core.name)/\(module.rawValue)")
+ }
+}
+
+// MARK: - ProjectDescription.Path + Shared
+
+public extension ProjectDescription.Path {
+ static var shared: Self {
+ return .relativeToRoot("Projects/\(ModulePath.Shared.name)")
+ }
+
+ static func shared(implementation module: ModulePath.Shared) -> Self {
+ return .relativeToRoot("Projects/\(ModulePath.Shared.name)/\(module.rawValue)")
+ }
+}
diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Project+Environment.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Project+Environment.swift
new file mode 100644
index 00000000..e50721c2
--- /dev/null
+++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Project+Environment.swift
@@ -0,0 +1,16 @@
+//
+// Project+Environment.swift
+// DependencyPlugin
+//
+// Created by 임현규 on 7/17/24.
+//
+
+import ProjectDescription
+
+public extension Project {
+ enum Environment {
+ public static let appName = "Bottle"
+ public static let deploymentTarget = DeploymentTargets.iOS("16.0")
+ public static let bundlePrefix = "asia.bottles"
+ }
+}
diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Modules.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Modules.swift
new file mode 100644
index 00000000..6e7b4470
--- /dev/null
+++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Modules.swift
@@ -0,0 +1,109 @@
+//
+// TargetDependency+Modules.swift
+// DependencyPlugin
+//
+// Created by 임현규 on 7/17/24.
+//
+
+import ProjectDescription
+
+// MARK: - TargetDependency + App
+
+public extension TargetDependency {
+ static var app: Self {
+ return .project(target: ModulePath.App.name, path: .app)
+ }
+
+ static func app(implements module: ModulePath.App) -> Self {
+ return .target(name: ModulePath.App.name + module.rawValue)
+ }
+}
+
+// MARK: - TargetDependency + Feature
+
+public extension TargetDependency {
+ static var feature: Self {
+ return .project(target: ModulePath.Feature.name, path: .feature)
+ }
+
+ static func feature(implements module: ModulePath.Feature) -> Self {
+ return .project(target: ModulePath.Feature.name + module.rawValue, path: .feature(implementation: module))
+ }
+
+ static func feature(interface module: ModulePath.Feature) -> Self {
+ return .project(target: ModulePath.Feature.name + module.rawValue + "Interface", path: .feature(implementation: module))
+ }
+
+ static func feature(tests module: ModulePath.Feature) -> Self {
+ return .project(target: ModulePath.Feature.name + module.rawValue + "Tests", path: .feature(implementation: module))
+ }
+
+ static func feature(testing module: ModulePath.Feature) -> Self {
+ return .project(target: ModulePath.Feature.name + module.rawValue + "Testing", path: .feature(implementation: module))
+ }
+
+}
+
+// MARK: - TargetDependency + Domain
+
+public extension TargetDependency {
+ static var domain: Self {
+ return .project(target: ModulePath.Domain.name, path: .domain)
+ }
+
+ static func domain(implements module: ModulePath.Domain) -> Self {
+ return .project(target: ModulePath.Domain.name + module.rawValue, path: .domain(implementation: module))
+ }
+
+ static func domain(interface module: ModulePath.Domain) -> Self {
+ return .project(target: ModulePath.Domain.name + module.rawValue + "Interface", path: .domain(implementation: module))
+ }
+
+ static func domain(tests module: ModulePath.Domain) -> Self {
+ return .project(target: ModulePath.Domain.name + module.rawValue + "Tests", path: .domain(implementation: module))
+ }
+
+ static func domain(testing module: ModulePath.Domain) -> Self {
+ return .project(target: ModulePath.Domain.name + module.rawValue + "Testing", path: .domain(implementation: module))
+ }
+}
+
+// MARK: - TargetDependency + Core
+
+public extension TargetDependency {
+ static var core: Self {
+ return .project(target: ModulePath.Core.name, path: .core)
+ }
+
+ static func core(implements module: ModulePath.Core) -> Self {
+ return .project(target: ModulePath.Core.name + module.rawValue, path: .core(implementation: module))
+ }
+
+ static func core(interface module: ModulePath.Core) -> Self {
+ return .project(target: ModulePath.Core.name + module.rawValue + "Interface", path: .core(implementation: module))
+ }
+
+ static func core(tests module: ModulePath.Core) -> Self {
+ return .project(target: ModulePath.Core.name + module.rawValue + "Tests", path: .core(implementation: module))
+ }
+
+ static func core(testing module: ModulePath.Core) -> Self {
+ return .project(target: ModulePath.Core.name + module.rawValue + "Testing", path: .core(implementation: module))
+ }
+}
+
+// MARK: - TargetDependency + Shared
+
+public extension TargetDependency {
+ static var shared: Self {
+ return .project(target: ModulePath.Shared.name, path: .shared)
+ }
+
+ static func shared(implements module: ModulePath.Shared) -> Self {
+ return .project(target: ModulePath.Shared.name + module.rawValue, path: .shared(implementation: module))
+ }
+
+ static func shared(interface module: ModulePath.Shared) -> Self {
+ return .project(target: ModulePath.Shared.name + module.rawValue + "Interface", path: .shared(implementation: module))
+ }
+}
diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+SPM.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+SPM.swift
new file mode 100644
index 00000000..ea30a422
--- /dev/null
+++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+SPM.swift
@@ -0,0 +1,25 @@
+//
+// TargetDependency+SPM.swift
+// DependencyPlugin
+//
+// Created by 임현규 on 7/19/24.
+//
+
+import ProjectDescription
+
+public extension TargetDependency {
+ struct SPM {}
+}
+
+public extension TargetDependency.SPM {
+ static let ComposableArchitecture: TargetDependency = .external(name: "ComposableArchitecture")
+ static let Kingfisher: TargetDependency = .external(name: "Kingfisher")
+ static let Alamofire: TargetDependency = .external(name: "Alamofire")
+ static let Moya: TargetDependency = .external(name: "Moya")
+ static let KakaoSDKAuth: TargetDependency = .external(name: "KakaoSDKAuth")
+ static let KakaoSDKUser: TargetDependency = .external(name: "KakaoSDKUser")
+ static let Lottie: TargetDependency = .external(name: "Lottie")
+ static let FirebaseAnalytics: TargetDependency = .external(name: "FirebaseAnalytics")
+ static let FirebaseCrashlytics: TargetDependency = .external(name: "FirebaseCrashlytics")
+ static let FirebaseMessaging: TargetDependency = .external(name: "FirebaseMessaging")
+}
diff --git a/Projects/App/Bottle.entitlements b/Projects/App/Bottle.entitlements
new file mode 100644
index 00000000..80b5221d
--- /dev/null
+++ b/Projects/App/Bottle.entitlements
@@ -0,0 +1,12 @@
+
+
+
+
+ aps-environment
+ development
+ com.apple.developer.applesignin
+
+ Default
+
+
+
diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift
new file mode 100644
index 00000000..0d11b4eb
--- /dev/null
+++ b/Projects/App/Project.swift
@@ -0,0 +1,23 @@
+import ProjectDescription
+import ProjectDescriptionHelpers
+
+let targets: [Target] = [
+ .app(implements: .iOS, factory: .init(
+ product: .staticFramework,
+ entitlements: "Bottle.entitlements",
+ dependencies: [
+ .feature
+ ]
+ ))
+]
+
+let project = Project.makeModule(
+ name: Project.Environment.appName,
+ targets: targets,
+ schemes: [
+ .makeScheme(.dev, name: Project.Environment.appName),
+ .makeScheme(.prod, name: Project.Environment.appName),
+ .makeScheme(.test, name: Project.Environment.appName)
+ ]
+)
+
diff --git a/Projects/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/Projects/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 00000000..eb878970
--- /dev/null
+++ b/Projects/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
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..0d14905e
--- /dev/null
+++ b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,116 @@
+{
+ "images" : [
+ {
+ "filename" : "Icon-40.png",
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "20x20"
+ },
+ {
+ "filename" : "Icon-60.png",
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "20x20"
+ },
+ {
+ "filename" : "Icon-58.png",
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "29x29"
+ },
+ {
+ "filename" : "Icon-87.png",
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "29x29"
+ },
+ {
+ "filename" : "Icon-80.png",
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "40x40"
+ },
+ {
+ "filename" : "Icon-120.png",
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "40x40"
+ },
+ {
+ "filename" : "Icon-120 1.png",
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "60x60"
+ },
+ {
+ "filename" : "Icon-180.png",
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "60x60"
+ },
+ {
+ "filename" : "Icon-20.png",
+ "idiom" : "ipad",
+ "scale" : "1x",
+ "size" : "20x20"
+ },
+ {
+ "filename" : "Icon-40 1.png",
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "20x20"
+ },
+ {
+ "filename" : "Icon-29.png",
+ "idiom" : "ipad",
+ "scale" : "1x",
+ "size" : "29x29"
+ },
+ {
+ "filename" : "Icon-58 1.png",
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "29x29"
+ },
+ {
+ "filename" : "Icon-40 2.png",
+ "idiom" : "ipad",
+ "scale" : "1x",
+ "size" : "40x40"
+ },
+ {
+ "filename" : "Icon-80 1.png",
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "40x40"
+ },
+ {
+ "filename" : "Icon-76.png",
+ "idiom" : "ipad",
+ "scale" : "1x",
+ "size" : "76x76"
+ },
+ {
+ "filename" : "Icon-152.png",
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "76x76"
+ },
+ {
+ "filename" : "Icon-167.png",
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "83.5x83.5"
+ },
+ {
+ "filename" : "Icon-1024.png",
+ "idiom" : "ios-marketing",
+ "scale" : "1x",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-1024.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-1024.png
new file mode 100644
index 00000000..856babc3
Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-1024.png differ
diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-120 1.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-120 1.png
new file mode 100644
index 00000000..79bd3534
Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-120 1.png differ
diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-120.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-120.png
new file mode 100644
index 00000000..79bd3534
Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-120.png differ
diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-152.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-152.png
new file mode 100644
index 00000000..7a7e389d
Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-152.png differ
diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-167.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-167.png
new file mode 100644
index 00000000..2e8395ab
Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-167.png differ
diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-180.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-180.png
new file mode 100644
index 00000000..eff450ed
Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-180.png differ
diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-20.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-20.png
new file mode 100644
index 00000000..c753f9b9
Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-20.png differ
diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-29.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-29.png
new file mode 100644
index 00000000..f3713ee4
Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-29.png differ
diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40 1.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40 1.png
new file mode 100644
index 00000000..9af9b038
Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40 1.png differ
diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40 2.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40 2.png
new file mode 100644
index 00000000..9af9b038
Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40 2.png differ
diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40.png
new file mode 100644
index 00000000..9af9b038
Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40.png differ
diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-58 1.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-58 1.png
new file mode 100644
index 00000000..c483a9cd
Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-58 1.png differ
diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-58.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-58.png
new file mode 100644
index 00000000..c483a9cd
Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-58.png differ
diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-60.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-60.png
new file mode 100644
index 00000000..bbed9c1d
Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-60.png differ
diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-76.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-76.png
new file mode 100644
index 00000000..716c6af0
Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-76.png differ
diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-80 1.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-80 1.png
new file mode 100644
index 00000000..8ec0edf1
Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-80 1.png differ
diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-80.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-80.png
new file mode 100644
index 00000000..8ec0edf1
Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-80.png differ
diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-87.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-87.png
new file mode 100644
index 00000000..a27cf087
Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-87.png differ
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/Resources/Assets.xcassets/splashColor.colorset/Contents.json b/Projects/App/Resources/Assets.xcassets/splashColor.colorset/Contents.json
new file mode 100644
index 00000000..6b0b28e0
--- /dev/null
+++ b/Projects/App/Resources/Assets.xcassets/splashColor.colorset/Contents.json
@@ -0,0 +1,56 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "241",
+ "green" : "101",
+ "red" : "78"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "light"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "241",
+ "green" : "101",
+ "red" : "78"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "241",
+ "green" : "101",
+ "red" : "78"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Projects/App/Resources/Assets.xcassets/splashImage.imageset/Contents.json b/Projects/App/Resources/Assets.xcassets/splashImage.imageset/Contents.json
new file mode 100644
index 00000000..57501fc7
--- /dev/null
+++ b/Projects/App/Resources/Assets.xcassets/splashImage.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "icon_splash.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Projects/App/Resources/Assets.xcassets/splashImage.imageset/icon_splash.svg b/Projects/App/Resources/Assets.xcassets/splashImage.imageset/icon_splash.svg
new file mode 100644
index 00000000..eb848180
--- /dev/null
+++ b/Projects/App/Resources/Assets.xcassets/splashImage.imageset/icon_splash.svg
@@ -0,0 +1,48 @@
+
diff --git a/Projects/App/Resources/Preview Content/Preview Assets.xcassets/Contents.json b/Projects/App/Resources/Preview Content/Preview Assets.xcassets/Contents.json
new file mode 100644
index 00000000..73c00596
--- /dev/null
+++ b/Projects/App/Resources/Preview Content/Preview Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Projects/App/Sources/AppDelegate.swift b/Projects/App/Sources/AppDelegate.swift
new file mode 100644
index 00000000..1a599a09
--- /dev/null
+++ b/Projects/App/Sources/AppDelegate.swift
@@ -0,0 +1,64 @@
+//
+// AppDelegate.swift
+// Bottle
+//
+// Created by JongHoon on 7/23/24.
+//
+
+import SwiftUI
+
+import CoreLoggerInterface
+import Feature
+
+import ComposableArchitecture
+import FirebaseCore
+import FirebaseMessaging
+
+final class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate {
+ var store = Store(
+ initialState: AppFeature.State(),
+ reducer: { AppFeature() }
+ )
+
+ func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
+ ) -> Bool {
+ FirebaseApp.configure()
+ UIApplication.shared.registerForRemoteNotifications()
+ UNUserNotificationCenter.current().delegate = self
+ Messaging.messaging().delegate = self
+
+ application.registerForRemoteNotifications()
+
+ store.send(.appDelegate(.didFinishLunching))
+ return true
+ }
+}
+
+extension AppDelegate: UNUserNotificationCenterDelegate {
+ func messaging(
+ _ messaging: Messaging,
+ didReceiveRegistrationToken fcmToken: String?
+ ) {
+ Log.debug("fcm token: \(fcmToken ?? "NO TOKEN")")
+ if let fcmToken {
+ // TODO: user defaults 설정 방법 변경
+ UserDefaults.standard.set(fcmToken, forKey: "fcmToken")
+ }
+ }
+
+ func application(
+ _ application: UIApplication,
+ didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
+ ) {
+ Messaging.messaging().apnsToken = deviceToken
+ }
+
+ func userNotificationCenter(
+ _ center: UNUserNotificationCenter,
+ willPresent notification: UNNotification
+ ) async -> UNNotificationPresentationOptions {
+ return [.badge, .sound, .banner, .list]
+ }
+}
diff --git a/Projects/App/Sources/AppRoot.swift b/Projects/App/Sources/AppRoot.swift
new file mode 100644
index 00000000..c7402e22
--- /dev/null
+++ b/Projects/App/Sources/AppRoot.swift
@@ -0,0 +1,27 @@
+import SwiftUI
+
+import Feature
+import CoreLoggerInterface
+import SharedDesignSystem
+
+import ComposableArchitecture
+import KakaoSDKAuth
+
+@main
+struct AppRoot: App {
+ @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
+ @Environment(\.scenePhase) private var scenePhase
+
+ var body: some Scene {
+ WindowGroup {
+ RootView {
+ AppView(store: appDelegate.store)
+ .onOpenURL(perform: { url in
+ if (AuthApi.isKakaoTalkLoginUrl(url)) {
+ _ = AuthController.handleOpenUrl(url: url)
+ }
+ })
+ }
+ }
+ }
+}
diff --git a/Projects/App/Tests/BottlesIOSTests.swift b/Projects/App/Tests/BottlesIOSTests.swift
new file mode 100644
index 00000000..a1196c76
--- /dev/null
+++ b/Projects/App/Tests/BottlesIOSTests.swift
@@ -0,0 +1,8 @@
+import Foundation
+import XCTest
+
+final class BottlesIOSTests: XCTestCase {
+ func test_twoPlusTwo_isFour() {
+ XCTAssertEqual(2+2, 4)
+ }
+}
\ No newline at end of file
diff --git a/Projects/Core/KeyChainStore/Interface/Sources/KeyChainStorable.swift b/Projects/Core/KeyChainStore/Interface/Sources/KeyChainStorable.swift
new file mode 100644
index 00000000..ff39c300
--- /dev/null
+++ b/Projects/Core/KeyChainStore/Interface/Sources/KeyChainStorable.swift
@@ -0,0 +1,15 @@
+//
+// KeyChainStorable.swift
+// CoreKeyChainStoreInterface
+//
+// Created by 임현규 on 7/31/24.
+//
+
+import Foundation
+
+public protocol KeyChainStorable {
+ func save(property: TokenStoreProperties, value: String)
+ func load(property: TokenStoreProperties) -> String
+ func delete(property: TokenStoreProperties)
+ func deleteAll()
+}
diff --git a/Projects/Core/KeyChainStore/Interface/Sources/TokenStoreProperties.swift b/Projects/Core/KeyChainStore/Interface/Sources/TokenStoreProperties.swift
new file mode 100644
index 00000000..71d2c3e5
--- /dev/null
+++ b/Projects/Core/KeyChainStore/Interface/Sources/TokenStoreProperties.swift
@@ -0,0 +1,17 @@
+//
+// TokenStoreProperties.swift
+// CoreKeyChainStoreInterface
+//
+// Created by 임현규 on 7/31/24.
+//
+
+import Foundation
+
+public enum TokenStoreProperties: String, CaseIterable {
+ case accessToken = "ACCESS-TOKEN"
+ case refreshToken = "REFRESH-TOKEN"
+ case AppleUserID = "APPLE-USER-ID"
+ case AppleRefreshToken = "APPLE-REFRESH-TOKEN"
+ case AppleClientSecret = "APPLE-CLIENT-SECRET"
+ case AppleAuthCode = "APPLE-AUTH-CODE"
+}
diff --git a/Projects/Core/KeyChainStore/Project.swift b/Projects/Core/KeyChainStore/Project.swift
new file mode 100644
index 00000000..0bcfaeaf
--- /dev/null
+++ b/Projects/Core/KeyChainStore/Project.swift
@@ -0,0 +1,44 @@
+import ProjectDescription
+import ProjectDescriptionHelpers
+import DependencyPlugin
+
+let project = Project.makeModule(
+ name: ModulePath.Core.name+ModulePath.Core.KeyChainStore.rawValue,
+ targets: [
+ .core(
+ interface: .KeyChainStore,
+ factory: .init(
+ dependencies: [
+ .shared
+ ]
+ )
+ ),
+ .core(
+ implements: .KeyChainStore,
+ factory: .init(
+ dependencies: [
+ .core(interface: .KeyChainStore)
+ ]
+ )
+ ),
+
+ .core(
+ testing: .KeyChainStore,
+ factory: .init(
+ dependencies: [
+ .core(interface: .KeyChainStore)
+ ]
+ )
+ ),
+ .core(
+ tests: .KeyChainStore,
+ factory: .init(
+ dependencies: [
+ .core(testing: .KeyChainStore),
+ .core(implements: .KeyChainStore)
+ ]
+ )
+ ),
+
+ ]
+)
diff --git a/Projects/Core/KeyChainStore/Sources/KeyChainTokenStore.swift b/Projects/Core/KeyChainStore/Sources/KeyChainTokenStore.swift
new file mode 100644
index 00000000..c8ebcde4
--- /dev/null
+++ b/Projects/Core/KeyChainStore/Sources/KeyChainTokenStore.swift
@@ -0,0 +1,52 @@
+//
+// KeyChainTokenStore.swift
+// CoreKeyChainStore
+//
+// Created by 임현규 on 7/31/24.
+//
+
+import Foundation
+
+import CoreKeyChainStoreInterface
+
+public struct KeyChainTokenStore: KeyChainStorable {
+ public static let shared = KeyChainTokenStore()
+
+ public func save(property: TokenStoreProperties, value: String) {
+ let query: NSDictionary = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrAccount: property.rawValue,
+ kSecValueData: value.data(using: .utf8, allowLossyConversion: false) ?? .init(),
+ ]
+ SecItemDelete(query)
+ SecItemAdd(query, nil)
+ }
+
+ public func load(property: TokenStoreProperties) -> String {
+ let query: NSDictionary = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrAccount: property.rawValue,
+ kSecReturnData: kCFBooleanTrue!,
+ kSecMatchLimit: kSecMatchLimitOne
+ ]
+ var dataTypeRef: AnyObject?
+ let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)
+ if status == errSecSuccess {
+ guard let data = dataTypeRef as? Data else { return "" }
+ return String(data: data, encoding: .utf8) ?? ""
+ }
+ return ""
+ }
+
+ public func delete(property: TokenStoreProperties) {
+ let query: NSDictionary = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrAccount: property.rawValue
+ ]
+ SecItemDelete(query)
+ }
+
+ public func deleteAll() {
+ TokenStoreProperties.allCases.forEach { delete(property: $0) }
+ }
+}
diff --git a/Projects/Core/KeyChainStore/Testing/Sources/KeyChainStoreTesting.swift b/Projects/Core/KeyChainStore/Testing/Sources/KeyChainStoreTesting.swift
new file mode 100644
index 00000000..b1853ce6
--- /dev/null
+++ b/Projects/Core/KeyChainStore/Testing/Sources/KeyChainStoreTesting.swift
@@ -0,0 +1 @@
+// This is for Tuist
diff --git a/Projects/Core/KeyChainStore/Tests/Sources/KeyChainStoreTest.swift b/Projects/Core/KeyChainStore/Tests/Sources/KeyChainStoreTest.swift
new file mode 100644
index 00000000..a1473dc7
--- /dev/null
+++ b/Projects/Core/KeyChainStore/Tests/Sources/KeyChainStoreTest.swift
@@ -0,0 +1,11 @@
+import XCTest
+
+final class KeyChainStoreTests: XCTestCase {
+ override func setUpWithError() throws {}
+
+ override func tearDownWithError() throws {}
+
+ func testExample() {
+ XCTAssertEqual(1, 1)
+ }
+}
diff --git a/Projects/Core/Logger/Interface/Sources/Log.swift b/Projects/Core/Logger/Interface/Sources/Log.swift
new file mode 100644
index 00000000..6faacd2c
--- /dev/null
+++ b/Projects/Core/Logger/Interface/Sources/Log.swift
@@ -0,0 +1,142 @@
+//
+// Log.swift
+// CoreLoggerInterface
+//
+// Created by 임현규 on 7/23/24.
+//
+
+import Foundation
+import OSLog
+
+public enum Log {
+ public enum Level {
+ case debug
+ case info
+ case error
+ case fault
+
+ fileprivate var category: String {
+ switch self {
+ case .debug:
+ return "Debug"
+ case .info:
+ return "Info"
+ case .error:
+ return "Error"
+ case .fault:
+ return "Fault"
+ }
+ }
+
+ fileprivate var osLogType: OSLogType {
+ switch self {
+ case .debug:
+ return .debug
+ case .info:
+ return .info
+ case .error:
+ return .error
+ case .fault:
+ return .fault
+ }
+ }
+ }
+}
+
+// MARK: - Private Method
+
+private extension Log {
+ static func log(message: Any?, level: Level, fileName: String, line: Int, funcName: StaticString) {
+#if DEBUG
+ let logger = Logger(subsystem: OSLog.subsystem, category: level.category)
+ let moduleName = fileName.prefix(while: { $0 != "/" })
+ let filename = fileName.components(separatedBy: "/").last ?? ""
+ var logMessage = "[\(moduleName), \(filename), \(line), \(funcName)]"
+ if let message = message {
+ logMessage += " - \(message)"
+ }
+
+ if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" {
+ print(logMessage)
+ return
+ }
+
+ switch level {
+ case .debug:
+ logger.debug("✨ \(logMessage, privacy: .public)")
+ case .info:
+ logger.info("ℹ️ \(logMessage, privacy: .public)")
+ case .error:
+ logger.error("🚨 \(logMessage, privacy: .public)")
+ case .fault:
+ logger.fault("‼️ \(logMessage, privacy: .public)")
+ }
+#endif
+ }
+
+ static func simpleLog(message: Any?, level: Level, fileName: String) {
+ #if DEBUG
+ let logger = Logger(subsystem: OSLog.subsystem, category: level.category)
+ let moduleName = fileName.prefix(while: { $0 != "/" })
+
+ var logMessage = "[\(moduleName)]"
+
+ if let message = message {
+ logMessage += " - \(message)"
+ }
+
+ if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" {
+ print(logMessage)
+ return
+ }
+
+ switch level {
+ case .debug:
+ logger.debug("✨ \(logMessage, privacy: .public)")
+ case .info:
+ logger.info("ℹ️ \(logMessage, privacy: .public)")
+ case .error:
+ logger.error("🚨 \(logMessage, privacy: .public)")
+ case .fault:
+ logger.fault("‼️ \(logMessage, privacy: .public)")
+ }
+
+ #endif
+ }
+}
+
+// MARK: - Public Methods
+
+public extension Log {
+ static func debug(_ message: Any?, fileName: String = #fileID, line: Int = #line, funcName: StaticString = #function) {
+ log(message: message, level: .debug, fileName: fileName, line: line, funcName: funcName)
+ }
+
+ static func info(_ message: Any?, fileName: String = #fileID, line: Int = #line, funcName: StaticString = #function) {
+ log(message: message, level: .info, fileName: fileName, line: line, funcName: funcName)
+ }
+
+ static func error(_ message: Any?, fileName: String = #fileID, line: Int = #line, funcName: StaticString = #function) {
+ log(message: message, level: .error, fileName: fileName, line: line, funcName: funcName)
+ }
+
+ static func fault(_ message: Any?, fileName: String = #fileID, line: Int = #line, funcName: StaticString = #function) {
+ log(message: message, level: .fault, fileName: fileName, line: line, funcName: funcName)
+ }
+
+ static func network(_ message: Any?, level: Level, fileName: String = #fileID) {
+ simpleLog(message: message, level: level, fileName: fileName)
+ }
+
+ static func assertion(message: Any?, level: Level = .fault, fileName: String = #fileID, line: Int = #line, funcName: StaticString = #function) {
+ let logMessage = "\(message ?? "")"
+ log(message: logMessage, level: level, fileName: fileName, line: line, funcName: funcName)
+ assertionFailure(logMessage)
+ }
+
+ static func fatal(message: Any?, level: Level = .fault, fileName: String = #fileID, line: Int = #line, funcName: StaticString = #function) {
+ let logMessage = "\(message ?? "")"
+ log(message: message, level: level, fileName: fileName, line: line, funcName: funcName)
+ fatalError(logMessage)
+ }
+}
diff --git a/Projects/Core/Logger/Interface/Sources/OSLog+Extensions.swift b/Projects/Core/Logger/Interface/Sources/OSLog+Extensions.swift
new file mode 100644
index 00000000..b60ab37f
--- /dev/null
+++ b/Projects/Core/Logger/Interface/Sources/OSLog+Extensions.swift
@@ -0,0 +1,13 @@
+//
+// OSLog+Extensions.swift
+// CoreLoggerInterface
+//
+// Created by 임현규 on 7/23/24.
+//
+
+import Foundation
+import OSLog
+
+extension OSLog {
+ static let subsystem = Bundle.main.bundleIdentifier ?? ""
+}
diff --git a/Projects/Core/Logger/Project.swift b/Projects/Core/Logger/Project.swift
new file mode 100644
index 00000000..63d815f2
--- /dev/null
+++ b/Projects/Core/Logger/Project.swift
@@ -0,0 +1,40 @@
+import ProjectDescription
+import ProjectDescriptionHelpers
+import DependencyPlugin
+
+let project = Project.makeModule(
+ name: ModulePath.Core.name+ModulePath.Core.Logger.rawValue,
+ targets: [
+ .core(
+ interface: .Logger,
+ factory: .init()
+ ),
+ .core(
+ implements: .Logger,
+ factory: .init(
+ dependencies: [
+ .core(interface: .Logger)
+ ]
+ )
+ ),
+
+ .core(
+ testing: .Logger,
+ factory: .init(
+ dependencies: [
+ .core(interface: .Logger)
+ ]
+ )
+ ),
+ .core(
+ tests: .Logger,
+ factory: .init(
+ dependencies: [
+ .core(testing: .Logger),
+ .core(implements: .Logger)
+ ]
+ )
+ ),
+
+ ]
+)
diff --git a/Projects/Core/Logger/Sources/Source.swift b/Projects/Core/Logger/Sources/Source.swift
new file mode 100644
index 00000000..b1853ce6
--- /dev/null
+++ b/Projects/Core/Logger/Sources/Source.swift
@@ -0,0 +1 @@
+// This is for Tuist
diff --git a/Projects/Core/Logger/Testing/Sources/LoggerTesting.swift b/Projects/Core/Logger/Testing/Sources/LoggerTesting.swift
new file mode 100644
index 00000000..b1853ce6
--- /dev/null
+++ b/Projects/Core/Logger/Testing/Sources/LoggerTesting.swift
@@ -0,0 +1 @@
+// This is for Tuist
diff --git a/Projects/Core/Logger/Tests/Sources/LoggerTest.swift b/Projects/Core/Logger/Tests/Sources/LoggerTest.swift
new file mode 100644
index 00000000..e10e8f26
--- /dev/null
+++ b/Projects/Core/Logger/Tests/Sources/LoggerTest.swift
@@ -0,0 +1,11 @@
+import XCTest
+
+final class LoggerTests: XCTestCase {
+ override func setUpWithError() throws {}
+
+ override func tearDownWithError() throws {}
+
+ func testExample() {
+ XCTAssertEqual(1, 1)
+ }
+}
diff --git a/Projects/Core/Network/Interface/Sources/API/AnyAPIType.swift b/Projects/Core/Network/Interface/Sources/API/AnyAPIType.swift
new file mode 100644
index 00000000..3f48257b
--- /dev/null
+++ b/Projects/Core/Network/Interface/Sources/API/AnyAPIType.swift
@@ -0,0 +1,35 @@
+//
+// AnyAPIType.swift
+// CoreNetworkInterface
+//
+// Created by 임현규 on 7/22/24.
+//
+
+import Foundation
+
+import Moya
+
+public enum AnyAPIType: BaseTargetType {
+ case apiType(BaseTargetType)
+
+ public init(_ apiType: any BaseTargetType) {
+ self = .apiType(apiType)
+ }
+
+ public var apiType: any BaseTargetType {
+ switch self {
+ case .apiType(let apiType):
+ return apiType
+ }
+ }
+
+ public var baseURL: URL { apiType.baseURL }
+
+ public var path: String { apiType.path }
+
+ public var method: Moya.Method { apiType.method }
+
+ public var task: Moya.Task { apiType.task }
+
+ public var headers: [String : String]? { apiType.headers }
+}
diff --git a/Projects/Core/Network/Interface/Sources/API/BaseTargetType.swift b/Projects/Core/Network/Interface/Sources/API/BaseTargetType.swift
new file mode 100644
index 00000000..a23ec83f
--- /dev/null
+++ b/Projects/Core/Network/Interface/Sources/API/BaseTargetType.swift
@@ -0,0 +1,29 @@
+//
+// BaseTargetType.swift
+// CoreNetwork
+//
+// Created by 임현규 on 7/21/24.
+//
+
+import Foundation
+import Moya
+
+public protocol BaseTargetType: TargetType {}
+
+public extension BaseTargetType {
+ var baseURL: URL {
+ guard let baseURL = Bundle.main.infoDictionary?["BASE_URL"] as? String else {
+ return URL(string: "")!
+ }
+
+ return URL(string: baseURL)!
+ }
+
+ var headers: [String : String]? {
+ return ["Content-type": "application/json"]
+ }
+
+ var validationType: ValidationType {
+ return .successCodes
+ }
+}
diff --git a/Projects/Core/Network/Interface/Sources/NetworkManager/NetworkManagable.swift b/Projects/Core/Network/Interface/Sources/NetworkManager/NetworkManagable.swift
new file mode 100644
index 00000000..6f1c3bb3
--- /dev/null
+++ b/Projects/Core/Network/Interface/Sources/NetworkManager/NetworkManagable.swift
@@ -0,0 +1,28 @@
+//
+// NetworkManagable.swift
+// CoreNetworkInterface
+//
+// Created by 임현규 on 7/22/24.
+//
+
+import Foundation
+
+public protocol NetworkManagable {
+ associatedtype APIType: BaseTargetType
+
+ /// Swift Concurrency로 네트워크 요청하여 얻은 데이터 디코딩 후 반환하는 메소드
+ /// - Parameters:
+ /// - api: BaseTargetType을 준수하는 API
+ /// - dto: 디코딩할 타입
+ func reqeust(api: APIType, dto: T.Type) async throws -> T
+
+ /// Swift Concurrency로 네트워크 요청하는 메소드 (요청만 하는 경우)
+ /// - Parameters:
+ /// - api: BaseTargetType을 준수하는 API
+ func reqeust(api: APIType) async throws
+
+ /// Endpoint의 Authorization HTTPHeader 추가하는 메소드
+ /// - Parameters:
+ /// - token: AccessToken or RefreshToken
+ func addAuthorizationHeader(token: String)
+}
diff --git a/Projects/Core/Network/Interface/Sources/Provider/Providable.swift b/Projects/Core/Network/Interface/Sources/Provider/Providable.swift
new file mode 100644
index 00000000..18c46f12
--- /dev/null
+++ b/Projects/Core/Network/Interface/Sources/Provider/Providable.swift
@@ -0,0 +1,20 @@
+//
+// Providable.swift
+// CoreNetworkInterface
+//
+// Created by 임현규 on 7/22/24.
+//
+
+import Foundation
+import Moya
+
+/// Moya Provider의 네트워크 요청을 async/await로 감싸기 위한 프로토콜
+public protocol Providable {
+ associatedtype APIType: BaseTargetType
+
+ /// swift concurrency로 네트워크 요청, Reponse 반환하는 메소드
+ /// - Parameters:
+ /// - api: BaseTargetType을 준수하는 API
+ func reqeust(api: APIType) async throws -> Response
+ func addAuthorizationHeader(token: String)
+}
diff --git a/Projects/Core/Network/Project.swift b/Projects/Core/Network/Project.swift
new file mode 100644
index 00000000..91468fd7
--- /dev/null
+++ b/Projects/Core/Network/Project.swift
@@ -0,0 +1,45 @@
+import ProjectDescription
+import ProjectDescriptionHelpers
+import DependencyPlugin
+
+let project = Project.makeModule(
+ name: ModulePath.Core.name+ModulePath.Core.Network.rawValue,
+ targets: [
+ .core(
+ interface: .Network,
+ factory: .init(
+ dependencies: [
+ .shared(implements: .ThirdPartyLib)
+ ]
+ )
+ ),
+ .core(
+ implements: .Network,
+ factory: .init(
+ dependencies: [
+ .core(interface: .Network),
+ .core(interface: .Logger)
+ ]
+ )
+ ),
+
+ .core(
+ testing: .Network,
+ factory: .init(
+ dependencies: [
+ .core(interface: .Network)
+ ]
+ )
+ ),
+ .core(
+ tests: .Network,
+ factory: .init(
+ dependencies: [
+ .core(testing: .Network),
+ .core(implements: .Network)
+ ]
+ )
+ ),
+
+ ]
+)
diff --git a/Projects/Core/Network/Sources/Interceptor/Refresh/RefreshAPI.swift b/Projects/Core/Network/Sources/Interceptor/Refresh/RefreshAPI.swift
new file mode 100644
index 00000000..1124db83
--- /dev/null
+++ b/Projects/Core/Network/Sources/Interceptor/Refresh/RefreshAPI.swift
@@ -0,0 +1,27 @@
+//
+// RefreshAPI.swift
+// CoreNetwork
+//
+// Created by 임현규 on 8/1/24.
+//
+
+import Moya
+import CoreNetworkInterface
+
+enum RefreshAPI {
+ case refresh
+}
+
+extension RefreshAPI: BaseTargetType {
+ var path: String {
+ return "api/v1/auth/refresh"
+ }
+
+ var method: Moya.Method {
+ return .post
+ }
+
+ var task: Moya.Task {
+ return .requestPlain
+ }
+}
diff --git a/Projects/Core/Network/Sources/Interceptor/Refresh/RefreshResponseDTO.swift b/Projects/Core/Network/Sources/Interceptor/Refresh/RefreshResponseDTO.swift
new file mode 100644
index 00000000..a1945b49
--- /dev/null
+++ b/Projects/Core/Network/Sources/Interceptor/Refresh/RefreshResponseDTO.swift
@@ -0,0 +1,14 @@
+//
+// RefreshResponseDTO.swift
+// CoreNetwork
+//
+// Created by 임현규 on 8/1/24.
+//
+
+import Foundation
+
+public struct RefreshResponseDTO: Decodable {
+ public let accessToken: String?
+ public let refreshToken: String?
+}
+
diff --git a/Projects/Core/Network/Sources/Interceptor/TokenInterceptor.swift b/Projects/Core/Network/Sources/Interceptor/TokenInterceptor.swift
new file mode 100644
index 00000000..ed6f5c1a
--- /dev/null
+++ b/Projects/Core/Network/Sources/Interceptor/TokenInterceptor.swift
@@ -0,0 +1,84 @@
+//
+// TokenInterceptor.swift
+// CoreNetwork
+//
+// Created by 임현규 on 8/1/24.
+//
+
+import Foundation
+
+import CoreKeyChainStore
+import CoreLoggerInterface
+
+import Alamofire
+import Dependencies
+
+
+public class TokenInterceptor: RequestInterceptor {
+ public static let shared = TokenInterceptor()
+ private var isRefreshing = false
+
+ public init() {}
+
+ public func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result) -> Void) {
+ var urlRequest = urlRequest
+ if let pathComponents = urlRequest.url?.pathComponents, pathComponents.contains("refresh") {
+ let refreshToken = KeyChainTokenStore.shared.load(property: .refreshToken)
+ urlRequest.setValue("Bearer \(refreshToken)", forHTTPHeaderField: "Authorization")
+ } else {
+ let accessToken = KeyChainTokenStore.shared.load(property: .accessToken)
+ urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+ }
+
+ print(urlRequest.headers)
+ completion(.success(urlRequest))
+ }
+
+ public func retry(_ request: Request, for session: Session, dueTo error: any Error, completion: @escaping (RetryResult) -> Void) {
+ guard let response = request.task?.response as? HTTPURLResponse,
+ response.statusCode == 401,
+ let pathComponents = request.request?.url?.pathComponents,
+ !pathComponents.contains("refresh") else {
+ completion(.doNotRetryWithError(error))
+ return
+ }
+
+ // header에 있는 token과 Local token 불일치 -> retry
+ let accessToken = "Bearer \(KeyChainTokenStore.shared.load(property: .accessToken))"
+ let headerToken = request.request?.headers.dictionary["Authorization"]
+
+ if accessToken != headerToken {
+ completion(.retry)
+ return
+ }
+
+ // 토큰 재발행 요청중인 경우 들어온 401 응답 -> retry
+ if isRefreshing {
+ completion(.retry)
+ return
+ }
+
+ isRefreshing = true
+
+ @Dependency(\.network) var networkManager
+
+ Task {
+ do {
+ let token = try await networkManager.reqeust(api: .apiType(RefreshAPI.refresh), dto: RefreshResponseDTO.self)
+
+ guard let newAccessToken = token.accessToken,
+ let newRefreshToken = token.refreshToken else {
+ completion(.doNotRetry)
+ return
+ }
+
+ KeyChainTokenStore.shared.save(property: .accessToken, value: newAccessToken)
+ KeyChainTokenStore.shared.save(property: .refreshToken, value: newRefreshToken)
+ self.isRefreshing = false
+ completion(.retry)
+ } catch {
+ completion(.doNotRetry)
+ }
+ }
+ }
+}
diff --git a/Projects/Core/Network/Sources/MoyaPulgins/MoyaLoggerPlugin.swift b/Projects/Core/Network/Sources/MoyaPulgins/MoyaLoggerPlugin.swift
new file mode 100644
index 00000000..ddec0549
--- /dev/null
+++ b/Projects/Core/Network/Sources/MoyaPulgins/MoyaLoggerPlugin.swift
@@ -0,0 +1,88 @@
+//
+// MoyaLoggerPlugin.swift
+// CoreNetwork
+//
+// Created by 임현규 on 7/24/24.
+//
+
+import Foundation
+import CoreLoggerInterface
+import Moya
+
+final class MoyaLoggerPlugin: PluginType {
+ // Request를 보낼 때 호출
+ func willSend(_ request: RequestType, target: TargetType) {
+ guard let httpRequest = request.request else {
+ var log = "네트워크 요청 실패\n"
+ log += "---------------------------------------------\n"
+ log += "httpRequest가 잘못되었습니다.\n"
+ log += "---------------------------------------------"
+
+ Log.network(log, level: .fault)
+ return
+ }
+ let url = httpRequest.description
+ let method = httpRequest.httpMethod ?? "unknown method"
+ var log = "네트워크 요청 성공\n"
+ log += "---------------------------------------------\n"
+ log += "Method: \(method)\n"
+ log += "URL: \(url)\n"
+ log += "API: \(target)\n"
+
+ if let headers = httpRequest.allHTTPHeaderFields, !headers.isEmpty {
+ log += "header: \(headers)\n"
+ }
+ if let body = httpRequest.httpBody, let bodyString = String(bytes: body, encoding: String.Encoding.utf8) {
+ log += "bodyString: \(bodyString)"
+ }
+
+ log += "---------------------------------------------"
+ Log.network(log, level: .debug)
+ }
+ // Response가 왔을 때
+ func didReceive(_ result: Result, target: TargetType) {
+ switch result {
+ case let .success(response):
+ onSuceed(response, target: target, isFromError: false)
+ case let .failure(error):
+ onFail(error, target: target)
+ }
+ }
+
+ func onSuceed(_ response: Response, target: TargetType, isFromError: Bool) {
+ let request = response.request
+ let url = request?.url?.absoluteString ?? "nil"
+ let statusCode = response.statusCode
+ var log = "네트워크 통신 성공\n"
+ log += "---------------------------------------------\n"
+ log += "statusCode: \(statusCode)\n"
+ log += "URL: \(url)\n"
+ log += "API: \(target)\n"
+ if let reString = String(bytes: response.data, encoding: String.Encoding.utf8) {
+ log.append("response.data: \(reString)\n")
+ }
+ log += "data size: \(response.data.count)byte\n"
+ log += "---------------------------------------------"
+ Log.network(log, level: .debug)
+ }
+
+ func onFail(_ error: MoyaError, target: TargetType) {
+ if let response = error.response {
+ onSuceed(response, target: target, isFromError: true)
+ return
+ }
+ var log = "네트워크 통신 실패\n"
+ log += "---------------------------------------------\n"
+ log += "Error - \(error.failureReason ?? error.errorDescription ?? "unknown error")\n"
+ log += "ErrorCode - \(error.errorCode)\n"
+
+ if let data = error.response?.data {
+ log += "Data - \(data)\n"
+ } else {
+ log += "Data - empty"
+ }
+
+ log += "---------------------------------------------"
+ Log.network(log, level: .error)
+ }
+}
diff --git a/Projects/Core/Network/Sources/NetworkManager/NetworkManager.swift b/Projects/Core/Network/Sources/NetworkManager/NetworkManager.swift
new file mode 100644
index 00000000..c139f8e3
--- /dev/null
+++ b/Projects/Core/Network/Sources/NetworkManager/NetworkManager.swift
@@ -0,0 +1,65 @@
+//
+// NetworkManager.swift
+// CoreNetworkInterface
+//
+// Created by 임현규 on 7/22/24.
+//
+
+import Foundation
+import CoreNetworkInterface
+import ComposableArchitecture
+
+// MARK: - NetworkManagagner
+
+public struct NetworkManager {
+ public typealias APIType = AnyAPIType
+
+ private let provider: any Providable
+
+ public let jsonDecoder: JSONDecoder = {
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return decoder
+ }()
+
+ public init(_ provider: any Providable) {
+ self.provider = provider
+ }
+
+ public init() {
+ self.provider = Provider()
+ }
+}
+
+// MARK: - NetworkManagable
+
+extension NetworkManager: NetworkManagable {
+ public func reqeust(api: APIType, dto: T.Type) async throws -> T where T : Decodable {
+ let response = try await provider.reqeust(api: api)
+ let data = try jsonDecoder.decode(dto, from: response.data)
+ return data
+ }
+
+ public func reqeust(api: APIType) async throws {
+ _ = try await provider.reqeust(api: api)
+ }
+
+ public func addAuthorizationHeader(token: String) {
+ provider.addAuthorizationHeader(token: token)
+ }
+}
+
+// MARK: - DependencyValues
+
+extension DependencyValues {
+ public var network: NetworkManager {
+ get { self[NetworkManager.self] }
+ set { self[NetworkManager.self] = newValue }
+ }
+}
+
+// MARK: - DependencyKey
+
+extension NetworkManager: DependencyKey {
+ public static var liveValue: NetworkManager = NetworkManager()
+}
diff --git a/Projects/Core/Network/Sources/Provider/Provider.swift b/Projects/Core/Network/Sources/Provider/Provider.swift
new file mode 100644
index 00000000..43d77609
--- /dev/null
+++ b/Projects/Core/Network/Sources/Provider/Provider.swift
@@ -0,0 +1,66 @@
+//
+// Provider.swift
+// CoreNetworkInterface
+//
+// Created by 임현규 on 7/22/24.
+//
+
+import Foundation
+import CoreNetworkInterface
+import Moya
+
+final class Provider: Providable {
+ private var moyaProvider: MoyaProvider
+
+ public init(moyaProvider: MoyaProvider) {
+ self.moyaProvider = moyaProvider
+ }
+
+ public init() {
+ self.moyaProvider = MoyaProvider.init(
+ session: Session(interceptor: TokenInterceptor.shared),
+ plugins: [MoyaLoggerPlugin()]
+ )
+ }
+
+ func reqeust(api: APIType) async throws -> Response {
+ return try await withCheckedThrowingContinuation { continuation in
+ moyaProvider.request(api) { result in
+ switch result {
+ case let .success(response) where 200..<300 ~= response.statusCode:
+ continuation.resume(returning: response)
+ case let .success(response) where 300... ~= response.statusCode:
+ continuation.resume(throwing: MoyaError.statusCode(response))
+ case let .failure(error):
+ continuation.resume(throwing: error)
+ default:
+ let error = NSError(domain: "Unkowned Error", code: 0)
+ continuation.resume(throwing: error)
+ }
+ }
+ }
+ }
+
+ func addAuthorizationHeader(token: String) {
+ let provider = MoyaProvider(
+ endpointClosure: endpointClouser(token: token),
+ session: Session(interceptor: TokenInterceptor.shared),
+ plugins: [MoyaLoggerPlugin()]
+ )
+
+ moyaProvider = provider
+ }
+}
+
+// MARK: - Private Extension
+private extension Provider {
+ func endpointClouser(token: String) -> MoyaProvider.EndpointClosure {
+ return { (targetType: APIType) -> Endpoint in
+ var endpoint = MoyaProvider.defaultEndpointMapping(for: targetType)
+ endpoint = endpoint.adding(newHTTPHeaderFields: ["Authorization": "Bearer \(token)"])
+ return endpoint
+ }
+ }
+}
+
+
diff --git a/Projects/Core/Network/Testing/Sources/NetworkTesting.swift b/Projects/Core/Network/Testing/Sources/NetworkTesting.swift
new file mode 100644
index 00000000..b1853ce6
--- /dev/null
+++ b/Projects/Core/Network/Testing/Sources/NetworkTesting.swift
@@ -0,0 +1 @@
+// This is for Tuist
diff --git a/Projects/Core/Network/Tests/Sources/NetworkTest.swift b/Projects/Core/Network/Tests/Sources/NetworkTest.swift
new file mode 100644
index 00000000..7a6f40d1
--- /dev/null
+++ b/Projects/Core/Network/Tests/Sources/NetworkTest.swift
@@ -0,0 +1,11 @@
+import XCTest
+
+final class NetworkTests: XCTestCase {
+ override func setUpWithError() throws {}
+
+ override func tearDownWithError() throws {}
+
+ func testExample() {
+ XCTAssertEqual(1, 1)
+ }
+}
diff --git a/Projects/Core/Project.swift b/Projects/Core/Project.swift
new file mode 100644
index 00000000..93e1550a
--- /dev/null
+++ b/Projects/Core/Project.swift
@@ -0,0 +1,27 @@
+//
+// Project.swift
+// AppManifests
+//
+// Created by 임현규 on 7/19/24.
+//
+
+import ProjectDescription
+import ProjectDescriptionHelpers
+import DependencyPlugin
+
+let targets: [Target] = [
+ .core(factory: .init(
+ product: .staticFramework,
+ sources: nil,
+ dependencies: [
+ .shared
+ ] + ModulePath.Core.allCases.map {
+ .core(implements: $0)
+ }
+ ))
+]
+
+let project = Project.makeModule(
+ name: "Core",
+ targets: targets
+)
diff --git a/Projects/Core/Toast/Interface/Sources/ToastClient.swift b/Projects/Core/Toast/Interface/Sources/ToastClient.swift
new file mode 100644
index 00000000..c429b35b
--- /dev/null
+++ b/Projects/Core/Toast/Interface/Sources/ToastClient.swift
@@ -0,0 +1,44 @@
+//
+// ToastClient.swift
+// CoreToast
+//
+// Created by JongHoon on 8/4/24.
+//
+
+import Foundation
+
+import SharedDesignSystem
+
+import Dependencies
+
+public struct ToastClient {
+ private let _presentToast: (_ message: String, _ durationSecond: Double) -> Void
+
+ public init(presentToast: @escaping (_ message: String, _ durationSecond: Double) -> Void) {
+ self._presentToast = presentToast
+ }
+
+ public func presentToast(message: String, durationSecond: Double = 2.0) {
+ _presentToast(message, durationSecond)
+ }
+}
+
+extension ToastClient: DependencyKey {
+ public static var liveValue: ToastClient {
+ return .init(
+ presentToast: { message, duration in
+ ToastManager.shared.present(
+ message: message,
+ durationSecond: duration
+ )
+ }
+ )
+ }
+}
+
+extension DependencyValues {
+ public var toastClient: ToastClient {
+ get { self[ToastClient.self] }
+ set { self[ToastClient.self] = newValue }
+ }
+}
diff --git a/Projects/Core/Toast/Project.swift b/Projects/Core/Toast/Project.swift
new file mode 100644
index 00000000..3865b277
--- /dev/null
+++ b/Projects/Core/Toast/Project.swift
@@ -0,0 +1,45 @@
+import ProjectDescription
+import ProjectDescriptionHelpers
+import DependencyPlugin
+
+let project = Project.makeModule(
+ name: ModulePath.Core.name+ModulePath.Core.Toast.rawValue,
+ targets: [
+ .core(
+ interface: .Toast,
+ factory: .init(
+ dependencies: [
+ .shared,
+ .shared(implements: .ThirdPartyLib)
+ ]
+ )
+ ),
+ .core(
+ implements: .Toast,
+ factory: .init(
+ dependencies: [
+ .core(interface: .Toast)
+ ]
+ )
+ ),
+
+ .core(
+ testing: .Toast,
+ factory: .init(
+ dependencies: [
+ .core(interface: .Toast)
+ ]
+ )
+ ),
+ .core(
+ tests: .Toast,
+ factory: .init(
+ dependencies: [
+ .core(testing: .Toast),
+ .core(implements: .Toast)
+ ]
+ )
+ ),
+
+ ]
+)
diff --git a/Projects/Core/Toast/Sources/Source.swift b/Projects/Core/Toast/Sources/Source.swift
new file mode 100644
index 00000000..b1853ce6
--- /dev/null
+++ b/Projects/Core/Toast/Sources/Source.swift
@@ -0,0 +1 @@
+// This is for Tuist
diff --git a/Projects/Core/Toast/Testing/Sources/ToastTesting.swift b/Projects/Core/Toast/Testing/Sources/ToastTesting.swift
new file mode 100644
index 00000000..b1853ce6
--- /dev/null
+++ b/Projects/Core/Toast/Testing/Sources/ToastTesting.swift
@@ -0,0 +1 @@
+// This is for Tuist
diff --git a/Projects/Core/Toast/Tests/Sources/ToastTest.swift b/Projects/Core/Toast/Tests/Sources/ToastTest.swift
new file mode 100644
index 00000000..0622c0a2
--- /dev/null
+++ b/Projects/Core/Toast/Tests/Sources/ToastTest.swift
@@ -0,0 +1,11 @@
+import XCTest
+
+final class ToastTests: XCTestCase {
+ override func setUpWithError() throws {}
+
+ override func tearDownWithError() throws {}
+
+ func testExample() {
+ XCTAssertEqual(1, 1)
+ }
+}
diff --git a/Projects/Core/Util/Interface/Sources/Source.swift b/Projects/Core/Util/Interface/Sources/Source.swift
new file mode 100644
index 00000000..437474f0
--- /dev/null
+++ b/Projects/Core/Util/Interface/Sources/Source.swift
@@ -0,0 +1,8 @@
+//
+// Source.swift
+// CoreUtilInterface
+//
+// Created by JongHoon on 8/2/24.
+//
+
+import Foundation
diff --git a/Projects/Core/Util/Project.swift b/Projects/Core/Util/Project.swift
new file mode 100644
index 00000000..0033eafa
--- /dev/null
+++ b/Projects/Core/Util/Project.swift
@@ -0,0 +1,22 @@
+import ProjectDescription
+import ProjectDescriptionHelpers
+import DependencyPlugin
+
+let project = Project.makeModule(
+ name: ModulePath.Core.name+ModulePath.Core.Util.rawValue,
+ targets: [
+ .core(
+ interface: .Util,
+ factory: .init()
+ ),
+ .core(
+ implements: .Util,
+ factory: .init(
+ dependencies: [
+ .core(interface: .Util)
+ ]
+ )
+ ),
+
+ ]
+)
diff --git a/Projects/Core/Util/Sources/Source.swift b/Projects/Core/Util/Sources/Source.swift
new file mode 100644
index 00000000..316e1f67
--- /dev/null
+++ b/Projects/Core/Util/Sources/Source.swift
@@ -0,0 +1,8 @@
+//
+// Source.swift
+// CoreUtil
+//
+// Created by JongHoon on 7/27/24.
+//
+
+import Foundation
diff --git a/Projects/Core/WebView/Interface/Sources/BottleWebViewAction.swift b/Projects/Core/WebView/Interface/Sources/BottleWebViewAction.swift
new file mode 100644
index 00000000..0eec0adc
--- /dev/null
+++ b/Projects/Core/WebView/Interface/Sources/BottleWebViewAction.swift
@@ -0,0 +1,149 @@
+//
+// BottleWebViewAction.swift
+// CoreWebViewInterface
+//
+// Created by JongHoon on 8/3/24.
+//
+
+import CoreLoggerInterface
+
+public enum BottleWebViewAction: Equatable {
+ // MARK: - General
+
+ /// 웹뷰 종료 호출
+ case closeWebView
+ /// 토스트 호출
+ case showTaost(message: String)
+ /// 토큰 전송
+ case tokenDidSend(accessToken: String, refreshToken: String)
+ /// web view loading 완료
+ case webViewLoadingDidCompleted
+
+ // MARK: - SignUp
+
+ /// 회원가입 성공 콜백
+ case signUpDidComplted(accessToken: String, refreshToken: String)
+ /// 외부 링크 이동
+ case openLink(href: String)
+
+ // MARK: - LogIn
+
+ /// 로그인 성공
+ case loginDidCompleted(accessToken: String, refreshToken: String, isCompletedOnboardingIntroduction: Bool)
+
+ // MARK: - Onboarding(Create Profile)
+
+ /// 프로필 작성 완료
+ case createProfileDidCompleted
+
+ // MARK: - Arrived Bottle
+
+ /// 문답 시작하기(보틀 수락)
+ case bottelDidAccepted
+
+ // MARK: - My Page
+
+ /// 로그아웃
+ case logOutButtonDidTapped
+ /// 회원탈퇴
+ case withdrawalButtonDidTap
+
+ public init?(
+ type: String,
+ message: String? = nil,
+ accessToken: String? = nil,
+ refreshToken: String? = nil,
+ href: String? = nil,
+ isCompletedOnboardingIntroduction: Bool? = nil
+ ) {
+ switch type {
+
+ // MARK: - General
+
+ case "onWebViewClose":
+ self = .closeWebView
+ case "onToastOpen":
+ self = .showTaost(message: message ?? "")
+ case "onTokenSend":
+ guard let accessToken,
+ let refreshToken
+ else {
+ Log.assertion(
+ message: "accessToken: \(String(describing: accessToken)), refreshToken: \(String(describing: refreshToken))"
+ )
+ return nil
+ }
+ self = .tokenDidSend(
+ accessToken: accessToken,
+ refreshToken: refreshToken
+ )
+
+ // MARK: - SignUp
+
+ case "onSignup":
+ guard let accessToken,
+ let refreshToken
+ else {
+ Log.assertion(
+ message: "accessToken: \(String(describing: accessToken)), refreshToken: \(String(describing: refreshToken))"
+ )
+ return nil
+ }
+ self = .signUpDidComplted(
+ accessToken: accessToken,
+ refreshToken: refreshToken
+ )
+ case "openLink":
+ guard let href
+ else {
+ Log.assertion(
+ message: "openLink: \(String(describing: href))"
+ )
+ return nil
+ }
+ self = .openLink(href: href)
+
+
+
+ // MARK: - LogIn
+
+ case "onLogin":
+ guard let accessToken,
+ let refreshToken,
+ let isCompletedOnboardingIntroduction
+ else {
+ Log.assertion(
+ message: "accessToken: \(String(describing: accessToken)), refreshToken: \(String(describing: refreshToken)), isCompletedOnboardingIntroduction: \(String(describing: isCompletedOnboardingIntroduction))"
+ )
+ return nil
+ }
+ // TODO: 웹뷰 값 변경되면 isCompletedOnboardingIntroduction 실제값 넣어줘야함
+ self = .loginDidCompleted(
+ accessToken: accessToken,
+ refreshToken: refreshToken,
+ isCompletedOnboardingIntroduction: isCompletedOnboardingIntroduction
+ )
+
+ // MARK: - Onboarding(Create Profile)
+
+ case "onCreateProfileComplete":
+ self = .createProfileDidCompleted
+
+ // MARK: - Arrived Bottle
+
+ case "onBottleAccept":
+ self = .bottelDidAccepted
+
+ // MARK: - My Page
+
+ case "logout":
+ self = .logOutButtonDidTapped
+
+ case "deleteUser":
+ self = .withdrawalButtonDidTap
+
+ default:
+ return nil
+ }
+ }
+}
diff --git a/Projects/Core/WebView/Interface/Sources/WebViewMessageHandler.swift b/Projects/Core/WebView/Interface/Sources/WebViewMessageHandler.swift
new file mode 100644
index 00000000..e0104aa7
--- /dev/null
+++ b/Projects/Core/WebView/Interface/Sources/WebViewMessageHandler.swift
@@ -0,0 +1,19 @@
+//
+// WebViewMessageHandler.swift
+// CoreUtilInterface
+//
+// Created by JongHoon on 8/3/24.
+//
+
+import Foundation
+
+public enum WebViewMessageHandler {
+ case `default`
+
+ public var name: String {
+ switch self {
+ case .default:
+ return (Bundle.main.infoDictionary?["WEB_VIEW_MESSAGE_HANDLER_DEFAULT_NAME"] as? String) ?? ""
+ }
+ }
+}
diff --git a/Projects/Core/WebView/Project.swift b/Projects/Core/WebView/Project.swift
new file mode 100644
index 00000000..0317ff0b
--- /dev/null
+++ b/Projects/Core/WebView/Project.swift
@@ -0,0 +1,44 @@
+import ProjectDescription
+import ProjectDescriptionHelpers
+import DependencyPlugin
+
+let project = Project.makeModule(
+ name: ModulePath.Core.name+ModulePath.Core.WebView.rawValue,
+ targets: [
+ .core(
+ interface: .WebView,
+ factory: .init(
+ dependencies: [
+ .shared(implements: .ThirdPartyLib)
+ ]
+ )
+ ),
+ .core(
+ implements: .WebView,
+ factory: .init(
+ dependencies: [
+ .core(interface: .WebView)
+ ]
+ )
+ ),
+
+ .core(
+ testing: .WebView,
+ factory: .init(
+ dependencies: [
+ .core(interface: .WebView)
+ ]
+ )
+ ),
+ .core(
+ tests: .WebView,
+ factory: .init(
+ dependencies: [
+ .core(testing: .WebView),
+ .core(implements: .WebView)
+ ]
+ )
+ ),
+
+ ]
+)
diff --git a/Projects/Core/WebView/Sources/Source.swift b/Projects/Core/WebView/Sources/Source.swift
new file mode 100644
index 00000000..1f528875
--- /dev/null
+++ b/Projects/Core/WebView/Sources/Source.swift
@@ -0,0 +1,8 @@
+//
+// Source.swift
+// CoreWebView
+//
+// Created by JongHoon on 8/3/24.
+//
+
+import Foundation
diff --git a/Projects/Core/WebView/Testing/Sources/WebViewTesting.swift b/Projects/Core/WebView/Testing/Sources/WebViewTesting.swift
new file mode 100644
index 00000000..b1853ce6
--- /dev/null
+++ b/Projects/Core/WebView/Testing/Sources/WebViewTesting.swift
@@ -0,0 +1 @@
+// This is for Tuist
diff --git a/Projects/Core/WebView/Tests/Sources/WebViewTest.swift b/Projects/Core/WebView/Tests/Sources/WebViewTest.swift
new file mode 100644
index 00000000..ae5617e4
--- /dev/null
+++ b/Projects/Core/WebView/Tests/Sources/WebViewTest.swift
@@ -0,0 +1,11 @@
+import XCTest
+
+final class WebViewTests: XCTestCase {
+ override func setUpWithError() throws {}
+
+ override func tearDownWithError() throws {}
+
+ func testExample() {
+ XCTAssertEqual(1, 1)
+ }
+}
diff --git a/Projects/Domain/Auth/Interface/Sources/API/AppleAuthAPI.swift b/Projects/Domain/Auth/Interface/Sources/API/AppleAuthAPI.swift
new file mode 100644
index 00000000..079e59d3
--- /dev/null
+++ b/Projects/Domain/Auth/Interface/Sources/API/AppleAuthAPI.swift
@@ -0,0 +1,66 @@
+//
+// AppleAuthAPI.swift
+// DomainAuthInterface
+//
+// Created by 임현규 on 8/21/24.
+//
+
+import Foundation
+
+import Moya
+
+import CoreKeyChainStore
+import CoreNetworkInterface
+
+public enum AppleAuthAPI {
+ case refreshToken
+ case revoke
+}
+
+extension AppleAuthAPI: BaseTargetType {
+ public var baseURL: URL {
+ return URL(string: "https://appleid.apple.com/auth")!
+ }
+
+ public var path: String {
+ switch self {
+ case .refreshToken: return "/token"
+ case .revoke: return "/revoke"
+ }
+ }
+
+ public var method: Moya.Method {
+ return .post
+ }
+
+ public var task: Moya.Task {
+ switch self {
+ case .refreshToken:
+ let appleAuthCode = KeyChainTokenStore.shared.load(property: .AppleAuthCode)
+ let clientSecret = KeyChainTokenStore.shared.load(property: .AppleClientSecret)
+ let parameters: [String: Any] = [
+ "client_id": "asia.bottles",
+ "client_secret": clientSecret,
+ "code": appleAuthCode,
+ "grant_type": "authorization_code"
+ ]
+
+ return .requestParameters(parameters: parameters, encoding: URLEncoding.default)
+ case .revoke:
+ let appleRefreshToken = KeyChainTokenStore.shared.load(property: .AppleRefreshToken)
+ let clientSecret = KeyChainTokenStore.shared.load(property: .AppleClientSecret)
+ let parameters: [String: Any] = [
+ "client_id": "asia.bottles",
+ "client_secret": clientSecret,
+ "token": appleRefreshToken,
+ "token_type_hint": "refresh_token"
+ ]
+
+ return .requestParameters(parameters: parameters, encoding: URLEncoding.default)
+ }
+ }
+
+ public var headers: [String: String]? {
+ return ["Content-Type": "application/x-www-form-urlencoded"]
+ }
+}
diff --git a/Projects/Domain/Auth/Interface/Sources/API/AuthAPI.swift b/Projects/Domain/Auth/Interface/Sources/API/AuthAPI.swift
new file mode 100644
index 00000000..a52ef8e2
--- /dev/null
+++ b/Projects/Domain/Auth/Interface/Sources/API/AuthAPI.swift
@@ -0,0 +1,74 @@
+//
+// AuthAPI.swift
+// DomainAuth
+//
+// Created by 임현규 on 7/31/24.
+//
+
+import Foundation
+
+import CoreNetworkInterface
+
+import Moya
+
+public enum AuthAPI {
+ case kakao(_ requestDTO: SignInRequestDTO)
+ case apple(_ requestDTO: SignInRequestDTO)
+ case withdraw
+ case logout(_ logOutRequestDTO: LogOutRequestDTO)
+ case revoke
+ case profile(_ requestDTO: ProfileRequestDTO)
+}
+
+extension AuthAPI: BaseTargetType {
+ public var path: String {
+ switch self {
+ case .kakao:
+ return "api/v1/auth/kakao"
+ case .apple:
+ return "api/v1/auth/apple"
+ case .withdraw:
+ return "api/v1/auth/delete"
+ case .logout:
+ return "api/v1/auth/logout"
+ case .revoke:
+ return "api/v1/auth/apple/revoke"
+ case .profile:
+ return "api/v2/auth/profile"
+ }
+ }
+
+ public var method: Moya.Method {
+ switch self {
+ case .kakao:
+ return .post
+ case .apple:
+ return .post
+ case .withdraw:
+ return .post
+ case .logout:
+ return .post
+ case .revoke:
+ return .get
+ case .profile:
+ return .post
+ }
+ }
+
+ public var task: Moya.Task {
+ switch self {
+ case .kakao(let requestDTO):
+ return .requestJSONEncodable(requestDTO)
+ case .apple(let requestDTO):
+ return .requestJSONEncodable(requestDTO)
+ case .withdraw:
+ return .requestPlain
+ case let .logout(logOutRequestDTO):
+ return .requestJSONEncodable(logOutRequestDTO)
+ case .revoke:
+ return .requestPlain
+ case .profile(let requestDTO):
+ return .requestJSONEncodable(requestDTO)
+ }
+ }
+}
diff --git a/Projects/Domain/Auth/Interface/Sources/AuthClient.swift b/Projects/Domain/Auth/Interface/Sources/AuthClient.swift
new file mode 100644
index 00000000..816bd13a
--- /dev/null
+++ b/Projects/Domain/Auth/Interface/Sources/AuthClient.swift
@@ -0,0 +1,85 @@
+//
+// AuthClient.swift
+// DomainAuthInterface
+//
+// Created by 임현규 on 7/31/24.
+//
+
+import Foundation
+
+public struct AuthClient {
+ private let signInWithKakao: () async throws -> UserInfo
+ private let signInWithApple: () async throws -> UserInfo
+ private let saveToken: (Token) -> Void
+ private let _checkTokenIsExist: () -> Bool
+ private let withdraw: () async throws -> Void
+ private let logout: () async throws -> Void
+ private let refreshAppleToken: () async throws -> AppleToken
+ private let _revokeAppleLogin: () async throws -> Void
+ private let fetchAppleClientSecret: () async throws -> String
+ private let registerUserProfile: (String) async throws -> Void
+
+ public init(
+ signInWithKakao: @escaping () async throws -> UserInfo,
+ signInWithApple: @escaping () async throws -> UserInfo,
+ saveToken: @escaping (Token) -> Void,
+ checkTokenIsExist: @escaping () -> Bool,
+ withdraw: @escaping () async throws -> Void,
+ logout: @escaping () async throws -> Void,
+ refreshAppleToken: @escaping () async throws -> AppleToken,
+ revokeAppleLogin: @escaping () async throws -> Void,
+ fetchAppleClientSecret: @escaping () async throws -> String,
+ registerUserProfile: @escaping (String) async throws -> Void
+ ) {
+ self.signInWithKakao = signInWithKakao
+ self.signInWithApple = signInWithApple
+ self.saveToken = saveToken
+ self._checkTokenIsExist = checkTokenIsExist
+ self.logout = logout
+ self.withdraw = withdraw
+ self.refreshAppleToken = refreshAppleToken
+ self._revokeAppleLogin = revokeAppleLogin
+ self.fetchAppleClientSecret = fetchAppleClientSecret
+ self.registerUserProfile = registerUserProfile
+ }
+
+ public func signInWithKakao() async throws -> UserInfo {
+ try await signInWithKakao()
+ }
+
+ public func signInWithApple() async throws -> UserInfo {
+ try await signInWithApple()
+ }
+ public func saveToken(token: Token) {
+ saveToken(token)
+ }
+
+ public func checkTokenIsExist() -> Bool {
+ return _checkTokenIsExist()
+ }
+
+ public func withdraw() async throws {
+ try await withdraw()
+ }
+
+ public func logout() async throws {
+ try await logout()
+ }
+
+ public func refreshAppleToken() async throws -> AppleToken {
+ return try await refreshAppleToken()
+ }
+
+ public func revokeAppleLogin() async throws {
+ try await _revokeAppleLogin()
+ }
+
+ public func fetchAppleClientSecret() async throws -> String {
+ try await fetchAppleClientSecret()
+ }
+
+ public func registerUserProfile(userName: String) async throws {
+ try await registerUserProfile(userName)
+ }
+}
+
diff --git a/Projects/Domain/Auth/Interface/Sources/AuthInterface.swift b/Projects/Domain/Auth/Interface/Sources/AuthInterface.swift
new file mode 100644
index 00000000..e51800d4
--- /dev/null
+++ b/Projects/Domain/Auth/Interface/Sources/AuthInterface.swift
@@ -0,0 +1,5 @@
+// This is for Tuist
+
+public protocol AuthInterface {
+
+}
diff --git a/Projects/Domain/Auth/Interface/Sources/DTO/Request/LogOutRequestDTO.swift b/Projects/Domain/Auth/Interface/Sources/DTO/Request/LogOutRequestDTO.swift
new file mode 100644
index 00000000..7e4699b1
--- /dev/null
+++ b/Projects/Domain/Auth/Interface/Sources/DTO/Request/LogOutRequestDTO.swift
@@ -0,0 +1,14 @@
+//
+// LogOutRequestDTO.swift
+// DomainAuthInterface
+//
+// Created by JongHoon on 8/21/24.
+//
+
+public struct LogOutRequestDTO: Encodable {
+ let fcmDeviceToken: String
+
+ public init(fcmDeviceToken: String) {
+ self.fcmDeviceToken = fcmDeviceToken
+ }
+}
diff --git a/Projects/Domain/Auth/Interface/Sources/DTO/Request/ProfileRequestDTO.swift b/Projects/Domain/Auth/Interface/Sources/DTO/Request/ProfileRequestDTO.swift
new file mode 100644
index 00000000..b724508c
--- /dev/null
+++ b/Projects/Domain/Auth/Interface/Sources/DTO/Request/ProfileRequestDTO.swift
@@ -0,0 +1,30 @@
+//
+// ProfileRequestDTO.swift
+// DomainAuth
+//
+// Created by 임현규 on 8/21/24.
+//
+
+import Foundation
+
+public struct ProfileRequestDTO: Encodable {
+ let birthDay: Int?
+ let birthMonth: Int?
+ let birthYear: Int?
+ let gender: String?
+ let name: String
+
+ public init(
+ birthDay: Int? = nil,
+ birthMonth: Int? = nil,
+ birthYear: Int? = nil,
+ gender: String? = nil,
+ name: String
+ ) {
+ self.birthDay = birthDay
+ self.birthMonth = birthMonth
+ self.birthYear = birthYear
+ self.gender = gender
+ self.name = name
+ }
+}
diff --git a/Projects/Domain/Auth/Interface/Sources/DTO/Request/SignInRequestDTO.swift b/Projects/Domain/Auth/Interface/Sources/DTO/Request/SignInRequestDTO.swift
new file mode 100644
index 00000000..318d452b
--- /dev/null
+++ b/Projects/Domain/Auth/Interface/Sources/DTO/Request/SignInRequestDTO.swift
@@ -0,0 +1,21 @@
+//
+// SignInRequestDTO.swift
+// DomainAuth
+//
+// Created by 임현규 on 7/31/24.
+//
+
+import Foundation
+
+public struct SignInRequestDTO: Encodable {
+ public let code: String
+ public let fcmDeviceToken: String
+
+ public init(
+ code: String,
+ fcmDeviceToken: String
+ ) {
+ self.code = code
+ self.fcmDeviceToken = fcmDeviceToken
+ }
+}
diff --git a/Projects/Domain/Auth/Interface/Sources/DTO/Response/AppleTokenResponseDTO.swift b/Projects/Domain/Auth/Interface/Sources/DTO/Response/AppleTokenResponseDTO.swift
new file mode 100644
index 00000000..d9e6d635
--- /dev/null
+++ b/Projects/Domain/Auth/Interface/Sources/DTO/Response/AppleTokenResponseDTO.swift
@@ -0,0 +1,32 @@
+//
+// AppleTokenResponseDTO.swift
+// DomainAuthInterface
+//
+// Created by 임현규 on 8/21/24.
+//
+
+import Foundation
+
+public struct AppleToken {
+ public let refreshToken: String
+}
+
+public struct AppleTokenResponseDTO: Decodable {
+ let accessToken: String?
+ let expriesIn: Int?
+ let idToken: String?
+ let refreshToken: String?
+ let tokenType: String?
+
+ enum CodingKeys: String, CodingKey {
+ case accessToken = "access_token"
+ case expriesIn = "expires_in"
+ case idToken = "id_token"
+ case refreshToken = "refresh_token"
+ case tokenType = "token_type"
+ }
+
+ public func toDomain() -> AppleToken {
+ return .init(refreshToken: refreshToken ?? "")
+ }
+}
diff --git a/Projects/Domain/Auth/Interface/Sources/DTO/Response/ClientSecretResponseDTO.swift b/Projects/Domain/Auth/Interface/Sources/DTO/Response/ClientSecretResponseDTO.swift
new file mode 100644
index 00000000..418312aa
--- /dev/null
+++ b/Projects/Domain/Auth/Interface/Sources/DTO/Response/ClientSecretResponseDTO.swift
@@ -0,0 +1,12 @@
+//
+// ClientSecretResponseDTO.swift
+// DomainAuthInterface
+//
+// Created by 임현규 on 8/21/24.
+//
+
+import Foundation
+
+public struct ClientSecretResponseDTO: Decodable {
+ public let clientSecret: String
+}
diff --git a/Projects/Domain/Auth/Interface/Sources/DTO/Response/SignInResponseDTO.swift b/Projects/Domain/Auth/Interface/Sources/DTO/Response/SignInResponseDTO.swift
new file mode 100644
index 00000000..38c1d3a8
--- /dev/null
+++ b/Projects/Domain/Auth/Interface/Sources/DTO/Response/SignInResponseDTO.swift
@@ -0,0 +1,45 @@
+//
+// SignInResponseDTO.swift
+// DomainAuth
+//
+// Created by 임현규 on 7/31/24.
+//
+
+import Foundation
+
+public struct UserInfo {
+ public let token: Token
+ public let isSignUp: Bool
+ public let isCompletedOnboardingIntroduction: Bool
+ public var userName: String?
+
+ public init(
+ token: Token,
+ isSignUp: Bool,
+ isCompletedOnboardingIntroduction: Bool,
+ userName: String? = nil
+ ) {
+ self.token = token
+ self.isSignUp = isSignUp
+ self.isCompletedOnboardingIntroduction = isCompletedOnboardingIntroduction
+ self.userName = userName
+ }
+}
+
+public struct SignInResponseDTO: Decodable {
+ public let accessToken: String
+ public let refreshToken: String
+ public let isSignUp: Bool
+ public let hasCompleteUserProfile: Bool
+
+ public func toDomain() -> UserInfo {
+ return UserInfo(
+ token: Token(
+ accessToken: accessToken,
+ refershToken: refreshToken),
+ // 서버에서 회원 가입 시 isSignUp: false로 내려 줌
+ isSignUp: !isSignUp,
+ isCompletedOnboardingIntroduction: hasCompleteUserProfile
+ )
+ }
+}
diff --git a/Projects/Domain/Auth/Interface/Sources/DataSource/LocalAuthDataSource.swift b/Projects/Domain/Auth/Interface/Sources/DataSource/LocalAuthDataSource.swift
new file mode 100644
index 00000000..73c15ba2
--- /dev/null
+++ b/Projects/Domain/Auth/Interface/Sources/DataSource/LocalAuthDataSource.swift
@@ -0,0 +1,14 @@
+//
+// LocalAuthDataSource.swift
+// DomainAuthInterface
+//
+// Created by 임현규 on 7/31/24.
+//
+
+import Foundation
+
+public protocol LocalAuthDataSource {
+ static func saveToken(token: Token)
+ static func loadAccessToken() -> Token
+ static func checkTokeinIsExist() -> Bool
+}
diff --git a/Projects/Domain/Auth/Interface/Sources/Entity/SignInResult.swift b/Projects/Domain/Auth/Interface/Sources/Entity/SignInResult.swift
new file mode 100644
index 00000000..8d635ae0
--- /dev/null
+++ b/Projects/Domain/Auth/Interface/Sources/Entity/SignInResult.swift
@@ -0,0 +1,18 @@
+//
+// SignInResult.swift
+// DomainAuth
+//
+// Created by 임현규 on 8/21/24.
+//
+
+import Foundation
+
+public struct SignInResult {
+ public let accessToken: String
+ public let userName: String?
+
+ public init(accessToken: String, userName: String? = nil) {
+ self.accessToken = accessToken
+ self.userName = userName
+ }
+}
diff --git a/Projects/Domain/Auth/Interface/Sources/Entity/Token.swift b/Projects/Domain/Auth/Interface/Sources/Entity/Token.swift
new file mode 100644
index 00000000..304ace36
--- /dev/null
+++ b/Projects/Domain/Auth/Interface/Sources/Entity/Token.swift
@@ -0,0 +1,21 @@
+//
+// TokenEntity.swift
+// DomainAuthInterface
+//
+// Created by 임현규 on 7/31/24.
+//
+
+import Foundation
+
+public struct Token: Equatable {
+ public init(
+ accessToken: String,
+ refershToken: String
+ ) {
+ self.accessToken = accessToken
+ self.refershToken = refershToken
+ }
+
+ public let accessToken: String
+ public let refershToken: String
+}
diff --git a/Projects/Domain/Auth/Interface/Sources/LoginManager/LoginManager.swift b/Projects/Domain/Auth/Interface/Sources/LoginManager/LoginManager.swift
new file mode 100644
index 00000000..23b30733
--- /dev/null
+++ b/Projects/Domain/Auth/Interface/Sources/LoginManager/LoginManager.swift
@@ -0,0 +1,33 @@
+//
+// LoginManager.swift
+// DomainAuth
+//
+// Created by 임현규 on 8/2/24.
+//
+
+import Foundation
+
+import CoreLoggerInterface
+
+import ComposableArchitecture
+
+public struct LoginManager {
+ public enum LoginType {
+ case kakao
+ case apple
+ case sms
+ }
+
+ private var signIn: (_ loginType: LoginType) async throws -> SignInResult
+
+ public init(signIn: @escaping (_ loginType: LoginType) async throws -> SignInResult) {
+ self.signIn = signIn
+ }
+
+ /// 로그인 타입에 따라 Provider로 부터 받은 AuthToken & AccessToken을 반환합니다.
+ /// - Parameters:
+ /// - loginType: 로그인하는 Type
+ public func signIn(loginType: LoginType) async throws -> SignInResult {
+ try await signIn(loginType)
+ }
+}
diff --git a/Projects/Domain/Auth/Project.swift b/Projects/Domain/Auth/Project.swift
new file mode 100644
index 00000000..d92862ca
--- /dev/null
+++ b/Projects/Domain/Auth/Project.swift
@@ -0,0 +1,44 @@
+import ProjectDescription
+import ProjectDescriptionHelpers
+import DependencyPlugin
+
+let project = Project.makeModule(
+ name: ModulePath.Domain.name+ModulePath.Domain.Auth.rawValue,
+ targets: [
+ .domain(
+ interface: .Auth,
+ factory: .init(
+ dependencies: [
+ .core
+ ]
+ )
+ ),
+ .domain(
+ implements: .Auth,
+ factory: .init(
+ dependencies: [
+ .domain(interface: .Auth)
+ ]
+ )
+ ),
+
+ .domain(
+ testing: .Auth,
+ factory: .init(
+ dependencies: [
+ .domain(interface: .Auth)
+ ]
+ )
+ ),
+ .domain(
+ tests: .Auth,
+ factory: .init(
+ dependencies: [
+ .domain(testing: .Auth),
+ .domain(implements: .Auth)
+ ]
+ )
+ ),
+
+ ]
+)
diff --git a/Projects/Domain/Auth/Sources/AuthClient.swift b/Projects/Domain/Auth/Sources/AuthClient.swift
new file mode 100644
index 00000000..2d0293b1
--- /dev/null
+++ b/Projects/Domain/Auth/Sources/AuthClient.swift
@@ -0,0 +1,97 @@
+//
+// AuthClient.swift
+// DomainAuth
+//
+// Created by 임현규 on 7/31/24.
+//
+
+import Foundation
+
+import DomainAuthInterface
+import CoreNetwork
+import CoreLoggerInterface
+
+import ComposableArchitecture
+import Moya
+
+extension AuthClient: DependencyKey {
+ public static var liveValue: AuthClient = .live()
+ private static func live() -> AuthClient {
+ @Dependency(\.network) var networkManager
+ @Dependency(\.loginManager) var loginManager
+
+ return .init(
+ signInWithKakao: {
+ let signInResult = try await loginManager.signIn(loginType: .kakao)
+ let accessToken = signInResult.accessToken
+ guard let fcmToken = UserDefaults.standard.string(forKey: "fcmToken")
+ else {
+ Log.fault("no fcm token")
+ fatalError()
+ }
+ let data = SignInRequestDTO(code: accessToken, fcmDeviceToken: fcmToken)
+ let responseData = try await networkManager.reqeust(api: .apiType(AuthAPI.kakao(data)), dto: SignInResponseDTO.self)
+ let userInfo = responseData.toDomain()
+ return userInfo
+ },
+
+ signInWithApple: {
+ // TODO: apple Login API로 수정
+ let signInResult = try await loginManager.signIn(loginType: .apple)
+ let accessToken = signInResult.accessToken
+ let userName = signInResult.userName
+
+ guard let fcmToken = UserDefaults.standard.string(forKey: "fcmToken")
+ else {
+ Log.fault("no fcm token")
+ fatalError()
+ }
+ let data = SignInRequestDTO(code: accessToken, fcmDeviceToken: fcmToken)
+ let responseData = try await networkManager.reqeust(api: .apiType(AuthAPI.apple(data)), dto: SignInResponseDTO.self)
+ var userInfo = responseData.toDomain()
+ userInfo.userName = userName
+ return userInfo
+ },
+ saveToken: { token in
+ LocalAuthDataSourceImpl.saveToken(token: token)
+ },
+ checkTokenIsExist: {
+ LocalAuthDataSourceImpl.checkTokeinIsExist()
+ },
+ withdraw: {
+ try await networkManager.reqeust(api: .apiType(AuthAPI.withdraw))
+ },
+ logout: {
+ guard let fcmToken = UserDefaults.standard.string(forKey: "fcmToken")
+ else {
+ Log.fault("no fcm token")
+ return
+ }
+ try await networkManager.reqeust(api: .apiType(AuthAPI.logout(LogOutRequestDTO(fcmDeviceToken: fcmToken))))
+ },
+ refreshAppleToken: {
+ let appleToken = try await networkManager.reqeust(api: .apiType(AppleAuthAPI.refreshToken), dto: AppleTokenResponseDTO.self).toDomain()
+ return appleToken
+ },
+ revokeAppleLogin: {
+ try await networkManager.reqeust(api: .apiType(AppleAuthAPI.revoke))
+ },
+ fetchAppleClientSecret: {
+ let responseData = try await networkManager.reqeust(api: .apiType(AuthAPI.revoke), dto: ClientSecretResponseDTO.self)
+ let clientSecret = responseData.clientSecret
+ return clientSecret
+ },
+ registerUserProfile: { userName in
+ let requestDTO = ProfileRequestDTO(name: userName)
+ try await networkManager.reqeust(api: .apiType(AuthAPI.profile(requestDTO)))
+ }
+ )
+ }
+}
+
+extension DependencyValues {
+ public var authClient: AuthClient {
+ get { self[AuthClient.self] }
+ set { self[AuthClient.self] = newValue }
+ }
+}
diff --git a/Projects/Domain/Auth/Sources/DataSource/LocalAuthDataSourceImpl.swift b/Projects/Domain/Auth/Sources/DataSource/LocalAuthDataSourceImpl.swift
new file mode 100644
index 00000000..2d089f4c
--- /dev/null
+++ b/Projects/Domain/Auth/Sources/DataSource/LocalAuthDataSourceImpl.swift
@@ -0,0 +1,28 @@
+//
+// LocalAuthDataSourceImpl.swift
+// DomainAuth
+//
+// Created by 임현규 on 7/31/24.
+//
+
+import DomainAuthInterface
+
+import CoreKeyChainStore
+
+struct LocalAuthDataSourceImpl: LocalAuthDataSource {
+ static func saveToken(token: Token) {
+ KeyChainTokenStore.shared.save(property: .accessToken, value: token.accessToken)
+ KeyChainTokenStore.shared.save(property: .refreshToken, value: token.refershToken)
+ }
+
+ static func loadAccessToken() -> Token {
+ return Token(
+ accessToken: KeyChainTokenStore.shared.load(property: .accessToken),
+ refershToken: KeyChainTokenStore.shared.load(property: .refreshToken)
+ )
+ }
+
+ static func checkTokeinIsExist() -> Bool {
+ return !KeyChainTokenStore.shared.load(property: .accessToken).isEmpty
+ }
+}
diff --git a/Projects/Domain/Auth/Sources/LoginManager/AppleLoginManager.swift b/Projects/Domain/Auth/Sources/LoginManager/AppleLoginManager.swift
new file mode 100644
index 00000000..888cdbe3
--- /dev/null
+++ b/Projects/Domain/Auth/Sources/LoginManager/AppleLoginManager.swift
@@ -0,0 +1,84 @@
+//
+// AppleLoginManager.swift
+// DomainAuth
+//
+// Created by 임현규 on 8/20/24.
+//
+
+import Foundation
+import AuthenticationServices
+
+import DomainAuthInterface
+import CoreKeyChainStore
+
+final class AppleLoginManager: NSObject, ASAuthorizationControllerDelegate {
+
+ private var continuation: CheckedContinuation?
+
+ func signInWithApple() async throws -> SignInResult {
+ try await withCheckedThrowingContinuation { [weak self] continuation in
+ guard let self = self else { return }
+ let request = ASAuthorizationAppleIDProvider().createRequest()
+ request.requestedScopes = [.fullName, .email]
+ let controller = ASAuthorizationController(authorizationRequests: [request])
+ controller.performRequests()
+ controller.delegate = self
+
+ if self.continuation == nil {
+ self.continuation = continuation
+ }
+ }
+ }
+
+ func authorizationController(
+ controller: ASAuthorizationController,
+ didCompleteWithAuthorization authorization: ASAuthorization
+ ) {
+ // TODO: - Domain Error 추가.
+
+ guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential else {
+ let error = NSError(domain: "invalidCrendential", code: 0)
+ continuation?.resume(throwing: error)
+ continuation = nil
+ return
+ }
+
+ guard let identityToken = credential.identityToken,
+ let decodedIdentityToken = String(data: identityToken, encoding: .utf8) else {
+ let error = NSError(domain: "invalidIdentityToken", code: 0)
+ continuation?.resume(throwing: error)
+ continuation = nil
+ return
+ }
+
+ guard let authorizationCodeData = credential.authorizationCode,
+ let authorizationCodeString = String(data: authorizationCodeData, encoding: .utf8) else {
+ let error = NSError(domain: "invalidAuthorizationCode", code: 0)
+ continuation?.resume(throwing: error)
+ continuation = nil
+ return
+ }
+
+
+ let user = credential.user
+ let fullName = credential.fullName
+ let name = ((fullName?.familyName ?? "") + (fullName?.givenName ?? ""))
+
+ KeyChainTokenStore.shared.save(property: .AppleUserID, value: user)
+ KeyChainTokenStore.shared.save(property: .AppleAuthCode, value: authorizationCodeString)
+
+ let signInResult = SignInResult(accessToken: decodedIdentityToken, userName: name == "" ? nil : name)
+ continuation?.resume(returning: signInResult)
+ continuation = nil
+ }
+
+ func authorizationController(
+ controller: ASAuthorizationController,
+ didCompleteWithError error: any Error
+ ) {
+ let error = NSError(domain: error.localizedDescription, code: 0)
+ continuation?.resume(throwing: error)
+ continuation = nil
+ }
+}
+
diff --git a/Projects/Domain/Auth/Sources/LoginManager/LoginManager.swift b/Projects/Domain/Auth/Sources/LoginManager/LoginManager.swift
new file mode 100644
index 00000000..23a6d3be
--- /dev/null
+++ b/Projects/Domain/Auth/Sources/LoginManager/LoginManager.swift
@@ -0,0 +1,102 @@
+//
+// LoginManager.swift
+// DomainAuth
+//
+// Created by 임현규 on 7/31/24.
+//
+
+import Foundation
+import AuthenticationServices
+
+import DomainAuthInterface
+import CoreLoggerInterface
+
+import KakaoSDKUser
+import ComposableArchitecture
+
+extension LoginManager: DependencyKey {
+ static public let liveValue: LoginManager = .live()
+
+ static public func live() -> Self {
+ return .init(
+ signIn: { loginType in
+ switch loginType {
+ case .kakao:
+ return try await signInWithKakao()
+ case .apple:
+ return try await signInWithApple()
+ case .sms:
+ return .init(accessToken: "")
+ }
+ }
+ )
+ }
+}
+
+extension DependencyValues {
+ public var loginManager: LoginManager {
+ get { self[LoginManager.self] }
+ set { self[LoginManager.self] = newValue }
+ }
+}
+
+// MARK: - Kakao SignIn Methods
+extension LoginManager {
+ @MainActor
+ private static func signInWithKakao() async throws -> SignInResult {
+ var accessToken = ""
+ if UserApi.isKakaoTalkLoginAvailable() {
+ accessToken = try await loginWithKakaoTalk()
+ } else {
+ accessToken = try await loginWithKakaoAccount()
+ }
+
+ return .init(accessToken: accessToken)
+ }
+
+ @MainActor
+ private static func loginWithKakaoTalk() async throws -> String {
+ return try await withCheckedThrowingContinuation { continuation in
+ UserApi.shared.loginWithKakaoTalk { oauthToken, error in
+ if let error = error {
+ Log.error(error)
+ continuation.resume(throwing: error)
+ } else if let token = oauthToken?.accessToken {
+ Log.debug("Received accessToken: \(token)")
+ continuation.resume(returning: token)
+ } else {
+ continuation.resume(throwing: URLError(.badServerResponse))
+ Log.error("Kakao Login Error")
+ }
+ }
+ }
+ }
+
+ @MainActor
+ private static func loginWithKakaoAccount() async throws -> String {
+ return try await withCheckedThrowingContinuation { continuation in
+ UserApi.shared.loginWithKakaoAccount { oauthToken, error in
+ if let error = error {
+ Log.error(error)
+ continuation.resume(throwing: error)
+ } else if let token = oauthToken?.accessToken {
+ Log.debug("Received accessToken: \(token)")
+ continuation.resume(returning: token)
+ } else {
+ continuation.resume(throwing: URLError(.badServerResponse))
+ Log.error("Kakao Login Error")
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Apple Login Methods
+private extension LoginManager {
+ static func signInWithApple() async throws -> SignInResult {
+ let appleLoginManager = AppleLoginManager()
+ let signInResult = try await appleLoginManager.signInWithApple()
+ return signInResult
+ }
+}
+
diff --git a/Projects/Domain/Auth/Testing/Sources/AuthTesting.swift b/Projects/Domain/Auth/Testing/Sources/AuthTesting.swift
new file mode 100644
index 00000000..b1853ce6
--- /dev/null
+++ b/Projects/Domain/Auth/Testing/Sources/AuthTesting.swift
@@ -0,0 +1 @@
+// This is for Tuist
diff --git a/Projects/Domain/Auth/Tests/Sources/AuthTest.swift b/Projects/Domain/Auth/Tests/Sources/AuthTest.swift
new file mode 100644
index 00000000..f1a2b457
--- /dev/null
+++ b/Projects/Domain/Auth/Tests/Sources/AuthTest.swift
@@ -0,0 +1,11 @@
+import XCTest
+
+final class AuthTests: XCTestCase {
+ override func setUpWithError() throws {}
+
+ override func tearDownWithError() throws {}
+
+ func testExample() {
+ XCTAssertEqual(1, 1)
+ }
+}
diff --git a/Projects/Domain/Bottle/Interface/Sources/API/BottleAPI.swift b/Projects/Domain/Bottle/Interface/Sources/API/BottleAPI.swift
new file mode 100644
index 00000000..06e4e9ba
--- /dev/null
+++ b/Projects/Domain/Bottle/Interface/Sources/API/BottleAPI.swift
@@ -0,0 +1,88 @@
+//
+// BottleAPI.swift
+// DomainBottleInterface
+//
+// Created by 임현규 on 7/29/24.
+//
+
+import CoreNetworkInterface
+
+import Moya
+
+public enum BottleAPI {
+ case fetchBottles
+ case fetchBottleStorageList
+ case fetchBottlePingPong(bottleID: Int)
+ case registerLetterAnswer(
+ bottleID: Int,
+ registerLetterAnswerRequestDTO: RegisterLetterAnswerRequestDTO
+ )
+ case imageShare(
+ bottleID: Int,
+ imageShareRequestDTO: BottleImageShareRequestDTO
+ )
+ case finalSelect(
+ bottleID: Int,
+ finalSelectRequestDTO: FinalSelectRequestDTO
+ )
+ case stopTalk(bottleID: Int)
+}
+
+extension BottleAPI: BaseTargetType {
+ public var path: String {
+ switch self {
+ case .fetchBottles:
+ return "api/v1/bottles"
+ case .fetchBottleStorageList:
+ return "api/v1/bottles/ping-pong"
+ case let .fetchBottlePingPong(bottleID):
+ return "api/v1/bottles/ping-pong/\(bottleID)"
+ case let .registerLetterAnswer(bottleID, _):
+ return "api/v1/bottles/ping-pong/\(bottleID)/letters"
+ case let .imageShare(bottleID, _):
+ return "api/v1/bottles/ping-pong/\(bottleID)/image"
+ case let .finalSelect(bottleID, _):
+ return "api/v1/bottles/ping-pong/\(bottleID)/match"
+ case let .stopTalk(bottleID):
+ return "api/v1/bottles/ping-pong/\(bottleID)/stop"
+ }
+ }
+
+ public var method: Moya.Method {
+ switch self {
+ case .fetchBottles:
+ return .get
+ case .fetchBottleStorageList:
+ return .get
+ case .fetchBottlePingPong:
+ return .get
+ case .registerLetterAnswer:
+ return .post
+ case .imageShare:
+ return .post
+ case .finalSelect:
+ return .post
+ case .stopTalk:
+ return .post
+ }
+ }
+
+ public var task: Moya.Task {
+ switch self {
+ case .fetchBottles:
+ return .requestPlain
+ case .fetchBottleStorageList:
+ return .requestPlain
+ case .fetchBottlePingPong:
+ return .requestPlain
+ case let .registerLetterAnswer(_, registerLetterAnswerRequestDTO):
+ return .requestJSONEncodable(registerLetterAnswerRequestDTO)
+ case let .imageShare(_, imageShareRequestDTO):
+ return .requestJSONEncodable(imageShareRequestDTO)
+ case let .finalSelect(_, finalSelectRequestDTO):
+ return .requestJSONEncodable(finalSelectRequestDTO)
+ case .stopTalk:
+ return .requestPlain
+ }
+ }
+}
diff --git a/Projects/Domain/Bottle/Interface/Sources/BottleClient.swift b/Projects/Domain/Bottle/Interface/Sources/BottleClient.swift
new file mode 100644
index 00000000..72e53039
--- /dev/null
+++ b/Projects/Domain/Bottle/Interface/Sources/BottleClient.swift
@@ -0,0 +1,65 @@
+//
+// BottleClient.swift
+// DomainBottle
+//
+// Created by 임현규 on 7/29/24.
+//
+
+import Foundation
+import ComposableArchitecture
+
+public struct BottleClient {
+ private let fetchUserBottleInfo: () async throws -> UserBottleInfo
+ private let fetchBottleStorageList: () async throws -> BottleStorageList
+ private let fetchBottlePingPong: (_ bottleID: Int) async throws -> BottlePingPong
+ private let registerLetterAnswer: (_ bottleID: Int, _ order: Int, _ answer: String) async throws -> Void
+ private let shareImage: (_ bottleID: Int, _ willShare: Bool) async throws -> Void
+ private let finalSelect: (_ bottleID: Int, _ willMatch: Bool) async throws -> Void
+ private let stopTalk: (_ bottleID: Int) async throws -> Void
+
+ public init(
+ fetchUserBottleInfo: @escaping () async throws -> UserBottleInfo,
+ fetchBottleStorageList: @escaping () async throws -> BottleStorageList,
+ fetchBottlePingPong: @escaping (_ bottleID: Int) async throws -> BottlePingPong,
+ registerLetterAnswer: @escaping (_ bottleID: Int, _ order: Int, _ answer: String) async throws -> Void,
+ shareImage: @escaping (_ bottleID: Int, _ willShare: Bool) async throws -> Void,
+ finalSelect: @escaping (_ bottleID: Int, _ willMatch: Bool) async throws -> Void,
+ stopTalk: @escaping (_ bottleID: Int) async throws -> Void
+ ) {
+ self.fetchUserBottleInfo = fetchUserBottleInfo
+ self.fetchBottleStorageList = fetchBottleStorageList
+ self.fetchBottlePingPong = fetchBottlePingPong
+ self.registerLetterAnswer = registerLetterAnswer
+ self.shareImage = shareImage
+ self.finalSelect = finalSelect
+ self.stopTalk = stopTalk
+ }
+
+ public func fetchUserBottleInfo() async throws -> UserBottleInfo {
+ try await fetchUserBottleInfo()
+ }
+
+ public func fetchBottleStorageList() async throws -> BottleStorageList {
+ try await fetchBottleStorageList()
+ }
+
+ public func fetchBottlePingPong(bottleID: Int) async throws -> BottlePingPong {
+ try await fetchBottlePingPong(bottleID)
+ }
+
+ public func registerLetterAnswer(bottleID: Int, order: Int, answer: String) async throws {
+ try await registerLetterAnswer(bottleID, order, answer)
+ }
+
+ public func shareImage(bottleID: Int, willShare: Bool) async throws {
+ try await shareImage(bottleID, willShare)
+ }
+
+ public func finalSelect(bottleID: Int, willMatch: Bool) async throws {
+ try await finalSelect(bottleID, willMatch)
+ }
+
+ public func stopTalk(bottleID: Int) async throws {
+ try await stopTalk(bottleID)
+ }
+}
diff --git a/Projects/Domain/Bottle/Interface/Sources/DTO/Request/BottleImageShareRequestDTO.swift b/Projects/Domain/Bottle/Interface/Sources/DTO/Request/BottleImageShareRequestDTO.swift
new file mode 100644
index 00000000..7b46f927
--- /dev/null
+++ b/Projects/Domain/Bottle/Interface/Sources/DTO/Request/BottleImageShareRequestDTO.swift
@@ -0,0 +1,14 @@
+//
+// BottleImageShareRequestDTO.swift
+// DomainBottle
+//
+// Created by JongHoon on 8/12/24.
+//
+
+public struct BottleImageShareRequestDTO: Encodable {
+ public let willShare: Bool
+
+ public init(willShare: Bool) {
+ self.willShare = willShare
+ }
+}
diff --git a/Projects/Domain/Bottle/Interface/Sources/DTO/Request/FinalSelectRequestDTO.swift b/Projects/Domain/Bottle/Interface/Sources/DTO/Request/FinalSelectRequestDTO.swift
new file mode 100644
index 00000000..ee78cf0a
--- /dev/null
+++ b/Projects/Domain/Bottle/Interface/Sources/DTO/Request/FinalSelectRequestDTO.swift
@@ -0,0 +1,14 @@
+//
+// FinalSelectRequestDTO.swift
+// DomainBottle
+//
+// Created by JongHoon on 8/12/24.
+//
+
+public struct FinalSelectRequestDTO: Encodable {
+ public let willMatch: Bool
+
+ public init(willMatch: Bool) {
+ self.willMatch = willMatch
+ }
+}
diff --git a/Projects/Domain/Bottle/Interface/Sources/DTO/Request/RegisterLetterAnswerRequestDTO.swift b/Projects/Domain/Bottle/Interface/Sources/DTO/Request/RegisterLetterAnswerRequestDTO.swift
new file mode 100644
index 00000000..9cb606a3
--- /dev/null
+++ b/Projects/Domain/Bottle/Interface/Sources/DTO/Request/RegisterLetterAnswerRequestDTO.swift
@@ -0,0 +1,19 @@
+//
+// RegisterLetterAnswerRequestDTO.swift
+// DomainBottle
+//
+// Created by JongHoon on 8/12/24.
+//
+
+public struct RegisterLetterAnswerRequestDTO: Encodable {
+ public let answer: String
+ public let order: Int
+
+ public init(
+ answer: String,
+ order: Int
+ ) {
+ self.answer = answer
+ self.order = order
+ }
+}
diff --git a/Projects/Domain/Bottle/Interface/Sources/DTO/Response/BottleListResponseDTO.swift b/Projects/Domain/Bottle/Interface/Sources/DTO/Response/BottleListResponseDTO.swift
new file mode 100644
index 00000000..2e99cd2e
--- /dev/null
+++ b/Projects/Domain/Bottle/Interface/Sources/DTO/Response/BottleListResponseDTO.swift
@@ -0,0 +1,32 @@
+//
+// BottleListResponseDTO.swift
+// DomainBottle
+//
+// Created by 임현규 on 7/29/24.
+//
+
+import Foundation
+
+// MARK: - BottleList
+public struct BottleListResponseDTO: Decodable {
+ public let randomBottles: [BottleItemDTO]?
+ public let sentBottles: [BottleItemDTO]?
+ public let nextBottleLeftHours: Int?
+
+ // MARK: - BottleItem
+ public struct BottleItemDTO: Codable {
+ let age: Int?
+ let expiredAt: String?
+ let id: Int?
+ let keyword: [String]?
+ let mbti: String?
+ let userName: String?
+ }
+
+ public func toUserBottleInfoDomain() -> UserBottleInfo {
+ return .init(
+ randomBottleCount: randomBottles?.count ?? 0,
+ sendBottleCount: sentBottles?.count ?? 0,
+ nextBottlLeftHours: nextBottleLeftHours)
+ }
+}
diff --git a/Projects/Domain/Bottle/Interface/Sources/DTO/Response/BottlePingPongResponseDTO.swift b/Projects/Domain/Bottle/Interface/Sources/DTO/Response/BottlePingPongResponseDTO.swift
new file mode 100644
index 00000000..087381a7
--- /dev/null
+++ b/Projects/Domain/Bottle/Interface/Sources/DTO/Response/BottlePingPongResponseDTO.swift
@@ -0,0 +1,216 @@
+//
+// BottlePingPongResponseDTO.swift
+// DomainBottleInterface
+//
+// Created by JongHoon on 8/11/24.
+//
+
+import Foundation
+
+public struct BottlePingPongResponseDTO: Decodable {
+ let introduction: [IntroductionDTO]?
+ let isStopped: Bool?
+ let letters: [LetterDTO]?
+ let matchResult: MatchResultDTO?
+ let photo: PhotoDTO?
+ let stopUserName: String?
+ let userProfile: UserProfileDTO?
+
+ public func toDomain() -> BottlePingPong {
+ return .init(
+ introduction: (introduction?.map { $0.toDomain() }) ?? [],
+ isStopped: isStopped ?? true,
+ letters: (letters?.map { $0.toDomain() }) ?? [],
+ matchResult: matchResult?.toDomain() ?? MatchResult(
+ isFirstSelect: false,
+ matchStatus: .inConversation,
+ otherContact: "",
+ shouldAnswer: false,
+ meetingPlace: nil,
+ meetingPlaceImageUrl: nil
+ ),
+ photo: photo?.toDomain() ?? Photo(
+ isDone: false,
+ shouldAnswer: false
+ ),
+ stopUserName: stopUserName,
+ userProfile: userProfile?.toDomain() ?? UserProfile(
+ userId: -1,
+ age: -1,
+ userName: ""
+ )
+ )
+ }
+
+ public struct IntroductionDTO: Decodable {
+ let answer: String?
+ let question: String?
+
+ public func toDomain() -> QuestionAndAnswer {
+ return .init(
+ answer: answer ?? "",
+ question: question ?? ""
+ )
+ }
+ }
+
+ public struct LetterDTO: Decodable {
+ let canshow: Bool?
+ let isDone: Bool?
+ let myAnswer: String?
+ let order: Int?
+ let otherAnswer: String?
+ let question: String?
+ let shouldAnswer: Bool?
+
+ public func toDomain() -> Letter {
+ return .init(
+ canshow: canshow,
+ isDone: isDone ?? false,
+ myAnswer: myAnswer,
+ order: order,
+ otherAnswer: otherAnswer,
+ question: question,
+ shouldAnswer: shouldAnswer ?? false
+ )
+ }
+ }
+
+ public struct MatchResultDTO: Decodable {
+ let isFirstSelect: Bool?
+ let matchStatus: String?
+ let otherContact: String?
+ let shouldAnswer: Bool?
+ let meetingPlace: String?
+ let meetingPlaceImageUrl: String?
+
+ public func toDomain() -> MatchResult {
+ let matchStatus: BottleMatchStatus = switch matchStatus {
+ case "IN_CONVERSATION": .inConversation
+ case "MATCH_SUCCEEDED": .matchSucceeded
+ case "MATCH_FAILED": .matchFailed
+ case "NONE": .inConversation
+ case "REQUIRE_SELECT": .inConversation
+ case "WAITING_OTHER_ANSWER": .inConversation
+ default: .matchFailed
+ }
+
+ return .init(
+ isFirstSelect: isFirstSelect ?? false,
+ matchStatus: matchStatus,
+ otherContact: otherContact ?? "",
+ shouldAnswer: shouldAnswer ?? false,
+ meetingPlace: meetingPlace,
+ meetingPlaceImageUrl: meetingPlaceImageUrl
+ )
+ }
+ }
+
+ public struct PhotoDTO: Decodable {
+ let isDone: Bool?
+ let myAnswer: Bool?
+ let myImageUrl: String?
+ let otherAnswer: Bool?
+ let otherImageUrl: String?
+ let shouldAnswer: Bool?
+
+ public func toDomain() -> Photo {
+ return .init(
+ isDone: isDone ?? false,
+ myAnswer: myAnswer,
+ myImageURL: myImageUrl,
+ otherAnswer: otherAnswer,
+ otherImageURL: otherImageUrl,
+ shouldAnswer: shouldAnswer ?? false
+ )
+ }
+ }
+
+ public struct UserProfileDTO: Decodable {
+ let userId: Int?
+ let age: Int?
+ let profileSelect: ProfileSelectDTO?
+ let userImageURL: String?
+ let userName: String?
+
+ enum CodingKeys: String, CodingKey {
+ case userId
+ case age
+ case profileSelect
+ case userImageURL = "userImageUrl"
+ case userName
+ }
+
+ public func toDomain() -> UserProfile {
+ return .init(
+ userId: userId ?? -1,
+ age: age ?? -1,
+ profileSelect: profileSelect?.toDomain(),
+ userImageURL: userImageURL,
+ userName: userName ?? ""
+ )
+ }
+
+ public struct ProfileSelectDTO: Decodable {
+ let alcohol: String?
+ let height: Int?
+ let interest: InterestDTO?
+ let job: String?
+ let keyword: [String]?
+ let mbti: String?
+ let region: RegionDTO?
+ let religion: String?
+ let smoking: String?
+
+ public func toDomain() -> ProfileSelect {
+ return .init(
+ alcohol: alcohol ?? "",
+ height: height ?? -1,
+ interest: interest?.toDomain() ?? Interest(
+ culture: [],
+ entertainment: [],
+ etc: [],
+ sports: []
+ ),
+ job: job ?? "",
+ keyword: keyword ?? [],
+ mbti: mbti ?? "",
+ region: region?.toDomain() ?? Region(
+ city: "",
+ state: ""
+ ),
+ religion: religion ?? "",
+ smoking: smoking ?? ""
+ )
+ }
+
+ public struct InterestDTO: Decodable {
+ let culture: [String]?
+ let entertainment: [String]?
+ let etc: [String]?
+ let sports: [String]?
+
+ public func toDomain() -> Interest {
+ return .init(
+ culture: culture ?? [],
+ entertainment: entertainment ?? [],
+ etc: etc ?? [],
+ sports: sports ?? []
+ )
+ }
+ }
+
+ public struct RegionDTO: Decodable {
+ let city: String?
+ let state: String?
+
+ public func toDomain() -> Region {
+ return .init(
+ city: city ?? "",
+ state: state ?? ""
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/Projects/Domain/Bottle/Interface/Sources/DTO/Response/BottleStorageListResponseDTO.swift b/Projects/Domain/Bottle/Interface/Sources/DTO/Response/BottleStorageListResponseDTO.swift
new file mode 100644
index 00000000..3a397c74
--- /dev/null
+++ b/Projects/Domain/Bottle/Interface/Sources/DTO/Response/BottleStorageListResponseDTO.swift
@@ -0,0 +1,44 @@
+//
+// BottleStorageListItem.swift
+// DomainBottleInterface
+//
+// Created by JongHoon on 8/8/24.
+//
+
+import Foundation
+
+// MARK: - Bottle Storage List
+
+public struct BottleStorageListResponseDTO: Decodable {
+ let activeBottles: [BottleStorageItemResponseDTO]?
+ let doneBottles: [BottleStorageItemResponseDTO]?
+
+ public struct BottleStorageItemResponseDTO: Decodable {
+ let age: Int?
+ let id: Int?
+ let isRead: Bool?
+ let keyword: [String]?
+ let mbti: String?
+ let userImageUrl: String?
+ let userName: String?
+
+ public func toDomain() -> BottleStorageItem {
+ return BottleStorageItem(
+ age: age ?? -1,
+ id: id ?? Int.random(in: Int.min...Int.max),
+ isRead: isRead ?? false,
+ keyword: keyword ?? [],
+ mbti: mbti ?? "",
+ userImageUrl: userImageUrl ?? "",
+ userName: userName ?? ""
+ )
+ }
+ }
+
+ public func toDomain() -> BottleStorageList {
+ return BottleStorageList(
+ activeBottles: activeBottles?.map { $0.toDomain() } ?? [],
+ doneBottles: doneBottles?.map { $0.toDomain() } ?? []
+ )
+ }
+}
diff --git a/Projects/Domain/Bottle/Interface/Sources/Entity/BottlePingPong.swift b/Projects/Domain/Bottle/Interface/Sources/Entity/BottlePingPong.swift
new file mode 100644
index 00000000..863edc17
--- /dev/null
+++ b/Projects/Domain/Bottle/Interface/Sources/Entity/BottlePingPong.swift
@@ -0,0 +1,227 @@
+//
+// BottlePingPong.swift
+// DomainBottleInterface
+//
+// Created by JongHoon on 8/11/24.
+//
+
+import Foundation
+
+public struct BottlePingPong: Equatable {
+ public let introduction: [QuestionAndAnswer]?
+ public let isStopped: Bool
+ public let letters: [Letter]
+ public let matchResult: MatchResult
+ public let photo: Photo
+ public let stopUserName: String?
+ public let userProfile: UserProfile
+
+ public init(
+ introduction: [QuestionAndAnswer]? = nil,
+ isStopped: Bool,
+ letters: [Letter],
+ matchResult: MatchResult,
+ photo: Photo,
+ stopUserName: String? = nil,
+ userProfile: UserProfile
+ ) {
+ self.introduction = introduction
+ self.isStopped = isStopped
+ self.letters = letters
+ self.matchResult = matchResult
+ self.photo = photo
+ self.stopUserName = stopUserName
+ self.userProfile = userProfile
+ }
+}
+
+public struct QuestionAndAnswer: Codable, Equatable {
+ public let answer: String
+ public let question: String
+
+ public init(
+ answer: String,
+ question: String
+ ) {
+ self.answer = answer
+ self.question = question
+ }
+}
+
+public struct Letter: Equatable {
+ public let canshow: Bool?
+ public let isDone: Bool
+ public let myAnswer: String?
+ public let order: Int?
+ public let otherAnswer: String?
+ public let question: String?
+ public let shouldAnswer: Bool
+
+ public init(
+ canshow: Bool? = nil,
+ isDone: Bool,
+ myAnswer: String? = nil,
+ order: Int? = nil,
+ otherAnswer: String? = nil,
+ question: String? = nil,
+ shouldAnswer: Bool
+ ) {
+ self.canshow = canshow
+ self.isDone = isDone
+ self.myAnswer = myAnswer
+ self.order = order
+ self.otherAnswer = otherAnswer
+ self.question = question
+ self.shouldAnswer = shouldAnswer
+ }
+}
+
+public struct MatchResult: Equatable {
+ public let isFirstSelect: Bool
+ public let matchStatus: BottleMatchStatus
+ public let otherContact: String
+ public let shouldAnswer: Bool
+ public let meetingPlace: String?
+ public let meetingPlaceImageURL: String?
+
+ init(
+ isFirstSelect: Bool,
+ matchStatus: BottleMatchStatus,
+ otherContact: String,
+ shouldAnswer: Bool,
+ meetingPlace: String?,
+ meetingPlaceImageUrl: String?
+ ) {
+ self.isFirstSelect = isFirstSelect
+ self.matchStatus = matchStatus
+ self.otherContact = otherContact
+ self.shouldAnswer = shouldAnswer
+ self.meetingPlace = meetingPlace
+ self.meetingPlaceImageURL = meetingPlaceImageUrl
+ }
+}
+
+public enum BottleMatchStatus {
+ case inConversation
+ case matchFailed
+ case matchSucceeded
+}
+
+public struct Photo: Equatable {
+ public let isDone: Bool
+ public let myAnswer: Bool?
+ public let myImageURL: String?
+ public let otherAnswer: Bool?
+ public let otherImageURL: String?
+ public let shouldAnswer: Bool
+
+ init(
+ isDone: Bool,
+ myAnswer: Bool? = nil,
+ myImageURL: String? = nil,
+ otherAnswer: Bool? = nil,
+ otherImageURL: String? = nil,
+ shouldAnswer: Bool
+ ) {
+ self.isDone = isDone
+ self.myAnswer = myAnswer
+ self.myImageURL = myImageURL
+ self.otherAnswer = otherAnswer
+ self.otherImageURL = otherImageURL
+ self.shouldAnswer = shouldAnswer
+ }
+}
+
+public struct UserProfile: Equatable {
+ public let userId: Int
+ public let age: Int
+ public let profileSelect: ProfileSelect?
+ public let userImageURL: String?
+ public let userName: String
+
+ public init(
+ userId: Int,
+ age: Int,
+ profileSelect: ProfileSelect? = nil,
+ userImageURL: String? = nil,
+ userName: String
+ ) {
+ self.userId = userId
+ self.age = age
+ self.profileSelect = profileSelect
+ self.userImageURL = userImageURL
+ self.userName = userName
+ }
+}
+
+public struct ProfileSelect: Equatable {
+ public static func == (lhs: ProfileSelect, rhs: ProfileSelect) -> Bool {
+ return lhs.id == rhs.id
+ }
+
+ public let id: UUID
+ public let alcohol: String
+ public let height: Int
+ public let interest: Interest
+ public let job: String
+ public let keyword: [String]
+ public let mbti: String
+ public let region: Region
+ public let religion: String
+ public let smoking: String
+
+ public init(
+ alcohol: String,
+ height: Int,
+ interest: Interest,
+ job: String,
+ keyword: [String],
+ mbti: String,
+ region: Region,
+ religion: String,
+ smoking: String
+ ) {
+ self.id = UUID()
+ self.alcohol = alcohol
+ self.height = height
+ self.interest = interest
+ self.job = job
+ self.keyword = keyword
+ self.mbti = mbti
+ self.region = region
+ self.religion = religion
+ self.smoking = smoking
+ }
+}
+
+public struct Interest: Equatable {
+ public let culture: [String]
+ public let entertainment: [String]
+ public let etc: [String]
+ public let sports: [String]
+
+ public init(
+ culture: [String],
+ entertainment: [String],
+ etc: [String],
+ sports: [String]
+ ) {
+ self.culture = culture
+ self.entertainment = entertainment
+ self.etc = etc
+ self.sports = sports
+ }
+}
+
+public struct Region: Equatable {
+ public let city: String
+ public let state: String
+
+ public init(
+ city: String,
+ state: String
+ ) {
+ self.city = city
+ self.state = state
+ }
+}
diff --git a/Projects/Domain/Bottle/Interface/Sources/Entity/BottleStorageList.swift b/Projects/Domain/Bottle/Interface/Sources/Entity/BottleStorageList.swift
new file mode 100644
index 00000000..61b083e8
--- /dev/null
+++ b/Projects/Domain/Bottle/Interface/Sources/Entity/BottleStorageList.swift
@@ -0,0 +1,48 @@
+//
+// BottleStorageList.swift
+// DomainBottleInterface
+//
+// Created by JongHoon on 8/8/24.
+//
+
+public struct BottleStorageList: Decodable {
+ public let activeBottles: [BottleStorageItem]
+ public let doneBottles: [BottleStorageItem]
+
+ public init(
+ activeBottles: [BottleStorageItem],
+ doneBottles: [BottleStorageItem]
+ ) {
+ self.activeBottles = activeBottles
+ self.doneBottles = doneBottles
+ }
+
+}
+
+public struct BottleStorageItem: Decodable, Equatable {
+ public let age: Int?
+ public let id: Int
+ public let isRead: Bool?
+ public let keyword: [String]
+ public let mbti: String
+ public let userImageUrl: String
+ public let userName: String?
+
+ public init(
+ age: Int?,
+ id: Int,
+ isRead: Bool?,
+ keyword: [String],
+ mbti: String,
+ userImageUrl: String,
+ userName: String?
+ ) {
+ self.age = age
+ self.id = id
+ self.isRead = isRead
+ self.keyword = keyword
+ self.mbti = mbti
+ self.userImageUrl = userImageUrl
+ self.userName = userName
+ }
+}
diff --git a/Projects/Domain/Bottle/Interface/Sources/Entity/UserBottleInfo.swift b/Projects/Domain/Bottle/Interface/Sources/Entity/UserBottleInfo.swift
new file mode 100644
index 00000000..40eab012
--- /dev/null
+++ b/Projects/Domain/Bottle/Interface/Sources/Entity/UserBottleInfo.swift
@@ -0,0 +1,24 @@
+//
+// UserBottleInfo.swift
+// DomainBottleInterface
+//
+// Created by 임현규 on 8/13/24.
+//
+
+import Foundation
+
+public struct UserBottleInfo {
+ public let randomBottleCount: Int
+ public let sendBottleCount: Int
+ public let nextBottlLeftHours: Int?
+
+ public init(
+ randomBottleCount: Int,
+ sendBottleCount: Int,
+ nextBottlLeftHours: Int?
+ ) {
+ self.randomBottleCount = randomBottleCount
+ self.sendBottleCount = sendBottleCount
+ self.nextBottlLeftHours = nextBottlLeftHours
+ }
+}
diff --git a/Projects/Domain/Bottle/Project.swift b/Projects/Domain/Bottle/Project.swift
new file mode 100644
index 00000000..0230f344
--- /dev/null
+++ b/Projects/Domain/Bottle/Project.swift
@@ -0,0 +1,44 @@
+import ProjectDescription
+import ProjectDescriptionHelpers
+import DependencyPlugin
+
+let project = Project.makeModule(
+ name: ModulePath.Domain.name+ModulePath.Domain.Bottle.rawValue,
+ targets: [
+ .domain(
+ interface: .Bottle,
+ factory: .init(
+ dependencies: [
+ .core
+ ]
+ )
+ ),
+ .domain(
+ implements: .Bottle,
+ factory: .init(
+ dependencies: [
+ .domain(interface: .Bottle)
+ ]
+ )
+ ),
+
+ .domain(
+ testing: .Bottle,
+ factory: .init(
+ dependencies: [
+ .domain(interface: .Bottle)
+ ]
+ )
+ ),
+ .domain(
+ tests: .Bottle,
+ factory: .init(
+ dependencies: [
+ .domain(testing: .Bottle),
+ .domain(implements: .Bottle)
+ ]
+ )
+ ),
+
+ ]
+)
diff --git a/Projects/Domain/Bottle/Sources/BottleClient.swift b/Projects/Domain/Bottle/Sources/BottleClient.swift
new file mode 100644
index 00000000..e6656134
--- /dev/null
+++ b/Projects/Domain/Bottle/Sources/BottleClient.swift
@@ -0,0 +1,69 @@
+//
+// BottleClient.swift
+// DomainBottle
+//
+// Created by 임현규 on 7/29/24.
+//
+
+import DomainBottleInterface
+import CoreNetwork
+
+import ComposableArchitecture
+import Moya
+
+extension BottleClient: DependencyKey {
+ public static let liveValue: BottleClient = .live()
+
+ private static func live() -> BottleClient {
+ @Dependency(\.network) var networkManager
+
+ return .init(
+ fetchUserBottleInfo: {
+ let userBottleInfo = try await networkManager.reqeust(api: .apiType(BottleAPI.fetchBottles), dto: BottleListResponseDTO.self).toUserBottleInfoDomain()
+ return userBottleInfo
+ },
+ fetchBottleStorageList: {
+ let bottleStorageList = try await networkManager.reqeust(
+ api: .apiType(BottleAPI.fetchBottleStorageList),
+ dto: BottleStorageListResponseDTO.self
+ ).toDomain()
+ return bottleStorageList
+ },
+ fetchBottlePingPong: { id in
+ let bottlePingPong = try await networkManager.reqeust(
+ api: .apiType(BottleAPI.fetchBottlePingPong(bottleID: id)),
+ dto: BottlePingPongResponseDTO.self
+ ).toDomain()
+ return bottlePingPong
+ },
+ registerLetterAnswer: { bottleID, order, answer in
+ try await networkManager.reqeust(api: .apiType(BottleAPI.registerLetterAnswer(
+ bottleID: bottleID,
+ registerLetterAnswerRequestDTO: .init(answer: answer, order: order)
+ )))
+ },
+ shareImage: { bottleID, willShare in
+ try await networkManager.reqeust(api: .apiType(BottleAPI.imageShare(
+ bottleID: bottleID,
+ imageShareRequestDTO: .init(willShare: willShare)
+ )))
+ },
+ finalSelect: { bottleID, willMatch in
+ try await networkManager.reqeust(api: .apiType(BottleAPI.finalSelect(
+ bottleID: bottleID,
+ finalSelectRequestDTO: .init(willMatch: willMatch)
+ )))
+ },
+ stopTalk: { bottleID in
+ try await networkManager.reqeust(api: .apiType(BottleAPI.stopTalk(bottleID: bottleID)))
+ }
+ )
+ }
+}
+
+extension DependencyValues {
+ public var bottleClient: BottleClient {
+ get { self[BottleClient.self] }
+ set { self[BottleClient.self] = newValue }
+ }
+}
diff --git a/Projects/Domain/Bottle/Testing/Sources/BottleTesting.swift b/Projects/Domain/Bottle/Testing/Sources/BottleTesting.swift
new file mode 100644
index 00000000..b1853ce6
--- /dev/null
+++ b/Projects/Domain/Bottle/Testing/Sources/BottleTesting.swift
@@ -0,0 +1 @@
+// This is for Tuist
diff --git a/Projects/Domain/Bottle/Tests/Sources/BottleTest.swift b/Projects/Domain/Bottle/Tests/Sources/BottleTest.swift
new file mode 100644
index 00000000..3337affd
--- /dev/null
+++ b/Projects/Domain/Bottle/Tests/Sources/BottleTest.swift
@@ -0,0 +1,11 @@
+import XCTest
+
+final class BottleTests: XCTestCase {
+ override func setUpWithError() throws {}
+
+ override func tearDownWithError() throws {}
+
+ func testExample() {
+ XCTAssertEqual(1, 1)
+ }
+}
diff --git a/Projects/Domain/Profile/Interface/Sources/API/ProfileAPI.swift b/Projects/Domain/Profile/Interface/Sources/API/ProfileAPI.swift
new file mode 100644
index 00000000..2e8d5367
--- /dev/null
+++ b/Projects/Domain/Profile/Interface/Sources/API/ProfileAPI.swift
@@ -0,0 +1,74 @@
+//
+// ProfileAPI.swift
+// DomainProfileInterface
+//
+// Created by 임현규 on 7/29/24.
+//
+
+import Foundation
+
+import CoreNetworkInterface
+
+import Moya
+
+public enum ProfileAPI {
+ case fetchProfile
+ case registerIntroduction(requestData: RegisterIntroductionRequestDTO)
+ case checkIntroduction
+ case uploadProfileImage(data: Data)
+ case fetchUserProfileStatus
+}
+
+extension ProfileAPI: BaseTargetType {
+ public var path: String {
+ switch self {
+ case .fetchProfile:
+ return "api/v1/profile"
+ case .registerIntroduction:
+ return "api/v1/profile/introduction"
+ case .checkIntroduction:
+ return "api/v1/profile/introduction/exist"
+ case .uploadProfileImage:
+ return "api/v1/profile/images"
+ case .fetchUserProfileStatus:
+ return "api/v1/profile/status"
+ }
+ }
+
+ public var method: Moya.Method {
+ switch self {
+ case .fetchProfile:
+ return .get
+ case .registerIntroduction:
+ return .post
+ case .checkIntroduction:
+ return .get
+ case .uploadProfileImage:
+ return .post
+ case .fetchUserProfileStatus:
+ return .get
+ }
+ }
+
+ public var task: Moya.Task {
+ switch self {
+ case .fetchProfile:
+ return .requestPlain
+ case .registerIntroduction(let requestData):
+ return .requestJSONEncodable(requestData)
+ case .checkIntroduction:
+ return .requestPlain
+ case .uploadProfileImage(let data):
+ let imageData = MultipartFormData(
+ provider: .data(data),
+ name: "file",
+ fileName: "filename.jpeg",
+ mimeType: "image/jpeg"
+ )
+
+ return .uploadMultipart([imageData])
+ case .fetchUserProfileStatus:
+ return .requestPlain
+ }
+ }
+}
diff --git a/Projects/Domain/Profile/Interface/Sources/DTO/Request/RegisterIntroductionRequestDTO.swift b/Projects/Domain/Profile/Interface/Sources/DTO/Request/RegisterIntroductionRequestDTO.swift
new file mode 100644
index 00000000..9b51ede3
--- /dev/null
+++ b/Projects/Domain/Profile/Interface/Sources/DTO/Request/RegisterIntroductionRequestDTO.swift
@@ -0,0 +1,26 @@
+//
+// RegisterIntroductionRequestDTO.swift
+// DomainProfile
+//
+// Created by 임현규 on 8/2/24.
+//
+
+import Foundation
+
+public struct RegisterIntroductionRequestDTO: Encodable {
+ public let introduction: [IntroductionReqeustDTO]
+
+ public init(introduction: [IntroductionReqeustDTO]) {
+ self.introduction = introduction
+ }
+}
+
+public struct IntroductionReqeustDTO: Encodable {
+ public let answer: String
+ public let question: String
+
+ public init(answer: String, question: String) {
+ self.answer = answer
+ self.question = question
+ }
+}
diff --git a/Projects/Domain/Profile/Interface/Sources/DTO/Response/IntroductionExistResponseDTO.swift b/Projects/Domain/Profile/Interface/Sources/DTO/Response/IntroductionExistResponseDTO.swift
new file mode 100644
index 00000000..c78eab82
--- /dev/null
+++ b/Projects/Domain/Profile/Interface/Sources/DTO/Response/IntroductionExistResponseDTO.swift
@@ -0,0 +1,12 @@
+//
+// IntroductionExistResponseDTO.swift
+// DomainProfile
+//
+// Created by 임현규 on 8/2/24.
+//
+
+import Foundation
+
+public struct IntroductionExistResponseDTO: Decodable {
+ public let isExist: Bool?
+}
diff --git a/Projects/Domain/Profile/Interface/Sources/DTO/Response/ProfileResponseDTO.swift b/Projects/Domain/Profile/Interface/Sources/DTO/Response/ProfileResponseDTO.swift
new file mode 100644
index 00000000..240ef5e0
--- /dev/null
+++ b/Projects/Domain/Profile/Interface/Sources/DTO/Response/ProfileResponseDTO.swift
@@ -0,0 +1,136 @@
+//
+// ProfileResponseDTO.swift
+// DomainProfileInterface
+//
+// Created by 임현규 on 7/29/24.
+//
+
+import Foundation
+
+// MARK: - Profile
+public struct ProfileResponseDTO: Decodable {
+ public let userName: String?
+ public let imageUrl: String?
+ public let age: Int?
+ public let introduction: [IntroductionDTO]?
+ public let profileSelect: ProfileSelectDTO?
+
+ // MARK: - Introduction
+ public struct IntroductionDTO: Decodable {
+ let answer, question: String?
+ }
+
+ // MARK: - ProfileSelect
+ public struct ProfileSelectDTO: Decodable {
+ let alcohol: String?
+ let height: Int?
+ let interest: InterestDTO?
+ let job: String?
+ let keyword: [String]?
+ let mbti: String?
+ let region: RegionDTO?
+ let religion, smoking: String?
+
+ public func toDomain() -> ProfileSelect {
+ return .init(
+ mbti: mbti ?? "",
+ keyword: keyword ?? [],
+ interset: interest?.toDomain() ??
+ UserInterest(culture: [], sports: [], entertainment: [], etc: []),
+ job: job ?? "",
+ smoke: smoking ?? "",
+ alcohol: alcohol ?? "",
+ height: height ?? 0,
+ region: region?.toDomain() ??
+ UserRegion(city: "", state: "")
+ )
+ }
+ }
+
+ // MARK: - Interest
+ struct InterestDTO: Decodable {
+ let culture, entertainment, etc, sports: [String]?
+
+ public func toDomain() -> UserInterest {
+ return .init(
+ culture: culture,
+ sports: sports,
+ entertainment: entertainment,
+ etc: etc)
+ }
+ }
+
+ // MARK: - Region
+ struct RegionDTO: Decodable {
+ let city, state: String?
+
+ func toDomain() -> UserRegion {
+ return .init(
+ city: city ?? "",
+ state: state ?? ""
+ )
+ }
+ }
+
+ public func toProfileDomain() -> UserProfile {
+ return .init(
+ userInfo: UserInfo(
+ userAge: age ?? -1,
+ userImageURL: imageUrl ?? "",
+ userName: userName ?? ""),
+ introduction: Introduction(answer: introduction?.first?.answer ?? "", question: introduction?.first?.question ?? ""),
+ profileSelect: profileSelect?.toDomain() ?? ProfileSelect(
+ mbti: "",
+ keyword: [],
+ interset: UserInterest(
+ culture: nil,
+ sports: nil,
+ entertainment: nil,
+ etc: nil
+ ),
+ job: "",
+ smoke: "",
+ alcohol: "",
+ height: 0,
+ region: UserRegion(
+ city: "",
+ state: ""
+ )
+ )
+ )
+ }
+}
+
+public struct UserInfo: Equatable {
+ public let userAge: Int
+ public let userImageURL: String
+ public let userName: String
+
+ public init(userAge: Int, userImageURL: String, userName: String) {
+ self.userAge = userAge
+ self.userImageURL = userImageURL
+ self.userName = userName
+ }
+}
+
+public struct Introduction: Equatable {
+ public let answer: String
+ public let question: String
+
+ public init(answer: String, question: String) {
+ self.answer = answer
+ self.question = question
+ }
+}
+
+public struct UserProfile {
+ public let userInfo: UserInfo
+ public let introduction: Introduction
+ public let profileSelect: ProfileSelect
+
+ public init(userInfo: UserInfo, introduction: Introduction, profileSelect: ProfileSelect) {
+ self.userInfo = userInfo
+ self.introduction = introduction
+ self.profileSelect = profileSelect
+ }
+}
diff --git a/Projects/Domain/Profile/Interface/Sources/DTO/Response/ProfileStatusResponseDTO.swift b/Projects/Domain/Profile/Interface/Sources/DTO/Response/ProfileStatusResponseDTO.swift
new file mode 100644
index 00000000..57475fb0
--- /dev/null
+++ b/Projects/Domain/Profile/Interface/Sources/DTO/Response/ProfileStatusResponseDTO.swift
@@ -0,0 +1,27 @@
+//
+// ProfileStatusResponseDTO.swift
+// DomainProfileInterface
+//
+// Created by 임현규 on 8/18/24.
+//
+
+import Foundation
+
+public struct ProfileStatusResponseDTO: Decodable {
+ let userProfileStatus: String?
+
+ public func toDomain() -> UserProfileStatus {
+ switch userProfileStatus {
+ case "EMPTY":
+ return .empty
+ case "ONLY_PROFILE_CREATED":
+ return .doneProfileSelect
+ case "INTRODUCE_DONE":
+ return .doneIntroduction
+ case "PHOTO_DONE":
+ return .doneProfileImage
+ default:
+ return .empty
+ }
+ }
+}
diff --git a/Projects/Domain/Profile/Interface/Sources/Entity/UserInterest.swift b/Projects/Domain/Profile/Interface/Sources/Entity/UserInterest.swift
new file mode 100644
index 00000000..b7102420
--- /dev/null
+++ b/Projects/Domain/Profile/Interface/Sources/Entity/UserInterest.swift
@@ -0,0 +1,27 @@
+//
+// UserInterest.swift
+// DomainProfileInterface
+//
+// Created by 임현규 on 8/2/24.
+//
+
+import Foundation
+
+public struct UserInterest {
+ public let culture: [String]?
+ public let sports: [String]?
+ public let entertainment: [String]?
+ public let etc: [String]?
+
+ public init(
+ culture: [String]?,
+ sports: [String]?,
+ entertainment: [String]?,
+ etc: [String]?
+ ) {
+ self.culture = culture
+ self.sports = sports
+ self.entertainment = entertainment
+ self.etc = etc
+ }
+}
diff --git a/Projects/Domain/Profile/Interface/Sources/Entity/UserProfile.swift b/Projects/Domain/Profile/Interface/Sources/Entity/UserProfile.swift
new file mode 100644
index 00000000..609b80de
--- /dev/null
+++ b/Projects/Domain/Profile/Interface/Sources/Entity/UserProfile.swift
@@ -0,0 +1,40 @@
+//
+// UserProfile.swift
+// DomainProfileInterface
+//
+// Created by 임현규 on 8/2/24.
+//
+
+import Foundation
+
+public struct ProfileSelect {
+ public let mbti: String
+ public let keyword: [String]
+ public let interset: UserInterest
+ public let job: String
+ public let smoke: String
+ public let alcohol: String
+ public let height: Int
+ public let region: UserRegion
+
+ public init(
+ mbti: String,
+ keyword: [String],
+ interset: UserInterest,
+ job: String,
+ smoke: String,
+ alcohol: String,
+ height: Int,
+ region: UserRegion
+ ) {
+ self.mbti = mbti
+ self.keyword = keyword
+ self.interset = interset
+ self.job = job
+ self.smoke = smoke
+ self.alcohol = alcohol
+ self.height = height
+ self.region = region
+ }
+}
+
diff --git a/Projects/Domain/Profile/Interface/Sources/Entity/UserProfileStatus.swift b/Projects/Domain/Profile/Interface/Sources/Entity/UserProfileStatus.swift
new file mode 100644
index 00000000..f4e7d598
--- /dev/null
+++ b/Projects/Domain/Profile/Interface/Sources/Entity/UserProfileStatus.swift
@@ -0,0 +1,19 @@
+//
+// UserProfileStatus.swift
+// DomainProfileInterface
+//
+// Created by 임현규 on 8/18/24.
+//
+
+import Foundation
+
+public enum UserProfileStatus {
+ /// 프로필이 아무것도 작성되지 않음
+ case empty
+ /// 온보딩 프로필 키워드 선택까지 완료된 상태
+ case doneProfileSelect
+ /// 자기소개까지 작성 완료된 상태
+ case doneIntroduction
+ /// 프로필 이미지까지 완료된 상태
+ case doneProfileImage
+}
diff --git a/Projects/Domain/Profile/Interface/Sources/Entity/UserRegion.swift b/Projects/Domain/Profile/Interface/Sources/Entity/UserRegion.swift
new file mode 100644
index 00000000..abd6a9be
--- /dev/null
+++ b/Projects/Domain/Profile/Interface/Sources/Entity/UserRegion.swift
@@ -0,0 +1,18 @@
+//
+// UserRegion.swift
+// DomainProfileInterface
+//
+// Created by 임현규 on 8/2/24.
+//
+
+import Foundation
+
+public struct UserRegion {
+ public let city: String
+ public let state: String
+
+ public init(city: String, state: String) {
+ self.city = city
+ self.state = state
+ }
+}
diff --git a/Projects/Domain/Profile/Interface/Sources/ProfileClient.swift b/Projects/Domain/Profile/Interface/Sources/ProfileClient.swift
new file mode 100644
index 00000000..3dcfe4ed
--- /dev/null
+++ b/Projects/Domain/Profile/Interface/Sources/ProfileClient.swift
@@ -0,0 +1,59 @@
+//
+// ProfileClient.swift
+// DomainProfileInterface
+//
+// Created by 임현규 on 7/29/24.
+//
+
+import Foundation
+import ComposableArchitecture
+
+public struct ProfileClient {
+ private var checkExistIntroduction: () async throws -> Bool
+ private var registerIntroduction: (String) async throws -> Void
+ private var fetchProfileSelect: () async throws -> ProfileSelect
+ private var uploadProfileImage: (Data) async throws -> Void
+ private var fetchUserProfile: () async throws -> UserProfile
+ private var fetchUserProfileSelect: () async throws -> UserProfileStatus
+
+ public init(
+ checkExistIntroduction: @escaping () async throws -> Bool,
+ registerIntroduction: @escaping (String) async throws -> Void,
+ fetchProfileSelect: @escaping () async throws -> ProfileSelect,
+ uploadProfileImage: @escaping (Data) async throws -> Void,
+ fetchUserProfile: @escaping () async throws -> UserProfile,
+ fetchUserProfileSelect: @escaping () async throws -> UserProfileStatus
+ ) {
+ self.checkExistIntroduction = checkExistIntroduction
+ self.registerIntroduction = registerIntroduction
+ self.fetchProfileSelect = fetchProfileSelect
+ self.uploadProfileImage = uploadProfileImage
+ self.fetchUserProfile = fetchUserProfile
+ self.fetchUserProfileSelect = fetchUserProfileSelect
+ }
+
+ public func checkExistIntroduction() async throws -> Bool {
+ try await checkExistIntroduction()
+ }
+
+ public func registerIntroduction(answer: String) async throws -> Void {
+ try await registerIntroduction(answer)
+ }
+
+ public func fetchProfileSelect() async throws -> ProfileSelect {
+ try await fetchProfileSelect()
+ }
+
+ public func uploadProfileImage(imageData: Data) async throws {
+ try await uploadProfileImage(imageData)
+ }
+
+ public func fetchUserProfile() async throws -> UserProfile {
+ try await fetchUserProfile()
+ }
+
+ public func fetchUserProfileSelect() async throws -> UserProfileStatus {
+ try await fetchUserProfileSelect()
+ }
+}
+
diff --git a/Projects/Domain/Profile/Project.swift b/Projects/Domain/Profile/Project.swift
new file mode 100644
index 00000000..97494e1a
--- /dev/null
+++ b/Projects/Domain/Profile/Project.swift
@@ -0,0 +1,42 @@
+import ProjectDescription
+import ProjectDescriptionHelpers
+import DependencyPlugin
+
+let project = Project.makeModule(
+ name: ModulePath.Domain.name+ModulePath.Domain.Profile.rawValue,
+ targets: [
+ .domain(
+ interface: .Profile,
+ factory: .init(dependencies: [
+ .core
+ ])
+ ),
+ .domain(
+ implements: .Profile,
+ factory: .init(
+ dependencies: [
+ .domain(interface: .Profile)
+ ]
+ )
+ ),
+
+ .domain(
+ testing: .Profile,
+ factory: .init(
+ dependencies: [
+ .domain(interface: .Profile)
+ ]
+ )
+ ),
+ .domain(
+ tests: .Profile,
+ factory: .init(
+ dependencies: [
+ .domain(testing: .Profile),
+ .domain(implements: .Profile)
+ ]
+ )
+ ),
+
+ ]
+)
diff --git a/Projects/Domain/Profile/Sources/ProfileClient.swift b/Projects/Domain/Profile/Sources/ProfileClient.swift
new file mode 100644
index 00000000..d6265d3f
--- /dev/null
+++ b/Projects/Domain/Profile/Sources/ProfileClient.swift
@@ -0,0 +1,59 @@
+//
+// ProfileClient.swift
+// DomainProfileInterface
+//
+// Created by 임현규 on 7/29/24.
+//
+
+import DomainProfileInterface
+import CoreNetwork
+
+import ComposableArchitecture
+import Moya
+
+extension ProfileClient: DependencyKey {
+ public static let liveValue: ProfileClient = .live()
+
+ private static func live() -> ProfileClient {
+ @Dependency(\.network) var networkManager
+
+ return .init(
+ checkExistIntroduction: {
+ let isExistIntroduction = try await networkManager.reqeust(api: .apiType(ProfileAPI.checkIntroduction), dto: IntroductionExistResponseDTO.self)
+ guard let isExist = isExistIntroduction.isExist else { return false }
+ return isExist
+ },
+ registerIntroduction: { answer in
+ let requestData = RegisterIntroductionRequestDTO(introduction: [IntroductionReqeustDTO(answer: answer, question: "")])
+ try await networkManager.reqeust(
+ api: .apiType(ProfileAPI.registerIntroduction(requestData: requestData))
+ )
+ },
+ fetchProfileSelect: {
+ let responseData = try await networkManager.reqeust(api: .apiType(ProfileAPI.fetchProfile), dto: ProfileResponseDTO.self)
+ let userProfile = responseData.toProfileDomain()
+ return userProfile.profileSelect
+ },
+ uploadProfileImage: { imageData in
+ try await networkManager.reqeust(api: .apiType(ProfileAPI.uploadProfileImage(data: imageData)))
+ },
+ fetchUserProfile: {
+ let responseData = try await networkManager.reqeust(api: .apiType(ProfileAPI.fetchProfile), dto: ProfileResponseDTO.self)
+ let userProfile = responseData.toProfileDomain()
+ return userProfile
+ },
+ fetchUserProfileSelect: {
+ let responseData = try await networkManager.reqeust(api: .apiType(ProfileAPI.fetchUserProfileStatus), dto: ProfileStatusResponseDTO.self)
+ let userStatus = responseData.toDomain()
+ return userStatus
+ }
+ )
+ }
+}
+
+extension DependencyValues {
+ public var profileClient: ProfileClient {
+ get { self[ProfileClient.self] }
+ set { self[ProfileClient.self] = newValue }
+ }
+}
diff --git a/Projects/Domain/Profile/Testing/Sources/ProfileTesting.swift b/Projects/Domain/Profile/Testing/Sources/ProfileTesting.swift
new file mode 100644
index 00000000..b1853ce6
--- /dev/null
+++ b/Projects/Domain/Profile/Testing/Sources/ProfileTesting.swift
@@ -0,0 +1 @@
+// This is for Tuist
diff --git a/Projects/Domain/Profile/Tests/Sources/ProfileTest.swift b/Projects/Domain/Profile/Tests/Sources/ProfileTest.swift
new file mode 100644
index 00000000..8ebe3e58
--- /dev/null
+++ b/Projects/Domain/Profile/Tests/Sources/ProfileTest.swift
@@ -0,0 +1,11 @@
+import XCTest
+
+final class ProfileTests: XCTestCase {
+ override func setUpWithError() throws {}
+
+ override func tearDownWithError() throws {}
+
+ func testExample() {
+ XCTAssertEqual(1, 1)
+ }
+}
diff --git a/Projects/Domain/Project.swift b/Projects/Domain/Project.swift
new file mode 100644
index 00000000..63729654
--- /dev/null
+++ b/Projects/Domain/Project.swift
@@ -0,0 +1,27 @@
+//
+// Project.swift
+// AppManifests
+//
+// Created by 임현규 on 7/19/24.
+//
+
+import ProjectDescription
+import ProjectDescriptionHelpers
+import DependencyPlugin
+
+let targets: [Target] = [
+ .domain(factory: .init(
+ product: .staticFramework,
+ sources: nil,
+ dependencies: [
+ .core
+ ] + ModulePath.Domain.allCases.map {
+ .domain(implements: $0)
+ }
+ ))
+]
+
+let project = Project.makeModule(
+ name: "Domain",
+ targets: targets
+)
diff --git a/Projects/Domain/Report/Interface/Sources/API/ReportAPI.swift b/Projects/Domain/Report/Interface/Sources/API/ReportAPI.swift
new file mode 100644
index 00000000..6c5fd655
--- /dev/null
+++ b/Projects/Domain/Report/Interface/Sources/API/ReportAPI.swift
@@ -0,0 +1,41 @@
+//
+// ReportAPI.swift
+// DomainReportInterface
+//
+// Created by 임현규 on 8/12/24.
+//
+
+import Foundation
+
+import CoreNetworkInterface
+
+import Moya
+
+public enum ReportAPI {
+ case reportUser(requestData: ReportUserRequestDTO)
+}
+
+extension ReportAPI: BaseTargetType {
+ public var path: String {
+ switch self {
+ case .reportUser:
+ return "/api/v1/user/report"
+ }
+ }
+
+ public var method: Moya.Method {
+ switch self {
+ case .reportUser:
+ return .post
+ }
+ }
+
+ public var task: Moya.Task {
+ switch self {
+ case .reportUser(let requestData):
+ return .requestJSONEncodable(requestData)
+ }
+ }
+
+
+}
diff --git a/Projects/Domain/Report/Interface/Sources/DTO/Request/ReportUserRequestDTO.swift b/Projects/Domain/Report/Interface/Sources/DTO/Request/ReportUserRequestDTO.swift
new file mode 100644
index 00000000..159e4bd2
--- /dev/null
+++ b/Projects/Domain/Report/Interface/Sources/DTO/Request/ReportUserRequestDTO.swift
@@ -0,0 +1,18 @@
+//
+// ReportUserRequestDTO.swift
+// DomainReportInterface
+//
+// Created by 임현규 on 8/12/24.
+//
+
+import Foundation
+
+public struct ReportUserRequestDTO: Encodable {
+ let reportReasonShortAnswer: String
+ let userId: Int
+
+ public init(reportReasonShortAnswer: String, userId: Int) {
+ self.reportReasonShortAnswer = reportReasonShortAnswer
+ self.userId = userId
+ }
+}
diff --git a/Projects/Domain/Report/Interface/Sources/Entity/UserReportInfo.swift b/Projects/Domain/Report/Interface/Sources/Entity/UserReportInfo.swift
new file mode 100644
index 00000000..8bee63a8
--- /dev/null
+++ b/Projects/Domain/Report/Interface/Sources/Entity/UserReportInfo.swift
@@ -0,0 +1,18 @@
+//
+// UserReportInfo.swift
+// DomainReport
+//
+// Created by 임현규 on 8/12/24.
+//
+
+import Foundation
+
+public struct UserReportInfo {
+ public let reason: String
+ public let userId: Int
+
+ public init(reason: String, userId: Int) {
+ self.reason = reason
+ self.userId = userId
+ }
+}
diff --git a/Projects/Domain/Report/Interface/Sources/ReportClient.swift b/Projects/Domain/Report/Interface/Sources/ReportClient.swift
new file mode 100644
index 00000000..6f6a5b2d
--- /dev/null
+++ b/Projects/Domain/Report/Interface/Sources/ReportClient.swift
@@ -0,0 +1,20 @@
+//
+// ReportClient.swift
+// DomainReportInterface
+//
+// Created by 임현규 on 8/12/24.
+//
+
+import Foundation
+
+public struct ReportClient {
+ private let reportUser: (UserReportInfo) async throws -> Void
+
+ public init(reportUser: @escaping (UserReportInfo) async throws -> Void) {
+ self.reportUser = reportUser
+ }
+
+ public func reportUser(userReportInfo: UserReportInfo) async throws -> Void {
+ try await reportUser(userReportInfo)
+ }
+}
diff --git a/Projects/Domain/Report/Project.swift b/Projects/Domain/Report/Project.swift
new file mode 100644
index 00000000..6f92cb22
--- /dev/null
+++ b/Projects/Domain/Report/Project.swift
@@ -0,0 +1,44 @@
+import ProjectDescription
+import ProjectDescriptionHelpers
+import DependencyPlugin
+
+let project = Project.makeModule(
+ name: ModulePath.Domain.name+ModulePath.Domain.Report.rawValue,
+ targets: [
+ .domain(
+ interface: .Report,
+ factory: .init(
+ dependencies: [
+ .core
+ ]
+ )
+ ),
+ .domain(
+ implements: .Report,
+ factory: .init(
+ dependencies: [
+ .domain(interface: .Report)
+ ]
+ )
+ ),
+
+ .domain(
+ testing: .Report,
+ factory: .init(
+ dependencies: [
+ .domain(interface: .Report)
+ ]
+ )
+ ),
+ .domain(
+ tests: .Report,
+ factory: .init(
+ dependencies: [
+ .domain(testing: .Report),
+ .domain(implements: .Report)
+ ]
+ )
+ ),
+
+ ]
+)
diff --git a/Projects/Domain/Report/Sources/ReportClient.swift b/Projects/Domain/Report/Sources/ReportClient.swift
new file mode 100644
index 00000000..641e26e6
--- /dev/null
+++ b/Projects/Domain/Report/Sources/ReportClient.swift
@@ -0,0 +1,34 @@
+//
+// ReportClient.swift
+// DomainReport
+//
+// Created by 임현규 on 8/12/24.
+//
+
+import DomainReportInterface
+import CoreNetwork
+
+import ComposableArchitecture
+import Moya
+
+extension ReportClient: DependencyKey {
+ public static let liveValue: ReportClient = .live()
+
+ private static func live() -> ReportClient {
+ @Dependency(\.network) var networkManager
+
+ return .init(
+ reportUser: { userReportInfo in
+ let requestData = ReportUserRequestDTO(reportReasonShortAnswer: userReportInfo.reason, userId: userReportInfo.userId)
+ try await networkManager.reqeust(api: .apiType(ReportAPI.reportUser(requestData: requestData)))
+ }
+ )
+ }
+}
+
+extension DependencyValues {
+ public var reportClient: ReportClient {
+ get { self[ReportClient.self] }
+ set { self[ReportClient.self] = newValue }
+ }
+}
diff --git a/Projects/Domain/Report/Sources/Source.swift b/Projects/Domain/Report/Sources/Source.swift
new file mode 100644
index 00000000..b1853ce6
--- /dev/null
+++ b/Projects/Domain/Report/Sources/Source.swift
@@ -0,0 +1 @@
+// This is for Tuist
diff --git a/Projects/Domain/Report/Testing/Sources/ReportTesting.swift b/Projects/Domain/Report/Testing/Sources/ReportTesting.swift
new file mode 100644
index 00000000..b1853ce6
--- /dev/null
+++ b/Projects/Domain/Report/Testing/Sources/ReportTesting.swift
@@ -0,0 +1 @@
+// This is for Tuist
diff --git a/Projects/Domain/Report/Tests/Sources/ReportTest.swift b/Projects/Domain/Report/Tests/Sources/ReportTest.swift
new file mode 100644
index 00000000..2fc66850
--- /dev/null
+++ b/Projects/Domain/Report/Tests/Sources/ReportTest.swift
@@ -0,0 +1,11 @@
+import XCTest
+
+final class ReportTests: XCTestCase {
+ override func setUpWithError() throws {}
+
+ override func tearDownWithError() throws {}
+
+ func testExample() {
+ XCTAssertEqual(1, 1)
+ }
+}
diff --git a/Projects/Domain/WebView/Interface/Sources/WebViewHandleClientInterface.swift b/Projects/Domain/WebView/Interface/Sources/WebViewHandleClientInterface.swift
new file mode 100644
index 00000000..584197ab
--- /dev/null
+++ b/Projects/Domain/WebView/Interface/Sources/WebViewHandleClientInterface.swift
@@ -0,0 +1,32 @@
+//
+// WebViewHandleClientInterface.swift
+// CoreWebViewInterface
+//
+// Created by JongHoon on 8/3/24.
+//
+
+import Foundation
+
+import CoreWebViewInterface
+
+import Dependencies
+
+public struct WebViewClient {
+ private let messageToAction: (_ message: Any) throws -> BottleWebViewAction
+
+ public init(
+ messageToAction: @escaping (_: Any) throws -> BottleWebViewAction
+ ) {
+ self.messageToAction = messageToAction
+ }
+
+ public let jsonDecoder: JSONDecoder = {
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return decoder
+ }()
+
+ public func messageToAction(with message: Any) throws -> BottleWebViewAction {
+ try messageToAction(message)
+ }
+}
diff --git a/Projects/Domain/WebView/Project.swift b/Projects/Domain/WebView/Project.swift
new file mode 100644
index 00000000..cb41bf3c
--- /dev/null
+++ b/Projects/Domain/WebView/Project.swift
@@ -0,0 +1,44 @@
+import ProjectDescription
+import ProjectDescriptionHelpers
+import DependencyPlugin
+
+let project = Project.makeModule(
+ name: ModulePath.Domain.name+ModulePath.Domain.WebView.rawValue,
+ targets: [
+ .domain(
+ interface: .WebView,
+ factory: .init(
+ dependencies: [
+ .core
+ ]
+ )
+ ),
+ .domain(
+ implements: .WebView,
+ factory: .init(
+ dependencies: [
+ .domain(interface: .WebView)
+ ]
+ )
+ ),
+
+ .domain(
+ testing: .WebView,
+ factory: .init(
+ dependencies: [
+ .domain(interface: .WebView)
+ ]
+ )
+ ),
+ .domain(
+ tests: .WebView,
+ factory: .init(
+ dependencies: [
+ .domain(testing: .WebView),
+ .domain(implements: .WebView)
+ ]
+ )
+ ),
+
+ ]
+)
diff --git a/Projects/Domain/WebView/Sources/WebViewClient.swift b/Projects/Domain/WebView/Sources/WebViewClient.swift
new file mode 100644
index 00000000..afa1bede
--- /dev/null
+++ b/Projects/Domain/WebView/Sources/WebViewClient.swift
@@ -0,0 +1,51 @@
+//
+// WebViewClient.swift
+// CoreWebViewInterface
+//
+// Created by JongHoon on 8/3/24.
+//
+
+import Foundation
+
+import CoreWebViewInterface
+import DomainWebViewInterface
+
+import Dependencies
+
+extension DependencyValues {
+ public var webViewClient: WebViewClient {
+ get { self[WebViewClient.self] }
+ set { self[WebViewClient.self] = newValue }
+ }
+}
+
+extension WebViewClient: DependencyKey {
+ public static var liveValue: WebViewClient = WebViewClient { message in
+ guard let message = message as? String,
+ let jsonData = message.data(using: .utf8)
+ else {
+ // TODO: Domain error 로 수정
+ throw NSError(domain: "unknown", code: 0)
+ }
+
+ if let dict = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any] {
+ guard let type = dict["type"],
+ let action = BottleWebViewAction(
+ type: type as? String ?? "",
+ message: dict["message"] as? String ?? "",
+ accessToken: dict["accessToken"] as? String ?? "",
+ refreshToken: dict["refreshToken"] as? String ?? "",
+ href: dict["href"] as? String ?? "",
+ isCompletedOnboardingIntroduction: dict["hasCompleteIntroduction"] as? Bool ?? false
+ )
+ else {
+ throw NSError(domain: "unknown", code: 0)
+ }
+ return action
+ } else {
+ // TODO: Domain error 로 수정
+ throw NSError(domain: "unknown", code: 0)
+ }
+ }
+}
+
diff --git a/Projects/Domain/WebView/Testing/Sources/WebViewTesting.swift b/Projects/Domain/WebView/Testing/Sources/WebViewTesting.swift
new file mode 100644
index 00000000..a4bbf9c2
--- /dev/null
+++ b/Projects/Domain/WebView/Testing/Sources/WebViewTesting.swift
@@ -0,0 +1,2 @@
+// This is for Tuist
+
diff --git a/Projects/Domain/WebView/Tests/Sources/WebViewTest.swift b/Projects/Domain/WebView/Tests/Sources/WebViewTest.swift
new file mode 100644
index 00000000..ae5617e4
--- /dev/null
+++ b/Projects/Domain/WebView/Tests/Sources/WebViewTest.swift
@@ -0,0 +1,11 @@
+import XCTest
+
+final class WebViewTests: XCTestCase {
+ override func setUpWithError() throws {}
+
+ override func tearDownWithError() throws {}
+
+ func testExample() {
+ XCTAssertEqual(1, 1)
+ }
+}
diff --git a/Projects/Feature/BaseWebView/Example/Sources/AppView.swift b/Projects/Feature/BaseWebView/Example/Sources/AppView.swift
new file mode 100644
index 00000000..5411b88e
--- /dev/null
+++ b/Projects/Feature/BaseWebView/Example/Sources/AppView.swift
@@ -0,0 +1,11 @@
+import SwiftUI
+
+@main
+struct AppView: App {
+ var body: some Scene {
+ WindowGroup {
+ Text("Hello Tuist!")
+ }
+ }
+}
+
diff --git a/Projects/Feature/BaseWebView/Interface/Sources/BaseWebView.swift b/Projects/Feature/BaseWebView/Interface/Sources/BaseWebView.swift
new file mode 100644
index 00000000..3d6ff33e
--- /dev/null
+++ b/Projects/Feature/BaseWebView/Interface/Sources/BaseWebView.swift
@@ -0,0 +1,100 @@
+//
+// BottleWebView.swift
+// CoreWebViewInterface
+//
+// Created by JongHoon on 8/3/24.
+//
+
+import SwiftUI
+import WebKit
+
+import CoreLoggerInterface
+import CoreWebViewInterface
+import DomainWebView
+
+import ComposableArchitecture
+
+public struct BaseWebView: UIViewRepresentable {
+ private let webView: WKWebView
+ private let type: BottleWebViewType
+ private let isScrollEnabled: Bool
+ private let actionDidInputted: ((BottleWebViewAction) -> Void)?
+
+ public init(
+ type: BottleWebViewType,
+ isScrollEnabled: Bool = false,
+ actionDidInputted: ((BottleWebViewAction) -> Void)? = nil
+ ) {
+ self.type = type
+ self.isScrollEnabled = isScrollEnabled
+ self.actionDidInputted = actionDidInputted
+ let preferences = WKPreferences()
+ preferences.javaScriptCanOpenWindowsAutomatically = true
+ let configuration = WKWebViewConfiguration()
+ configuration.preferences = preferences
+ configuration.defaultWebpagePreferences.allowsContentJavaScript = true
+ webView = WKWebView(frame: .zero, configuration: configuration)
+ }
+
+ public func makeUIView(context: Context) -> WKWebView {
+ webView.configuration.userContentController.add(
+ context.coordinator,
+ name: type.messageHandler.name
+ )
+ webView.navigationDelegate = context.coordinator
+ webView.scrollView.isScrollEnabled = isScrollEnabled
+
+ let request = URLRequest(url: type.url)
+ webView.load(request)
+ return webView
+ }
+
+ public func updateUIView(_ uiView: WKWebView, context: Context) {
+ }
+
+ public func makeCoordinator() -> Coordinator {
+ Coordinator(
+ parent: self,
+ actionDidInputted: actionDidInputted
+ )
+ }
+
+ final public class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
+ @Dependency(\.webViewClient) private var webViewClient
+ private let parent: BaseWebView
+ private let actionDidInputted: ((BottleWebViewAction) -> Void)?
+
+ init(
+ parent: BaseWebView,
+ actionDidInputted: ((BottleWebViewAction) -> Void)?
+ ) {
+ self.parent = parent
+ self.actionDidInputted = actionDidInputted
+ }
+
+ public func userContentController(
+ _ userContentController: WKUserContentController,
+ didReceive message: WKScriptMessage
+ ) {
+ guard message.name == WebViewMessageHandler.default.name
+ else {
+ return
+ }
+ do {
+ let action = try webViewClient.messageToAction(with: message.body)
+ actionDidInputted?(action)
+ Log.debug(action)
+ } catch {
+ Log.assertion(message: "webview action parsing error")
+ return
+ }
+ }
+
+ public func webView(
+ _ webView: WKWebView,
+ didFinish navigation: WKNavigation!
+ ) {
+ actionDidInputted?(.webViewLoadingDidCompleted)
+ }
+ }
+}
diff --git a/Projects/Feature/BaseWebView/Interface/Sources/BaseWebViewType.swift b/Projects/Feature/BaseWebView/Interface/Sources/BaseWebViewType.swift
new file mode 100644
index 00000000..376ba1d4
--- /dev/null
+++ b/Projects/Feature/BaseWebView/Interface/Sources/BaseWebViewType.swift
@@ -0,0 +1,64 @@
+//
+// BaseWebViewType.swift
+// FeatureBaseWebViewInterface
+//
+// Created by JongHoon on 8/9/24.
+//
+
+import Foundation
+
+import CoreWebViewInterface
+import CoreKeyChainStoreInterface
+import CoreKeyChainStore
+
+public enum BottleWebViewType: String {
+ private var baseURL: String {
+ (Bundle.main.infoDictionary?["WEB_VIEW_BASE_URL"] as? String) ?? ""
+ }
+
+ case createProfile = "create-profile"
+ case myPage = "my"
+ case signUp = "signup"
+ case login
+ case bottles
+
+ public var url: URL {
+ switch self {
+ case .createProfile:
+ return makeUrlWithToken(rawValue)
+
+ case .myPage:
+ return makeUrlWithToken(rawValue)
+
+ case .signUp:
+ return URL(string: baseURL + "/" + rawValue)!
+
+ case .login:
+ return URL(string: baseURL + "/" + rawValue)!
+
+ case .bottles:
+ return makeUrlWithToken(rawValue)
+ }
+ }
+
+ public var messageHandler: WebViewMessageHandler {
+ switch self {
+ default:
+ return .default
+ }
+ }
+}
+
+// MARK: - private methods
+private extension BottleWebViewType {
+ func makeUrlWithToken(_ path: String) -> URL {
+ var components = URLComponents(string: baseURL)
+ components?.path = "/\(path)"
+ components?.queryItems = [
+ URLQueryItem(name: "accessToken", value: KeyChainTokenStore.shared.load(property: .accessToken)),
+ URLQueryItem(name: "refreshToken", value: KeyChainTokenStore.shared.load(property: .refreshToken))
+ ]
+
+ return (components?.url)!
+ }
+}
diff --git a/Projects/Feature/BaseWebView/Project.swift b/Projects/Feature/BaseWebView/Project.swift
new file mode 100644
index 00000000..49b4babd
--- /dev/null
+++ b/Projects/Feature/BaseWebView/Project.swift
@@ -0,0 +1,53 @@
+import ProjectDescription
+import ProjectDescriptionHelpers
+import DependencyPlugin
+
+let project = Project.makeModule(
+ name: ModulePath.Feature.name+ModulePath.Feature.BaseWebView.rawValue,
+ targets: [
+ .feature(
+ interface: .BaseWebView,
+ factory: .init(
+ dependencies: [
+ .domain
+ ]
+ )
+ ),
+ .feature(
+ implements: .BaseWebView,
+ factory: .init(
+ dependencies: [
+ .feature(interface: .BaseWebView)
+ ]
+ )
+ ),
+
+ .feature(
+ testing: .BaseWebView,
+ factory: .init(
+ dependencies: [
+ .feature(interface: .BaseWebView)
+ ]
+ )
+ ),
+ .feature(
+ tests: .BaseWebView,
+ factory: .init(
+ dependencies: [
+ .feature(testing: .BaseWebView),
+ .feature(implements: .BaseWebView)
+ ]
+ )
+ ),
+
+ .feature(
+ example: .BaseWebView,
+ factory: .init(
+ dependencies: [
+ .feature(testing: .BaseWebView),
+ .feature(implements: .BaseWebView)
+ ]
+ )
+ )
+ ]
+)
diff --git a/Projects/Feature/BaseWebView/Sources/Source.swift b/Projects/Feature/BaseWebView/Sources/Source.swift
new file mode 100644
index 00000000..b1853ce6
--- /dev/null
+++ b/Projects/Feature/BaseWebView/Sources/Source.swift
@@ -0,0 +1 @@
+// This is for Tuist
diff --git a/Projects/Feature/BaseWebView/Testing/Sources/BottleWebViewTesting.swift b/Projects/Feature/BaseWebView/Testing/Sources/BottleWebViewTesting.swift
new file mode 100644
index 00000000..b1853ce6
--- /dev/null
+++ b/Projects/Feature/BaseWebView/Testing/Sources/BottleWebViewTesting.swift
@@ -0,0 +1 @@
+// This is for Tuist
diff --git a/Projects/Feature/BaseWebView/Tests/Sources/BottleWebViewTest.swift b/Projects/Feature/BaseWebView/Tests/Sources/BottleWebViewTest.swift
new file mode 100644
index 00000000..0508cf68
--- /dev/null
+++ b/Projects/Feature/BaseWebView/Tests/Sources/BottleWebViewTest.swift
@@ -0,0 +1,11 @@
+import XCTest
+
+final class BottleWebViewTests: XCTestCase {
+ override func setUpWithError() throws {}
+
+ override func tearDownWithError() throws {}
+
+ func testExample() {
+ XCTAssertEqual(1, 1)
+ }
+}
diff --git a/Projects/Feature/BottleArrival/Example/Sources/AppView.swift b/Projects/Feature/BottleArrival/Example/Sources/AppView.swift
new file mode 100644
index 00000000..5411b88e
--- /dev/null
+++ b/Projects/Feature/BottleArrival/Example/Sources/AppView.swift
@@ -0,0 +1,11 @@
+import SwiftUI
+
+@main
+struct AppView: App {
+ var body: some Scene {
+ WindowGroup {
+ Text("Hello Tuist!")
+ }
+ }
+}
+
diff --git a/Projects/Feature/BottleArrival/Interface/Sources/BottleArrivalFeature.swift b/Projects/Feature/BottleArrival/Interface/Sources/BottleArrivalFeature.swift
new file mode 100644
index 00000000..75f50fea
--- /dev/null
+++ b/Projects/Feature/BottleArrival/Interface/Sources/BottleArrivalFeature.swift
@@ -0,0 +1,43 @@
+//
+// BottleArrivalFeature.swift
+// FeatureBottleArrivalInterface
+//
+// Created by 임현규 on 8/11/24.
+//
+
+import Foundation
+
+import CoreToastInterface
+
+import ComposableArchitecture
+
+extension BottleArrivalFeature {
+ public init() {
+ @Dependency(\.toastClient) var toastClient
+ let reducer = Reduce { state, action in
+ switch action {
+ case .onAppear:
+ return .none
+
+ case .webViewLoadingDidCompleted:
+ state.isLoading = false
+ return .none
+
+ case .bottelDidAccepted:
+ return .send(.delegate(.bottelDidAccepted))
+
+ case .closeWebView:
+ return .send(.delegate(.closeWebView))
+
+ case let .presentToastDidRequired(message):
+ toastClient.presentToast(message: message)
+ return .none
+
+ default:
+ return .none
+ }
+ }
+
+ self.init(reducer: reducer)
+ }
+}
diff --git a/Projects/Feature/BottleArrival/Interface/Sources/BottleArrivalFeatureInterface.swift b/Projects/Feature/BottleArrival/Interface/Sources/BottleArrivalFeatureInterface.swift
new file mode 100644
index 00000000..01a1decd
--- /dev/null
+++ b/Projects/Feature/BottleArrival/Interface/Sources/BottleArrivalFeatureInterface.swift
@@ -0,0 +1,47 @@
+//
+// BottleArrivalFeatureInterface.swift
+// FeatureBottleArrivalInterface
+//
+// Created by 임현규 on 8/11/24.
+//
+
+import Foundation
+
+import ComposableArchitecture
+
+@Reducer
+public struct BottleArrivalFeature {
+ private let reducer: Reduce
+
+ public init(reducer: Reduce) {
+ self.reducer = reducer
+ }
+ @ObservableState
+ public struct State: Equatable {
+ var isLoading: Bool
+
+ public init() {
+ self.isLoading = true
+ }
+ }
+
+ public enum Action {
+ // View Life Cycle
+ case onAppear
+ case webViewLoadingDidCompleted
+ case bottelDidAccepted
+ case closeWebView
+ case presentToastDidRequired(message: String)
+ // Delegate
+ case delegate(Delegate)
+
+ public enum Delegate {
+ case bottelDidAccepted
+ case closeWebView
+ }
+ }
+
+ public var body: some ReducerOf {
+ reducer
+ }
+}
diff --git a/Projects/Feature/BottleArrival/Interface/Sources/BottleArrivalView.swift b/Projects/Feature/BottleArrival/Interface/Sources/BottleArrivalView.swift
new file mode 100644
index 00000000..18e02dc9
--- /dev/null
+++ b/Projects/Feature/BottleArrival/Interface/Sources/BottleArrivalView.swift
@@ -0,0 +1,51 @@
+//
+// BottleArrivalView.swift
+// FeatureBottleArrivalInterface
+//
+// Created by 임현규 on 8/11/24.
+//
+
+import SwiftUI
+
+import SharedDesignSystem
+import FeatureBaseWebViewInterface
+
+import ComposableArchitecture
+
+public struct BottleArrivalView: View {
+ private let store: StoreOf
+
+ public init(store: StoreOf) {
+ self.store = store
+ }
+
+ public var body: some View {
+
+ WithPerceptionTracking {
+ BaseWebView(
+ type: .bottles) { action in
+ switch action {
+ case .webViewLoadingDidCompleted:
+ store.send(.webViewLoadingDidCompleted)
+ case .bottelDidAccepted:
+ store.send(.bottelDidAccepted)
+ case .closeWebView:
+ store.send(.closeWebView)
+ case let .showTaost(message):
+ store.send(.presentToastDidRequired(message: message))
+
+ default:
+ break
+ }
+ }
+ .overlay {
+ if store.isLoading {
+ LoadingIndicator()
+ }
+ }
+ .ignoresSafeArea(.all, edges: .bottom)
+ .toolbar(.hidden, for: .navigationBar)
+ .toolbar(.hidden, for: .bottomBar)
+ }
+ }
+}
diff --git a/Projects/Feature/BottleArrival/Project.swift b/Projects/Feature/BottleArrival/Project.swift
new file mode 100644
index 00000000..1376554c
--- /dev/null
+++ b/Projects/Feature/BottleArrival/Project.swift
@@ -0,0 +1,53 @@
+import ProjectDescription
+import ProjectDescriptionHelpers
+import DependencyPlugin
+
+let project = Project.makeModule(
+ name: ModulePath.Feature.name+ModulePath.Feature.BottleArrival.rawValue,
+ targets: [
+ .feature(
+ interface: .BottleArrival,
+ factory: .init(dependencies: [
+ .domain,
+ .feature(interface: .BaseWebView)
+ ])
+ ),
+ .feature(
+ implements: .BottleArrival,
+ factory: .init(
+ dependencies: [
+ .feature(interface: .BottleArrival)
+ ]
+ )
+ ),
+
+ .feature(
+ testing: .BottleArrival,
+ factory: .init(
+ dependencies: [
+ .feature(interface: .BottleArrival)
+ ]
+ )
+ ),
+ .feature(
+ tests: .BottleArrival,
+ factory: .init(
+ dependencies: [
+ .feature(testing: .BottleArrival),
+ .feature(implements: .BottleArrival)
+ ]
+ )
+ ),
+
+ .feature(
+ example: .BottleArrival,
+ factory: .init(
+ dependencies: [
+ .feature(testing: .BottleArrival),
+ .feature(implements: .BottleArrival)
+ ]
+ )
+ )
+
+ ]
+)
diff --git a/Projects/Feature/BottleArrival/Sources/Source.swift b/Projects/Feature/BottleArrival/Sources/Source.swift
new file mode 100644
index 00000000..b1853ce6
--- /dev/null
+++ b/Projects/Feature/BottleArrival/Sources/Source.swift
@@ -0,0 +1 @@
+// This is for Tuist
diff --git a/Projects/Feature/BottleArrival/Testing/Sources/BottleArrivalTesting.swift b/Projects/Feature/BottleArrival/Testing/Sources/BottleArrivalTesting.swift
new file mode 100644
index 00000000..b1853ce6
--- /dev/null
+++ b/Projects/Feature/BottleArrival/Testing/Sources/BottleArrivalTesting.swift
@@ -0,0 +1 @@
+// This is for Tuist
diff --git a/Projects/Feature/BottleArrival/Tests/Sources/BottleArrivalTest.swift b/Projects/Feature/BottleArrival/Tests/Sources/BottleArrivalTest.swift
new file mode 100644
index 00000000..26514bf0
--- /dev/null
+++ b/Projects/Feature/BottleArrival/Tests/Sources/BottleArrivalTest.swift
@@ -0,0 +1,11 @@
+import XCTest
+
+final class BottleArrivalTests: XCTestCase {
+ override func setUpWithError() throws {}
+
+ override func tearDownWithError() throws {}
+
+ func testExample() {
+ XCTAssertEqual(1, 1)
+ }
+}
diff --git a/Projects/Feature/BottleStorage/Example/Sources/AppView.swift b/Projects/Feature/BottleStorage/Example/Sources/AppView.swift
new file mode 100644
index 00000000..ef784883
--- /dev/null
+++ b/Projects/Feature/BottleStorage/Example/Sources/AppView.swift
@@ -0,0 +1,33 @@
+//
+// Source.swift
+// FeatureBottleStorageExample
+//
+// Created by JongHoon on 7/24/24.
+//
+
+import SwiftUI
+
+import FeatureBottleStorageInterface
+import DomainAuth
+import DomainAuthInterface
+
+import ComposableArchitecture
+
+@main
+struct AppView: App {
+ var body: some Scene {
+ WindowGroup {
+ BottleStorageView(store: Store(
+ initialState: BottleStorageFeature.State(),
+ reducer: { BottleStorageFeature() }
+ ))
+ .onAppear {
+ AuthClient.liveValue.saveToken(token: .init(
+ accessToken: "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwiaWF0IjoxNzIzMTE3ODk1LCJleHAiOjE3MjMxNTM4OTV9.HjjnS1onaAUA6nJGOV-f6FE55eAihUGTFNYGmmyETQc",
+ refershToken: "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwiaWF0IjoxNzIzMTE3ODk1LCJleHAiOjE3Mzc2MzMwOTV9.Af-L2h_5pBQWrZCc1OQI3tm1DGwowqCAId-rK5vAPaQ"
+ ))
+ }
+ }
+ }
+}
+
diff --git a/Projects/Feature/BottleStorage/Interface/Sources/BottleStorage/BottleStorageFeature.swift b/Projects/Feature/BottleStorage/Interface/Sources/BottleStorage/BottleStorageFeature.swift
new file mode 100644
index 00000000..3b8388cc
--- /dev/null
+++ b/Projects/Feature/BottleStorage/Interface/Sources/BottleStorage/BottleStorageFeature.swift
@@ -0,0 +1,94 @@
+//
+// BottleStorageFeature.swift
+// FeatureBottleStorage
+//
+// Created by JongHoon on 7/25/24.
+//
+
+import CoreLoggerInterface
+import DomainBottle
+import FeatureReportInterface
+
+import ComposableArchitecture
+
+extension BottleStorageFeature {
+ public init() {
+ @Dependency(\.bottleClient) var bottleClient
+
+ let reducer = Reduce { state, action in
+ switch action {
+ case .onAppear:
+ return popToRootAndReload(state: &state)
+
+ case let .bottleStorageListFetched(bottleStorageList):
+ state.activeBottleList = bottleStorageList.activeBottles
+ state.doneBottlsList = bottleStorageList.doneBottles
+ return .none
+
+ case let .bottleStorageItemDidTapped(bottleID, userName):
+ state.path.append(.pingPongDetail(.init(bottleID: bottleID, userName: userName)))
+ return .none
+
+ case let .bottleActiveStateTabButtonTapped(activeState):
+ state.selectedActiveStateTab = activeState
+ return .none
+
+ case let .path(.element(id: _, action: .pingPongDetail(.delegate(delegate)))):
+
+ switch delegate {
+ case .backButtonDidTapped:
+ state.path.removeLast()
+ return .none
+ case .reportButtonDidTapped(let userReportProfile):
+ state.path.append(.report(ReportUserFeature.State(userProfile: userReportProfile)))
+ return .none
+ case .otherBottleButtonDidTapped:
+ return popToRootAndReload(state: &state)
+ case .popToRootDidRequired:
+ return popToRootAndReload(state: &state)
+ }
+
+ case let .path(.element(id: _, action: .report(.delegate(delegate)))):
+ switch delegate {
+ case .reportDidCompleted:
+ state.path.removeAll()
+ return .run { send in
+ let bottleStorageList = try await bottleClient.fetchBottleStorageList()
+ await send(.bottleStorageListFetched(bottleStorageList))
+ }
+ case .backButtonDidTapped:
+ state.path.removeLast()
+ return .none
+ }
+
+ case let .selectedTabDidChanged(selectedTab):
+ return .send(.delegate(.selectedTabDidChanged(selectedTab: selectedTab)))
+ default:
+ return .none
+ }
+ }
+ self.init(reducer: reducer)
+
+ func popToRootAndReload(state: inout State) -> Effect {
+ state.selectedActiveStateTab = .active
+ state.path.removeAll()
+ return .run { send in
+ let bottleStorageList = try await bottleClient.fetchBottleStorageList()
+ await send(.bottleStorageListFetched(bottleStorageList))
+ } catch: { error,send in
+ // TODO: Error Handling
+ Log.error(error)
+ }
+ }
+ }
+}
+
+extension BottleStorageFeature {
+ // MARK: - Path
+
+ @Reducer(state: .equatable)
+ public enum Path {
+ case pingPongDetail(PingPongDetailFeature)
+ case report(ReportUserFeature)
+ }
+}
diff --git a/Projects/Feature/BottleStorage/Interface/Sources/BottleStorage/BottleStorageFeatureInterface.swift b/Projects/Feature/BottleStorage/Interface/Sources/BottleStorage/BottleStorageFeatureInterface.swift
new file mode 100644
index 00000000..28831cb4
--- /dev/null
+++ b/Projects/Feature/BottleStorage/Interface/Sources/BottleStorage/BottleStorageFeatureInterface.swift
@@ -0,0 +1,89 @@
+//
+// BottleStorageFeatureInterface.swift
+// FeatureBottleStorageExample
+//
+// Created by JongHoon on 7/24/24.
+//
+
+import Foundation
+
+import FeatureTabBarInterface
+import DomainBottleInterface
+
+import ComposableArchitecture
+
+@Reducer
+public struct BottleStorageFeature {
+ private let reducer: Reduce
+
+ public init(reducer: Reduce) {
+ self.reducer = reducer
+ }
+
+ @ObservableState
+ public struct State: Equatable {
+ // 보틀 상태 선택 탭(대화 중, 완료)
+ let bottleActiveStateTabs: [BottleActiveState]
+ var selectedActiveStateTab: BottleActiveState
+ var currentSelectedBottles: [BottleStorageItem] {
+ switch selectedActiveStateTab {
+ case .active:
+ return activeBottleList ?? []
+ case .done:
+ return doneBottlsList ?? []
+ }
+ }
+
+ // 보틀 리스트
+ var activeBottleList: [BottleStorageItem]?
+ var doneBottlsList: [BottleStorageItem]?
+
+ var path = StackState()
+
+ public init() {
+ self.bottleActiveStateTabs = BottleActiveState.allCases
+ self.selectedActiveStateTab = .active
+ }
+ }
+
+ public enum Action: BindableAction {
+ // View Life Cycle
+ case onAppear
+
+ // 보틀 상태 선택 탭(대화 중, 완료)
+ case bottleActiveStateTabButtonTapped(BottleActiveState)
+
+ // 보틀 리스트
+ case bottleStorageListFetched(BottleStorageList)
+ case bottleStorageItemDidTapped(bottleID: Int, userName: String)
+ case selectedTabDidChanged(selectedTab: TabType)
+ case delegate(Delegate)
+ // ETC.
+ case path(StackAction)
+ case binding(BindingAction)
+
+ public enum Delegate {
+ case selectedTabDidChanged(selectedTab: TabType)
+ }
+ }
+
+ public var body: some ReducerOf {
+ BindingReducer()
+ reducer
+ .forEach(\.path, action: \.path)
+ }
+}
+
+public enum BottleActiveState: String, CaseIterable, Equatable {
+ case active
+ case done
+
+ var title: String {
+ switch self {
+ case .active:
+ return "대화 중"
+ case .done:
+ return "완료"
+ }
+ }
+}
diff --git a/Projects/Feature/BottleStorage/Interface/Sources/BottleStorage/BottleStorageView.swift b/Projects/Feature/BottleStorage/Interface/Sources/BottleStorage/BottleStorageView.swift
new file mode 100644
index 00000000..6e36bc6f
--- /dev/null
+++ b/Projects/Feature/BottleStorage/Interface/Sources/BottleStorage/BottleStorageView.swift
@@ -0,0 +1,138 @@
+//
+// BottleStorageView.swift
+// FeatureBottleStorage
+//
+// Created by JongHoon on 7/25/24.
+//
+
+import SwiftUI
+
+import SharedDesignSystem
+import FeatureReportInterface
+import FeatureTabBarInterface
+
+import ComposableArchitecture
+
+public struct BottleStorageView: View {
+ @Perception.Bindable private var store: StoreOf
+
+ public init(store: StoreOf) {
+ self.store = store
+ }
+
+ public var body: some View {
+ WithPerceptionTracking {
+ NavigationStack(path: $store.scope(state: \.path, action: \.path)) {
+ VStack(spacing: 0.0) {
+ bottleActiveStateSelectTab
+
+ bottlsList
+ .padding(.horizontal, .md)
+ .padding(.top, 32.0)
+ .padding(.bottom, 36.0)
+
+ Spacer()
+ }
+ .setTabBar(selectedTab: .bottleStorage) { selectedTab in
+ store.send(.selectedTabDidChanged(selectedTab: selectedTab))
+ }
+
+ .frame(maxHeight: .infinity, alignment: .top)
+ .background(to: ColorToken.background(.primary))
+ } destination: { store in
+ WithPerceptionTracking {
+ switch store.state {
+ case .pingPongDetail:
+ if let store = store.scope(
+ state: \.pingPongDetail,
+ action: \.pingPongDetail) {
+ PingPongDetailView(store: store)
+ }
+ case .report:
+ if let store = store.scope(
+ state: \.report,
+ action: \.report) {
+ ReportUserView(store: store)
+ }
+ }
+ }
+ }
+ .onAppear {
+ store.send(.onAppear)
+ }
+ }
+ }
+}
+
+// MARK: - Private Views
+
+private extension BottleStorageView {
+ var bottleActiveStateSelectTab: some View {
+ HStack(spacing: .xs) {
+ OutlinedStyleButton(
+ .small(contentType: .text),
+ title: BottleActiveState.active.title,
+ buttonType: .throttle,
+ isSelected: store.selectedActiveStateTab == BottleActiveState.active,
+ action: {
+ store.send(.bottleActiveStateTabButtonTapped(.active))
+ }
+ )
+
+ OutlinedStyleButton(
+ .small(contentType: .text),
+ title: BottleActiveState.done.title,
+ buttonType: .throttle,
+ isSelected: store.selectedActiveStateTab == BottleActiveState.done,
+ action: {
+ store.send(.bottleActiveStateTabButtonTapped(.done))
+ }
+ )
+
+ Spacer()
+ }
+ .padding(.md)
+ }
+
+ @ViewBuilder
+ var bottlsList: some View {
+ if store.currentSelectedBottles.isEmpty && store.activeBottleList != nil {
+ VStack(spacing: .xxl) {
+ HStack(spacing: 0.0) {
+ WantedSansStyleText(
+ "아직 보관 중인\n보틀이 없어요!",
+ style: .title1,
+ color: .primary
+ )
+
+ Spacer()
+ }
+
+ GeometryReader { geometry in
+ BottleImageView(type: .local(bottleImageSystem: .illustraition(.basket)))
+ .frame(height: geometry.size.width)
+ }
+ .aspectRatio(1.0, contentMode: .fit)
+ }
+ } else {
+ ScrollView {
+ VStack(spacing: .md) {
+ ForEach(store.currentSelectedBottles, id: \.id) { bottle in
+ BottleStorageItem(
+ userName: bottle.userName ?? "(없음)",
+ age: bottle.age ?? 0,
+ mbti: bottle.mbti,
+ keywords: bottle.keyword,
+ imageURL: bottle.userImageUrl,
+ isRead: bottle.isRead ?? false
+ )
+ .asButton {
+ store.send(.bottleStorageItemDidTapped(bottleID: bottle.id, userName: bottle.userName ?? ""))
+ }
+ }
+ }
+ }
+ .scrollIndicators(.hidden)
+ }
+ }
+}
diff --git a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/FinalSelectPingPongView.swift b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/FinalSelectPingPongView.swift
new file mode 100644
index 00000000..dbfad362
--- /dev/null
+++ b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/FinalSelectPingPongView.swift
@@ -0,0 +1,124 @@
+//
+// FinalSelectPingPongView.swift
+// SharedDesignSystem
+//
+// Created by 임현규 on 8/9/24.
+//
+
+import SwiftUI
+import SharedDesignSystem
+
+public struct FinalSelectPingPongView: View {
+ private let isActive: Bool
+ private let pingPongTitle: String
+ private let finalSelectState: FinalSelectStateType
+ @Binding var isSelctedYesButton: Bool
+ @Binding var isSelctedNoButton: Bool
+ private let doneButtonAction: (() -> Void)?
+
+ public init(
+ isActive: Bool,
+ pingPongTitle: String,
+ finalSelectState: FinalSelectStateType,
+ isSelctedYesButton: Binding = .constant(false),
+ isSelctedNoButton: Binding = .constant(false),
+ doneButtonAction: (() -> Void)? = nil
+ ) {
+ self.isActive = isActive
+ self.pingPongTitle = pingPongTitle
+ self.finalSelectState = finalSelectState
+ self._isSelctedYesButton = isSelctedYesButton
+ self._isSelctedNoButton = isSelctedNoButton
+ self.doneButtonAction = doneButtonAction
+ }
+
+ public var body: some View {
+ PingPongContainer(
+ isActive: isActive,
+ pingpongTitle: pingPongTitle) {
+ content
+ }
+ }
+}
+
+// MARK: - Views
+private extension FinalSelectPingPongView {
+ var content: some View {
+ VStack(spacing: .sm) {
+ questionText
+ if finalSelectState == .notSelected {
+ notSelectedView
+ } else if finalSelectState == .waitingForPeer {
+ waitingForPeerView
+ } else {
+ bothSelectedView
+ }
+ }
+ .padding(.horizontal, .md)
+ .padding(.vertical, .xl)
+ }
+
+ var questionText: some View {
+ HStack(spacing: 0) {
+ WantedSansStyleText(
+ "나의 카카오톡 아이디를 공유할까요?",
+ style: .subTitle1,
+ color: .focusePrimary
+ )
+ Spacer()
+ }
+ .padding(.bottom, .sm)
+ }
+
+ var notSelectedView: some View {
+ VStack(spacing: .sm) {
+ HStack(spacing: .sm) {
+ OutlinedStyleButton(
+ .medium(contentType: .image(type: .local(bottleImageSystem: .illustraition(.yes)))),
+ title: "네! 좋아요",
+ buttonType: .normal,
+ isSelected: isSelctedYesButton,
+ action: {
+ isSelctedYesButton.toggle()
+ isSelctedNoButton = !isSelctedYesButton
+ print("좋아요 클릭")
+ }
+ )
+
+ OutlinedStyleButton(
+ .medium(contentType: .image(type: .local(bottleImageSystem: .illustraition(.no)))),
+ title: "아니요",
+ buttonType: .normal,
+ isSelected: isSelctedNoButton,
+ action: {
+ isSelctedNoButton.toggle()
+ isSelctedYesButton = !isSelctedNoButton
+ print("아니요 클릭")
+ }
+ )
+ }
+
+ SolidButton(
+ title: "선택 완료",
+ sizeType: .medium,
+ buttonType: .throttle,
+ action: doneButtonAction ?? {})
+ }
+ }
+
+ var waitingForPeerView: some View {
+ VStack(spacing: .sm) {
+ makeRightBubbleText(text: "선택을 완료했어요")
+ makeLeftBubbleText(text: "상대방의 선택을 기다리고 있어요")
+ makeLeftBubbleText(text: "두근두근, 매칭이 이뤄질까요?")
+ }
+ }
+
+ var bothSelectedView: some View {
+ VStack(spacing: .sm) {
+ makeRightBubbleText(text: "선택을 완료했어요")
+ makeLeftBubbleText(text: "선택을 완료했어요")
+ makeLeftBubbleText(text: "매칭 탭에서 결과를 확인해주세요")
+ }
+ }
+}
diff --git a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/PhotoSharePingPongView.swift b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/PhotoSharePingPongView.swift
new file mode 100644
index 00000000..4bc3a73b
--- /dev/null
+++ b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/PhotoSharePingPongView.swift
@@ -0,0 +1,176 @@
+//
+// PhotoSharePingPongView.swift
+// DesignSystemExample
+//
+// Created by 임현규 on 8/9/24.
+//
+
+import SwiftUI
+import SharedDesignSystem
+
+public struct PhotoSharePingPongView: View {
+ private let isActive: Bool
+ private let pingPongTitle: String
+ private let photoShareState: PhotoShareStateType
+ @Binding var isSelctedYesButton: Bool
+ @Binding var isSelctedNoButton: Bool
+ private let doneButtonAction: (() -> Void)?
+
+ public init(
+ isActive: Bool,
+ pingPongTitle: String,
+ photoShareState: PhotoShareStateType,
+ isSelctedYesButton: Binding = .constant(false),
+ isSelctedNoButton: Binding = .constant(false),
+ doneButtonAction: (() -> Void)? = nil
+ ) {
+ self.isActive = isActive
+ self.pingPongTitle = pingPongTitle
+ self.photoShareState = photoShareState
+ self._isSelctedYesButton = isSelctedYesButton
+ self._isSelctedNoButton = isSelctedNoButton
+ self.doneButtonAction = doneButtonAction
+ }
+
+ public var body: some View {
+ PingPongContainer(
+ isActive: isActive,
+ pingpongTitle: pingPongTitle
+ ) {
+ content
+ }
+ }
+}
+
+// MARK: - Views
+private extension PhotoSharePingPongView {
+ var content: some View {
+ VStack(spacing: .sm) {
+ questionText
+
+ if photoShareState == .notSelected {
+ notSelectedView
+ } else if photoShareState == .waitingForPeer {
+ waitingForPeerView
+ } else if photoShareState == .eitherPrivate {
+ eitherPrivateView
+ } else {
+ bothPublicView
+ }
+ }
+ .padding(.horizontal, .md)
+ .padding(.vertical, .xl)
+ }
+
+
+ var notSelectedView: some View {
+ VStack(spacing: .sm) {
+ HStack(spacing: .sm) {
+ OutlinedStyleButton(
+ .medium(contentType: .image(type: .local(bottleImageSystem: .illustraition(.yes)))),
+ title: "네! 좋아요",
+ buttonType: .normal,
+ isSelected: isSelctedYesButton,
+ action: {
+ isSelctedYesButton.toggle()
+ isSelctedNoButton = !isSelctedYesButton
+ print("좋아요 클릭")
+ }
+ )
+
+ OutlinedStyleButton(
+ .medium(contentType: .image(type: .local(bottleImageSystem: .illustraition(.no)))),
+ title: "아니요",
+ buttonType: .normal,
+ isSelected: isSelctedNoButton,
+ action: {
+ isSelctedNoButton.toggle()
+ isSelctedYesButton = !isSelctedNoButton
+ print("아니요 클릭")
+ }
+ )
+ }
+
+ SolidButton(
+ title: "선택 완료",
+ sizeType: .medium,
+ buttonType: .throttle,
+ action: doneButtonAction ?? {})
+ }
+ }
+
+ var waitingForPeerView: some View {
+ VStack(spacing: .sm) {
+ makeRightBubbleText(text: "공유를 완료했어요")
+ makeLeftBubbleText(text: "상대방의 답변을 기다리고 있어요")
+ makeLeftBubbleText(text: "서로가 모두 공유에 동의한 경우에 공개돼요")
+ }
+ }
+
+ var eitherPrivateView: some View {
+ // TODO: 디자인 바뀌면 수정
+ makeRightBubbleText(text: "사진 공개가 실패했어요")
+ }
+
+ var bothPublicView: some View {
+ HStack(spacing: .sm) {
+ peerProfileImage
+ myProfileImage
+ }
+ }
+
+
+ @ViewBuilder
+ var questionText: some View {
+ if photoShareState == .notSelected || photoShareState == .waitingForPeer {
+ HStack(spacing: 0) {
+ WantedSansStyleText(
+ "나의 프로필 사진을 공유할까요?",
+ style: .subTitle1,
+ color: .focusePrimary
+ )
+ Spacer()
+ }
+ .padding(.bottom, .sm)
+ } else {
+ EmptyView()
+ }
+ }
+
+ @ViewBuilder
+ var peerProfileImage: some View {
+ if let peerProfileImageURL = photoShareState.peerProfileImageURL {
+ GeometryReader { geo in
+ RemoteImageView(
+ imageURL: peerProfileImageURL,
+ downsamplingWidth: 150,
+ downsamplingHeight: 150
+ )
+ .cornerRadius(.md, corenrs: [.topRight, .bottomLeft, .bottomRight])
+ .frame(height: geo.size.width)
+ }
+ .aspectRatio(1, contentMode: .fit)
+ } else {
+ EmptyView()
+ }
+ }
+
+ @ViewBuilder
+ var myProfileImage: some View {
+ if let myProfileImageURL = photoShareState.myProfileImageURL {
+ GeometryReader { geo in
+
+ RemoteImageView(
+ imageURL: myProfileImageURL,
+ downsamplingWidth: 150,
+ downsamplingHeight: 150
+ )
+ .cornerRadius(.md, corenrs: [.topRight, .topLeft, .bottomLeft])
+ .frame(height: geo.size.width)
+ }
+ .aspectRatio(1, contentMode: .fit)
+ } else {
+ EmptyView()
+ }
+ }
+}
diff --git a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/PingPongDetail/PingPongDetailFeature.swift b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/PingPongDetail/PingPongDetailFeature.swift
new file mode 100644
index 00000000..f886e760
--- /dev/null
+++ b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/PingPongDetail/PingPongDetailFeature.swift
@@ -0,0 +1,114 @@
+//
+// PingPongDetailFeature.swift
+// FeatureBottleStorageInterface
+//
+// Created by JongHoon on 8/10/24.
+//
+
+import Foundation
+
+import CoreLoggerInterface
+import FeatureReportInterface
+import DomainBottle
+
+import ComposableArchitecture
+
+extension PingPongDetailFeature {
+ public init() {
+ @Dependency(\.bottleClient) var bottleClient
+
+ let reducer = Reduce { state, action in
+ switch action {
+ case .onLoad:
+ return fetchPingPong(state: &state)
+
+ case let .pingPongDetailViewTabDidTapped(tab):
+ state.selectedTab = tab
+ return .none
+
+ case let .pingPongDidFetched(pingPong):
+ state.pingPong = pingPong
+ return .none
+
+ case .backButtonDidTapped:
+ return .send(.delegate(.backButtonDidTapped))
+
+ case .reportButtonDidTapped:
+ let userId = state.pingPong?.userProfile.userId
+ let imageURL = state.pingPong?.userProfile.userImageURL
+ let userName = state.userName
+ let userAge = state.pingPong?.userProfile.age
+ let userReportProfile = UserReportProfile(
+ imageURL: imageURL ?? "", userID: userId ?? -1, userName: userName, userAge: userAge ?? -1)
+ return .send(.delegate(.reportButtonDidTapped(userReportProfile)))
+
+ case let .introduction(.delegate(delegate)):
+ switch delegate {
+ case .popToRootDidRequired:
+ return .send(.delegate(.popToRootDidRequired))
+ }
+
+ case let .questionAndAnswer(.delegate(delegate)):
+ switch delegate {
+ case .reloadPingPongRequired:
+ return fetchPingPong(state: &state)
+ case .popToRootDidRequired:
+ return .send(.delegate(.popToRootDidRequired))
+ }
+
+ case let .matching(.delegate(delegate)):
+ switch delegate {
+ case .otherBottleButtonDidTapped:
+ return .send(.delegate(.otherBottleButtonDidTapped))
+ }
+
+ default:
+ return .none
+ }
+ }
+
+ self.init(reducer: reducer)
+
+ func fetchPingPong(state: inout State) -> Effect {
+ return .run { [bottleID = state.bottleID, userName = state.userName] send in
+ let pingPong = try await bottleClient.fetchBottlePingPong(bottleID: bottleID)
+
+ await send(.pingPongDidFetched(pingPong))
+
+ await send(.introduction(.profileFetched(pingPong.userProfile)))
+ await send(.introduction(.isStoppedFetched(pingPong.isStopped)))
+ await send(.introduction(.introductionFetched(pingPong.introduction ?? [])))
+
+ await send(.questionAndAnswer(.pingPongDidFetched(pingPong)))
+ await send(.matching(.matchingStateDidFetched(
+ matchResult: pingPong.matchResult,
+ userName: userName,
+ matchingPlace: pingPong.matchResult.meetingPlace,
+ matchingPlaceImageURL: pingPong.matchResult.meetingPlaceImageURL
+ )))
+ await send(.pingPongDidFetched(pingPong))
+ } catch: { error, send in
+ Log.error(error)
+ }
+ }
+ }
+}
+
+public enum PingPongDetailViewTabType: CaseIterable {
+ case introduction
+ case questionAndAnswer
+ case matching
+
+ var title: String {
+ switch self {
+ case .introduction:
+ return "소개"
+
+ case .questionAndAnswer:
+ return "가치관 문답"
+
+ case .matching:
+ return "매칭"
+ }
+ }
+}
diff --git a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/PingPongDetail/PingPongDetailFeatureInterface.swift b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/PingPongDetail/PingPongDetailFeatureInterface.swift
new file mode 100644
index 00000000..8233870c
--- /dev/null
+++ b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/PingPongDetail/PingPongDetailFeatureInterface.swift
@@ -0,0 +1,89 @@
+//
+// PingPongDetailFeatureInterface.swift
+// FeatureBottleStorageInterface
+//
+// Created by JongHoon on 8/10/24.
+//
+
+import Foundation
+
+import DomainBottleInterface
+import FeatureReportInterface
+
+import ComposableArchitecture
+
+@Reducer
+public struct PingPongDetailFeature {
+ private let reducer: Reduce
+
+ public init(reducer: Reduce) {
+ self.reducer = reducer
+ }
+
+ @ObservableState
+ public struct State: Equatable {
+ let bottleID: Int
+ var userName: String
+ var pingPong: BottlePingPong?
+ var isStopped: Bool {
+ return pingPong?.isStopped == true || pingPong?.isStopped == nil
+ }
+
+
+ var introduction: IntroductionFeature.State
+ var questionAndAnswer: QuestionAndAnswerFeature.State
+ var matching: MatchingFeature.State
+ var selectedTab: PingPongDetailViewTabType
+
+ public init(bottleID: Int, userName: String) {
+ self.introduction = .init(bottleID: bottleID)
+ self.questionAndAnswer = .init(bottleID: bottleID)
+ self.matching = .init()
+ self.selectedTab = .introduction
+ self.bottleID = bottleID
+ self.userName = userName
+ }
+ }
+
+ public enum Action: BindableAction {
+ // View Life Cycle
+ case onLoad
+
+ // User Action
+ case pingPongDetailViewTabDidTapped(_: PingPongDetailViewTabType)
+ case pingPongDidFetched(_: BottlePingPong)
+ case backButtonDidTapped
+ case reportButtonDidTapped
+
+
+ // Delegate
+ case delegate(Delegate)
+ public enum Delegate {
+ case backButtonDidTapped
+ case reportButtonDidTapped(UserReportProfile)
+ case otherBottleButtonDidTapped
+ case popToRootDidRequired
+ }
+
+ // ETC.
+ case introduction(IntroductionFeature.Action)
+ case questionAndAnswer(QuestionAndAnswerFeature.Action)
+ case matching(MatchingFeature.Action)
+ case binding(BindingAction)
+ }
+
+ public var body: some ReducerOf {
+ BindingReducer()
+ Scope(state: \.introduction, action: \.introduction) {
+ IntroductionFeature()
+ }
+ Scope(state: \.questionAndAnswer, action: \.questionAndAnswer) {
+ QuestionAndAnswerFeature()
+ }
+ Scope(state: \.matching, action: \.matching) {
+ MatchingFeature()
+ }
+ reducer
+ }
+}
+
diff --git a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/PingPongDetail/PingPongDetailView.swift b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/PingPongDetail/PingPongDetailView.swift
new file mode 100644
index 00000000..b6640124
--- /dev/null
+++ b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/PingPongDetail/PingPongDetailView.swift
@@ -0,0 +1,81 @@
+//
+// PingPongDetail.swift
+// FeatureBottleStorageInterface
+//
+// Created by JongHoon on 8/10/24.
+//
+
+import SwiftUI
+
+import SharedDesignSystem
+
+import ComposableArchitecture
+
+public struct PingPongDetailView: View {
+ @Perception.Bindable private var store: StoreOf
+
+ public init(store: StoreOf) {
+ self.store = store
+ }
+
+ public var body: some View {
+ WithPerceptionTracking {
+ VStack(alignment: .leading, spacing: 0.0) {
+ tabButtons
+ switch store.selectedTab {
+ case .introduction:
+ IntroductionView(store: store.scope(state: \.introduction, action: \.introduction))
+
+ case .questionAndAnswer:
+ QuestionAndAnswerView(store: store.scope(state: \.questionAndAnswer, action: \.questionAndAnswer))
+
+ case .matching:
+ MatchingView(store: store.scope(state: \.matching, action: \.matching))
+ }
+ }
+ .onLoad {
+ store.send(.onLoad)
+ }
+ }
+ .setNavigationBar(
+ leftView: {
+ makeNaivgationleftButton {
+ store.send(.backButtonDidTapped)
+ }
+ }, centerView: {
+ WantedSansStyleText(
+ store.userName,
+ style: .body,
+ color: .secondary
+ )
+ }, rightView: {
+ makeNavigationReportButton {
+ store.send(.reportButtonDidTapped)
+ }
+ }
+ )
+ .ignoresSafeArea(.all, edges: .bottom)
+ }
+}
+
+private extension PingPongDetailView {
+ var tabButtons: some View {
+ HStack(spacing: .xs) {
+ ForEach(PingPongDetailViewTabType.allCases, id: \.title, content: { tab in
+ OutlinedStyleButton(
+ .small(contentType: .text),
+ title: tab.title,
+ buttonType: .throttle,
+ isSelected: store.selectedTab == tab,
+ action: { store.send(.pingPongDetailViewTabDidTapped(tab)) }
+ )
+ .disabled(tab == .matching && (store.pingPong?.matchResult.matchStatus == .inConversation || store.pingPong?.matchResult.matchStatus == .none))
+ })
+
+ Spacer()
+ }
+ .padding(.sm)
+ .frame(maxWidth: .infinity)
+ .background(to: ColorToken.background(.primary))
+ }
+}
diff --git a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/QuestionPingPongView.swift b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/QuestionPingPongView.swift
new file mode 100644
index 00000000..69738fc5
--- /dev/null
+++ b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/QuestionPingPongView.swift
@@ -0,0 +1,138 @@
+//
+// QuestionPingPongView.swift
+// FeatureBottleStorageInterface
+//
+// Created by 임현규 on 8/9/24.
+//
+
+import SwiftUI
+import SharedDesignSystem
+
+public struct QuestionPingPongView: View {
+
+ @Binding private var textFieldContent: String
+ @Binding private var textFieldState: TextFieldState
+ private var pingpongTitle: String
+ private let isActive: Bool
+ private let questionContent: String
+ private let questionState: QuestionStateType
+ private let doneButtonAction: (() -> Void)?
+
+ public init(
+ pingpongTitle: String,
+ textFieldContent: Binding = Binding.constant(""),
+ textFieldState: Binding = Binding.constant(.enabled),
+ isActive: Bool,
+ questionContent: String = "",
+ questionState: QuestionStateType = .none,
+ doneButtonAction: (() -> Void)? = nil
+ ) {
+ self.pingpongTitle = pingpongTitle
+ self._textFieldContent = textFieldContent
+ self._textFieldState = textFieldState
+ self.isActive = isActive
+ self.questionContent = questionContent
+ self.questionState = questionState
+ self.doneButtonAction = doneButtonAction
+ }
+
+ public var body: some View {
+ PingPongContainer(
+ isActive: isActive,
+ pingpongTitle: pingpongTitle) {
+ content
+ }
+ }
+}
+
+private extension QuestionPingPongView {
+ var content: some View {
+ VStack(spacing: .sm) {
+ questionText
+
+ if questionState.peerAnswer != nil {
+ if questionState.myAnswer != nil {
+ // 상대방 O, 본인 O
+ peerAnswerText
+ myAnswerText
+ } else {
+ // 상대방 O, 본인 X
+ peerAnswerArrivedText
+ checkText
+ textField
+ }
+ } else {
+ if questionState.myAnswer != nil {
+ // 상대방 X, 본인 O
+ myAnswerText
+ peerWaitingText
+ } else {
+ // 상대방 X, 본인 X
+ peerWaitingText
+ textField
+ }
+ }
+ }
+ .onTapEndEditing()
+ .padding(.horizontal, .md)
+ .padding(.vertical, .xl)
+ .transition(.move(edge: .top))
+ .zIndex(1)
+ }
+
+
+ @ViewBuilder
+ var textField: some View {
+ LinesTextField(
+ textFieldType: .letter,
+ textFieldState: $textFieldState,
+ text: $textFieldContent,
+ placeHolder: "솔직하게 작성할수록 서로를 알아가는데 도움이 돼요",
+ buttonTitle: "작성 완료",
+ textLimit: 150,
+ action: doneButtonAction
+ )
+ }
+
+ var questionText: some View {
+ HStack(spacing: 0) {
+ WantedSansStyleText(
+ questionContent,
+ style: .subTitle1,
+ color: .focusePrimary
+ )
+ Spacer()
+ }
+ .padding(.bottom, .sm)
+ }
+
+ @ViewBuilder
+ var myAnswerText: some View {
+ if let myAnswer = questionState.myAnswer {
+ makeRightBubbleText(text: myAnswer)
+ } else {
+ EmptyView()
+ }
+ }
+
+ @ViewBuilder
+ var peerAnswerText: some View {
+ if let peerAnswer = questionState.peerAnswer {
+ makeLeftBubbleText(text: peerAnswer)
+ } else {
+ EmptyView()
+ }
+ }
+
+ var peerWaitingText: some View {
+ makeLeftBubbleText(text: "상대방의 답변을 기다리고 있어요")
+ }
+
+ var peerAnswerArrivedText: some View {
+ makeLeftBubbleText(text: "상대방의 답변이 도착했어요")
+ }
+
+ var checkText: some View {
+ makeLeftBubbleText(text: "답변을 작성하면 확인할 수 있어요!")
+ }
+}
diff --git a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Introduction/IntroductionFeature.swift b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Introduction/IntroductionFeature.swift
new file mode 100644
index 00000000..3a88ad0d
--- /dev/null
+++ b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Introduction/IntroductionFeature.swift
@@ -0,0 +1,74 @@
+//
+// IntroductionFeature.swift
+// FeatureBottleStorage
+//
+// Created by JongHoon on 8/11/24.
+//
+
+import Foundation
+
+import CoreLoggerInterface
+
+import ComposableArchitecture
+
+extension IntroductionFeature {
+ public init() {
+ @Dependency(\.bottleClient) var bottleClient
+
+ let reducer = Reduce { state, action in
+ switch action {
+ case .onAppear:
+ return .none
+
+ case let .profileFetched(userProfile):
+ state.userProfile = userProfile
+ return .none
+
+ case let .isStoppedFetched(isStopped):
+ state.isStopped = isStopped
+ return .none
+
+ case let .introductionFetched(introductions):
+ state.introductions = introductions
+ return .none
+
+ case .stopTaskButtonTapped:
+ state.destination = .alert(.init(
+ title: { TextState("중단하기") },
+ actions: {
+ ButtonState(
+ role: .destructive,
+ action: .confirmStopTalk,
+ label: { TextState("중단하기") })
+ },
+ message: { TextState("중단 시 모든 핑퐁 내용이 사라져요. 정말 중단하시겠어요?") }
+ ))
+ return .none
+
+ case let .destination(.presented(.alert(alert))):
+ switch alert {
+ case .confirmStopTalk:
+ return .run { [bottleID = state.bottleID] send in
+ try await bottleClient.stopTalk(bottleID: bottleID)
+ await send(.delegate(.popToRootDidRequired))
+ }
+ }
+
+ case .refreshPingPongDidRequired:
+ return .run { [bottleID = state.bottleID] send in
+ let pingPong = try await bottleClient.fetchBottlePingPong(bottleID: bottleID)
+ await send(.profileFetched(pingPong.userProfile))
+ await send(.isStoppedFetched(pingPong.isStopped))
+ await send(.introductionFetched(pingPong.introduction ?? []))
+ }
+
+ case .binding, .alert:
+ return .none
+
+ default:
+ return .none
+ }
+ }
+ self.init(reducer: reducer)
+ }
+}
diff --git a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Introduction/IntroductionFeatureInterface.swift b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Introduction/IntroductionFeatureInterface.swift
new file mode 100644
index 00000000..52be5fb3
--- /dev/null
+++ b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Introduction/IntroductionFeatureInterface.swift
@@ -0,0 +1,119 @@
+//
+// IntroductionFeatureInterface.swift
+// FeatureBottleStorage
+//
+// Created by JongHoon on 8/11/24.
+//
+
+import Foundation
+
+import DomainBottleInterface
+
+import ComposableArchitecture
+
+@Reducer
+public struct IntroductionFeature {
+ private let reducer: Reduce
+
+ public init(reducer: Reduce) {
+ self.reducer = reducer
+ }
+
+ @ObservableState
+ public struct State: Equatable {
+ let bottleID: Int
+ var userProfile: UserProfile?
+ var isStopped: Bool?
+
+ // 프로필
+ var userName: String {
+ userProfile?.userName ?? ""
+ }
+ var age: Int {
+ userProfile?.age ?? 0
+ }
+ var userImageURL: String {
+ userProfile?.userImageURL ?? ""
+ }
+
+ // 편지
+ var introductions: [QuestionAndAnswer]?
+ var introduction: String {
+ if let introductions = introductions,
+ !introductions.isEmpty {
+ return introductions[0].answer
+ } else {
+ return ""
+ }
+ }
+
+ // 정보
+ var basicInfos: [String] {
+ return [
+ userProfile?.profileSelect?.job ?? "",
+ userProfile?.profileSelect?.mbti ?? "",
+ userProfile?.profileSelect?.region.city ?? "",
+ String(userProfile?.profileSelect?.height ?? 0),
+ userProfile?.profileSelect?.smoking ?? "",
+ userProfile?.profileSelect?.alcohol ?? ""
+ ]
+ }
+ var characterInfos: [String] {
+ return userProfile?.profileSelect?.keyword ?? []
+ }
+ var hobyInfos: [String] {
+ let interest = userProfile?.profileSelect?.interest
+ return (interest?.culture ?? [])
+ + (interest?.entertainment ?? [])
+ + (interest?.etc ?? [])
+ + (interest?.sports ?? [])
+ }
+
+ @Presents var destination: Destination.State?
+
+ public init (bottleID: Int) {
+ self.bottleID = bottleID
+ }
+ }
+
+ public enum Action: BindableAction {
+ // View Life Cycle
+ case onAppear
+
+ case profileFetched(UserProfile)
+ case isStoppedFetched(Bool)
+ case introductionFetched([QuestionAndAnswer])
+ case stopTaskButtonTapped
+ case refreshPingPongDidRequired
+
+ // ETC.
+ case binding(BindingAction)
+ case destination(PresentationAction)
+
+ case alert(Alert)
+ public enum Alert: Equatable {
+ case confirmStopTalk
+ }
+
+ case delegate(Delegate)
+
+ public enum Delegate {
+ case popToRootDidRequired
+ }
+ }
+
+ public var body: some ReducerOf {
+ reducer
+ .ifLet(\.$destination, action: \.destination)
+ }
+}
+
+// MARK: - Destination
+
+extension IntroductionFeature {
+ @Reducer(state: .equatable)
+ public enum Destination {
+ case alert(AlertState)
+ }
+}
+
diff --git a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Introduction/IntroductionView.swift b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Introduction/IntroductionView.swift
new file mode 100644
index 00000000..8ee0e5b0
--- /dev/null
+++ b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Introduction/IntroductionView.swift
@@ -0,0 +1,88 @@
+//
+// IntroductionView.swift
+// FeatureBottleStorage
+//
+// Created by JongHoon on 8/11/24.
+//
+
+import SwiftUI
+
+import SharedDesignSystem
+
+import ComposableArchitecture
+
+public struct IntroductionView: View {
+ @Perception.Bindable private var store: StoreOf
+
+ public init(store: StoreOf) {
+ self.store = store
+ }
+
+ public var body: some View {
+ WithPerceptionTracking {
+ ScrollView {
+ VStack(spacing: 0.0) {
+ if store.isStopped == true {
+ StopCardView(userName: store.userName)
+ } else if store.isStopped == false {
+ UserProfileView(
+ imageURL: store.userImageURL,
+ userName: store.userName,
+ userAge: store.age
+ )
+
+ Spacer()
+ .frame(height: 24.0)
+
+ LettetCardView(
+ title: "\(store.userName)님이 보내는 편지",
+ letterContent: store.introduction
+ )
+ }
+
+ Spacer()
+ .frame(height: 12.0)
+
+ ClipListContainerView(
+ clipItemList: [
+ .init(
+ title: "기본 정보",
+ list: store.basicInfos
+ ),
+ .init(
+ title: "나의 성격은",
+ list: store.characterInfos
+ ),
+ .init(
+ title: "내가 푹 빠진 취미는",
+ list: store.hobyInfos
+ )
+ ]
+ )
+
+ Spacer()
+ .frame(height: 24.0)
+
+ WantedSansStyleText(
+ "대화 중단하기",
+ style: .subTitle2,
+ color: .enableSecondary
+ )
+ .asThrottleButton {
+ store.send(.stopTaskButtonTapped)
+ }
+ .disabled(store.isStopped == true)
+
+ Spacer()
+ .frame(height: 30)
+ }
+ .padding(.horizontal, 16.0)
+ .padding(.top, 32.0)
+ }
+ .scrollIndicators(.hidden)
+ .alert($store.scope(state: \.destination?.alert, action: \.destination.alert))
+ .background(to: ColorToken.background(.primary))
+
+ }
+ }
+}
diff --git a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Matching/MatchingFeature.swift b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Matching/MatchingFeature.swift
new file mode 100644
index 00000000..62a4b76c
--- /dev/null
+++ b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Matching/MatchingFeature.swift
@@ -0,0 +1,63 @@
+//
+// MatchingFeature.swift
+// FeatureBottleStorage
+//
+// Created by JongHoon on 8/11/24.
+//
+
+import Foundation
+
+import CoreToastInterface
+import CoreLoggerInterface
+
+import ComposableArchitecture
+
+extension MatchingFeature {
+ public init() {
+ @Dependency(\.toastClient) var toastClient
+
+ let reducer = Reduce {
+ state,
+ action in
+ switch action {
+ case .onAppear:
+ return .none
+
+ case let .matchingStateDidFetched(matchResult, userName, matchingPlace, matchingPlaceImageURL):
+ state.matchingPlace = matchingPlace
+ state.matchingPlaceImageURL = matchingPlaceImageURL
+
+ // 사용자 최종 선택 X
+ state.peerUserName = userName
+
+ // 매칭 성공
+ if matchResult.matchStatus == .matchSucceeded {
+ state.matchingState = .success
+ state.kakaoTalkId = matchResult.otherContact
+ return .none
+ }
+
+ // 매칭 실패
+ if matchResult.matchStatus == .matchFailed {
+ state.matchingState = .failure
+ return .none
+ }
+
+ // 상대방 답변 X
+ state.matchingState = .waiting
+
+ return .none
+ case .copyButtonDidTapped:
+ toastClient.presentToast(message: "카카오톡 아이디를 복사했어요")
+ return .none
+
+ case .otherBottleButtonDidTapped:
+ return .send(.delegate(.otherBottleButtonDidTapped))
+
+ default:
+ return .none
+ }
+ }
+ self.init(reducer: reducer)
+ }
+}
diff --git a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Matching/MatchingFeatureInterface.swift b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Matching/MatchingFeatureInterface.swift
new file mode 100644
index 00000000..2076b40b
--- /dev/null
+++ b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Matching/MatchingFeatureInterface.swift
@@ -0,0 +1,80 @@
+//
+// MatchingFeatureInterface.swift
+// FeatureBottleStorage
+//
+// Created by JongHoon on 8/11/24.
+//
+
+import Foundation
+
+import DomainBottleInterface
+
+import ComposableArchitecture
+
+public enum MatchingStateType: Equatable {
+ /// 상대방 결정 기다리는 중
+ case waiting
+ /// 매칭 성공
+ case success
+ /// 매칭 실패
+ case failure
+ /// 매칭 비활성화
+ case none
+}
+
+@Reducer
+public struct MatchingFeature {
+ private let reducer: Reduce
+
+ public init(reducer: Reduce) {
+ self.reducer = reducer
+ }
+
+ @ObservableState
+ public struct State: Equatable {
+ public var matchingState: MatchingStateType
+ public var kakaoTalkId: String?
+ public var peerUserName: String?
+ public var matchingPlace: String?
+ public var matchingPlaceImageURL: String?
+
+ public init(
+ matchingState: MatchingStateType = .none,
+ kakaoTalkId: String? = nil,
+ peerUserName: String? = nil
+ ) {
+ self.matchingState = matchingState
+ self.kakaoTalkId = kakaoTalkId
+ self.peerUserName = peerUserName
+ }
+ }
+
+ public enum Action: BindableAction {
+ // View Life Cycle
+ case onAppear
+
+ // User Action
+ case matchingStateDidFetched(
+ matchResult: MatchResult,
+ userName: String,
+ matchingPlace: String?,
+ matchingPlaceImageURL: String?
+ )
+ case copyButtonDidTapped
+ case otherBottleButtonDidTapped
+
+ // Delegate
+ case delegate(Delegate)
+ public enum Delegate {
+ case otherBottleButtonDidTapped
+ }
+
+ // ETC.
+ case binding(BindingAction)
+ }
+
+ public var body: some ReducerOf {
+ reducer
+ }
+}
+
diff --git a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Matching/MatchingView.swift b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Matching/MatchingView.swift
new file mode 100644
index 00000000..26f248b1
--- /dev/null
+++ b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Matching/MatchingView.swift
@@ -0,0 +1,244 @@
+//
+// MatchingView.swift
+// FeatureBottleStorage
+//
+// Created by JongHoon on 8/11/24.
+//
+
+import SwiftUI
+import UniformTypeIdentifiers
+
+import SharedDesignSystem
+import ComposableArchitecture
+
+public struct MatchingView: View {
+ @Perception.Bindable private var store: StoreOf
+
+ public init(store: StoreOf) {
+ self.store = store
+ }
+
+ public var body: some View {
+ WithPerceptionTracking {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 0.0) {
+ title
+ .padding(.vertical, 32)
+
+ matchingInfo
+
+ if store.matchingState == .success,
+ let placeName = store.matchingPlace,
+ let placeImageURL = store.matchingPlaceImageURL {
+ Spacer().frame(height: 12.0)
+ placeRecommendView(
+ placeName: placeName,
+ placeImageURL: placeImageURL
+ )
+ }
+
+ Spacer()
+
+ bottomButton
+
+ Spacer()
+ .frame(height: 30)
+ }
+ .padding(.horizontal, .md)
+ .frame(maxHeight: .infinity)
+ .background(to: ColorToken.background(.primary))
+ }
+ .background(to: ColorToken.background(.primary))
+ .scrollIndicators(.hidden)
+ }
+ }
+}
+
+// MARK: - Views
+
+private extension MatchingView {
+ @ViewBuilder
+ var title: some View {
+ switch store.matchingState {
+ case .waiting:
+ TitleView(
+ title: "\(store.peerUserName ?? "")님의\n결정을 기다리고 있어요",
+ caption: "조금만 더 기다려주세요!"
+ )
+ case .success:
+ TitleView(
+ title: "축하해요! 지금부터 찐-하게\n서로를 알아가 보세요",
+ caption: "아이디를 복사해 더 깊은 대화를 나눠보세요"
+ )
+ case .failure:
+ TitleView(
+ title: "다른 보틀을\n열어보는 건 어때요",
+ caption: "아쉽지만 매칭에 실패했어요"
+ )
+ default:
+ EmptyView()
+ }
+ }
+
+ @ViewBuilder
+ var matchingInfo: some View {
+ switch store.matchingState {
+ case .waiting:
+ GeometryReader { geometryProxy in
+ WithPerceptionTracking {
+ let width = geometryProxy.size.width - 50
+ HStack(spacing: 0 ) {
+ Spacer()
+ BottleImageView(
+ type: .local(
+ bottleImageSystem: .illustraition(.phone)
+ )
+ )
+ .frame(width: width, height: width)
+ Spacer()
+ }
+ }
+ }
+ .aspectRatio(1, contentMode: .fit)
+
+ case .success:
+ kakaoTalkIdShareView
+
+ case .failure:
+ GeometryReader { geometryProxy in
+ WithPerceptionTracking {
+ let width = geometryProxy.size.width - 50
+ HStack(spacing: 0 ) {
+ Spacer()
+ BottleImageView(
+ type: .local(
+ bottleImageSystem: .illustraition(.bottle1)
+ )
+ )
+ .frame(width: width, height: width)
+ Spacer()
+ }
+ }
+ }
+ .aspectRatio(1, contentMode: .fit)
+
+ default:
+ EmptyView()
+ }
+ }
+
+ var kakaoTalkIdShareView: some View {
+ VStack(spacing: .xl) {
+ WantedSansStyleText(
+ "카카오톡 아이디",
+ style: .body,
+ color: .quinary
+ )
+ .padding(.vertical, 2)
+ .padding(.horizontal, .xs)
+ .background {
+ RoundedRectangle(cornerRadius: BottleRadiusType.xs.value)
+ .fill(ColorToken.container(.secondary).color)
+ }
+
+ WantedSansStyleText(
+ store.kakaoTalkId ?? "hello",
+ style: .subTitle1,
+ color: .primary
+ )
+
+ OutlinedStyleButton(
+ .small(contentType: .image(type: .local(bottleImageSystem: .icom(.share)))),
+ title: "복사하기",
+ buttonType: .throttle
+ ) {
+ let clipboard = UIPasteboard.general
+ clipboard.setValue(store.kakaoTalkId ?? "", forPasteboardType: UTType.plainText.identifier)
+ store.send(.copyButtonDidTapped)
+ }
+ }
+ .padding(.horizontal, .md)
+ .padding(.vertical, .xl)
+ .frame(maxWidth: .infinity)
+ .overlay(
+ RoundedRectangle(cornerRadius: BottleRadiusType.xl.value)
+ .strokeBorder(
+ ColorToken.border(.primary).color,
+ lineWidth: 1.0
+ )
+ )
+ }
+
+ @ViewBuilder
+ var bottomButton: some View {
+ switch store.matchingState {
+ case .waiting:
+ EmptyView()
+ case .success:
+ EmptyView()
+ case .failure:
+ SolidButton(
+ title: "다른 보틀 열어보기",
+ sizeType: .large,
+ buttonType: .throttle,
+ action: { store.send(.otherBottleButtonDidTapped) }
+ )
+ default:
+ EmptyView()
+ }
+ }
+
+ func placeRecommendView(placeName: String, placeImageURL: String) -> some View {
+ VStack(alignment: .leading, spacing: .xl) {
+ VStack(spacing: .xs) {
+ VStack(alignment: .leading, spacing: 0.0) {
+ WantedSansStyleText(
+ "두근두근 첫만남, 이런장소는 어때요?",
+ style: .subTitle1,
+ color: .primary
+ )
+
+ WantedSansStyleText(
+ "보틀 AI가 취향에 맞는 장소를 추천 드려요!",
+ style: .subTitle1,
+ color: .tertiary
+ )
+ }
+
+ HStack(spacing: 0.0) {
+ Spacer()
+ WantedSansStyleText(
+ placeName,
+ style: .subTitle1,
+ color: .primary
+ )
+ Spacer()
+ }
+
+ HStack(spacing: 0.0) {
+ Spacer()
+
+ BottleImageView(type: .remote(
+ url: placeImageURL,
+ downsamplingWidth: 300.0,
+ downsamplingHeight: 300.0
+ ))
+ .frame(width: 200.0, height: 200.0)
+ .clipShape(RoundedRectangle(cornerRadius: BottleRadiusType.md.value))
+
+ Spacer()
+ }
+ }
+ }
+ .padding(.horizontal, .md)
+ .padding(.vertical, .xl)
+ .overlay(
+ RoundedRectangle(cornerRadius: BottleRadiusType.xl.value)
+ .strokeBorder(
+ ColorToken.border(.primary).color,
+ lineWidth: 1.0
+ )
+ )
+ .padding(.bottom, 32.0)
+ }
+}
diff --git a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/QuestionAndAnswer/QuestionAndAnswerFeature.swift b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/QuestionAndAnswer/QuestionAndAnswerFeature.swift
new file mode 100644
index 00000000..a2aa05db
--- /dev/null
+++ b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/QuestionAndAnswer/QuestionAndAnswerFeature.swift
@@ -0,0 +1,141 @@
+//
+// QuestionAndAnswerFeature.swift
+// FeatureBottleStorage
+//
+// Created by JongHoon on 8/11/24.
+//
+
+import Foundation
+
+import CoreLoggerInterface
+
+import ComposableArchitecture
+
+extension QuestionAndAnswerFeature {
+ public init() {
+ @Dependency(\.bottleClient) var bottleClient
+
+ let reducer = Reduce { state, action in
+ switch action {
+ case .onAppear:
+ return .none
+
+ case let .pingPongDidFetched(pingPong):
+ state.configurePingPong(pingPong)
+ return .none
+
+ case let .texFieldDidFocused(isFocused):
+ state.textFieldState = isFocused ? .focused : .active
+ return .none
+
+ case let .letterDoneButtonDidTapped(order, answer):
+ state.isShowLoadingIndicator = true
+ return .run { [bottleID = state.bottleID] send in
+ try await bottleClient.registerLetterAnswer(
+ bottleID: bottleID,
+ order: order,
+ answer: answer
+ )
+ await send(.refreshPingPongDidRequired)
+ } catch: { error, send in
+ Log.error(error)
+ }
+
+ case .refreshPingPongDidRequired:
+ return .run { [bottleId = state.bottleID] send in
+ let pingPong = try await bottleClient.fetchBottlePingPong(bottleID: bottleId)
+ await send(.pingPongDidFetched(pingPong))
+ await send(.configureShowLoadingIndicatorRequired(isShow: false))
+ }
+
+ case let .configureShowLoadingIndicatorRequired(isShow):
+ state.isShowLoadingIndicator = isShow
+ return .none
+
+ case let .sharePhotoSelectButtonDidTapped(willShare):
+ state.isShowLoadingIndicator = true
+ return .run { [bottleID = state.bottleID] send in
+ try await bottleClient.shareImage(
+ bottleID: bottleID,
+ willShare: willShare
+ )
+ switch willShare {
+ case true:
+ await send(.refreshPingPongDidRequired)
+ case false:
+ await send(.delegate(.popToRootDidRequired))
+ }
+ }
+
+ case let .finalSelectButtonDidTapped(willMatch: willMatch):
+ state.isShowLoadingIndicator = true
+ return .run { [bottleID = state.bottleID] send in
+ try await bottleClient.finalSelect(
+ bottleID: bottleID,
+ willMatch: willMatch
+ )
+ switch willMatch {
+ case true:
+ await send(.refreshPingPongDidRequired)
+ case false:
+ await send(.delegate(.popToRootDidRequired))
+ }
+ }
+
+ case .stopTalkButtonTapped:
+ state.destination = .alert(.init(
+ title: { TextState("중단하기") },
+ actions: {
+ ButtonState(
+ role: .destructive,
+ action: .confirmStopTalk,
+ label: { TextState("중단하기") })
+ },
+ message: { TextState("중단 시 모든 핑퐁 내용이 사라져요. 정말 중단하시겠어요?") }
+ ))
+ return .none
+
+ case let .destination(.presented(.alert(alert))):
+ switch alert {
+ case .confirmStopTalk:
+ state.isShowLoadingIndicator = true
+ return .run { [bottleID = state.bottleID] send in
+ try await bottleClient.stopTalk(bottleID: bottleID)
+ await send(.delegate(.popToRootDidRequired))
+ }
+ }
+
+ case .binding(\.firstLetterTextFieldContent):
+ if state.firstLetterTextFieldContent.count >= 50 {
+ state.textFieldState = .focused
+ } else {
+ state.textFieldState = .error
+ }
+ return .none
+
+ case .binding(\.secondLetterTextFieldContent):
+ if state.secondLetterTextFieldContent.count >= 50 {
+ state.textFieldState = .focused
+ } else {
+ state.textFieldState = .error
+ }
+ return .none
+
+ case .binding(\.thirdLetterTextFieldContent):
+ if state.thirdLetterTextFieldContent.count >= 50 {
+ state.textFieldState = .focused
+ } else {
+ state.textFieldState = .error
+ }
+ return .none
+
+ case .binding, .destination, .alert:
+ return .none
+
+ default:
+ return .none
+ }
+ }
+ self.init(reducer: reducer)
+ }
+}
diff --git a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/QuestionAndAnswer/QuestionAndAnswerFeatureInterface.swift b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/QuestionAndAnswer/QuestionAndAnswerFeatureInterface.swift
new file mode 100644
index 00000000..325aa352
--- /dev/null
+++ b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/QuestionAndAnswer/QuestionAndAnswerFeatureInterface.swift
@@ -0,0 +1,240 @@
+//
+// QuestionAndAnswerFeatureInterface.swift
+// FeatureBottleStorage
+//
+// Created by JongHoon on 8/11/24.
+//
+
+import Foundation
+
+import DomainBottleInterface
+import SharedDesignSystem
+
+import ComposableArchitecture
+
+@Reducer
+public struct QuestionAndAnswerFeature {
+ private let reducer: Reduce
+
+ public init(reducer: Reduce) {
+ self.reducer = reducer
+ }
+
+ @ObservableState
+ public struct State: Equatable {
+ let bottleID: Int
+ private var pingPong: BottlePingPong?
+ var isStopped: Bool {
+ return pingPong?.isStopped == true
+ }
+
+ var isShowLoadingIndicator: Bool
+
+ // first letter
+ var firstLetter: Letter? {
+ return pingPong?.letters.first(where: { $0.order == 1 })
+ }
+ var isFirstLetterActive: Bool {
+ guard let firstLetter
+ else {
+ return false
+ }
+ return firstLetter.canshow == true
+ }
+ var firstLetterQuestionState: QuestionStateType {
+ letterQuestionState(firstLetter)
+ }
+ var firstLetterTextFieldContent: String
+
+ // second letter
+ var secondLetter: Letter? {
+ return pingPong?.letters.first(where: { $0.order == 2 })
+ }
+ var isSecondLetterActive: Bool {
+ guard let secondLetter
+ else {
+ return false
+ }
+ return secondLetter.canshow == true
+ }
+ var secondLetterQuestionState: QuestionStateType {
+ letterQuestionState(secondLetter)
+ }
+ var secondLetterTextFieldContent: String
+
+ // third letter
+ var thirdLetter: Letter? {
+ return pingPong?.letters.first(where: { $0.order == 3 })
+ }
+ var isThirdLetterActive: Bool {
+ guard let thirdLetter
+ else {
+ return false
+ }
+ return thirdLetter.canshow == true
+ }
+ var thirdLetterQuestionState: QuestionStateType {
+ letterQuestionState(thirdLetter)
+ }
+ var thirdLetterTextFieldContent: String
+
+ var textFieldState: TextFieldState
+
+ // 사진 선택
+ var photoShareIsActive: Bool {
+ return thirdLetter?.isDone == true
+ }
+
+ var photoShareStateType: PhotoShareStateType {
+ guard let photo = pingPong?.photo
+ else {
+ return .notSelected
+ }
+
+ guard photo.shouldAnswer == false
+ else {
+ return .notSelected
+ }
+
+ guard photo.otherAnswer == true
+ else {
+ return .waitingForPeer
+ }
+
+ guard let myAnswer = photo.myAnswer,
+ let otherAnswer = photo.otherAnswer
+ else {
+ return .notSelected
+ }
+
+ if myAnswer && otherAnswer {
+ return .bothPublic(
+ peerProfileImageURL: photo.otherImageURL ?? "",
+ myProfileImageURL: photo.myImageURL ?? ""
+ )
+ }
+
+ return .eitherPrivate
+ }
+
+ var photoIsSelctedYesButton: Bool
+ var photoIsSelctedNoButton: Bool
+
+ // 최종 선택
+ var finalSelectIsActive: Bool {
+ return pingPong?.photo.isDone == true
+ }
+ var finalSelectStateType: FinalSelectStateType {
+ if pingPong?.matchResult.matchStatus == .inConversation
+ && pingPong?.matchResult.shouldAnswer == true {
+ return .notSelected
+ }
+
+ if pingPong?.matchResult.matchStatus == .inConversation && pingPong?.matchResult.shouldAnswer == false {
+ return .waitingForPeer
+ }
+
+ if pingPong?.matchResult.matchStatus != .inConversation {
+ return .bothSelected
+ }
+
+ return .notSelected
+ }
+ var finalSelectIsSelctedYesButton: Bool
+ var finalSelectIsSelctedNoButton: Bool
+
+ @Presents var destination: Destination.State?
+
+ public init(bottleID: Int) {
+ self.bottleID = bottleID
+ self.isShowLoadingIndicator = false
+ self.firstLetterTextFieldContent = ""
+ self.secondLetterTextFieldContent = ""
+ self.thirdLetterTextFieldContent = ""
+ self.textFieldState = .enabled
+ self.photoIsSelctedYesButton = false
+ self.photoIsSelctedNoButton = false
+ self.finalSelectIsSelctedYesButton = false
+ self.finalSelectIsSelctedNoButton = false
+ }
+
+ mutating func configurePingPong(_ pingPong: BottlePingPong) {
+ self.pingPong = pingPong
+ }
+
+ func letterQuestionState(_ letter: Letter?) -> QuestionStateType {
+ guard let letter
+ else {
+ return .none
+ }
+
+ let mySelf = letter.myAnswer ?? "(작성한 내용이 없습니다.)"
+ let peer = letter.otherAnswer ?? "(작성한 내용이 없습니다.)"
+
+ switch (letter.myAnswer == nil, letter.otherAnswer == nil) {
+ case (true, false):
+ return .peerAnswered(peer: peer)
+
+ case (true, true):
+ return .noAnswer
+
+ case (false, true):
+ return .selfAnswered(mySelf: mySelf)
+
+ case (false, false):
+ return .bothAnswered(
+ peer: peer,
+ mySelf: mySelf
+ )
+ }
+ }
+ }
+
+ public enum Action: BindableAction {
+ // View Life Cycle
+ case onAppear
+
+ case pingPongDidFetched(BottlePingPong)
+ case texFieldDidFocused(isFocused: Bool)
+ case letterDoneButtonDidTapped(
+ order: Int,
+ answer: String
+ )
+ case sharePhotoSelectButtonDidTapped(willShare: Bool)
+ case finalSelectButtonDidTapped(willMatch: Bool)
+ case refreshPingPongDidRequired
+ case configureShowLoadingIndicatorRequired(isShow: Bool)
+ case stopTalkButtonTapped
+
+ // ETC.
+ case binding(BindingAction)
+ case destination(PresentationAction)
+
+ case alert(Alert)
+ public enum Alert: Equatable {
+ case confirmStopTalk
+ }
+
+ case delegate(Delegate)
+
+ public enum Delegate {
+ case reloadPingPongRequired
+ case popToRootDidRequired
+ }
+ }
+
+ public var body: some ReducerOf {
+ BindingReducer()
+ reducer
+ .ifLet(\.$destination, action: \.destination)
+ }
+}
+
+// MARK: - Destination
+
+extension QuestionAndAnswerFeature {
+ @Reducer(state: .equatable)
+ public enum Destination {
+ case alert(AlertState)
+ }
+}
diff --git a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/QuestionAndAnswer/QuestionAndAnswerView.swift b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/QuestionAndAnswer/QuestionAndAnswerView.swift
new file mode 100644
index 00000000..8123ba2f
--- /dev/null
+++ b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/QuestionAndAnswer/QuestionAndAnswerView.swift
@@ -0,0 +1,137 @@
+//
+// QuestionAndAnswerView.swift
+// FeatureBottleStorage
+//
+// Created by JongHoon on 8/11/24.
+//
+
+import SwiftUI
+
+import SharedDesignSystem
+import CoreLoggerInterface
+
+import ComposableArchitecture
+
+public struct QuestionAndAnswerView: View {
+ @Perception.Bindable private var store: StoreOf
+ @FocusState private var isTextFieldFocused: Bool
+
+ public init(store: StoreOf) {
+ self.store = store
+ }
+
+ public var body: some View {
+ WithPerceptionTracking {
+ ScrollView {
+ VStack(alignment: .leading, spacing: .sm) {
+ // 질문
+ QuestionPingPongView(
+ pingpongTitle: "첫 번째 질문",
+ textFieldContent: $store.firstLetterTextFieldContent,
+ textFieldState: $store.textFieldState,
+ isActive: store.isFirstLetterActive,
+ questionContent: store.firstLetter?.question ?? "",
+ questionState: store.firstLetterQuestionState,
+ doneButtonAction: {
+ store.send(.letterDoneButtonDidTapped(
+ order: 1,
+ answer: store.firstLetterTextFieldContent
+ ))
+ }
+ )
+ .focused($isTextFieldFocused)
+
+ QuestionPingPongView(
+ pingpongTitle: "두 번째 질문",
+ textFieldContent: $store.secondLetterTextFieldContent,
+ textFieldState: $store.textFieldState,
+ isActive: store.isSecondLetterActive,
+ questionContent: store.secondLetter?.question ?? "",
+ questionState: store.secondLetterQuestionState,
+ doneButtonAction: {
+ store.send(.letterDoneButtonDidTapped(
+ order: 2,
+ answer: store.secondLetterTextFieldContent
+ ))
+ }
+ )
+ .focused($isTextFieldFocused)
+
+ QuestionPingPongView(
+ pingpongTitle: "세 번째 질문",
+ textFieldContent: $store.thirdLetterTextFieldContent,
+ textFieldState: $store.textFieldState,
+ isActive: store.isThirdLetterActive,
+ questionContent: store.thirdLetter?.question ?? "",
+ questionState: store.thirdLetterQuestionState,
+ doneButtonAction: {
+ store.send(.letterDoneButtonDidTapped(
+ order: 3,
+ answer: store.thirdLetterTextFieldContent
+ ))
+ }
+ )
+ .focused($isTextFieldFocused)
+
+ PhotoSharePingPongView(
+ isActive: store.photoShareIsActive,
+ pingPongTitle: "사진 공개",
+ photoShareState: store.photoShareStateType,
+ isSelctedYesButton: $store.photoIsSelctedYesButton,
+ isSelctedNoButton: $store.photoIsSelctedNoButton,
+ doneButtonAction: {
+ store.send(.sharePhotoSelectButtonDidTapped(willShare: store.photoIsSelctedYesButton))
+ }
+ )
+
+ FinalSelectPingPongView(
+ isActive: store.finalSelectIsActive,
+ pingPongTitle: "최종 선택",
+ finalSelectState: store.finalSelectStateType,
+ isSelctedYesButton: $store.finalSelectIsSelctedYesButton,
+ isSelctedNoButton: $store.finalSelectIsSelctedNoButton,
+ doneButtonAction: {
+ store.send(.finalSelectButtonDidTapped(willMatch: store.finalSelectIsSelctedYesButton))
+ }
+ )
+
+ HStack(spacing: 0.0) {
+ Spacer()
+ WantedSansStyleText(
+ "대화 중단하기",
+ style: .subTitle2,
+ color: .enableSecondary
+ )
+ .asThrottleButton {
+ store.send(.stopTalkButtonTapped)
+ }
+ .padding(.top, 12.0)
+ .disabled(store.isStopped == true)
+ Spacer()
+ }
+
+ Spacer()
+ .frame(height: 14)
+ }
+ .padding(.md)
+ .frame(maxWidth: .infinity)
+ .onChange(of: isTextFieldFocused) { isFocused in
+ store.send(.texFieldDidFocused(isFocused: isFocused))
+ }
+ .onChange(of: store.textFieldState) { textFieldState in
+ isTextFieldFocused = textFieldState == .active || textFieldState == .enabled ? false : true
+ }
+ }
+ .scrollIndicators(.hidden)
+ .overlay {
+ if store.isShowLoadingIndicator {
+ LoadingIndicator()
+ }
+ }
+ .alert($store.scope(state: \.destination?.alert, action: \.destination.alert))
+ .background(to: ColorToken.background(.primary))
+ .toolbar(.hidden, for: .bottomBar)
+
+ }
+ }
+}
diff --git a/Projects/Feature/BottleStorage/Project.swift b/Projects/Feature/BottleStorage/Project.swift
new file mode 100644
index 00000000..cee7679e
--- /dev/null
+++ b/Projects/Feature/BottleStorage/Project.swift
@@ -0,0 +1,57 @@
+import ProjectDescription
+import ProjectDescriptionHelpers
+import DependencyPlugin
+
+let project = Project.makeModule(
+ name: ModulePath.Feature.name+ModulePath.Feature.BottleStorage.rawValue,
+ targets: [
+ .feature(
+ interface: .BottleStorage,
+ factory: .init(
+ dependencies: [
+ .domain,
+ .feature(interface: .Report),
+ .feature(interface: .TabBar)
+
+ ]
+ )
+ ),
+ .feature(
+ implements: .BottleStorage,
+ factory: .init(
+ dependencies: [
+ .feature(interface: .BottleStorage)
+ ]
+ )
+ ),
+
+ .feature(
+ testing: .BottleStorage,
+ factory: .init(
+ dependencies: [
+ .feature(interface: .BottleStorage)
+ ]
+ )
+ ),
+ .feature(
+ tests: .BottleStorage,
+ factory: .init(
+ dependencies: [
+ .feature(testing: .BottleStorage),
+ .feature(implements: .BottleStorage)
+ ]
+ )
+ ),
+
+ .feature(
+ example: .BottleStorage,
+ factory: .init(
+ dependencies: [
+ .feature(testing: .BottleStorage),
+ .feature(implements: .BottleStorage)
+ ]
+ )
+ )
+
+ ]
+)
diff --git a/Projects/Feature/BottleStorage/Sources/Source.swift b/Projects/Feature/BottleStorage/Sources/Source.swift
new file mode 100644
index 00000000..952e0d6e
--- /dev/null
+++ b/Projects/Feature/BottleStorage/Sources/Source.swift
@@ -0,0 +1,8 @@
+//
+// Source.swift
+// FeatureBottleStorage
+//
+// Created by JongHoon on 8/7/24.
+//
+
+import Foundation
diff --git a/Projects/Feature/BottleStorage/Testing/Sources/BottleStorageTesting.swift b/Projects/Feature/BottleStorage/Testing/Sources/BottleStorageTesting.swift
new file mode 100644
index 00000000..b1853ce6
--- /dev/null
+++ b/Projects/Feature/BottleStorage/Testing/Sources/BottleStorageTesting.swift
@@ -0,0 +1 @@
+// This is for Tuist
diff --git a/Projects/Feature/BottleStorage/Tests/Sources/BottleStorageTest.swift b/Projects/Feature/BottleStorage/Tests/Sources/BottleStorageTest.swift
new file mode 100644
index 00000000..9dfb945b
--- /dev/null
+++ b/Projects/Feature/BottleStorage/Tests/Sources/BottleStorageTest.swift
@@ -0,0 +1,11 @@
+import XCTest
+
+final class BottleStorageTests: XCTestCase {
+ override func setUpWithError() throws {}
+
+ override func tearDownWithError() throws {}
+
+ func testExample() {
+ XCTAssertEqual(1, 1)
+ }
+}
diff --git a/Projects/Feature/GeneralSignUp/Example/Sources/AppView.swift b/Projects/Feature/GeneralSignUp/Example/Sources/AppView.swift
new file mode 100644
index 00000000..248eb4dc
--- /dev/null
+++ b/Projects/Feature/GeneralSignUp/Example/Sources/AppView.swift
@@ -0,0 +1,19 @@
+import SwiftUI
+
+import FeatureGeneralSignUpInterface
+import FeatureGeneralSignUp
+
+import ComposableArchitecture
+
+@main
+struct AppView: App {
+ var body: some Scene {
+ WindowGroup {
+ GeneralSignUpView(store: Store(
+ initialState: GeneralSignUpFeature.State(),
+ reducer: { GeneralSignUpFeature() }
+ ))
+ }
+ }
+}
+
diff --git a/Projects/Feature/GeneralSignUp/Interface/Sources/GeneralSignUpFeature.swift b/Projects/Feature/GeneralSignUp/Interface/Sources/GeneralSignUpFeature.swift
new file mode 100644
index 00000000..cf526e9c
--- /dev/null
+++ b/Projects/Feature/GeneralSignUp/Interface/Sources/GeneralSignUpFeature.swift
@@ -0,0 +1,59 @@
+//
+// GeneralSignUpFeature.swift
+// FeatureGeneralSignUpInterface
+//
+// Created by JongHoon on 8/4/24.
+//
+
+import Foundation
+
+import CoreToastInterface
+import DomainAuthInterface
+
+import ComposableArchitecture
+
+extension GeneralSignUpFeature {
+ public init() {
+ @Dependency(\.dismiss) var dismiss
+ @Dependency(\.toastClient) var toastClient
+
+ let reducer = Reduce { state, action in
+ switch action {
+ case .onAppear:
+ return .none
+
+ case .webViewLoadingDidCompleted:
+ state.isShowLoadingProgressView = false
+ return .none
+
+ case .closeButtonDidTap:
+ return .run { _ in
+ await dismiss()
+ }
+
+ case let .presentToastDidRequired(message):
+ toastClient.presentToast(message: message)
+ return .none
+
+ case let .openURLDidRequired(url):
+ state.termsURL = url
+ state.isPresentTerms = true
+ return .none
+
+ case let .signUpDidCompleted(accessToken, refreshToken):
+ return .send(.delegate(.signUpDidCompleted(.init(
+ token: .init(
+ accessToken: accessToken,
+ refershToken: refreshToken
+ ),
+ isSignUp: true,
+ isCompletedOnboardingIntroduction: false
+ ))))
+
+ case .binding, .delegate:
+ return .none
+ }
+ }
+ self.init(reducer: reducer)
+ }
+}
diff --git a/Projects/Feature/GeneralSignUp/Interface/Sources/GeneralSignUpFeatureInterface.swift b/Projects/Feature/GeneralSignUp/Interface/Sources/GeneralSignUpFeatureInterface.swift
new file mode 100644
index 00000000..b05d2dfd
--- /dev/null
+++ b/Projects/Feature/GeneralSignUp/Interface/Sources/GeneralSignUpFeatureInterface.swift
@@ -0,0 +1,59 @@
+//
+// GeneralSignUpFeatureInterface.swift
+// FeatureGeneralSignUpInterface
+//
+// Created by JongHoon on 8/4/24.
+//
+
+import Foundation
+
+import DomainAuthInterface
+
+import ComposableArchitecture
+
+@Reducer
+public struct GeneralSignUpFeature {
+ private let reducer: Reduce
+
+ public init(reducer: Reduce) {
+ self.reducer = reducer
+ }
+
+ @ObservableState
+ public struct State: Equatable {
+ var isShowLoadingProgressView: Bool
+ var termsURL: String?
+ var isPresentTerms: Bool
+
+ public init() {
+ isShowLoadingProgressView = true
+ self.isPresentTerms = false
+ }
+ }
+
+ public enum Action: BindableAction {
+ // View Life Cycle
+ case onAppear
+
+ case webViewLoadingDidCompleted
+ case closeButtonDidTap
+ case presentToastDidRequired(message: String)
+ case openURLDidRequired(url: String)
+ case signUpDidCompleted(accessToken: String, refreshToken: String)
+
+ // ETC
+ case binding(BindingAction)
+
+ // Delegate
+ case delegate(Delegate)
+
+ public enum Delegate {
+ case signUpDidCompleted(UserInfo)
+ }
+ }
+
+ public var body: some ReducerOf {
+ BindingReducer()
+ reducer
+ }
+}
diff --git a/Projects/Feature/GeneralSignUp/Interface/Sources/GeneralSignUpView.swift b/Projects/Feature/GeneralSignUp/Interface/Sources/GeneralSignUpView.swift
new file mode 100644
index 00000000..6cb6f38c
--- /dev/null
+++ b/Projects/Feature/GeneralSignUp/Interface/Sources/GeneralSignUpView.swift
@@ -0,0 +1,63 @@
+//
+// GeneralSignUpView.swift
+// FeatureGeneralSignUpInterface
+//
+// Created by JongHoon on 8/4/24.
+//
+
+import SwiftUI
+
+import SharedDesignSystem
+import FeatureBaseWebViewInterface
+
+import ComposableArchitecture
+
+public struct GeneralSignUpView: View {
+ @Perception.Bindable private var store: StoreOf
+
+ public init(store: StoreOf) {
+ self.store = store
+ }
+
+ public var body: some View {
+ WithPerceptionTracking {
+ BaseWebView(
+ type: .signUp,
+ actionDidInputted: { action in
+ switch action {
+ case .webViewLoadingDidCompleted:
+ store.send(.webViewLoadingDidCompleted)
+
+ case .closeWebView:
+ store.send(.closeButtonDidTap)
+
+ case let .showTaost(message):
+ store.send(.presentToastDidRequired(message: message))
+
+ case let .signUpDidComplted(accessToken, refreshToken):
+ store.send(.signUpDidCompleted(
+ accessToken: accessToken,
+ refreshToken: refreshToken
+ ))
+
+ case let .openLink(href):
+ store.send(.openURLDidRequired(url: href))
+
+ default:
+ break
+ }
+ }
+ )
+ .overlay {
+ if store.isShowLoadingProgressView {
+ LoadingIndicator()
+ }
+ }
+ .toolbar(.hidden, for: .navigationBar)
+ .ignoresSafeArea(.all, edges: .bottom)
+ .sheet(isPresented: $store.isPresentTerms) {
+ TermsWebView(url: store.termsURL ?? "")
+ }
+ }
+ }
+}
diff --git a/Projects/Feature/GeneralSignUp/Interface/Sources/TermsWebView.swift b/Projects/Feature/GeneralSignUp/Interface/Sources/TermsWebView.swift
new file mode 100644
index 00000000..86edd267
--- /dev/null
+++ b/Projects/Feature/GeneralSignUp/Interface/Sources/TermsWebView.swift
@@ -0,0 +1,58 @@
+//
+// TermsWebView.swift
+// FeatureGeneralSignUpInterface
+//
+// Created by JongHoon on 8/13/24.
+//
+
+import SwiftUI
+import WebKit
+
+public struct TermsWebView: UIViewRepresentable {
+ private let webView: WKWebView
+ private let url: String
+
+ public init(
+ url: String
+ ) {
+ self.url = url
+ let preferences = WKPreferences()
+ preferences.javaScriptCanOpenWindowsAutomatically = true
+ let configuration = WKWebViewConfiguration()
+ configuration.preferences = preferences
+ configuration.defaultWebpagePreferences.allowsContentJavaScript = true
+ webView = WKWebView(frame: .zero, configuration: configuration)
+ }
+
+ public func makeUIView(context: Context) -> WKWebView {
+ webView.navigationDelegate = context.coordinator
+ let request = URLRequest(url: URL(string: url)!)
+ webView.load(request)
+ return webView
+ }
+
+ public func updateUIView(_ uiView: WKWebView, context: Context) {
+ }
+
+ public func makeCoordinator() -> Coordinator {
+ Coordinator(parent: self)
+ }
+
+ final public class Coordinator: NSObject, WKNavigationDelegate {
+ private let parent: TermsWebView
+
+ init(
+ parent: TermsWebView
+ ) {
+ self.parent = parent
+ }
+
+
+ public func webView(
+ _ webView: WKWebView,
+ didFinish navigation: WKNavigation!
+ ) {
+
+ }
+ }
+}
diff --git a/Projects/Feature/GeneralSignUp/Project.swift b/Projects/Feature/GeneralSignUp/Project.swift
new file mode 100644
index 00000000..994b7e8a
--- /dev/null
+++ b/Projects/Feature/GeneralSignUp/Project.swift
@@ -0,0 +1,55 @@
+import ProjectDescription
+import ProjectDescriptionHelpers
+import DependencyPlugin
+
+let project = Project.makeModule(
+ name: ModulePath.Feature.name+ModulePath.Feature.GeneralSignUp.rawValue,
+ targets: [
+ .feature(
+ interface: .GeneralSignUp,
+ factory: .init(
+ dependencies: [
+ .domain,
+ .feature(interface: .BaseWebView)
+ ]
+ )
+ ),
+ .feature(
+ implements: .GeneralSignUp,
+ factory: .init(
+ dependencies: [
+ .feature(interface: .GeneralSignUp)
+ ]
+ )
+ ),
+
+ .feature(
+ testing: .GeneralSignUp,
+ factory: .init(
+ dependencies: [
+ .feature(interface: .GeneralSignUp)
+ ]
+ )
+ ),
+ .feature(
+ tests: .GeneralSignUp,
+ factory: .init(
+ dependencies: [
+ .feature(testing: .GeneralSignUp),
+ .feature(implements: .GeneralSignUp)
+ ]
+ )
+ ),
+
+ .feature(
+ example: .GeneralSignUp,
+ factory: .init(
+ dependencies: [
+ .feature(testing: .GeneralSignUp),
+ .feature(implements: .GeneralSignUp)
+ ]
+ )
+ )
+
+ ]
+)
diff --git a/Projects/Feature/GeneralSignUp/Sources/Source.swift b/Projects/Feature/GeneralSignUp/Sources/Source.swift
new file mode 100644
index 00000000..db9d8e51
--- /dev/null
+++ b/Projects/Feature/GeneralSignUp/Sources/Source.swift
@@ -0,0 +1,8 @@
+//
+// Source.swift
+// FeatureGeneralSignUp
+//
+// Created by JongHoon on 8/10/24.
+//
+
+import Foundation
diff --git a/Projects/Feature/GeneralSignUp/Testing/Sources/GeneralSignUpTesting.swift b/Projects/Feature/GeneralSignUp/Testing/Sources/GeneralSignUpTesting.swift
new file mode 100644
index 00000000..b1853ce6
--- /dev/null
+++ b/Projects/Feature/GeneralSignUp/Testing/Sources/GeneralSignUpTesting.swift
@@ -0,0 +1 @@
+// This is for Tuist
diff --git a/Projects/Feature/GeneralSignUp/Tests/Sources/GeneralSignUpTest.swift b/Projects/Feature/GeneralSignUp/Tests/Sources/GeneralSignUpTest.swift
new file mode 100644
index 00000000..92261762
--- /dev/null
+++ b/Projects/Feature/GeneralSignUp/Tests/Sources/GeneralSignUpTest.swift
@@ -0,0 +1,11 @@
+import XCTest
+
+final class GeneralSignUpTests: XCTestCase {
+ override func setUpWithError() throws {}
+
+ override func tearDownWithError() throws {}
+
+ func testExample() {
+ XCTAssertEqual(1, 1)
+ }
+}
diff --git a/Projects/Feature/Login/Example/Sources/AppView.swift b/Projects/Feature/Login/Example/Sources/AppView.swift
new file mode 100644
index 00000000..53bcf588
--- /dev/null
+++ b/Projects/Feature/Login/Example/Sources/AppView.swift
@@ -0,0 +1,36 @@
+import SwiftUI
+
+import FeatureLoginInterface
+import FeatureLogin
+
+import ComposableArchitecture
+import KakaoSDKAuth
+import KakaoSDKCommon
+
+@main
+struct AppView: App {
+ let store = Store(
+ initialState: LoginFeature.State(),
+ reducer: { LoginFeature() }
+ )
+ var body: some Scene {
+ WindowGroup {
+ LoginView(store: store)
+ .onAppear(perform: {
+ guard let kakaoAppKey = Bundle.main.infoDictionary?["KAKAO_APP_KEY"] as? String else {
+ fatalError("XCConfig Setting Error")
+ }
+
+ KakaoSDK.initSDK(appKey: kakaoAppKey)
+
+ })
+ .onOpenURL(perform: { url in
+ if (AuthApi.isKakaoTalkLoginUrl(url)) {
+ _ = AuthController.handleOpenUrl(url: url)
+ }
+ }
+ )
+ }
+ }
+}
+
diff --git a/Projects/Feature/Login/Interface/LoginView.swift b/Projects/Feature/Login/Interface/LoginView.swift
new file mode 100644
index 00000000..bbf0b5cd
--- /dev/null
+++ b/Projects/Feature/Login/Interface/LoginView.swift
@@ -0,0 +1,18 @@
+//
+// LoginView.swift
+// FeatureLogin
+//
+// Created by JongHoon on 7/24/24.
+//
+
+import SwiftUI
+
+import ComposableArchitecture
+
+public struct LoginView: View {
+ private let store: StoreOf
+
+ public var body: some View {
+ Text("login view")
+ }
+}
diff --git a/Projects/Feature/Login/Interface/Sources/GeneralLogIn/GeneralLogInFeatureInterface.swift b/Projects/Feature/Login/Interface/Sources/GeneralLogIn/GeneralLogInFeatureInterface.swift
new file mode 100644
index 00000000..b7c8f5b3
--- /dev/null
+++ b/Projects/Feature/Login/Interface/Sources/GeneralLogIn/GeneralLogInFeatureInterface.swift
@@ -0,0 +1,59 @@
+//
+// GeneralLogInFeatureInterface.swift
+// FeatureLoginInterface
+//
+// Created by JongHoon on 8/4/24.
+//
+
+import Foundation
+
+import DomainAuthInterface
+
+import ComposableArchitecture
+
+@Reducer
+public struct GeneralLogInFeature {
+ private let reducer: Reduce
+
+ public init(reducer: Reduce) {
+ self.reducer = reducer
+ }
+
+ @ObservableState
+ public struct State: Equatable {
+ var isShowLoadingProgressView: Bool
+
+ public init() {
+ self.isShowLoadingProgressView = true
+ }
+ }
+
+ public enum Action: BindableAction {
+ // View Life Cycle
+ case onAppear
+
+ case webViewLoadingDidCompleted
+ case presentToastDidRequired(message: String)
+ case loginDidCompleted(
+ accessToken: String,
+ refreshToken: String,
+ isCompletedOnboardingIntroduction: Bool
+ )
+ case closeButtonDidTapped
+
+ // ETC
+ case binding(BindingAction)
+
+ // Delegate
+ case delegate(Delegate)
+
+ public enum Delegate {
+ case generalLogInDidSucess(UserInfo)
+ }
+ }
+
+ public var body: some ReducerOf {
+ BindingReducer()
+ reducer
+ }
+}
diff --git a/Projects/Feature/Login/Interface/Sources/GeneralLogIn/GeneralLogInView.swift b/Projects/Feature/Login/Interface/Sources/GeneralLogIn/GeneralLogInView.swift
new file mode 100644
index 00000000..7b50c6ac
--- /dev/null
+++ b/Projects/Feature/Login/Interface/Sources/GeneralLogIn/GeneralLogInView.swift
@@ -0,0 +1,58 @@
+//
+// GeneralLogInView.swift
+// FeatureLoginInterface
+//
+// Created by JongHoon on 8/4/24.
+//
+
+import SwiftUI
+
+import SharedDesignSystem
+import FeatureBaseWebViewInterface
+
+import ComposableArchitecture
+
+public struct GeneralLogInView: View {
+ private let store: StoreOf
+
+ public init(store: StoreOf) {
+ self.store = store
+ }
+
+ public var body: some View {
+ WithPerceptionTracking {
+ BaseWebView(
+ type: .login,
+ actionDidInputted: { action in
+ switch action {
+ case .webViewLoadingDidCompleted:
+ store.send(.webViewLoadingDidCompleted)
+
+ case let .showTaost(message):
+ store.send(.presentToastDidRequired(message: message))
+
+ case let .loginDidCompleted(accessToken, refreshToken, isCompletedOnboardingIntroduction):
+ store.send(.loginDidCompleted(
+ accessToken: accessToken,
+ refreshToken: refreshToken,
+ isCompletedOnboardingIntroduction: isCompletedOnboardingIntroduction
+ ))
+
+ case .closeWebView:
+ store.send(.closeButtonDidTapped)
+
+ default:
+ break
+ }
+ }
+ )
+ .overlay {
+ if store.isShowLoadingProgressView {
+ LoadingIndicator()
+ }
+ }
+ .ignoresSafeArea(.all, edges: .bottom)
+ .toolbar(.hidden, for: .navigationBar)
+ }
+ }
+}
diff --git a/Projects/Feature/Login/Interface/Sources/GeneralLogIn/GeneralLoginFeature.swift b/Projects/Feature/Login/Interface/Sources/GeneralLogIn/GeneralLoginFeature.swift
new file mode 100644
index 00000000..a70092b6
--- /dev/null
+++ b/Projects/Feature/Login/Interface/Sources/GeneralLogIn/GeneralLoginFeature.swift
@@ -0,0 +1,56 @@
+//
+// GeneralLoginFeature.swift
+// FeatureLoginInterface
+//
+// Created by JongHoon on 8/10/24.
+//
+
+import Foundation
+
+import CoreToastInterface
+import DomainAuth
+
+import ComposableArchitecture
+
+extension GeneralLogInFeature {
+ public init() {
+ let reducer = Reduce { state, action in
+ @Dependency(\.authClient) var authClient
+ @Dependency(\.dismiss) var dismiss
+ @Dependency(\.toastClient) var toastClient
+
+ switch action {
+ case .onAppear:
+ return .none
+
+ case .webViewLoadingDidCompleted:
+ state.isShowLoadingProgressView = false
+ return .none
+
+ case let .presentToastDidRequired(message):
+ toastClient.presentToast(message: message)
+ return .none
+
+ case let .loginDidCompleted(accessToken, refreshToken, isCompletedOnboardingIntroduction):
+ return .send(.delegate(.generalLogInDidSucess(.init(
+ token: .init(
+ accessToken: accessToken,
+ refershToken: refreshToken
+ ),
+ isSignUp: true,
+ isCompletedOnboardingIntroduction: isCompletedOnboardingIntroduction
+ ))))
+
+ case .closeButtonDidTapped:
+ return .run { _ in
+ await dismiss()
+ }
+
+ case .binding, .delegate:
+ return .none
+ }
+ }
+ self.init(reducer: reducer)
+ }
+}
+
diff --git a/Projects/Feature/Login/Interface/Sources/Login/LoginFeature.swift b/Projects/Feature/Login/Interface/Sources/Login/LoginFeature.swift
new file mode 100644
index 00000000..c82c41dc
--- /dev/null
+++ b/Projects/Feature/Login/Interface/Sources/Login/LoginFeature.swift
@@ -0,0 +1,160 @@
+//
+// LoginFeature.swift
+// FeatureLogin
+//
+// Created by JongHoon on 7/25/24.
+//
+
+import Foundation
+
+import DomainAuth
+import DomainAuthInterface
+import CoreLoggerInterface
+import CoreKeyChainStore
+import FeatureOnboardingInterface
+import FeatureGeneralSignUpInterface
+
+import ComposableArchitecture
+
+extension LoginFeature {
+ public init() {
+ @Dependency(\.authClient) var authClient
+
+ let reducer = Reduce { state, action in
+ switch action {
+ case .onAppear:
+ return .none
+
+ case .signInKakaoButtonDidTapped:
+ return .run { send in
+ let userInfo = try await authClient.signInWithKakao()
+ await send(.socialLoginDidSuccess(userInfo))
+ } catch: { error, send in
+ await send(.goToGeneralLogin)
+ }
+
+ case .signInGeneralButtonDidTapped:
+ state.path.append(.generalLogin(.init()))
+ return .none
+
+ case .signInAppleButtonDidTapped:
+ return .run { send in
+ await send(.indicatorStateChanged(isLoading: true))
+ let userInfo = try await authClient.signInWithApple()
+ await send(.socialLoginDidSuccess(userInfo))
+ // clientSceret 받아오기
+
+ let clientSceret = try await authClient.fetchAppleClientSecret()
+ KeyChainTokenStore.shared.save(property: .AppleClientSecret, value: clientSceret)
+
+ // refresh 토큰 받아오기
+ let appleToken = try await authClient.refreshAppleToken()
+ let appleRefreshToken = appleToken.refreshToken
+ KeyChainTokenStore.shared.save(property: .AppleRefreshToken, value: appleRefreshToken)
+ await send(.indicatorStateChanged(isLoading: false))
+ } catch: { error, send in
+ // TODO: apple Login error
+ Log.error(error.localizedDescription)
+ await send(.indicatorStateChanged(isLoading: false))
+ }
+
+ case .personalInformationTermButtonDidTapped:
+ state.termURL = "https://spiral-ogre-a4d.notion.site/abb2fd284516408e8c2fc267d07c6421"
+ state.isPresentTermView = true
+ return .none
+
+ case .utilizationTermButtonDidTapped:
+ state.termURL = "https://spiral-ogre-a4d.notion.site/240724-e3676639ea864147bb293cfcda40d99f"
+ state.isPresentTermView = true
+ return .none
+
+ case let .socialLoginDidSuccess(userInfo):
+ return handleLoginSuccessUserInfo(state: &state, userInfo: userInfo)
+
+ case let .indicatorStateChanged(isLoading):
+ state.isLoading = isLoading
+ return .none
+
+ case let .userProfileFetchRequired(userName):
+ return .run { send in
+ try await authClient.registerUserProfile(userName: userName)
+ await send(.userProfileFetchDiduccess)
+ }
+
+ case .userProfileFetchDiduccess:
+ return goToOboarding(state: &state)
+
+ case .goToGeneralLogin:
+ // TODO: - 일반 로그인 화면으로 이동.
+ return .none
+
+ case let .signUpCheckCompleted(isSignUp):
+ if isSignUp {
+ return .send(.goToMainTab)
+ } else {
+ return goToOboarding(state: &state)
+ }
+
+ case .path(.element(id: _, action: .onBoarding(.delegate(.createOnboardingProfileDidCompleted)))):
+ return .send(.delegate(.createOnboardingProfileDidCompleted))
+
+ case let .path(.element(id: _, action: .generalLogin(.delegate(delegate)))):
+ switch delegate {
+ case let .generalLogInDidSucess(userInfo):
+ return handleLoginSuccessUserInfo(state: &state, userInfo: userInfo)
+ }
+
+ case let .path(.element(id: _, action: .generalSignUp(.delegate(delegate)))):
+ switch delegate {
+ case let .signUpDidCompleted(userInfo):
+ return handleLoginSuccessUserInfo(state: &state, userInfo: userInfo)
+ }
+
+ default:
+ return .none
+ }
+
+ // MARK: - Inner Methods
+
+ func goToOboarding(state: inout LoginFeature.State) -> Effect {
+ state.path.append(.onBoarding(.init()))
+ return .none
+ }
+
+ func handleLoginSuccessUserInfo(state: inout State, userInfo: UserInfo) -> Effect {
+ let token = userInfo.token, isSignUp = userInfo.isSignUp
+ let isCompletedOnboardingIntroduction = userInfo.isCompletedOnboardingIntroduction
+ authClient.saveToken(token: token)
+ Log.error(token)
+
+ if let userName = userInfo.userName, !isSignUp {
+ return .send(.userProfileFetchRequired(userName: userName))
+ }
+
+ if isSignUp && !isCompletedOnboardingIntroduction {
+ return goToOboarding(state: &state)
+ }
+ return .send(.signUpCheckCompleted(isSignUp: isSignUp))
+ }
+ }
+ self.init(reducer: reducer)
+ }
+}
+
+extension LoginFeature {
+ // MARK: - Path
+
+ @Reducer(state: .equatable)
+ public enum Path {
+ case generalLogin(GeneralLogInFeature)
+ case generalSignUp(GeneralSignUpFeature)
+ case onBoarding(OnboardingFeature)
+ }
+
+ // MARK: - Destination
+
+ @Reducer(state: .equatable)
+ public enum Destination {
+ case termsView
+ }
+}
diff --git a/Projects/Feature/Login/Interface/Sources/Login/LoginFeatureInterface.swift b/Projects/Feature/Login/Interface/Sources/Login/LoginFeatureInterface.swift
new file mode 100644
index 00000000..ad6f44ec
--- /dev/null
+++ b/Projects/Feature/Login/Interface/Sources/Login/LoginFeatureInterface.swift
@@ -0,0 +1,72 @@
+//
+// LoginFeatureInterface.swift
+// FeatureLogin
+//
+// Created by JongHoon on 7/24/24.
+//
+
+import Foundation
+
+import CoreLoggerInterface
+import CoreNetwork
+import DomainAuth
+import DomainAuthInterface
+import ComposableArchitecture
+import KakaoSDKCommon
+
+@Reducer
+public struct LoginFeature {
+ private let reducer: Reduce
+
+ public init(reducer: Reduce) {
+ self.reducer = reducer
+ }
+
+ @ObservableState
+ public struct State: Equatable {
+ var isPresentTermView: Bool
+ var termURL: String
+ var isLoading: Bool
+
+ var path = StackState()
+ public init() {
+ self.isPresentTermView = false
+ self.termURL = ""
+ self.isLoading = false
+ }
+ }
+
+ public enum Action: BindableAction {
+ // View Life Cycle
+ case onAppear
+
+ case signInKakaoButtonDidTapped
+ case signInGeneralButtonDidTapped
+ case signInAppleButtonDidTapped
+
+ case personalInformationTermButtonDidTapped
+ case utilizationTermButtonDidTapped
+
+ case indicatorStateChanged(isLoading: Bool)
+ case socialLoginDidSuccess(UserInfo)
+ case signUpCheckCompleted(isSignUp: Bool)
+ case goToMainTab
+ case goToGeneralLogin
+ case userProfileFetchRequired(userName: String)
+ case userProfileFetchDiduccess
+ case path(StackAction)
+ case binding(BindingAction)
+
+ case delegate(Delegate)
+
+ public enum Delegate {
+ case createOnboardingProfileDidCompleted
+ }
+ }
+
+ public var body: some ReducerOf {
+ BindingReducer()
+ reducer
+ .forEach(\.path, action: \.path)
+ }
+}
diff --git a/Projects/Feature/Login/Interface/Sources/Login/LoginView.swift b/Projects/Feature/Login/Interface/Sources/Login/LoginView.swift
new file mode 100644
index 00000000..8526f634
--- /dev/null
+++ b/Projects/Feature/Login/Interface/Sources/Login/LoginView.swift
@@ -0,0 +1,180 @@
+//
+// LoginView.swift
+// FeatureLogin
+//
+// Created by JongHoon on 7/25/24.
+//
+
+import SwiftUI
+import AuthenticationServices
+
+import SharedDesignSystem
+import FeatureOnboardingInterface
+import FeatureGeneralSignUpInterface
+import CoreLoggerInterface
+
+import ComposableArchitecture
+
+public struct LoginView: View {
+ @Perception.Bindable private var store: StoreOf
+
+ public init(store: StoreOf) {
+ self.store = store
+ }
+
+ public var body: some View {
+ WithPerceptionTracking {
+ NavigationStack(path: $store.scope(state: \.path, action: \.path)) {
+ VStack(spacing: 0) {
+ Spacer()
+ .frame(height: 52)
+ whiteLogo
+ .padding(.top, 52)
+ .padding(.bottom, .xl)
+
+ mainText
+
+ Spacer()
+ VStack(spacing: 12.0) {
+ signInWithKakaoButton
+
+ signInWithAppleButton
+
+ termsGuides
+ }
+ .padding(.bottom, 30.0)
+ }
+ .background {
+ BottleImageView(
+ type: .local(bottleImageSystem: .illustraition(.loginBackground))
+ )
+ }
+ .edgesIgnoringSafeArea([.top, .bottom])
+ .sheet(
+ isPresented: $store.isPresentTermView,
+ content: {
+ TermsWebView(url: store.termURL)
+ }
+ )
+ .overlay {
+ if store.isLoading {
+ LoadingIndicator()
+ }
+ }
+
+ } destination: { store in
+ WithPerceptionTracking {
+ switch store.state {
+ case .generalLogin:
+ if let store = store.scope(state: \.generalLogin, action: \.generalLogin) {
+ GeneralLogInView(store: store)
+ }
+ case .onBoarding:
+ if let store = store.scope(state: \.onBoarding, action: \.onBoarding) {
+ OnboardingView(store: store)
+ }
+ case .generalSignUp:
+ if let store = store.scope(state: \.generalSignUp, action: \.generalSignUp) {
+ GeneralSignUpView(store: store)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+public extension LoginView {
+ var whiteLogo: some View {
+ BottleImageView(type: .local(bottleImageSystem: .illustraition(.whiteLogo)))
+ .frame(width: 117.1, height: 30)
+ }
+
+ var mainText: some View {
+ WantedSansStyleText("진심을 담은 보틀로\n서로를 밀도있게 알아가요", style: .title2, color: .quaternary)
+ .padding(.horizontal, .md)
+ .multilineTextAlignment(.center)
+ }
+
+ var bottleImage: some View {
+ BottleImageView(type: .remote(
+ url: "https://static.wikia.nocookie.net/wallaceandgromit/images/3/38/Gromit-3.png/revision/latest/scale-to-width/360?cb=20191228190308",
+ downsamplingWidth: 180,
+ downsamplingHeight: 180))
+ .frame(width: 180, height: 180)
+ }
+
+ // TODO: 카카오 로그인 버튼 Style로 수정
+ var signInWithKakaoButton: some View {
+ SolidButton(
+ title: "카카오 로그인",
+ sizeType: .large,
+ buttonType: .throttle,
+ buttonApperance: .kakao,
+ action: { store.send(.signInKakaoButtonDidTapped) }
+ )
+ .padding(.horizontal, .md)
+ }
+
+ var signInWithAppleButton: some View {
+ SolidButton(
+ title: "Apple로 로그인",
+ sizeType: .large,
+ buttonType: .throttle,
+ buttonApperance: .apple,
+ action: { store.send(.signInAppleButtonDidTapped) }
+ )
+ .padding(.horizontal, .md)
+ }
+
+ var signInWithGeneralButton: some View {
+ SolidButton(
+ title: "일반 로그인",
+ sizeType: .large,
+ buttonType: .throttle,
+ buttonApperance: .generalSignIn,
+ action: { store.send(.signInGeneralButtonDidTapped) }
+ )
+ .padding(.horizontal, .md)
+ }
+
+ var termsGuides: some View {
+ VStack(alignment: .center, spacing: .xxs) {
+ HStack(spacing: 0.0) {
+ WantedSansStyleText(
+ "로그인 버튼을 누르면 ",
+ style: .caption,
+ color: .secondary
+ )
+
+ WantedSansStyleText(
+ "개인정보처리방침",
+ style: .caption,
+ color: .secondary
+ )
+ .underline(color: ColorToken.text(.secondary).color)
+ .asThrottleButton {
+ store.send(.personalInformationTermButtonDidTapped)
+ }
+ }
+
+ HStack(spacing: 0.0) {
+ WantedSansStyleText(
+ "보틀이용약관",
+ style: .caption,
+ color: .secondary
+ )
+ .underline(color: ColorToken.text(.secondary).color)
+ .asThrottleButton {
+ store.send(.utilizationTermButtonDidTapped)
+ }
+
+ WantedSansStyleText(
+ "에 동의한 것으로 간주합니다.",
+ style: .caption,
+ color: .secondary
+ )
+ }
+ }
+ }
+}
diff --git a/Projects/Feature/Login/Project.swift b/Projects/Feature/Login/Project.swift
new file mode 100644
index 00000000..5f7d0259
--- /dev/null
+++ b/Projects/Feature/Login/Project.swift
@@ -0,0 +1,57 @@
+import ProjectDescription
+import ProjectDescriptionHelpers
+import DependencyPlugin
+
+let project = Project.makeModule(
+ name: ModulePath.Feature.name+ModulePath.Feature.Login.rawValue,
+ targets: [
+ .feature(
+ interface: .Login,
+ factory: .init(
+ dependencies: [
+ .domain,
+ .feature(interface: .BaseWebView),
+ .feature(interface: .Onboarding),
+ .feature(interface: .GeneralSignUp)
+ ]
+ )
+ ),
+ .feature(
+ implements: .Login,
+ factory: .init(
+ dependencies: [
+ .feature(interface: .Login)
+ ]
+ )
+ ),
+
+ .feature(
+ testing: .Login,
+ factory: .init(
+ dependencies: [
+ .feature(interface: .Login)
+ ]
+ )
+ ),
+ .feature(
+ tests: .Login,
+ factory: .init(
+ dependencies: [
+ .feature(testing: .Login),
+ .feature(implements: .Login)
+ ]
+ )
+ ),
+
+ .feature(
+ example: .Login,
+ factory: .init(
+ dependencies: [
+ .feature(testing: .Login),
+ .feature(implements: .Login)
+ ]
+ )
+ )
+
+ ]
+)
diff --git a/Projects/Feature/Login/Sources/Source.swift b/Projects/Feature/Login/Sources/Source.swift
new file mode 100644
index 00000000..9dbc5c9f
--- /dev/null
+++ b/Projects/Feature/Login/Sources/Source.swift
@@ -0,0 +1,8 @@
+//
+// Source.swift
+// FeatureLogin
+//
+// Created by JongHoon on 8/5/24.
+//
+
+import Foundation
diff --git a/Projects/Feature/Login/Testing/Sources/LoginTesting.swift b/Projects/Feature/Login/Testing/Sources/LoginTesting.swift
new file mode 100644
index 00000000..b1853ce6
--- /dev/null
+++ b/Projects/Feature/Login/Testing/Sources/LoginTesting.swift
@@ -0,0 +1 @@
+// This is for Tuist
diff --git a/Projects/Feature/Login/Tests/Sources/LoginTest.swift b/Projects/Feature/Login/Tests/Sources/LoginTest.swift
new file mode 100644
index 00000000..ea3d8926
--- /dev/null
+++ b/Projects/Feature/Login/Tests/Sources/LoginTest.swift
@@ -0,0 +1,11 @@
+import XCTest
+
+final class LoginTests: XCTestCase {
+ override func setUpWithError() throws {}
+
+ override func tearDownWithError() throws {}
+
+ func testExample() {
+ XCTAssertEqual(1, 1)
+ }
+}
diff --git a/Projects/Feature/MyPage/Example/Sources/AppView.swift b/Projects/Feature/MyPage/Example/Sources/AppView.swift
new file mode 100644
index 00000000..cc918e49
--- /dev/null
+++ b/Projects/Feature/MyPage/Example/Sources/AppView.swift
@@ -0,0 +1,18 @@
+import SwiftUI
+
+import FeatureMyPageInterface
+import FeatureMyPage
+
+import ComposableArchitecture
+
+@main
+struct AppView: App {
+ var body: some Scene {
+ WindowGroup {
+ MyPageView(store: Store(
+ initialState: MyPageFeature.State(),
+ reducer: { MyPageFeature() }
+ ))
+ }
+ }
+}
diff --git a/Projects/Feature/MyPage/Interface/Sources/MyPage/MyPageFeature.swift b/Projects/Feature/MyPage/Interface/Sources/MyPage/MyPageFeature.swift
new file mode 100644
index 00000000..1a24112f
--- /dev/null
+++ b/Projects/Feature/MyPage/Interface/Sources/MyPage/MyPageFeature.swift
@@ -0,0 +1,125 @@
+//
+// MyPageFeature.swift
+// FeatureMyPageInterface
+//
+// Created by JongHoon on 7/24/24.
+//
+
+import Foundation
+
+import DomainAuth
+import DomainProfile
+import CoreKeyChainStore
+import CoreToastInterface
+import CoreLoggerInterface
+import SharedDesignSystem
+import ComposableArchitecture
+
+extension MyPageFeature {
+ public init() {
+ @Dependency(\.authClient) var authClient
+ @Dependency(\.toastClient) var toastClient
+ @Dependency(\.profileClient) var profileClient
+ let reducer = Reduce { state, action in
+ switch action {
+ case .onLoad:
+ state.isShowLoadingProgressView = true
+ return .run { send in
+ let userProfile = try await profileClient.fetchUserProfile()
+ await send(.userProfileDidFetched(userProfile))
+ }
+
+ case .logOutButtonDidTapped:
+ state.destination = .alert(.init(
+ title: { TextState("로그아웃") },
+ actions: { ButtonState(role: .destructive, action: .confirmLogOut, label: { TextState("로그아웃") }) },
+ message: { TextState("정말 로그아웃 하시겠어요?") }
+ ))
+ return .none
+
+ case .logOutDidCompleted:
+ KeyChainTokenStore.shared.deleteAll()
+ return .send(.delegate(.logoutDidCompleted))
+
+ case .withdrawalButtonDidTapped:
+ state.destination = .alert(.init(
+ title: { TextState("탈퇴하기") },
+ actions: { ButtonState(role: .destructive, action: .confirmWithdrawal, label: { TextState("탈퇴하기") }) },
+ message: { TextState("탈퇴 시 계정 복구가 어려워요.\n정말 탈퇴하시겠어요?") }
+ ))
+ return .none
+
+ case .withdrawalDidCompleted:
+ KeyChainTokenStore.shared.deleteAll()
+ return .send(.delegate(.withdrawalDidCompleted))
+
+ case let .destination(.presented(.alert(alert))):
+ switch alert {
+ case .confirmLogOut:
+ return .run { send in
+ try await authClient.logout()
+ await send(.logOutDidCompleted)
+ }
+
+ case .confirmWithdrawal:
+ return .run { send in
+ await send(.delegate(.withdrawalButtonDidTapped))
+ try await authClient.withdraw()
+ if !KeyChainTokenStore.shared.load(property: .AppleUserID).isEmpty {
+ // clientSceret 받아오기
+ let clientSceret = try await authClient.fetchAppleClientSecret()
+ KeyChainTokenStore.shared.save(property: .AppleClientSecret, value: clientSceret)
+ try await authClient.revokeAppleLogin()
+ }
+ await send(.withdrawalDidCompleted)
+ }
+ }
+
+ case .userProfileDidFetched(let userProfile):
+ let profileSelect = userProfile.profileSelect
+ let userInfo = userProfile.userInfo
+ let introduction = userProfile.introduction
+
+ Log.debug(userProfile)
+
+ state.keywordItem = [
+ ClipItem(
+ title: "기본 정보",
+ list: [profileSelect.job, profileSelect.mbti, "\(profileSelect.region.city) \(profileSelect.region.state)", "\(profileSelect.height)", profileSelect.smoke, profileSelect.alcohol]
+ ),
+
+ ClipItem(
+ title: "나의 성격은",
+ list: profileSelect.keyword
+ ),
+
+ ClipItem(
+ title: "내가 푹 빠진 취미는",
+ list: (profileSelect.interset.culture ?? [])
+ + (profileSelect.interset.entertainment ?? [])
+ + (profileSelect.interset.sports ?? [])
+ + (profileSelect.interset.etc ?? [])
+ )
+ ]
+
+ state.userInfo = userInfo
+ state.introduction = introduction
+ state.isShowLoadingProgressView = false
+
+ return .none
+ case let .selectedTabDidChanged(selectedTap):
+ return .send(.delegate(.selectedTabDidChanged(selectedTap)))
+
+ case .userProfileUpdateDidRequest:
+ return .run { send in
+ let userProfile = try await profileClient.fetchUserProfile()
+ await send(.userProfileDidFetched(userProfile))
+ }
+ default:
+ return .none
+ }
+ }
+ self.init(reducer: reducer)
+ }
+}
+
diff --git a/Projects/Feature/MyPage/Interface/Sources/MyPage/MyPageFeatureInterface.swift b/Projects/Feature/MyPage/Interface/Sources/MyPage/MyPageFeatureInterface.swift
new file mode 100644
index 00000000..cfd4f513
--- /dev/null
+++ b/Projects/Feature/MyPage/Interface/Sources/MyPage/MyPageFeatureInterface.swift
@@ -0,0 +1,85 @@
+//
+// MyPageFeatureInterface.swift
+// FeatureMyPageInterface
+//
+// Created by JongHoon on 7/24/24.
+//
+
+import Foundation
+
+import SharedDesignSystem
+import DomainProfileInterface
+import FeatureTabBarInterface
+
+import ComposableArchitecture
+
+@Reducer
+public struct MyPageFeature {
+ private let reducer: Reduce
+
+ public init(reducer: Reduce) {
+ self.reducer = reducer
+ }
+
+ @ObservableState
+ public struct State: Equatable {
+ var isShowLoadingProgressView: Bool
+ public var keywordItem: [ClipItem]
+ public var userInfo: UserInfo
+ public var introduction: Introduction
+
+ @Presents var destination: Destination.State?
+
+ public init(
+ keywordItem: [ClipItem] = []
+ ) {
+ self.isShowLoadingProgressView = true
+ self.keywordItem = keywordItem
+ self.userInfo = .init(userAge: -1, userImageURL: "", userName: "")
+ self.introduction = .init(answer: "", question: "")
+ }
+ }
+
+ public enum Action: BindableAction {
+ // View Life Cycle
+ case onLoad
+ case userProfileDidFetched(UserProfile)
+ case userProfileUpdateDidRequest
+ case logOutButtonDidTapped
+ case logOutDidCompleted
+ case withdrawalButtonDidTapped
+ case withdrawalDidCompleted
+ case selectedTabDidChanged(TabType)
+
+ case delegate(Delegate)
+
+ public enum Delegate {
+ case withdrawalButtonDidTapped
+ case withdrawalDidCompleted
+ case logoutDidCompleted
+ case selectedTabDidChanged(TabType)
+ }
+
+ case alert(Alert)
+ public enum Alert: Equatable {
+ case confirmLogOut
+ case confirmWithdrawal
+ }
+
+ // ETC
+ case destination(PresentationAction)
+ case binding(BindingAction)
+ }
+
+ public var body: some ReducerOf {
+ reducer
+ .ifLet(\.$destination, action: \.destination)
+ }
+}
+
+extension MyPageFeature {
+ @Reducer(state: .equatable)
+ public enum Destination {
+ case alert(AlertState)
+ }
+}
diff --git a/Projects/Feature/MyPage/Interface/Sources/MyPage/MyPageView.swift b/Projects/Feature/MyPage/Interface/Sources/MyPage/MyPageView.swift
new file mode 100644
index 00000000..74fb50f4
--- /dev/null
+++ b/Projects/Feature/MyPage/Interface/Sources/MyPage/MyPageView.swift
@@ -0,0 +1,122 @@
+//
+// MyPageView.swift
+// FeatureMyPageInterface
+//
+// Created by JongHoon on 7/24/24.
+//
+
+import SwiftUI
+
+import FeatureTabBarInterface
+import FeatureBaseWebViewInterface
+import SharedDesignSystem
+
+import ComposableArchitecture
+
+public struct MyPageView: View {
+ @Perception.Bindable private var store: StoreOf
+
+ public init(store: StoreOf) {
+ self.store = store
+ }
+
+ public var body: some View {
+ WithPerceptionTracking {
+ ScrollView {
+ VStack(spacing: 0) {
+ Spacer()
+ .frame(height: 52.0)
+ userProfile
+ myIntroduction
+ myKeywords
+
+ HStack(spacing: 0) {
+ Spacer()
+ logoutButton
+ Spacer()
+ withdrawalButton
+ Spacer()
+ }
+ .padding(.bottom, .xl)
+ }
+ .padding(.horizontal, .md)
+ }
+ .scrollIndicators(.hidden)
+ .background(to: ColorToken.container(.primary))
+ .padding(.bottom, 106)
+ .padding(.top, 1)
+ .setTabBar(selectedTab: .myPage) { selectedTab in
+ store.send(.selectedTabDidChanged(selectedTab))
+ }
+ .onLoad {
+ store.send(.onLoad)
+ }
+ .overlay {
+ if store.isShowLoadingProgressView {
+ WithPerceptionTracking {
+ LoadingIndicator()
+ }
+ }
+ }
+ .alert($store.scope(state: \.destination?.alert, action: \.destination.alert))
+ }
+ }
+}
+
+// MARK: - Views
+private extension MyPageView {
+ var userProfile: some View {
+ VStack(spacing: .sm) {
+ RemoteImageView(
+ imageURL: store.userInfo.userImageURL,
+ downsamplingWidth: 80.0,
+ downsamplingHeight: 80.0
+ )
+ .clipShape(Circle())
+ .frame(width: 80, height: 80)
+
+ WantedSansStyleText(
+ store.userInfo.userName,
+ style: .subTitle1,
+ color: .secondary)
+ }
+ .padding(.bottom, .xl)
+ }
+
+ @ViewBuilder
+ var myIntroduction: some View {
+ if store.introduction.answer == "" {
+ EmptyView()
+ } else {
+ LettetCardView(title: "내가 쓴 편지" , letterContent: store.introduction.answer)
+ .padding(.bottom, .sm)
+ }
+ }
+
+ var myKeywords: some View {
+ ClipListContainerView(clipItemList: store.keywordItem)
+ .padding(.bottom, .md)
+ }
+
+ var logoutButton: some View {
+ WantedSansStyleText(
+ "로그아웃",
+ style: .subTitle2,
+ color: .enableSecondary
+ )
+ .asThrottleButton {
+ store.send(.logOutButtonDidTapped)
+ }
+ }
+
+ var withdrawalButton: some View {
+ WantedSansStyleText(
+ "탈퇴하기",
+ style: .subTitle2,
+ color: .enableSecondary
+ )
+ .asThrottleButton {
+ store.send(.withdrawalButtonDidTapped)
+ }
+ }
+}
diff --git a/Projects/Feature/MyPage/Project.swift b/Projects/Feature/MyPage/Project.swift
new file mode 100644
index 00000000..8398d1f4
--- /dev/null
+++ b/Projects/Feature/MyPage/Project.swift
@@ -0,0 +1,56 @@
+import ProjectDescription
+import ProjectDescriptionHelpers
+import DependencyPlugin
+
+let project = Project.makeModule(
+ name: ModulePath.Feature.name+ModulePath.Feature.MyPage.rawValue,
+ targets: [
+ .feature(
+ interface: .MyPage,
+ factory: .init(
+ dependencies: [
+ .domain,
+ .feature(interface: .BaseWebView),
+ .feature(interface: .TabBar)
+ ]
+ )
+ ),
+ .feature(
+ implements: .MyPage,
+ factory: .init(
+ dependencies: [
+ .feature(interface: .MyPage)
+ ]
+ )
+ ),
+
+ .feature(
+ testing: .MyPage,
+ factory: .init(
+ dependencies: [
+ .feature(interface: .MyPage)
+ ]
+ )
+ ),
+ .feature(
+ tests: .MyPage,
+ factory: .init(
+ dependencies: [
+ .feature(testing: .MyPage),
+ .feature(implements: .MyPage)
+ ]
+ )
+ ),
+
+ .feature(
+ example: .MyPage,
+ factory: .init(
+ dependencies: [
+ .feature(testing: .MyPage),
+ .feature(implements: .MyPage)
+ ]
+ )
+ )
+
+ ]
+)
diff --git a/Projects/Feature/MyPage/Sources/Source.swift b/Projects/Feature/MyPage/Sources/Source.swift
new file mode 100644
index 00000000..754a957f
--- /dev/null
+++ b/Projects/Feature/MyPage/Sources/Source.swift
@@ -0,0 +1,8 @@
+//
+// Source.swift
+// FeatureMyPage
+//
+// Created by JongHoon on 8/9/24.
+//
+
+import Foundation
diff --git a/Projects/Feature/MyPage/Testing/Sources/MyPageTesting.swift b/Projects/Feature/MyPage/Testing/Sources/MyPageTesting.swift
new file mode 100644
index 00000000..b1853ce6
--- /dev/null
+++ b/Projects/Feature/MyPage/Testing/Sources/MyPageTesting.swift
@@ -0,0 +1 @@
+// This is for Tuist
diff --git a/Projects/Feature/MyPage/Tests/Sources/MyPageTest.swift b/Projects/Feature/MyPage/Tests/Sources/MyPageTest.swift
new file mode 100644
index 00000000..2a621ebb
--- /dev/null
+++ b/Projects/Feature/MyPage/Tests/Sources/MyPageTest.swift
@@ -0,0 +1,11 @@
+import XCTest
+
+final class MyPageTests: XCTestCase {
+ override func setUpWithError() throws {}
+
+ override func tearDownWithError() throws {}
+
+ func testExample() {
+ XCTAssertEqual(1, 1)
+ }
+}
diff --git a/Projects/Feature/Onboarding/Example/Sources/AppView.swift b/Projects/Feature/Onboarding/Example/Sources/AppView.swift
new file mode 100644
index 00000000..acf50ca6
--- /dev/null
+++ b/Projects/Feature/Onboarding/Example/Sources/AppView.swift
@@ -0,0 +1,18 @@
+import SwiftUI
+
+import FeatureOnboarding
+import FeatureOnboardingInterface
+
+import ComposableArchitecture
+
+@main
+struct AppView: App {
+ var body: some Scene {
+ WindowGroup {
+ OnboardingView(store: Store(
+ initialState: OnboardingFeature.State(),
+ reducer: { OnboardingFeature() }
+ ))
+ }
+ }
+}
diff --git a/Projects/Feature/Onboarding/Interface/Sources/Onboarding/OnboardingFeature.swift b/Projects/Feature/Onboarding/Interface/Sources/Onboarding/OnboardingFeature.swift
new file mode 100644
index 00000000..f66080b5
--- /dev/null
+++ b/Projects/Feature/Onboarding/Interface/Sources/Onboarding/OnboardingFeature.swift
@@ -0,0 +1,43 @@
+//
+// OnboardingFeature.swift
+// FeatureOnboarding
+//
+// Created by JongHoon on 7/31/24.
+//
+
+import Foundation
+
+import CoreLoggerInterface
+import CoreToastInterface
+
+import ComposableArchitecture
+
+extension OnboardingFeature {
+ public init() {
+ @Dependency(\.toastClient) var toastClient
+
+ let reducer = Reduce { state, action in
+ switch action {
+ case .onAppear:
+ return .none
+
+ case let .presentToastDidRequired(message):
+ toastClient.presentToast(message: message)
+ return .none
+
+ case .webViewLoadingCompleted:
+ state.isShowLoadingProgressView = false
+ return .none
+
+ case .createProfileDidCompleted:
+ return .run { send in
+ await send(.delegate(.createOnboardingProfileDidCompleted))
+ }
+
+ case .binding, .delegate:
+ return .none
+ }
+ }
+ self.init(reducer: reducer)
+ }
+}
diff --git a/Projects/Feature/Onboarding/Interface/Sources/Onboarding/OnboardingFeatureInterface.swift b/Projects/Feature/Onboarding/Interface/Sources/Onboarding/OnboardingFeatureInterface.swift
new file mode 100644
index 00000000..7f1bbc10
--- /dev/null
+++ b/Projects/Feature/Onboarding/Interface/Sources/Onboarding/OnboardingFeatureInterface.swift
@@ -0,0 +1,49 @@
+//
+// OnboardingFeatureInterface.swift
+// FeatureOnboarding
+//
+// Created by JongHoon on 7/31/24.
+//
+
+import Foundation
+
+import CoreWebViewInterface
+
+import ComposableArchitecture
+
+@Reducer
+public struct OnboardingFeature {
+ private let reducer: Reduce
+
+ public init(reducer: Reduce) {
+ self.reducer = reducer
+ }
+
+ @ObservableState
+ public struct State: Equatable {
+ var isShowLoadingProgressView: Bool
+
+ public init() {
+ self.isShowLoadingProgressView = true
+ }
+ }
+
+ public enum Action: BindableAction {
+ case onAppear
+ case presentToastDidRequired(message: String)
+ case createProfileDidCompleted
+ case webViewLoadingCompleted
+ case delegate(Delegate)
+
+ case binding(BindingAction)
+
+ public enum Delegate {
+ case createOnboardingProfileDidCompleted
+ }
+ }
+
+ public var body: some ReducerOf {
+ BindingReducer()
+ reducer
+ }
+}
diff --git a/Projects/Feature/Onboarding/Interface/Sources/Onboarding/OnboardingView.swift b/Projects/Feature/Onboarding/Interface/Sources/Onboarding/OnboardingView.swift
new file mode 100644
index 00000000..fb869298
--- /dev/null
+++ b/Projects/Feature/Onboarding/Interface/Sources/Onboarding/OnboardingView.swift
@@ -0,0 +1,55 @@
+//
+// OnboardingView.swift
+// FeatureOnboarding
+//
+// Created by JongHoon on 7/31/24.
+//
+
+import SwiftUI
+import WebKit
+
+import CoreLoggerInterface
+import CoreWebViewInterface
+import SharedDesignSystem
+import FeatureBaseWebViewInterface
+
+import ComposableArchitecture
+
+public struct OnboardingView: View {
+ private var store: StoreOf
+
+ public init(store: StoreOf) {
+ self.store = store
+ }
+
+ public var body: some View {
+ WithPerceptionTracking {
+ BaseWebView(
+ type: .createProfile,
+ actionDidInputted: { action in
+ switch action {
+ case .webViewLoadingDidCompleted:
+ store.send(.webViewLoadingCompleted)
+
+ case let .showTaost(message):
+ store.send(.presentToastDidRequired(message: message))
+
+ case .createProfileDidCompleted:
+ store.send(.createProfileDidCompleted)
+
+ default:
+ Log.assertion(message: "not handled web view action")
+ break
+ }
+ }
+ )
+ .ignoresSafeArea(.all, edges: .bottom)
+ .toolbar(.hidden, for: .navigationBar)
+ .overlay {
+ if store.isShowLoadingProgressView {
+ LoadingIndicator()
+ }
+ }
+ }
+ }
+}
diff --git a/Projects/Feature/Onboarding/Project.swift b/Projects/Feature/Onboarding/Project.swift
new file mode 100644
index 00000000..6f8e6ec2
--- /dev/null
+++ b/Projects/Feature/Onboarding/Project.swift
@@ -0,0 +1,53 @@
+import ProjectDescription
+import ProjectDescriptionHelpers
+import DependencyPlugin
+
+let project = Project.makeModule(
+ name: ModulePath.Feature.name+ModulePath.Feature.Onboarding.rawValue,
+ targets: [
+ .feature(
+ interface: .Onboarding,
+ factory: .init(
+ dependencies: [
+ .domain,
+ .feature(interface: .BaseWebView)
+ ]
+ )
+ ),
+ .feature(
+ implements: .Onboarding,
+ factory: .init(
+ dependencies: [
+ .feature(interface: .Onboarding)
+ ]
+ )
+ ),
+ .feature(
+ testing: .Onboarding,
+ factory: .init(
+ dependencies: [
+ .feature(interface: .Onboarding)
+ ]
+ )
+ ),
+ .feature(
+ tests: .Onboarding,
+ factory: .init(
+ dependencies: [
+ .feature(testing: .Onboarding),
+ .feature(implements: .Onboarding)
+ ]
+ )
+ ),
+
+ .feature(
+ example: .Onboarding,
+ factory: .init(
+ dependencies: [
+ .feature(testing: .Onboarding),
+ .feature(implements: .Onboarding)
+ ]
+ )
+ )
+ ]
+)
diff --git a/Projects/Feature/Onboarding/Sources/Source.swift b/Projects/Feature/Onboarding/Sources/Source.swift
new file mode 100644
index 00000000..9728b38d
--- /dev/null
+++ b/Projects/Feature/Onboarding/Sources/Source.swift
@@ -0,0 +1,8 @@
+//
+// Source.swift
+// FeatureOnboarding
+//
+// Created by JongHoon on 8/8/24.
+//
+
+import Foundation
diff --git a/Projects/Feature/Onboarding/Testing/Sources/OnboardingTesting.swift b/Projects/Feature/Onboarding/Testing/Sources/OnboardingTesting.swift
new file mode 100644
index 00000000..b1853ce6
--- /dev/null
+++ b/Projects/Feature/Onboarding/Testing/Sources/OnboardingTesting.swift
@@ -0,0 +1 @@
+// This is for Tuist
diff --git a/Projects/Feature/Onboarding/Tests/Sources/OnboardingTest.swift b/Projects/Feature/Onboarding/Tests/Sources/OnboardingTest.swift
new file mode 100644
index 00000000..95741139
--- /dev/null
+++ b/Projects/Feature/Onboarding/Tests/Sources/OnboardingTest.swift
@@ -0,0 +1,11 @@
+import XCTest
+
+final class OnboardingTests: XCTestCase {
+ override func setUpWithError() throws {}
+
+ override func tearDownWithError() throws {}
+
+ func testExample() {
+ XCTAssertEqual(1, 1)
+ }
+}
diff --git a/Projects/Feature/ProfileSetup/Example/Sources/AppView.swift b/Projects/Feature/ProfileSetup/Example/Sources/AppView.swift
new file mode 100644
index 00000000..8f118108
--- /dev/null
+++ b/Projects/Feature/ProfileSetup/Example/Sources/AppView.swift
@@ -0,0 +1,17 @@
+import SwiftUI
+
+import ComposableArchitecture
+import FeatureProfileSetupInterface
+
+@main
+struct AppView: App {
+ let store = Store(
+ initialState: IntroductionSetupFeature.State(),
+ reducer: { IntroductionSetupFeature() })
+
+ var body: some Scene {
+ WindowGroup {
+ IntroductionSetupView(store: store)
+ }
+ }
+}
diff --git a/Projects/Feature/ProfileSetup/Interface/Sources/IntroductionSetup/IntroductionSetupFeature.swift b/Projects/Feature/ProfileSetup/Interface/Sources/IntroductionSetup/IntroductionSetupFeature.swift
new file mode 100644
index 00000000..380f3bb9
--- /dev/null
+++ b/Projects/Feature/ProfileSetup/Interface/Sources/IntroductionSetup/IntroductionSetupFeature.swift
@@ -0,0 +1,154 @@
+//
+// IntroductionSetupFeature.swift
+// FeatureProfileSetupInterface
+//
+// Created by 임현규 on 8/5/24.
+//
+
+import Foundation
+
+import DomainProfileInterface
+import DomainProfile
+import SharedDesignSystem
+import CoreLoggerInterface
+
+import ComposableArchitecture
+
+@Reducer
+public struct IntroductionSetupFeature {
+ private let reducer: Reduce
+
+ public init(reducer: Reduce) {
+ self.reducer = reducer
+ }
+
+ @ObservableState
+ public struct State: Equatable {
+ public var introductionText: String
+ public var textFieldState: TextFieldState
+ public var keywordItem: [ClipItem]
+ public var isNextButtonDisable: Bool
+ public var maxLength: Int
+ public var isLoading: Bool
+
+ public init(
+ introductionText: String = "",
+ textFieldState: TextFieldState = .enabled,
+ keywordItem: [ClipItem] = [],
+ isNextButtonDisable: Bool = true,
+ maxLength: Int = 50,
+ isLoading: Bool = false
+ ) {
+ self.introductionText = introductionText
+ self.textFieldState = textFieldState
+ self.keywordItem = keywordItem
+ self.isNextButtonDisable = isNextButtonDisable
+ self.maxLength = maxLength
+ self.isLoading = isLoading
+ }
+ }
+
+ public enum Action: BindableAction {
+ // View Life Cycle
+ case onLoad
+
+ // User Action
+ case texFieldDidFocused(isFocused: Bool)
+ case profileSelectDidFatched(ProfileSelect)
+ case nextButtonDidTapped
+ case onTapGesture
+ case backButtonDidTapped
+
+ // Delegate
+ case delegate(Delegate)
+ case binding(BindingAction)
+
+ public enum Delegate {
+ case nextButtonDidTapped(introductionText: String)
+ }
+ }
+
+ public var body: some ReducerOf {
+ BindingReducer()
+ reducer
+ }
+}
+
+extension IntroductionSetupFeature {
+ public init() {
+ @Dependency(\.dismiss) var dismiss
+ let reducer = Reduce { state, action in
+ @Dependency(\.profileClient) var profileClient
+
+ switch action {
+ case .onLoad:
+ state.isLoading = true
+ return .run { send in
+ let profileSelect = try await profileClient.fetchProfileSelect()
+ await send(.profileSelectDidFatched(profileSelect))
+ }
+ case let .texFieldDidFocused(isFocused):
+ state.textFieldState = isFocused ? .focused : .active
+ return .none
+ case .profileSelectDidFatched(let profileSelect):
+ // TODO: 코드 개선
+ // TODO: 없으면 ClipItem nil로
+ state.keywordItem = [
+ ClipItem(
+ title: "내 키워드를 참고해보세요",
+ list: [profileSelect.job, profileSelect.mbti, "\(profileSelect.region.city) \(profileSelect.region.state)", "\(profileSelect.height)", profileSelect.smoke, profileSelect.alcohol]
+ ),
+
+ ClipItem(
+ title: "나의 성격은",
+ list: profileSelect.keyword
+ ),
+
+ ClipItem(
+ title: "내가 푹 빠진 취미는",
+ list: (profileSelect.interset.culture ?? [])
+ + (profileSelect.interset.entertainment ?? [])
+ + (profileSelect.interset.sports ?? [])
+ + (profileSelect.interset.etc ?? [])
+ )
+ ]
+ state.isLoading = false
+ return .none
+ case .binding(\.introductionText):
+ if state.introductionText.count >= state.maxLength {
+ state.textFieldState = .focused
+ state.isNextButtonDisable = false
+ } else {
+ state.textFieldState = .error
+ state.isNextButtonDisable = true
+ }
+ return .none
+
+ case .nextButtonDidTapped:
+ return .run { [introductionText = state.introductionText] send in
+ Log.debug("nextButtonDidTapped")
+ await send(.delegate(.nextButtonDidTapped(introductionText: introductionText)))
+ }
+ case .onTapGesture:
+ if state.introductionText.count == 0 {
+ state.textFieldState = .enabled
+ } else {
+ state.textFieldState = .active
+ }
+ return .none
+
+ case .backButtonDidTapped:
+ return .run { _ in
+ await dismiss()
+ }
+
+ case .binding(_):
+ return .none
+
+ case .delegate:
+ return .none
+ }
+ }
+ self.init(reducer: reducer)
+ }
+}
diff --git a/Projects/Feature/ProfileSetup/Interface/Sources/IntroductionSetup/IntroductionSetupView.swift b/Projects/Feature/ProfileSetup/Interface/Sources/IntroductionSetup/IntroductionSetupView.swift
new file mode 100644
index 00000000..272f7671
--- /dev/null
+++ b/Projects/Feature/ProfileSetup/Interface/Sources/IntroductionSetup/IntroductionSetupView.swift
@@ -0,0 +1,109 @@
+//
+// IntroductionSetupView.swift
+// FeatureProfileSetupInterface
+//
+// Created by 임현규 on 8/5/24.
+//
+
+import SwiftUI
+
+import SharedDesignSystem
+import CoreLoggerInterface
+
+import ComposableArchitecture
+
+public struct IntroductionSetupView: View {
+ @Perception.Bindable private var store: StoreOf
+ @FocusState private var isTextFieldFocused: Bool
+
+ public init(store: StoreOf) {
+ self.store = store
+ }
+
+ public var body: some View {
+ WithPerceptionTracking {
+ if store.isLoading {
+ LoadingIndicator()
+ } else {
+ ScrollView {
+ introductionTitle
+ introductionTextField
+ keywordList
+ nextButton
+ }.onTapGesture {
+ store.send(.onTapGesture)
+ }.setNavigationBar {
+ makeNaivgationleftButton {
+ store.send(.backButtonDidTapped)
+ }
+ }
+ }
+ }
+ .onLoad {
+ store.send(.onLoad)
+ }
+ .scrollIndicators(.hidden)
+ .ignoresSafeArea(.all, edges: .bottom)
+ .toolbar(.hidden, for: .bottomBar)
+ }
+}
+
+private extension IntroductionSetupView {
+ var introductionTitle: some View {
+ TitleView(
+ pageInfo: PageInfo(nowPage: 1, totalCount: 2),
+ title: "보틀에 담을\n소개를 작성해 주세요",
+ caption: "수정이 어려우니 신중하게 작성해주세요"
+ )
+ .padding(.top, .xl)
+ .padding(.bottom, 32)
+ .padding(.horizontal, .md)
+ }
+
+ var introductionTextField: some View {
+ LinesTextField(
+ textFieldType: .introduction,
+ textFieldState: $store.textFieldState,
+ text: $store.introductionText,
+ placeHolder: "호기심이 많고 새로운 경험을 즐깁니다. 주말엔 책을 읽거나 맛집을 찾아다니며 여유를 즐기고, 친구들과 소소한 모임으로 에너지를 충전해요.",
+ errorMessage: "최소 \(store.maxLength)글자 이상 작성해주세요",
+ textLimit: 300
+ )
+ .focused($isTextFieldFocused)
+ .padding(.horizontal, .md)
+ .padding(.bottom, .sm)
+ .onChange(of: isTextFieldFocused) { isFocused in
+ store.send(.texFieldDidFocused(isFocused: isFocused))
+ }
+ .onChange(of: store.textFieldState) { textFieldState in
+ Log.error(textFieldState)
+ isTextFieldFocused = textFieldState == .active || textFieldState == .enabled ? false : true
+ }
+ }
+
+ var keywordList: some View {
+ ClipListContainerView(
+ clipItemList: store.keywordItem
+ )
+ .padding(.horizontal, .md)
+ .padding(.bottom, 47)
+ }
+
+ var nextButton: some View {
+ SolidButton(
+ title: "다음",
+ sizeType: .full,
+ buttonType: .throttle,
+ action: { store.send(.nextButtonDidTapped) }
+ )
+ .padding(.horizontal, .md)
+ .padding(.bottom, .xl)
+ .disabled(store.isNextButtonDisable)
+ }
+}
+
+extension View {
+ func endTextEditing() {
+ UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
+ }
+}
diff --git a/Projects/Feature/ProfileSetup/Interface/Sources/ProfileImageUpload/ProfileImageUploadFeature.swift b/Projects/Feature/ProfileSetup/Interface/Sources/ProfileImageUpload/ProfileImageUploadFeature.swift
new file mode 100644
index 00000000..7162c692
--- /dev/null
+++ b/Projects/Feature/ProfileSetup/Interface/Sources/ProfileImageUpload/ProfileImageUploadFeature.swift
@@ -0,0 +1,92 @@
+//
+// ProfileImageUploadFeature.swift
+// FeatureProfileSetupInterface
+//
+// Created by 임현규 on 8/5/24.
+//
+
+import Foundation
+
+import CoreLoggerInterface
+
+import ComposableArchitecture
+
+@Reducer
+public struct ProfileImageUploadFeature {
+ private let reducer: Reduce
+
+ public init(reducer: Reduce) {
+ self.reducer = reducer
+ }
+
+ @ObservableState
+ public struct State: Equatable {
+ public var selectedImageData: Data
+ public var isDisableDoneButton: Bool
+
+ public init(
+ selectedImageData: Data = .init(),
+ isDisableDoneButton: Bool = true
+ ) {
+ self.selectedImageData = selectedImageData
+ self.isDisableDoneButton = isDisableDoneButton
+ }
+ }
+
+ public enum Action {
+ // View Life Cycle
+ case onAppear
+
+ // User Action
+ case doneButtonDidTapped
+ case imageDidSelected(selectedImageData: Data)
+ case imageDeleteButtonDidTapped
+ case backButtonDidTapped
+
+ // Delegate
+ case delegate(Delegate)
+
+ public enum Delegate {
+ case doneButtonDidTapped(selectedImageData: Data)
+ }
+ }
+
+ public var body: some ReducerOf {
+ reducer
+ }
+}
+
+extension ProfileImageUploadFeature {
+ public init() {
+ let reducer = Reduce { state, action in
+ @Dependency(\.dismiss) var dismiss
+
+ switch action {
+ case .onAppear:
+ return .none
+ case .doneButtonDidTapped:
+ return .run { [imageData = state.selectedImageData] send in
+ await send(.delegate(.doneButtonDidTapped(selectedImageData: imageData)))
+ }
+ case let .imageDidSelected(selectedImageData):
+ state.selectedImageData = selectedImageData
+ state.isDisableDoneButton = false
+ return .none
+
+ case .imageDeleteButtonDidTapped:
+ state.selectedImageData = .empty
+ state.isDisableDoneButton = true
+ return .none
+
+ case .backButtonDidTapped:
+ return .run { _ in
+ await dismiss()
+ }
+ case .delegate:
+ return .none
+ }
+ }
+
+ self.init(reducer: reducer)
+ }
+}
diff --git a/Projects/Feature/ProfileSetup/Interface/Sources/ProfileImageUpload/ProfileImageUploadView.swift b/Projects/Feature/ProfileSetup/Interface/Sources/ProfileImageUpload/ProfileImageUploadView.swift
new file mode 100644
index 00000000..2c2cd105
--- /dev/null
+++ b/Projects/Feature/ProfileSetup/Interface/Sources/ProfileImageUpload/ProfileImageUploadView.swift
@@ -0,0 +1,109 @@
+//
+// ProfileImageUploadView.swift
+// FeatureProfileSetupInterface
+//
+// Created by 임현규 on 8/5/24.
+//
+
+import SwiftUI
+import PhotosUI
+
+import SharedDesignSystem
+
+import ComposableArchitecture
+
+public struct ProfileImageUploadView: View {
+ @State private var selectedItems: [PhotosPickerItem] = []
+ @State private var selectedImage: [UIImage] = []
+
+ private var store: StoreOf
+
+ public init(store: StoreOf) {
+ self.store = store
+ }
+
+ public var body: some View {
+ ScrollView {
+ VStack(spacing: 0.0) {
+ titleView
+
+ PhotosPicker(
+ selection: $selectedItems,
+ maxSelectionCount: 1,
+ matching: .images
+ ) {
+ imagePickerButton
+ .frame(height: UIScreen.main.bounds.width - 16.0 * 2)
+ .padding(.bottom, .md)
+ }
+ .onChange(of: selectedItems) { item in
+ handleSelectedPhotos(item)
+ }
+
+ doneButton
+ }
+ .padding(.bottom, .xl)
+ }
+ .padding(.horizontal, .md)
+ .setNavigationBar {
+ makeNaivgationleftButton {
+ store.send(.backButtonDidTapped)
+ }
+ }
+ .scrollIndicators(.hidden)
+ .toolbar(.hidden, for: .bottomBar)
+ }
+}
+
+private extension ProfileImageUploadView {
+ var titleView: some View {
+ TitleView(
+ pageInfo: PageInfo(nowPage: 2, totalCount: 2),
+ title: "보틀에 담을 나의\n사진을 골라주세요",
+ caption: "가치관 문답을 마친 후 동의 하에 공개돼요"
+ )
+ .padding(.top, .xl)
+ .padding(.bottom, 32)
+ }
+
+ var imagePickerButton: some View {
+ ImagePickerButton(
+ selectedImage: $selectedImage,
+ action: { store.send(.imageDeleteButtonDidTapped) }
+ )
+ }
+
+ var doneButton: some View {
+ SolidButton(
+ title: "완료",
+ sizeType: .full,
+ buttonType: .throttle,
+ action: { store.send(.doneButtonDidTapped) }
+ )
+ .disabled(store.isDisableDoneButton)
+ }
+}
+
+private extension ProfileImageUploadView {
+ func handleSelectedPhotos(_ newPhotos: [PhotosPickerItem]) {
+ for newPhoto in newPhotos {
+ newPhoto.loadTransferable(type: Data.self) { result in
+ switch result {
+ case .success(let data):
+ if let data = data, let newImage = UIImage(data: data) {
+ DispatchQueue.main.async {
+ selectedImage = [newImage]
+ let compressData = newImage.compressImageData()
+ store.send(.imageDidSelected(selectedImageData: compressData ?? .init()))
+ }
+ }
+
+ // TODO: 이미지 로드 실패 처리
+ case .failure(_):
+ return
+ }
+ }
+ }
+ }
+}
+
diff --git a/Projects/Feature/ProfileSetup/Project.swift b/Projects/Feature/ProfileSetup/Project.swift
new file mode 100644
index 00000000..6a0b52d9
--- /dev/null
+++ b/Projects/Feature/ProfileSetup/Project.swift
@@ -0,0 +1,54 @@
+import ProjectDescription
+import ProjectDescriptionHelpers
+import DependencyPlugin
+
+let project = Project.makeModule(
+ name: ModulePath.Feature.name+ModulePath.Feature.ProfileSetup.rawValue,
+ targets: [
+ .feature(
+ interface: .ProfileSetup,
+ factory: .init(
+ dependencies: [
+ .domain
+ ]
+ )
+ ),
+ .feature(
+ implements: .ProfileSetup,
+ factory: .init(
+ dependencies: [
+ .feature(interface: .ProfileSetup)
+ ]
+ )
+ ),
+
+ .feature(
+ testing: .ProfileSetup,
+ factory: .init(
+ dependencies: [
+ .feature(interface: .ProfileSetup)
+ ]
+ )
+ ),
+ .feature(
+ tests: .ProfileSetup,
+ factory: .init(
+ dependencies: [
+ .feature(testing: .ProfileSetup),
+ .feature(implements: .ProfileSetup)
+ ]
+ )
+ ),
+
+ .feature(
+ example: .ProfileSetup,
+ factory: .init(
+ dependencies: [
+ .feature(testing: .ProfileSetup),
+ .feature(implements: .ProfileSetup)
+ ]
+ )
+ )
+
+ ]
+)
diff --git a/Projects/Feature/ProfileSetup/Sources/Sources.swift b/Projects/Feature/ProfileSetup/Sources/Sources.swift
new file mode 100644
index 00000000..1ef8cc51
--- /dev/null
+++ b/Projects/Feature/ProfileSetup/Sources/Sources.swift
@@ -0,0 +1,8 @@
+//
+// Sources.swift
+// FeatureProfileSetup
+//
+// Created by 임현규 on 8/5/24.
+//
+
+import Foundation
diff --git a/Projects/Feature/ProfileSetup/Testing/Sources/ProfileSetupTesting.swift b/Projects/Feature/ProfileSetup/Testing/Sources/ProfileSetupTesting.swift
new file mode 100644
index 00000000..b1853ce6
--- /dev/null
+++ b/Projects/Feature/ProfileSetup/Testing/Sources/ProfileSetupTesting.swift
@@ -0,0 +1 @@
+// This is for Tuist
diff --git a/Projects/Feature/ProfileSetup/Tests/Sources/ProfileSetupTest.swift b/Projects/Feature/ProfileSetup/Tests/Sources/ProfileSetupTest.swift
new file mode 100644
index 00000000..a5b77e73
--- /dev/null
+++ b/Projects/Feature/ProfileSetup/Tests/Sources/ProfileSetupTest.swift
@@ -0,0 +1,11 @@
+import XCTest
+
+final class ProfileSetupTests: XCTestCase {
+ override func setUpWithError() throws {}
+
+ override func tearDownWithError() throws {}
+
+ func testExample() {
+ XCTAssertEqual(1, 1)
+ }
+}
diff --git a/Projects/Feature/Project.swift b/Projects/Feature/Project.swift
new file mode 100644
index 00000000..8d09779e
--- /dev/null
+++ b/Projects/Feature/Project.swift
@@ -0,0 +1,27 @@
+//
+// Project.swift
+// AppManifests
+//
+// Created by 임현규 on 7/19/24.
+//
+
+import ProjectDescription
+import ProjectDescriptionHelpers
+import DependencyPlugin
+
+let targets: [Target] = [
+ .feature(factory: .init(
+ product: .staticFramework,
+ sources: .sources,
+ dependencies: [
+
+ ] + ModulePath.Feature.allCases.map {
+ .feature(implements: $0)
+ }
+ ))
+]
+
+let project = Project.makeModule(
+ name: "Feature",
+ targets: targets
+)
diff --git a/Projects/Feature/Report/Example/Sources/AppView.swift b/Projects/Feature/Report/Example/Sources/AppView.swift
new file mode 100644
index 00000000..3ccaab20
--- /dev/null
+++ b/Projects/Feature/Report/Example/Sources/AppView.swift
@@ -0,0 +1,21 @@
+import SwiftUI
+
+import FeatureReportInterface
+
+import ComposableArchitecture
+
+@main
+struct AppView: App {
+ let store = Store(
+ initialState: ReportUserFeature.State(
+ userProfile: .init(imageURL: "", userID: "", userName: "", userAge: 2)
+ ),
+ reducer: { ReportUserFeature() }
+ )
+ var body: some Scene {
+ WindowGroup {
+ ReportUserView(store: store)
+ }
+ }
+}
+
diff --git a/Projects/Feature/Report/Interface/Sources/ReportUserFeature.swift b/Projects/Feature/Report/Interface/Sources/ReportUserFeature.swift
new file mode 100644
index 00000000..9a008b48
--- /dev/null
+++ b/Projects/Feature/Report/Interface/Sources/ReportUserFeature.swift
@@ -0,0 +1,65 @@
+//
+// ReportUserFeature.swift
+// FeatureReportInterface
+//
+// Created by 임현규 on 8/12/24.
+//
+
+import Foundation
+
+import DomainReport
+import SharedDesignSystem
+
+import ComposableArchitecture
+
+extension ReportUserFeature {
+ public init() {
+ let reducer = Reduce { state, action in
+ @Dependency(\.reportClient) var reportClient
+
+ switch action {
+ case .onAppear:
+ return .none
+ case let .texFieldDidFocused(isFocused):
+ state.textFieldState = isFocused ? .focused : .active
+ return .none
+
+ case .doneButtonDidTapped:
+ print("tapped")
+ state.destination = .alert(.init(
+ title: { TextState("신고하기")},
+ actions: { ButtonState(
+ role: .destructive,
+ action: .confirmReport,
+ label: { TextState("신고하기") }) },
+ message: { TextState("접수 후 취소할 수 없으며 해당 사용자는 차단되요.\n정말 신고하시겠어요?")}))
+ return .none
+
+ case .onTapGesture:
+ if state.reportText.isEmpty {
+ state.textFieldState = .enabled
+ } else {
+ state.textFieldState = .active
+ }
+ return .none
+
+ case .destination(.presented(.alert(.confirmReport))):
+ return .run { [userProfile = state.userProfile, reportText = state.reportText] send in
+ try await reportClient.reportUser(userReportInfo: .init(reason: reportText, userId: userProfile.userID))
+ await send(.delegate(.reportDidCompleted))
+ }
+
+ case .binding(\.reportText):
+ state.isDisableDoneButton = state.reportText.isEmpty
+ return .none
+
+ case .backButtonDidTapped:
+ return .send(.delegate(.backButtonDidTapped))
+
+ default:
+ return .none
+ }
+ }
+ self.init(reducer: reducer)
+ }
+}
diff --git a/Projects/Feature/Report/Interface/Sources/ReportUserFeatureInterface.swift b/Projects/Feature/Report/Interface/Sources/ReportUserFeatureInterface.swift
new file mode 100644
index 00000000..9c8b487f
--- /dev/null
+++ b/Projects/Feature/Report/Interface/Sources/ReportUserFeatureInterface.swift
@@ -0,0 +1,85 @@
+//
+// ReportUserFeatureInterface.swift
+// FeatureReportInterface
+//
+// Created by 임현규 on 8/12/24.
+//
+
+import Foundation
+
+import SharedDesignSystem
+
+import ComposableArchitecture
+
+@Reducer
+public struct ReportUserFeature {
+ private let reducer: Reduce
+
+ public init(reducer: Reduce) {
+ self.reducer = reducer
+ }
+
+ // Desination
+ @Reducer(state: .equatable)
+ public enum Destination {
+ case alert(AlertState)
+ }
+
+ // State
+ @ObservableState
+ public struct State: Equatable {
+ public var reportText: String
+ public var userProfile: UserReportProfile
+ public var textFieldState: TextFieldState
+ public var isDisableDoneButton: Bool
+
+ @Presents var destination: Destination.State?
+
+ public init(
+ reportText: String = "",
+ userProfile: UserReportProfile,
+ textFieldState: TextFieldState = .enabled,
+ isDisableDoneButton: Bool = true
+ ) {
+ self.reportText = reportText
+ self.userProfile = userProfile
+ self.textFieldState = textFieldState
+ self.isDisableDoneButton = isDisableDoneButton
+ }
+ }
+
+ // Action
+ public enum Action: BindableAction {
+ // View Life Cycle
+ case onAppear
+
+ // User Action
+ case texFieldDidFocused(isFocused: Bool)
+ case onTapGesture
+ case backButtonDidTapped
+ case doneButtonDidTapped
+
+ // Delegate
+ case delegate(Delegate)
+ public enum Delegate {
+ case reportDidCompleted
+ case backButtonDidTapped
+ }
+
+ // Alert
+ case alert(Alert)
+ public enum Alert: Equatable {
+ case confirmReport
+ }
+
+ // ETC
+ case destination(PresentationAction)
+ case binding(BindingAction)
+ }
+
+ public var body: some ReducerOf {
+ BindingReducer()
+ reducer
+ .ifLet(\.$destination, action: \.destination)
+ }
+}
diff --git a/Projects/Feature/Report/Interface/Sources/ReportUserView.swift b/Projects/Feature/Report/Interface/Sources/ReportUserView.swift
new file mode 100644
index 00000000..f94a02b2
--- /dev/null
+++ b/Projects/Feature/Report/Interface/Sources/ReportUserView.swift
@@ -0,0 +1,83 @@
+//
+// ReportUserView.swift
+// FeatureReportInterface
+//
+// Created by 임현규 on 8/12/24.
+//
+
+import SwiftUI
+
+import SharedDesignSystem
+
+import ComposableArchitecture
+
+public struct ReportUserView: View {
+ @Perception.Bindable private var store: StoreOf
+ @FocusState private var isTextFieldFocused: Bool
+
+ public init(store: StoreOf) {
+ self.store = store
+ }
+
+ public var body: some View {
+ WithPerceptionTracking {
+ VStack(spacing: .xl) {
+ title
+ userProfile
+ reasoneTextField
+ Spacer()
+ doneButton
+ }
+ .onTapEndEditing()
+ .setNavigationBar {
+ makeNaivgationleftButton() {
+ store.send(.backButtonDidTapped)
+ }
+ }
+ .padding(.horizontal, .md)
+ .alert($store.scope(state: \.destination?.alert, action: \.destination.alert))
+ .toolbar(.hidden, for: .bottomBar)
+ }
+ }
+}
+
+// MARK: - Views
+private extension ReportUserView {
+ var title: some View {
+ TitleView(title: "신고 사유를 간단하게\n작성해주세요")
+ }
+
+ var userProfile: some View {
+ UserProfileView(
+ imageURL: store.userProfile.imageURL,
+ userName: store.userProfile.userName,
+ userAge: store.userProfile.userAge
+ )
+ }
+ var doneButton: some View {
+ SolidButton(
+ title: "완료",
+ sizeType: .large,
+ buttonType: .throttle
+ ) {
+ store.send(.doneButtonDidTapped)
+ }
+ .disabled(store.isDisableDoneButton)
+ .padding(.bottom, .lg)
+ }
+
+ var reasoneTextField: some View {
+ LineTextField(
+ textFieldState: $store.textFieldState,
+ text: $store.reportText,
+ placeHolder: "예) 욕설을 사용했습니다."
+ )
+ .focused($isTextFieldFocused)
+ .onChange(of: isTextFieldFocused) { isFocused in
+ store.send(.texFieldDidFocused(isFocused: isFocused))
+ }
+ .onChange(of: store.textFieldState) { textFieldState in
+ isTextFieldFocused = textFieldState == .active || textFieldState == .enabled ? false : true
+ }
+ }
+}
diff --git a/Projects/Feature/Report/Interface/Sources/UserReportProfile.swift b/Projects/Feature/Report/Interface/Sources/UserReportProfile.swift
new file mode 100644
index 00000000..62c6a51d
--- /dev/null
+++ b/Projects/Feature/Report/Interface/Sources/UserReportProfile.swift
@@ -0,0 +1,27 @@
+//
+// UserReportProfile.swift
+// FeatureReportInterface
+//
+// Created by 임현규 on 8/12/24.
+//
+
+import Foundation
+
+public struct UserReportProfile: Equatable {
+ var imageURL: String
+ var userID: Int
+ var userName: String
+ var userAge: Int
+
+ public init(
+ imageURL: String,
+ userID: Int,
+ userName: String,
+ userAge: Int
+ ) {
+ self.imageURL = imageURL
+ self.userID = userID
+ self.userName = userName
+ self.userAge = userAge
+ }
+}
diff --git a/Projects/Feature/Report/Project.swift b/Projects/Feature/Report/Project.swift
new file mode 100644
index 00000000..b3acc42b
--- /dev/null
+++ b/Projects/Feature/Report/Project.swift
@@ -0,0 +1,54 @@
+import ProjectDescription
+import ProjectDescriptionHelpers
+import DependencyPlugin
+
+let project = Project.makeModule(
+ name: ModulePath.Feature.name+ModulePath.Feature.Report.rawValue,
+ targets: [
+ .feature(
+ interface: .Report,
+ factory: .init(
+ dependencies: [
+ .domain
+ ]
+ )
+ ),
+ .feature(
+ implements: .Report,
+ factory: .init(
+ dependencies: [
+ .feature(interface: .Report)
+ ]
+ )
+ ),
+
+ .feature(
+ testing: .Report,
+ factory: .init(
+ dependencies: [
+ .feature(interface: .Report)
+ ]
+ )
+ ),
+ .feature(
+ tests: .Report,
+ factory: .init(
+ dependencies: [
+ .feature(testing: .Report),
+ .feature(implements: .Report)
+ ]
+ )
+ ),
+
+ .feature(
+ example: .Report,
+ factory: .init(
+ dependencies: [
+ .feature(testing: .Report),
+ .feature(implements: .Report)
+ ]
+ )
+ )
+
+ ]
+)
diff --git a/Projects/Feature/Report/Sources/Source.swift b/Projects/Feature/Report/Sources/Source.swift
new file mode 100644
index 00000000..b1853ce6
--- /dev/null
+++ b/Projects/Feature/Report/Sources/Source.swift
@@ -0,0 +1 @@
+// This is for Tuist
diff --git a/Projects/Feature/Report/Testing/Sources/ReportTesting.swift b/Projects/Feature/Report/Testing/Sources/ReportTesting.swift
new file mode 100644
index 00000000..b1853ce6
--- /dev/null
+++ b/Projects/Feature/Report/Testing/Sources/ReportTesting.swift
@@ -0,0 +1 @@
+// This is for Tuist
diff --git a/Projects/Feature/Report/Tests/Sources/ReportTest.swift b/Projects/Feature/Report/Tests/Sources/ReportTest.swift
new file mode 100644
index 00000000..2fc66850
--- /dev/null
+++ b/Projects/Feature/Report/Tests/Sources/ReportTest.swift
@@ -0,0 +1,11 @@
+import XCTest
+
+final class ReportTests: XCTestCase {
+ override func setUpWithError() throws {}
+
+ override func tearDownWithError() throws {}
+
+ func testExample() {
+ XCTAssertEqual(1, 1)
+ }
+}
diff --git a/Projects/Feature/SandBeach/Example/Sources/AppView.swift b/Projects/Feature/SandBeach/Example/Sources/AppView.swift
new file mode 100644
index 00000000..3d23807c
--- /dev/null
+++ b/Projects/Feature/SandBeach/Example/Sources/AppView.swift
@@ -0,0 +1,20 @@
+import SwiftUI
+
+import FeatureSandBeachInterface
+import FeatureSandBeach
+
+import ComposableArchitecture
+
+@main
+struct AppView: App {
+ private let rootStore = Store(
+ initialState: SandBeachRootFeature.State(
+ sandBeach: .init(userState: .)),
+ reducer: { SandBeachRootFeature() })
+
+ var body: some Scene {
+ WindowGroup {
+ SandBeachRootView(store: rootStore)
+ }
+ }
+}
diff --git a/Projects/Feature/SandBeach/Interface/Sources/Root/SandBeachRootFeature.swift b/Projects/Feature/SandBeach/Interface/Sources/Root/SandBeachRootFeature.swift
new file mode 100644
index 00000000..0bd62c9a
--- /dev/null
+++ b/Projects/Feature/SandBeach/Interface/Sources/Root/SandBeachRootFeature.swift
@@ -0,0 +1,162 @@
+//
+// SandBeachRootFeature.swift
+// FeatureSandBeachInterface
+//
+// Created by 임현규 on 8/6/24.
+//
+
+import Foundation
+
+import FeatureProfileSetupInterface
+import FeatureBottleArrivalInterface
+import FeatureTabBarInterface
+import DomainProfile
+
+import ComposableArchitecture
+
+@Reducer
+public struct SandBeachRootFeature {
+ @Dependency(\.dismiss) var dismiss
+ private let reducer: Reduce
+
+ public init(reducer: Reduce) {
+ self.reducer = reducer
+ }
+
+
+ @Reducer(state: .equatable)
+ public enum Path {
+ case IntroductionSetup(IntroductionSetupFeature)
+ case ProfileImageUpload(ProfileImageUploadFeature)
+ case BottleArrival(BottleArrivalFeature)
+ }
+
+ @ObservableState
+ public struct State: Equatable {
+ var path = StackState()
+ var introduction: String
+ var profileImageData: Data
+ var isLoading: Bool
+ public var sandBeach: SandBeachFeature.State
+
+ public init(
+ path: StackState = StackState(),
+ introduction: String = "",
+ profileImageData: Data = .init(),
+ sandBeach: SandBeachFeature.State = .init(),
+ isLoading: Bool = false
+ ) {
+ self.path = path
+ self.introduction = introduction
+ self.profileImageData = profileImageData
+ self.sandBeach = sandBeach
+ self.isLoading = isLoading
+ }
+ }
+
+ public enum Action {
+ case path(StackAction)
+ case sandBeach(SandBeachFeature.Action)
+ case profileSetupDidCompleted
+ case delegate(Delegate)
+ case selectedTabDidChanged(selectedTab: TabType)
+
+ public enum Delegate {
+ case goToBottleStorageRequest
+ case selectedTabDidChanged(selectedTab: TabType)
+ case profileSetUpDidCompleted
+ }
+ }
+
+ public var body: some ReducerOf {
+ Scope(state: \.sandBeach, action: \.sandBeach) {
+ SandBeachFeature()
+ }
+
+ reducer
+ .forEach(\.path, action: \.path)
+ }
+}
+
+extension SandBeachRootFeature {
+ public init() {
+
+ let reducer = Reduce { state, action in
+ @Dependency(\.profileClient) var profileClient
+
+ switch action {
+
+ // IntrodctionSetup Delegate
+ case let .path(.element(id: _, action:
+ .IntroductionSetup(.delegate(.nextButtonDidTapped(introductionText))))):
+ state.introduction = introductionText
+ state.path.append(.ProfileImageUpload(ProfileImageUploadFeature.State()))
+ return .none
+
+ // ProfileImageUpload Delegate
+ case let .path(.element(id: _, action:
+ .ProfileImageUpload(.delegate(.doneButtonDidTapped(selectedImageData))))):
+ state.profileImageData = selectedImageData
+ state.isLoading = true
+ return .run { [introduction = state.introduction, imageData = state.profileImageData] send in
+ // TODO: - 병렬처리 임시 중단 추후 처리
+// try await withThrowingTaskGroup(of: Void.self) { group in
+// group.addTask {
+// try await profileClient.registerIntroduction(answer: introduction)
+// }
+//
+// group.addTask {
+// try await profileClient.uploadProfileImage(imageData: imageData)
+// }
+// for try await _ in group {}
+ try await profileClient.registerIntroduction(answer: introduction)
+ try await profileClient.uploadProfileImage(imageData: imageData)
+ await send(.profileSetupDidCompleted)
+
+ } catch: { error, send in
+ // TODO: 자기소개 만들기 완료 실패 - 에러 핸들링
+ }
+
+ // BottleArrival Delegate
+ case let .path(.element(id: _, action: .BottleArrival(.delegate(delegate)))):
+ switch delegate {
+ case .bottelDidAccepted:
+ state.path.removeLast()
+ return .send(.delegate(.goToBottleStorageRequest))
+
+ case .closeWebView:
+ state.path.removeLast()
+ return .none
+ }
+
+ // SandBeach Delegate
+ case let .sandBeach(.delegate(delegate)):
+ switch delegate {
+ case .bottleStorageIslandDidTapped:
+ return .send(.delegate(.goToBottleStorageRequest))
+
+ case .newBottleIslandDidTapped:
+ state.path.append(.BottleArrival(BottleArrivalFeature.State()))
+ return .none
+
+ case .writeButtonDidTapped:
+ state.path.append(.IntroductionSetup(IntroductionSetupFeature.State()))
+ return .none
+ }
+
+ case .profileSetupDidCompleted:
+ state.isLoading = false
+ state.path.removeAll()
+ return .send(.delegate(.profileSetUpDidCompleted))
+
+ case let .selectedTabDidChanged(selectedTab):
+ return .send(.delegate(.selectedTabDidChanged(selectedTab: selectedTab)))
+ default:
+ // TODO: 네트워크 에러 처리.
+ return .none
+ }
+ }
+
+ self.init(reducer: reducer)
+ }
+}
diff --git a/Projects/Feature/SandBeach/Interface/Sources/Root/SandBeachRootView.swift b/Projects/Feature/SandBeach/Interface/Sources/Root/SandBeachRootView.swift
new file mode 100644
index 00000000..2d974631
--- /dev/null
+++ b/Projects/Feature/SandBeach/Interface/Sources/Root/SandBeachRootView.swift
@@ -0,0 +1,65 @@
+//
+// SandBeachRootView.swift
+// FeatureSandBeach
+//
+// Created by 임현규 on 8/6/24.
+//
+
+import SwiftUI
+
+import FeatureProfileSetupInterface
+import FeatureBottleArrivalInterface
+import CoreLoggerInterface
+import FeatureTabBarInterface
+import SharedDesignSystem
+
+import ComposableArchitecture
+
+public struct SandBeachRootView: View {
+ @Perception.Bindable private var store: StoreOf
+
+ public init(store: StoreOf) {
+ self.store = store
+ }
+
+ public var body: some View {
+ WithPerceptionTracking {
+ NavigationStack(path: $store.scope(state: \.path, action: \.path)) {
+ SandBeachView(store: store.scope(state: \.sandBeach, action: \.sandBeach))
+ .setTabBar(selectedTab: .sandBeach) { selectedTab in
+ store.send(.selectedTabDidChanged(selectedTab: selectedTab))
+ }
+ } destination: { store in
+ WithPerceptionTracking {
+ switch store.state {
+ case .IntroductionSetup:
+ if let store = store.scope(
+ state: \.IntroductionSetup,
+ action: \.IntroductionSetup) {
+ IntroductionSetupView(store: store)
+ }
+
+ case .ProfileImageUpload:
+ if let store = store.scope(
+ state: \.ProfileImageUpload,
+ action: \.ProfileImageUpload) {
+ ProfileImageUploadView(store: store)
+ }
+
+ case .BottleArrival:
+ if let store = store.scope(
+ state: \.BottleArrival,
+ action: \.BottleArrival) {
+ BottleArrivalView(store: store)
+ }
+ }
+ }
+ }
+ }
+ .overlay {
+ if store.isLoading {
+ LoadingIndicator()
+ }
+ }
+ }
+}
diff --git a/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachFeatureInterface.swift b/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachFeatureInterface.swift
new file mode 100644
index 00000000..9970c8f1
--- /dev/null
+++ b/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachFeatureInterface.swift
@@ -0,0 +1,146 @@
+//
+// SandBeachFeatureInterface.swift
+// FeatureSandBeach
+//
+// Created by JongHoon on 7/25/24.
+//
+
+import Foundation
+
+import SharedDesignSystem
+import CoreLoggerInterface
+import DomainProfile
+import DomainBottle
+import SharedUtilInterface
+
+import ComposableArchitecture
+import FirebaseMessaging
+
+@Reducer
+public struct SandBeachFeature {
+ private let reducer: Reduce
+
+ public init(reducer: Reduce) {
+ self.reducer = reducer
+ }
+
+ @ObservableState
+ public struct State: Equatable {
+ public var userState: UserStateType = .none
+ public var isLoading: Bool = false
+ public var isDisableIslandBottle: Bool = false
+
+ public init() {}
+ }
+
+ public enum Action: Equatable {
+ case onAppear
+ case userStateFetchCompleted(userState: UserStateType, isDisableButton: Bool)
+ case updateIsDisableBottle(isDisable: Bool)
+ case writeButtonDidTapped
+ case newBottleIslandDidTapped
+ case bottleStorageIslandDidTapped
+ case delegate(Delegate)
+
+ public enum Delegate {
+ case writeButtonDidTapped
+ case newBottleIslandDidTapped
+ case bottleStorageIslandDidTapped
+ }
+ }
+
+ public var body: some ReducerOf {
+ reducer
+ }
+}
+
+// MARK: - init {
+extension SandBeachFeature {
+ public init() {
+ @Dependency(\.profileClient) var profileClient
+ @Dependency(\.bottleClient) var bottleClient
+
+ let reducer = Reduce { state, action in
+ switch action {
+ case .onAppear:
+ state.isLoading = true
+ let authOptions: UNAuthorizationOptions = [
+ .alert,
+ .badge,
+ .sound
+ ]
+ UNUserNotificationCenter.current().requestAuthorization(
+ options: authOptions,
+ completionHandler: { _, error in
+ if let error = error {
+ Log.error(error)
+ }
+ })
+
+ return .run { send in
+ let isExsit = try await profileClient.checkExistIntroduction()
+ // 자기소개 없는 상태
+ if !isExsit {
+ await send(.userStateFetchCompleted(
+ userState: .noIntroduction,
+ isDisableButton: true))
+ return
+ }
+
+ let userBottleInfo = try await bottleClient.fetchUserBottleInfo()
+ let newBottlesCount = userBottleInfo.randomBottleCount + userBottleInfo.sendBottleCount
+ // 새로 도착한 보틀이 있는 상태
+
+ if newBottlesCount > 0 {
+ await send(.userStateFetchCompleted(
+ userState: .hasNewBottle(bottleCount: newBottlesCount),
+ isDisableButton: false)
+ )
+ } else {
+ let bottlesStorageList = try await bottleClient.fetchBottleStorageList()
+ let activeBottlesCount = bottlesStorageList.activeBottles.count
+
+ // 자기소개만 작성한 상태
+ if activeBottlesCount <= 0 {
+ // TODO: time 설정
+ let nextBottleLeftHours = userBottleInfo.nextBottlLeftHours
+ await send(.userStateFetchCompleted(
+ userState: .noBottle(time: nextBottleLeftHours ?? 0),
+ isDisableButton: true)
+ )
+ } else { // 대화 중인 보틀이 있는 상태
+ await send(.userStateFetchCompleted(
+ userState: .hasActiveBottle(bottleCount: activeBottlesCount),
+ isDisableButton: false)
+ )
+ }
+ }
+ } catch: { error, send in
+ // TODO: 에러 핸들링
+ Log.error(error)
+ }
+
+ case let .userStateFetchCompleted(userState, isDisableButton):
+ state.userState = userState
+ state.isDisableIslandBottle = isDisableButton
+ state.isLoading = false
+ return .none
+
+ case .writeButtonDidTapped:
+ return .send(.delegate(.writeButtonDidTapped))
+
+ case .bottleStorageIslandDidTapped:
+ Log.debug("bottleStorageIslandDidTapped")
+ return .send(.delegate(.bottleStorageIslandDidTapped))
+
+ case .newBottleIslandDidTapped:
+ Log.debug("newBottleIslandDidTapped")
+ return .send(.delegate(.newBottleIslandDidTapped))
+
+ default:
+ return .none
+ }
+ }
+ self.init(reducer: reducer)
+ }
+}
diff --git a/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachView.swift b/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachView.swift
new file mode 100644
index 00000000..e5e1d3cf
--- /dev/null
+++ b/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachView.swift
@@ -0,0 +1,115 @@
+//
+// SandBeachView.swift
+// FeatureSandBeach
+//
+// Created by JongHoon on 7/25/24.
+//
+
+import SwiftUI
+
+import SharedDesignSystem
+
+import ComposableArchitecture
+
+public struct SandBeachView: View {
+ private let store: StoreOf
+
+ public init(store: StoreOf) {
+ self.store = store
+ }
+
+ public var body: some View {
+ WithPerceptionTracking {
+ GeometryReader { geo in
+ WithPerceptionTracking {
+ if store.userState == .none && store.isLoading {
+ LoadingIndicator()
+ } else {
+ VStack(spacing: 0) {
+ Spacer()
+ BottleImageView(type: .local(bottleImageSystem: .illustraition(.logo)))
+ .frame(width: 78.06, height: 20)
+ .padding(.top, geo.safeAreaInsets.top + 14)
+ .padding(.bottom, 38)
+
+ WantedSansStyleText(
+ store.userState.title, style: .title1, color: .secondary)
+ .frame(height: 62)
+ .multilineTextAlignment(.center)
+ .padding(.bottom, 24)
+ Spacer()
+
+ popup
+ .padding(.bottom, 8)
+
+ BottleImageView(type: .local(
+ bottleImageSystem:
+ store.userState.isEmptyBottle ? .illustraition(.islandEmptyBottle) : .illustraition(.islandHasBottle))
+ )
+ .frame(width: geo.size.width)
+ .frame(height: geo.size.width)
+ .asThrottleButton {
+ if store.userState.isHasNewBottle {
+ store.send(.newBottleIslandDidTapped)
+ }
+
+ if store.userState.isHasActiveBottle {
+ store.send(.bottleStorageIslandDidTapped)
+ }
+ }
+ .disabled(store.isDisableIslandBottle)
+
+ Spacer()
+ }
+ }
+ }
+ }
+ .onAppear {
+ store.send(.onAppear)
+ }
+ .background {
+ BottleImageView(
+ type: .local(bottleImageSystem: .illustraition(.sandBeachBackground))
+ )
+ }
+ }
+ .edgesIgnoringSafeArea([.top, .bottom])
+ }
+}
+
+// MARK: - Views
+
+public extension SandBeachView {
+ @ViewBuilder
+ var popup: some View {
+ let userState = store.userState
+
+ switch userState {
+ case .noIntroduction:
+ PopupView(
+ popupType: .button(
+ content: userState.popUpText,
+ buttonTitle: userState.buttonText ?? ""),
+ action: { store.send(.writeButtonDidTapped) })
+ case .noBottle:
+ PopupView(
+ popupType: .text(content: userState.popUpText)
+ )
+ case .hasNewBottle(let count):
+ PopupView(
+ popupType: .text(content: userState.popUpText)
+ )
+ .overlay(alignment: .topTrailing) {
+ CountLabel(text: "\(count)")
+ .offset(y: -12)
+ }
+ case .hasActiveBottle:
+ PopupView(
+ popupType: .text(content: userState.popUpText)
+ )
+ default:
+ EmptyView()
+ }
+ }
+}
+
diff --git a/Projects/Feature/SandBeach/Interface/Sources/SandBeach/UserStateType.swift b/Projects/Feature/SandBeach/Interface/Sources/SandBeach/UserStateType.swift
new file mode 100644
index 00000000..daaa2f6d
--- /dev/null
+++ b/Projects/Feature/SandBeach/Interface/Sources/SandBeach/UserStateType.swift
@@ -0,0 +1,67 @@
+//
+// UserStateType.swift
+// FeatureSandBeachInterface
+//
+// Created by 임현규 on 8/10/24.
+//
+
+import Foundation
+
+public extension SandBeachFeature {
+ enum UserStateType: Equatable {
+ /// 자기소개 작성 X
+ case noIntroduction
+ /// 도착한 보틀 X
+ case noBottle(time: Int)
+ /// 새로 도착한 보틀 O
+ case hasNewBottle(bottleCount: Int)
+ /// 확인한 보틀 O
+ case hasActiveBottle(bottleCount: Int)
+ /// 아무 상태도 아님
+ case none
+
+ var title: String {
+ switch self {
+ case .noIntroduction: return "보틀에 오신 것을\n환영해요!"
+ case .noBottle: return "아직 보틀을\n찾지 못했어요"
+ case .hasNewBottle(let bottleCount): return "\(bottleCount)개의 새로운\n보틀이 도착했어요"
+ case .hasActiveBottle(let bottleCount): return "\(bottleCount)개의 보틀이\n남아있어요"
+ case .none: return ""
+ }
+ }
+ var popUpText: String {
+ switch self {
+ case .noIntroduction: return "자기소개 작성 후 매칭을 받을 수 있어요"
+ case .noBottle(let time): return time == 0 ? "보틀이 곧 도착해요": "\(time)시간 후 새로운 보틀이 도착해요"
+ case .hasNewBottle: return "보틀을 클릭해보세요"
+ case .hasActiveBottle: return "보틀을 클릭해보세요"
+ case .none: return ""
+ }
+ }
+
+ var buttonText: String? {
+ switch self {
+ case .noIntroduction: return "자기소개 작성하기"
+ default: return nil
+ }
+ }
+
+ var isEmptyBottle: Bool {
+ switch self {
+ case .noBottle: return true
+ default: return false
+ }
+ }
+
+ var isHasNewBottle: Bool {
+ if case .hasNewBottle = self { return true }
+ return false
+ }
+
+
+ var isHasActiveBottle: Bool {
+ if case .hasActiveBottle = self { return true }
+ return false
+ }
+ }
+}
diff --git a/Projects/Feature/SandBeach/Project.swift b/Projects/Feature/SandBeach/Project.swift
new file mode 100644
index 00000000..cd7c2622
--- /dev/null
+++ b/Projects/Feature/SandBeach/Project.swift
@@ -0,0 +1,57 @@
+import ProjectDescription
+import ProjectDescriptionHelpers
+import DependencyPlugin
+
+let project = Project.makeModule(
+ name: ModulePath.Feature.name+ModulePath.Feature.SandBeach.rawValue,
+ targets: [
+ .feature(
+ interface: .SandBeach,
+ factory: .init(
+ dependencies: [
+ .domain,
+ .feature(interface: .ProfileSetup),
+ .feature(interface: .BottleArrival),
+ .feature(interface: .TabBar)
+ ]
+ )
+ ),
+ .feature(
+ implements: .SandBeach,
+ factory: .init(
+ dependencies: [
+ .feature(interface: .SandBeach)
+ ]
+ )
+ ),
+
+ .feature(
+ testing: .SandBeach,
+ factory: .init(
+ dependencies: [
+ .feature(interface: .SandBeach)
+ ]
+ )
+ ),
+ .feature(
+ tests: .SandBeach,
+ factory: .init(
+ dependencies: [
+ .feature(testing: .SandBeach),
+ .feature(implements: .SandBeach)
+ ]
+ )
+ ),
+
+ .feature(
+ example: .SandBeach,
+ factory: .init(
+ dependencies: [
+ .feature(testing: .SandBeach),
+ .feature(implements: .SandBeach)
+ ]
+ )
+ )
+
+ ]
+)
diff --git a/Projects/Feature/SandBeach/Sources/SandBeach/SandBeachFeature.swift b/Projects/Feature/SandBeach/Sources/SandBeach/SandBeachFeature.swift
new file mode 100644
index 00000000..850520e9
--- /dev/null
+++ b/Projects/Feature/SandBeach/Sources/SandBeach/SandBeachFeature.swift
@@ -0,0 +1,17 @@
+//
+// SandBeachFeature.swift
+// FeatureSandBeach
+//
+// Created by JongHoon on 7/25/24.
+//
+
+import Foundation
+
+import FeatureSandBeachInterface
+import DomainProfile
+import DomainBottle
+import CoreLoggerInterface
+
+import ComposableArchitecture
+
+
diff --git a/Projects/Feature/SandBeach/Testing/Sources/SandBeachTesting.swift b/Projects/Feature/SandBeach/Testing/Sources/SandBeachTesting.swift
new file mode 100644
index 00000000..b1853ce6
--- /dev/null
+++ b/Projects/Feature/SandBeach/Testing/Sources/SandBeachTesting.swift
@@ -0,0 +1 @@
+// This is for Tuist
diff --git a/Projects/Feature/SandBeach/Tests/Sources/SandBeachTest.swift b/Projects/Feature/SandBeach/Tests/Sources/SandBeachTest.swift
new file mode 100644
index 00000000..031e0424
--- /dev/null
+++ b/Projects/Feature/SandBeach/Tests/Sources/SandBeachTest.swift
@@ -0,0 +1,11 @@
+import XCTest
+
+final class SandBeachTests: XCTestCase {
+ override func setUpWithError() throws {}
+
+ override func tearDownWithError() throws {}
+
+ func testExample() {
+ XCTAssertEqual(1, 1)
+ }
+}
diff --git a/Projects/Feature/Sources/App/AppDelegateFeature.swift b/Projects/Feature/Sources/App/AppDelegateFeature.swift
new file mode 100644
index 00000000..d2ec159c
--- /dev/null
+++ b/Projects/Feature/Sources/App/AppDelegateFeature.swift
@@ -0,0 +1,42 @@
+//
+// AppDelegateFeature.swift
+// Feature
+//
+// Created by JongHoon on 7/23/24.
+//
+
+import Foundation
+
+import ComposableArchitecture
+
+import KakaoSDKCommon
+
+@Reducer
+public struct AppDelegateFeature {
+ public struct State: Equatable {
+ public init() {}
+ }
+
+ public enum Action {
+ case didFinishLunching
+ }
+
+ public var body: some ReducerOf {
+ Reduce(feature)
+ }
+
+ private func feature(
+ state: inout State,
+ action: Action
+ ) -> EffectOf {
+ switch action {
+ case .didFinishLunching:
+ guard let kakaoAppKey = Bundle.main.infoDictionary?["KAKAO_APP_KEY"] as? String else {
+ fatalError("XCConfig Setting Error")
+ }
+
+ KakaoSDK.initSDK(appKey: kakaoAppKey)
+ return .none
+ }
+ }
+}
diff --git a/Projects/Feature/Sources/App/AppFeature.swift b/Projects/Feature/Sources/App/AppFeature.swift
new file mode 100644
index 00000000..9831a230
--- /dev/null
+++ b/Projects/Feature/Sources/App/AppFeature.swift
@@ -0,0 +1,182 @@
+//
+// AppFeature.swift
+// Feature
+//
+// Created by JongHoon on 7/23/24.
+//
+
+import Foundation
+import AuthenticationServices
+
+import FeatureLoginInterface
+import FeatureOnboardingInterface
+
+import DomainAuth
+import DomainProfile
+import CoreKeyChainStore
+import CoreLoggerInterface
+
+import ComposableArchitecture
+
+@Reducer
+public struct AppFeature {
+ @Dependency(\.authClient) var authClient
+ @Dependency(\.profileClient) var profileClient
+
+ enum Root {
+ case Login
+ case MainTab
+ case Onboarding
+ }
+
+ @ObservableState
+ public struct State: Equatable {
+ public var appDelegate: AppDelegateFeature.State
+
+ var mainTab: MainTabViewFeature.State?
+ var login: LoginFeature.State?
+ var onboarding: OnboardingFeature.State?
+
+ public init() {
+ self.appDelegate = .init()
+ }
+ }
+
+ public enum Action {
+ case onAppear
+ case appDelegate(AppDelegateFeature.Action)
+ case mainTab(MainTabViewFeature.Action)
+ case login(LoginFeature.Action)
+ case onboarding(OnboardingFeature.Action)
+
+ case sceneDidActive
+ case appleUserIdDidRevoked
+ case loginCheckCompleted(isLoggedIn: Bool)
+ case profileSelectExistCheckCompleted(isExist: Bool)
+ }
+
+ public init() {}
+
+ public var body: some ReducerOf {
+ Scope(state: \.appDelegate, action: \.appDelegate) {
+ AppDelegateFeature()
+ }
+
+ Reduce(feature)
+ .ifLet(\.mainTab, action: \.mainTab) {
+ MainTabViewFeature()
+ }
+ .ifLet(\.login, action: \.login) {
+ LoginFeature()
+ }
+ .ifLet(\.onboarding, action: \.onboarding) {
+ OnboardingFeature()
+ }
+ }
+
+ private func feature(
+ state: inout State,
+ action: Action
+ ) -> EffectOf {
+ switch action {
+ case .onAppear:
+ let isLoggedIn = authClient.checkTokenIsExist()
+ return .send(.loginCheckCompleted(isLoggedIn: isLoggedIn))
+
+ case .login(.goToMainTab):
+ return changeRoot(.MainTab, state: &state)
+
+ case let .loginCheckCompleted(isLoggedIn):
+ if isLoggedIn {
+ return .run { send in
+ let userProfileStatus = try await profileClient.fetchUserProfileSelect()
+ let isExistProfileSelect = userProfileStatus == .empty ? false : true
+ return await send(.profileSelectExistCheckCompleted(isExist: isExistProfileSelect))
+ } catch: { error, send in
+ // TODO: - 네트워킹 에러 처리
+ }
+ } else {
+ return changeRoot(.Login, state: &state)
+ }
+
+ case let .profileSelectExistCheckCompleted(isExistProfileSelect):
+ if isExistProfileSelect {
+ return changeRoot(.MainTab, state: &state)
+ } else {
+ return changeRoot(.Onboarding, state: &state)
+ }
+
+ // Login Delegate
+ case let .login(.delegate(delegate)):
+ switch delegate {
+ case .createOnboardingProfileDidCompleted:
+ return changeRoot(.MainTab, state: &state)
+ }
+
+ // MainTab Delegate
+ case let .mainTab(.delegate(delegate)):
+ switch delegate {
+ case .logoutDidCompleted, .withdrawalDidCompleted:
+ return changeRoot(.Login, state: &state)
+ }
+
+ // Onboarding Delegate
+ case let .onboarding(.delegate(delegate)):
+ switch delegate {
+ case .createOnboardingProfileDidCompleted:
+ return changeRoot(.MainTab, state: &state)
+ }
+
+ case .sceneDidActive:
+ let appleIDProvider = ASAuthorizationAppleIDProvider()
+ let userID = KeyChainTokenStore.shared.load(property: .AppleUserID)
+
+ if userID.isEmpty { return .none }
+
+ return .run { send in
+ let credentialState = try await appleIDProvider.credentialState(forUserID: userID)
+
+ Log.debug(credentialState)
+ switch credentialState {
+ case .authorized:
+ Log.debug("애플 로그인 인증 성공")
+ case .revoked:
+ Log.error("애플 로그인 인증 만료")
+ return await send(.appleUserIdDidRevoked)
+ case .notFound:
+ Log.error("애플 Credential을 찾을 수 없음")
+ default:
+ break
+ }
+ } catch: { error, send in
+ Log.error(error)
+ }
+
+ case .appleUserIdDidRevoked:
+ KeyChainTokenStore.shared.deleteAll()
+ return changeRoot(.Login, state: &state)
+
+ default:
+ return .none
+ }
+ }
+
+ private func changeRoot(_ root: Root, state: inout State) -> Effect {
+ switch root {
+ case .Login:
+ state.mainTab = nil
+ state.onboarding = nil
+ state.login = LoginFeature.State()
+ case .MainTab:
+ state.login = nil
+ state.onboarding = nil
+ state.mainTab = MainTabViewFeature.State()
+ case .Onboarding:
+ state.login = nil
+ state.mainTab = nil
+ state.onboarding = OnboardingFeature.State()
+ }
+
+ return .none
+ }
+}
diff --git a/Projects/Feature/Sources/App/AppView.swift b/Projects/Feature/Sources/App/AppView.swift
new file mode 100644
index 00000000..9a02380c
--- /dev/null
+++ b/Projects/Feature/Sources/App/AppView.swift
@@ -0,0 +1,58 @@
+//
+// AppView.swift
+// Feature
+//
+// Created by JongHoon on 7/23/24.
+//
+
+import SwiftUI
+
+import FeatureLoginInterface
+import FeatureOnboardingInterface
+
+import ComposableArchitecture
+
+public struct AppView: View {
+ @Environment(\.scenePhase) private var scenePhase
+
+ private let store: StoreOf
+
+ public init(store: StoreOf) {
+ self.store = store
+ }
+
+ public var body: some View {
+ Group {
+ WithPerceptionTracking {
+ if let tabViewStore = store.scope(state: \.mainTab, action: \.mainTab) {
+ MainTabView(store: tabViewStore)
+ } else if let loginStore = store.scope(state: \.login, action: \.login) {
+ LoginView(store: loginStore)
+ } else if let onboardingStore = store.scope(state: \.onboarding, action: \.onboarding) {
+ OnboardingView(store: onboardingStore)
+ } else {
+ SplashView()
+ }
+ }
+ .onAppear {
+ store.send(.onAppear)
+ }
+ .onChange(of: scenePhase) { newValue in
+ if newValue == .active {
+ store.send(.sceneDidActive)
+ }
+ }
+ }
+ }
+}
+
+extension UINavigationController: UIGestureRecognizerDelegate {
+ override open func viewDidLoad() {
+ super.viewDidLoad()
+ interactivePopGestureRecognizer?.delegate = self
+ }
+
+ public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
+ return viewControllers.count > 1
+ }
+}
diff --git a/Projects/Feature/Sources/SplashView/SplashView.swift b/Projects/Feature/Sources/SplashView/SplashView.swift
new file mode 100644
index 00000000..402191db
--- /dev/null
+++ b/Projects/Feature/Sources/SplashView/SplashView.swift
@@ -0,0 +1,22 @@
+//
+// SplashView.swift
+// Feature
+//
+// Created by 임현규 on 8/12/24.
+//
+
+import SwiftUI
+import SharedDesignSystem
+
+public struct SplashView: View {
+ public init() {}
+ public var body: some View {
+ ZStack {
+ ColorToken.container(.pressed).color
+ .ignoresSafeArea()
+
+ Image.BottleImageSystem.illustraition(.splash).image
+ }
+ .ignoresSafeArea()
+ }
+}
diff --git a/Projects/Feature/Sources/TabView/MainTabView.swift b/Projects/Feature/Sources/TabView/MainTabView.swift
new file mode 100644
index 00000000..dcb03162
--- /dev/null
+++ b/Projects/Feature/Sources/TabView/MainTabView.swift
@@ -0,0 +1,57 @@
+//
+// MainTabView.swift
+// AppManifests
+//
+// Created by JongHoon on 7/22/24.
+//
+
+import SwiftUI
+
+import FeatureBottleStorageInterface
+import FeatureMyPageInterface
+import FeatureSandBeachInterface
+import FeatureTabBarInterface
+import SharedDesignSystem
+
+import ComposableArchitecture
+
+public struct MainTabView: View {
+ @Perception.Bindable private var store: StoreOf
+
+ public init(store: StoreOf) {
+ self.store = store
+ }
+
+ public var body: some View {
+ WithViewStore(store, observe: \.selectedTab) { viewStore in
+ WithPerceptionTracking {
+ TabView(selection: $store.selectedTab.sending(\.selectedTabChanged)) {
+ SandBeachRootView(store: store.scope(state: \.sandBeachRoot, action: \.sandBeachRoot))
+ .tag(TabType.sandBeach)
+ .toolbar(.hidden, for: .tabBar)
+
+ BottleStorageView(store: store.scope(state: \.bottleStorage, action: \.bottleStorage))
+ .tag(TabType.bottleStorage)
+ .toolbar(.hidden, for: .tabBar)
+
+ MyPageView(store: store.scope(state: \.myPage, action: \.myPage))
+ .tag(TabType.myPage)
+ .toolbar(.hidden, for: .tabBar)
+ }
+ .overlay {
+ if store.isLoading {
+ LoadingIndicator()
+ }
+ }
+ .accentColor(ColorToken.text(.selectSecondary).color)
+ }
+ }
+ }
+}
+
+extension UITabBarController {
+ open override func viewWillLayoutSubviews() {
+ super.viewWillLayoutSubviews()
+ tabBar.isHidden = true
+ }
+}
diff --git a/Projects/Feature/Sources/TabView/MainTabViewFeature.swift b/Projects/Feature/Sources/TabView/MainTabViewFeature.swift
new file mode 100644
index 00000000..92124157
--- /dev/null
+++ b/Projects/Feature/Sources/TabView/MainTabViewFeature.swift
@@ -0,0 +1,118 @@
+//
+// MainTabViewFeature.swift
+// Feature
+//
+// Created by JongHoon on 7/22/24.
+//
+
+import Foundation
+
+import FeatureBottleStorage
+import FeatureBottleStorageInterface
+import FeatureMyPage
+import FeatureMyPageInterface
+import FeatureSandBeach
+import FeatureSandBeachInterface
+import FeatureTabBarInterface
+
+import ComposableArchitecture
+
+@Reducer
+public struct MainTabViewFeature {
+
+ @ObservableState
+ public struct State: Equatable {
+ var sandBeachRoot: SandBeachRootFeature.State
+ var bottleStorage: BottleStorageFeature.State
+ var myPage: MyPageFeature.State
+ var selectedTab: TabType
+ var isLoading: Bool
+ public init() {
+ self.sandBeachRoot = .init()
+ self.bottleStorage = .init()
+ self.myPage = .init()
+ self.selectedTab = .sandBeach
+ self.isLoading = false
+ }
+ }
+
+ public enum Action: BindableAction {
+ case sandBeachRoot(SandBeachRootFeature.Action)
+ case bottleStorage(BottleStorageFeature.Action)
+ case myPage(MyPageFeature.Action)
+ case selectedTabChanged(TabType)
+
+ case binding(BindingAction)
+
+ case delegate(Delegate)
+
+ public enum Delegate {
+ case logoutDidCompleted
+ case withdrawalDidCompleted
+ }
+ }
+
+ public var body: some ReducerOf {
+ BindingReducer()
+ Scope(state: \.sandBeachRoot, action: \.sandBeachRoot) {
+ SandBeachRootFeature()
+ }
+ Scope(state: \.bottleStorage, action: \.bottleStorage) {
+ BottleStorageFeature()
+ }
+ Scope(state: \.myPage, action: \.myPage) {
+ MyPageFeature()
+ }
+ Reduce(feature)
+ }
+
+ private func feature(
+ state: inout State,
+ action: Action
+ ) -> EffectOf {
+ switch action {
+ case let .selectedTabChanged(tab):
+ state.selectedTab = tab
+ return .none
+
+ // SandBeachRoot Delegate
+ case let .sandBeachRoot(.delegate(delegate)):
+ switch delegate {
+ case .goToBottleStorageRequest:
+ state.selectedTab = .bottleStorage
+ case let .selectedTabDidChanged(selectedTab):
+ state.selectedTab = selectedTab
+ case .profileSetUpDidCompleted:
+ return .send(.myPage(.userProfileUpdateDidRequest))
+ }
+ return .none
+
+ // BottleStorage Delegate
+ case let .bottleStorage(.delegate(delegate)):
+ switch delegate {
+ case let .selectedTabDidChanged(selectedTab):
+ state.selectedTab = selectedTab
+ }
+ return .none
+
+ // MyPage Delegate
+ case let .myPage(.delegate(delegate)):
+ switch delegate {
+ case .logoutDidCompleted:
+ return .send(.delegate(.logoutDidCompleted))
+ case .withdrawalButtonDidTapped:
+ state.isLoading = true
+ return .none
+ case .withdrawalDidCompleted:
+ state.isLoading = false
+ return .send(.delegate(.withdrawalDidCompleted))
+ case let .selectedTabDidChanged(selectedTab):
+ state.selectedTab = selectedTab
+ return .none
+ }
+
+ default:
+ return .none
+ }
+ }
+}
diff --git a/Projects/Feature/TabBar/Example/Sources/AppView.swift b/Projects/Feature/TabBar/Example/Sources/AppView.swift
new file mode 100644
index 00000000..5411b88e
--- /dev/null
+++ b/Projects/Feature/TabBar/Example/Sources/AppView.swift
@@ -0,0 +1,11 @@
+import SwiftUI
+
+@main
+struct AppView: App {
+ var body: some Scene {
+ WindowGroup {
+ Text("Hello Tuist!")
+ }
+ }
+}
+
diff --git a/Projects/Feature/TabBar/Interface/Sources/TabBarModifier.swift b/Projects/Feature/TabBar/Interface/Sources/TabBarModifier.swift
new file mode 100644
index 00000000..0c151aa6
--- /dev/null
+++ b/Projects/Feature/TabBar/Interface/Sources/TabBarModifier.swift
@@ -0,0 +1,69 @@
+//
+// TabBarModifier.swift
+// SharedDesignSystem
+//
+// Created by 임현규 on 8/13/24.
+//
+
+import Foundation
+import SwiftUI
+
+import SharedDesignSystem
+
+private struct TabBarModifier: ViewModifier {
+
+ @State private var selectedTab: TabType
+
+ private let action: (TabType) -> Void
+
+ public init(
+ selectedTab: TabType,
+ action: @escaping (TabType) -> Void
+ ) {
+ self.selectedTab = selectedTab
+ self.action = action
+ }
+
+ func body(content: Content) -> some View {
+ ZStack {
+ content
+ VStack(spacing: 0) {
+ Spacer()
+ HStack(spacing: 0) {
+ ForEach(TabType.allCases, id: \.title) { item in
+ Spacer()
+ VStack(spacing: 8) {
+ BottleImageView(type: .local(bottleImageSystem: item.image))
+ .foregroundStyle(to: selectedTab == item ? ColorToken.text(.selectSecondary) : ColorToken.icon(.primary))
+
+ WantedSansStyleText(
+ "\(item.title)",
+ style: .caption,
+ color: selectedTab == item ? .primary : .enableTertiary
+ )
+ }
+ .offset(y: -9)
+ .asThrottleButton {
+ action(item)
+ }
+ }
+ Spacer()
+ }
+ // TODO: - DesignSystem -> 디자인들 Contants 관리
+ .frame(height: 106)
+ .background {
+ RoundedRectangle(cornerRadius: 0)
+ .fill(ColorToken.background(.secondary).color)
+ .cornerRadius(.xl, corenrs: [.topLeft, .topRight])
+ }
+ }
+ }
+ .ignoresSafeArea(.all, edges: [.bottom])
+ }
+}
+
+public extension View {
+ func setTabBar(selectedTab: TabType, action: @escaping (TabType) -> Void) -> some View {
+ modifier(TabBarModifier(selectedTab: selectedTab, action: action))
+ }
+}
diff --git a/Projects/Feature/TabBar/Interface/Sources/TabType.swift b/Projects/Feature/TabBar/Interface/Sources/TabType.swift
new file mode 100644
index 00000000..b001961c
--- /dev/null
+++ b/Projects/Feature/TabBar/Interface/Sources/TabType.swift
@@ -0,0 +1,41 @@
+//
+// TabType.swift
+// Feature
+//
+// Created by JongHoon on 7/27/24.
+//
+
+import SwiftUI
+import SharedDesignSystem
+
+public enum TabType: Hashable, CaseIterable {
+ case sandBeach
+ case bottleStorage
+ case myPage
+
+ public var title: String {
+ switch self {
+ case .sandBeach:
+ return "모래사장"
+
+ case .bottleStorage:
+ return "보틀 보관함"
+
+ case .myPage:
+ return "마이페이지"
+ }
+ }
+
+ var image: Image.BottleImageSystem {
+ switch self {
+ case .sandBeach:
+ return .icom(.sandBeach)
+
+ case .bottleStorage:
+ return .icom(.bottleStorage)
+
+ case .myPage:
+ return .icom(.myPage)
+ }
+ }
+}
diff --git a/Projects/Feature/TabBar/Project.swift b/Projects/Feature/TabBar/Project.swift
new file mode 100644
index 00000000..a963bdf8
--- /dev/null
+++ b/Projects/Feature/TabBar/Project.swift
@@ -0,0 +1,52 @@
+import ProjectDescription
+import ProjectDescriptionHelpers
+import DependencyPlugin
+
+let project = Project.makeModule(
+ name: ModulePath.Feature.name+ModulePath.Feature.TabBar.rawValue,
+ targets: [
+ .feature(
+ interface: .TabBar,
+ factory: .init(dependencies: [
+ .domain
+ ])
+ ),
+ .feature(
+ implements: .TabBar,
+ factory: .init(
+ dependencies: [
+ .feature(interface: .TabBar)
+ ]
+ )
+ ),
+
+ .feature(
+ testing: .TabBar,
+ factory: .init(
+ dependencies: [
+ .feature(interface: .TabBar)
+ ]
+ )
+ ),
+ .feature(
+ tests: .TabBar,
+ factory: .init(
+ dependencies: [
+ .feature(testing: .TabBar),
+ .feature(implements: .TabBar)
+ ]
+ )
+ ),
+
+ .feature(
+ example: .TabBar,
+ factory: .init(
+ dependencies: [
+ .feature(testing: .TabBar),
+ .feature(implements: .TabBar)
+ ]
+ )
+ )
+
+ ]
+)
diff --git a/Projects/Feature/TabBar/Sources/Source.swift b/Projects/Feature/TabBar/Sources/Source.swift
new file mode 100644
index 00000000..b1853ce6
--- /dev/null
+++ b/Projects/Feature/TabBar/Sources/Source.swift
@@ -0,0 +1 @@
+// This is for Tuist
diff --git a/Projects/Feature/TabBar/Testing/Sources/TabBarTesting.swift b/Projects/Feature/TabBar/Testing/Sources/TabBarTesting.swift
new file mode 100644
index 00000000..b1853ce6
--- /dev/null
+++ b/Projects/Feature/TabBar/Testing/Sources/TabBarTesting.swift
@@ -0,0 +1 @@
+// This is for Tuist
diff --git a/Projects/Feature/TabBar/Tests/Sources/TabBarTest.swift b/Projects/Feature/TabBar/Tests/Sources/TabBarTest.swift
new file mode 100644
index 00000000..7196ff20
--- /dev/null
+++ b/Projects/Feature/TabBar/Tests/Sources/TabBarTest.swift
@@ -0,0 +1,11 @@
+import XCTest
+
+final class TabBarTests: XCTestCase {
+ override func setUpWithError() throws {}
+
+ override func tearDownWithError() throws {}
+
+ func testExample() {
+ XCTAssertEqual(1, 1)
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Example/Sources/AppView.swift b/Projects/Shared/DesignSystem/Example/Sources/AppView.swift
new file mode 100644
index 00000000..3ba2c468
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Example/Sources/AppView.swift
@@ -0,0 +1,17 @@
+import SwiftUI
+
+import SharedDesignSystem
+
+@main
+struct AppView: App {
+ var body: some Scene {
+ WindowGroup {
+ RootView {
+ DesignSystemExampleView()
+ .onAppear {
+ SharedDesignSystemFontFamily.registerAllCustomFonts()
+ }
+ }
+ }
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Example/Sources/DesignSystemExampleView/DesignSystemExampleView.swift b/Projects/Shared/DesignSystem/Example/Sources/DesignSystemExampleView/DesignSystemExampleView.swift
new file mode 100644
index 00000000..d0b504db
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Example/Sources/DesignSystemExampleView/DesignSystemExampleView.swift
@@ -0,0 +1,333 @@
+//
+// DesignSystemExampleView.swift
+// DesignSystemExample
+//
+// Created by JongHoon on 7/27/24.
+//
+
+import SwiftUI
+
+import SharedDesignSystem
+
+struct DesignSystemExampleView: View {
+ var body: some View {
+ NavigationStack {
+ List {
+ CustomTextViewSection()
+ ButtonSection()
+ PopupSection()
+ LinesTextFieldSection()
+ CardSection()
+ EtcSection()
+ ToastSection()
+ ListSection()
+ }
+ .navigationBarTitleDisplayMode(.inline)
+ .navigationTitle("Design System Example View")
+ }
+ }
+}
+
+struct CustomTextViewSection: View {
+ var body: some View {
+ Section(
+ content: {
+ NavigationLink(
+ destination: WantedSansStyleTextTestView(),
+ label: { Text("Wanted Sans Text Test View") }
+ )
+ NavigationLink(
+ destination: RobotoStyleTextTestView(),
+ label: { Text("Roboto Text Test View") }
+ )
+ NavigationLink(
+ destination: LaundaryGothicsStyleTextTestView(),
+ label: { Text("Laundary Gothic Text Test View") }
+ )
+ },
+ header: {
+ Text("Custom Text View")
+ .font(.headline)
+ }
+ )
+ }
+}
+
+struct ButtonSection: View {
+ var body: some View {
+ Section(
+ content: {
+ NavigationLink(
+ destination: OutlinedStyleButtonTestView(),
+ label: { Text("Outlined Style Button") }
+ )
+ NavigationLink(
+ destination: OutlinedStyleToggleButtonTestView(),
+ label: { Text("Outlined Style Toggle Button") }
+ )
+ NavigationLink(
+ destination: SolidButtonTestView(),
+ label: { Text("SolidButton Test View") }
+ )
+ },
+ header: {
+ Text("Button")
+ .font(.headline)
+ }
+ )
+ }
+}
+
+struct PopupSection: View {
+ var body: some View {
+ Section(
+ content: {
+ NavigationLink(
+ destination: PopupView(
+ popupType: .text(
+ content: "1시간 후 새로운 보틀이 도착해요"
+ )
+ ),
+ label: {
+ Text("Text Popup View")
+ }
+ )
+
+ NavigationLink(
+ destination: PopupView(
+ popupType: .button(
+ content: "자기소개 작성 후 열어볼 수 있어요",
+ buttonTitle: "자기소개 작성하기"
+ )
+ ),
+ label: {
+ Text("Button Popup View")
+ }
+ )
+ },
+ header: {
+ Text("Popup")
+ .font(.headline)
+ }
+ )
+ }
+}
+
+struct LinesTextFieldSection: View {
+ var body: some View {
+ Section(
+ content: {
+ NavigationLink(
+ destination:
+ IntroductionTextFieldTestView(),
+ label: { Text("Introduction TextField") }
+ )
+ NavigationLink(
+ destination:
+ LetterTextFieldTestView(),
+ label: { Text("Letter TextField") }
+ )
+ },
+ header: {
+ Text("Lines TextField")
+ .font(.headline)
+ }
+ )
+ }
+}
+
+struct EtcSection: View {
+ var body: some View {
+ Section(
+ content: {
+ NavigationLink(
+ destination:
+ PageIndicatorTestView(),
+ label: { Text("PageIndicator") }
+ )
+
+ NavigationLink(
+ destination:
+ VStack(spacing: .xl) {
+
+ TitleView(title: "TitleTitleTitleTitleTitle")
+ .frame(maxWidth: .infinity)
+ .border(.red)
+
+
+ TitleView(title: "TitleTitleTitleTitleTitle", caption: "CaptionCaptionCaptionCaption")
+ .frame(maxWidth: .infinity)
+ .border(.red)
+
+
+ TitleView(pageInfo: PageInfo(nowPage: 1, totalCount: 2), title: "TitleTitleTitleTitleTitle", caption: "CaptionCaptionCaptionCaption")
+ .frame(maxWidth: .infinity)
+ .border(.red)
+
+
+ TitleView(pageInfo: PageInfo(nowPage: 1, totalCount: 2), title: "TitleTitleTitleTitleTitle")
+ .frame(maxWidth: .infinity)
+ .border(.red)
+ }
+ ,
+ label: { Text("TitleView With Only Title") }
+ )
+
+ NavigationLink(
+ destination:
+ ImagePickerButton(selectedImage: .constant([]), action: { })
+ .asDebounceButton {}
+ .padding(.xl)
+ ,
+ label: { Text("ImagePickerButton") }
+ )
+
+ NavigationLink(
+ "Loading Indicator",
+ destination: {
+ LoadingIndicator()
+ })
+
+ NavigationLink("Blur Image View") {
+ BlurImageView(
+ imageURL: "https://static.wikia.nocookie.net/wallaceandgromit/images/3/38/Gromit-3.png/revision/latest/scale-to-width/360?cb=20191228190308",
+ downsamplingWidth: 300.0,
+ downsamplingHeight: 300.0
+ )
+ .frame(width: 300.0, height: 300.0)
+ }
+ },
+ header: {
+ Text("ETC")
+ .font(.headline)
+ }
+ )
+ }
+}
+
+struct CardSection: View {
+
+ @State var textFieldState: TextFieldState = .enabled
+ @State var text: String = ""
+ @State var isSelctedYesButton: Bool = false
+ @State var isSelctedNoButton: Bool = false
+
+ var body: some View {
+ Section(
+ content: {
+ NavigationLink(
+ destination:
+ ClipListContainerViewTest(),
+ label: { Text("ClipListContainerView") }
+ )
+
+ NavigationLink(
+ destination:
+ ClipListViewTest(),
+ label: { Text("ClipListView") }
+ )
+
+ NavigationLink(
+ destination: LetterCardTestView(),
+ label: { Text("LettetCardView") }
+ )
+
+
+ NavigationLink(
+ destination: UserProfileTestView(),
+ label: {
+ Text("UserProfileView")
+ }
+ )
+
+ NavigationLink(
+ destination: StopCardTestView(),
+ label: {
+ Text("StopCardView")
+ }
+ )
+// NavigationLink(
+// destination:
+// QuestionPingPongTestView(),
+// label: {
+// Text("Question PingPong View")
+// }
+// )
+
+// NavigationLink(
+// destination:
+// PhotoSharePingPongTestView(),
+// label: {
+// Text("PhotoShare PingPong View")
+// }
+// )
+
+// NavigationLink(
+// destination:
+// FinalSelectPingPongTestView(),
+// label: {
+// Text("FinalSelectPingPongView")
+// }
+// )
+
+ NavigationLink(
+ destination: VStack(spacing: 30) {
+ HStack(spacing: 0) {
+ PingPongBubble(content: "어떤 날은 아침에 눈이 번쩍 떠지는 게 힘이 펄펄 나는 것 같은가 하면 또 어떤 날은 몸이 진흙으로 만들어진 것 같은 때가 있습니다. 몸이 힘들면 마음이 가라앉기 마련입니다. ", isRight: false)
+ Spacer()
+ }
+ HStack(spacing: 0) {
+ Spacer()
+ PingPongBubble(content: "ㅇㅇ", isRight: true)
+ }
+ },
+ label: {
+ Text("PingPongBubble")
+ }
+ )
+ },
+ header: {
+ Text("Card")
+ .font(.headline)
+ }
+ )
+ }
+}
+
+struct ToastSection: View {
+ var body: some View {
+ Section(
+ content: {
+ NavigationLink(
+ destination: ToastTestView(),
+ label: { Text("Toast Test") }
+ )
+ },
+ header: {
+ Text("Toast")
+ .font(.headline)
+ }
+ )
+ }
+}
+
+struct ListSection: View {
+ var body: some View {
+ Section(
+ content: {
+ NavigationLink(
+ destination: BottleStorageList(),
+ label: { Text("Bottle Storage List") }
+ )
+ },
+ header: {
+ Text("List")
+ .font(.headline)
+ }
+ )
+ }
+}
+
+#Preview {
+ DesignSystemExampleView()
+}
diff --git a/Projects/Shared/DesignSystem/Example/Sources/SubViews/BottleStorageList.swift b/Projects/Shared/DesignSystem/Example/Sources/SubViews/BottleStorageList.swift
new file mode 100644
index 00000000..21207d91
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Example/Sources/SubViews/BottleStorageList.swift
@@ -0,0 +1,90 @@
+//
+// BottleStorageList.swift
+// DesignSystemExample
+//
+// Created by JongHoon on 8/8/24.
+//
+
+import SwiftUI
+
+import SharedDesignSystem
+
+struct BottleItem: Identifiable {
+ let id: UUID
+ let userName: String
+ let age: Int
+ let mbti: String
+ let imageURL: String
+ let keyworkds: [String]
+ let isRead: Bool
+
+ init(
+ userName: String,
+ age: Int,
+ mbti: String,
+ imageURL: String,
+ keyworkds: [String],
+ isRead: Bool
+ ) {
+ self.id = UUID()
+ self.userName = userName
+ self.age = age
+ self.mbti = mbti
+ self.imageURL = imageURL
+ self.keyworkds = keyworkds
+ self.isRead = isRead
+ }
+}
+
+struct BottleStorageList: View {
+ let bottles = [
+ BottleItem(
+ userName: "Test1",
+ age: 20,
+ mbti: "INTP",
+ imageURL: "https://static.wikia.nocookie.net/wallaceandgromit/images/3/38/Gromit-3.png/revision/latest/scale-to-width/360?cb=20191228190308",
+ keyworkds: ["성격1", "성격2", "성격3"],
+ isRead: false
+ ),
+ BottleItem(
+ userName: "Test1",
+ age: 20,
+ mbti: "INTP",
+ imageURL: "https://static.wikia.nocookie.net/wallaceandgromit/images/3/38/Gromit-3.png/revision/latest/scale-to-width/360?cb=20191228190308",
+ keyworkds: ["성격1", "성격2", "성격3"],
+ isRead: true
+ ),
+ BottleItem(
+ userName: "Test1",
+ age: 20,
+ mbti: "INTP",
+ imageURL: "https://static.wikia.nocookie.net/wallaceandgromit/images/3/38/Gromit-3.png/revision/latest/scale-to-width/360?cb=20191228190308",
+ keyworkds: ["성격1", "성격2", "성격3"],
+ isRead: false
+ ),
+ BottleItem(
+ userName: "Test1",
+ age: 20,
+ mbti: "INTP",
+ imageURL: "https://static.wikia.nocookie.net/wallaceandgromit/images/3/38/Gromit-3.png/revision/latest/scale-to-width/360?cb=20191228190308",
+ keyworkds: ["성격1", "성격2", "성격3"],
+ isRead: true
+ )
+ ]
+
+ var body: some View {
+ VStack(spacing: 20.0) {
+ ForEach(bottles, id: \.id) { bottle in
+ BottleStorageItem(
+ userName: bottle.userName,
+ age: bottle.age,
+ mbti: bottle.mbti,
+ keywords: bottle.keyworkds,
+ imageURL: bottle.imageURL,
+ isRead: bottle.isRead
+ )
+ }
+ }
+ .padding(20.0)
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Example/Sources/SubViews/ClipListTest/ClipListContainerViewTest.swift b/Projects/Shared/DesignSystem/Example/Sources/SubViews/ClipListTest/ClipListContainerViewTest.swift
new file mode 100644
index 00000000..61151ccb
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Example/Sources/SubViews/ClipListTest/ClipListContainerViewTest.swift
@@ -0,0 +1,36 @@
+//
+// ClipListContainerViewTest.swift
+// SharedDesignSystem
+//
+// Created by 임현규 on 8/2/24.
+//
+
+import SwiftUI
+import SharedDesignSystem
+
+public struct ClipListContainerViewTest: View {
+ public init() {}
+ public var body: some View {
+ ClipListContainerView(
+ clipItemList: [
+ ClipItem(
+ title: "내 키워드를 참고해보세요",
+ list: ["직장인", "MBTI", "city_name", "height", "흡연 안해요", "술을 즐겨요"]
+ ),
+ ClipItem(
+ title: "나의 성격은",
+ list: ["적극적인", "열정적인", "예의바른", "자유로운", "쿨한"]
+ ),
+ ClipItem(
+ title: "내가 푹 빠진 취미는",
+ list: ["코인노래방", "헬스", "드라이브", "만화 웹툰 정주행", "자전거"]
+ )
+ ]
+ )
+ .padding(.horizontal, .xl)
+ }
+}
+
+#Preview {
+ ClipListContainerViewTest()
+}
diff --git a/Projects/Shared/DesignSystem/Example/Sources/SubViews/ClipListTest/ClipListViewTest.swift b/Projects/Shared/DesignSystem/Example/Sources/SubViews/ClipListTest/ClipListViewTest.swift
new file mode 100644
index 00000000..2b8136af
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Example/Sources/SubViews/ClipListTest/ClipListViewTest.swift
@@ -0,0 +1,23 @@
+//
+// ClipListViewTest.swift
+// SharedDesignSystem
+//
+// Created by 임현규 on 8/2/24.
+//
+
+import SwiftUI
+
+import SharedDesignSystem
+
+public struct ClipListViewTest: View {
+ public init() {}
+ public var body: some View {
+ ClipListView(
+ clipItem: ClipItem(
+ title: "내가 푹 빠진 취미는",
+ list: ["코인노래방", "헬스", "드라이브", "만화 웹툰 정주행", "자전거"]
+ )
+ )
+ .padding(.sm)
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Example/Sources/SubViews/ETCTest/PageIndicatorTestView.swift b/Projects/Shared/DesignSystem/Example/Sources/SubViews/ETCTest/PageIndicatorTestView.swift
new file mode 100644
index 00000000..f2b503b8
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Example/Sources/SubViews/ETCTest/PageIndicatorTestView.swift
@@ -0,0 +1,27 @@
+//
+// PageIndicatorTestView.swift
+// DesignSystemExample
+//
+// Created by 임현규 on 8/3/24.
+//
+
+import SwiftUI
+
+import SharedDesignSystem
+
+public struct PageIndicatorTestView: View {
+ public init() {}
+
+ public var body: some View {
+ PageIndicatorView(
+ pageInfo: PageInfo(
+ nowPage: 1,
+ totalCount: 2
+ )
+ )
+ }
+}
+
+#Preview {
+ PageIndicatorTestView()
+}
diff --git a/Projects/Shared/DesignSystem/Example/Sources/SubViews/LaundaryGothicsStyleTextTestView.swift b/Projects/Shared/DesignSystem/Example/Sources/SubViews/LaundaryGothicsStyleTextTestView.swift
new file mode 100644
index 00000000..b33de22f
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Example/Sources/SubViews/LaundaryGothicsStyleTextTestView.swift
@@ -0,0 +1,37 @@
+//
+// LaundaryGothicsStyleTextTestView.swift
+// SharedDesignSystem
+//
+// Created by JongHoon on 7/27/24.
+//
+
+import SwiftUI
+
+import SharedDesignSystem
+
+struct LaundaryGothicsStyleTextTestView: View {
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 8.0) {
+ ForEach(
+ Array(zip(
+ 0.. Void)?
+ private var action: () -> Void {
+ return _action ?? { print("\(title) button did tap") }
+ }
+
+ var title: String
+ private var configurationInfo: OutlinedStyleButton.ConfigurationInfo
+
+ init(
+ title: String,
+ configurationInfo: OutlinedStyleButton.ConfigurationInfo,
+ isSelected: Binding = .constant(false),
+ action: (() -> Void)? = nil
+ ) {
+ self.isDisabled = false
+ self.padding = 0.0
+ self.title = title
+ self.configurationInfo = configurationInfo
+ self._isSelected = isSelected
+ self._action = action
+ }
+
+ var body: some View {
+ LazyVStack(spacing: 10) {
+ Text("\(title) Button")
+ .font(to: .wantedSans(.subTitle1))
+
+ Slider(value: $padding, in: 0.0...200)
+ .padding(.horizontal, 100)
+
+ Text("horizontal Padding = \(Int(padding))")
+
+ Toggle(isOn: $isDisabled) {
+ Text("button is disabled")
+ .font(to: .wantedSans(.body))
+ }
+ .padding(.horizontal, 100)
+
+ OutlinedStyleButton(
+ configurationInfo,
+ title: title,
+ buttonType: .normal,
+ isSelected: isSelected,
+ action: action
+ )
+ .disabled(isDisabled)
+ .padding(.horizontal, padding)
+ .padding(.bottom, 20.0)
+
+ Divider()
+ .background(.black)
+ .padding(.horizontal, 20)
+ }
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Example/Sources/SubViews/OutlinedStyleButtonTest/OutlinedStyleButtonTestView.swift b/Projects/Shared/DesignSystem/Example/Sources/SubViews/OutlinedStyleButtonTest/OutlinedStyleButtonTestView.swift
new file mode 100644
index 00000000..52c658c1
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Example/Sources/SubViews/OutlinedStyleButtonTest/OutlinedStyleButtonTestView.swift
@@ -0,0 +1,47 @@
+//
+// OutlinedStyleButtonTestView.swift
+// DesignSystemExample
+//
+// Created by JongHoon on 7/27/24.
+//
+
+import SwiftUI
+
+import SharedDesignSystem
+
+struct OutlinedStyleButtonTestView: View {
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 16.0) {
+ OutlinedStyleButtonTestItem(
+ title: "Small Text",
+ configurationInfo: .small(contentType: .text)
+ )
+
+ OutlinedStyleButtonTestItem(
+ title: "Small Image",
+ configurationInfo: .small(contentType: .image(type: .remote(url: "https://static.wikia.nocookie.net/wallaceandgromit/images/3/38/Gromit-3.png/revision/latest/scale-to-width/360?cb=20191228190308")))
+ )
+
+ OutlinedStyleButtonTestItem(
+ title: "Medium Text",
+ configurationInfo: .medium(contentType: .text)
+ )
+
+ OutlinedStyleButtonTestItem(
+ title: "Medium Image",
+ configurationInfo: .medium(contentType: .image(type: .remote(url: "https://static.wikia.nocookie.net/wallaceandgromit/images/3/38/Gromit-3.png/revision/latest/scale-to-width/360?cb=20191228190308")))
+ )
+
+ OutlinedStyleButtonTestItem(
+ title: "large",
+ configurationInfo: .large
+ )
+ }
+ }
+ }
+}
+
+#Preview {
+ OutlinedStyleButtonTestView()
+}
diff --git a/Projects/Shared/DesignSystem/Example/Sources/SubViews/OutlinedStyleButtonTest/OutlinedStyleToggleButtonTestView.swift b/Projects/Shared/DesignSystem/Example/Sources/SubViews/OutlinedStyleButtonTest/OutlinedStyleToggleButtonTestView.swift
new file mode 100644
index 00000000..1d041fa5
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Example/Sources/SubViews/OutlinedStyleButtonTest/OutlinedStyleToggleButtonTestView.swift
@@ -0,0 +1,69 @@
+//
+// OutlinedStyleToggleButtonTestView.swift
+// SharedDesignSystem
+//
+// Created by JongHoon on 7/28/24.
+//
+
+import SwiftUI
+
+import SharedDesignSystem
+
+struct OutlinedStyleToggleButtonTestView: View {
+ @State private var smallTextButtonIsSelected = false
+ @State private var smallImageButtonIsSelected = false
+ @State private var mediumTextButtonIsSelected = false
+ @State private var mediumImageButtonIsSelected = false
+ @State private var largeButtonIsSelected = false
+
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 16.0) {
+ OutlinedStyleButtonTestItem(
+ title: "Small Text",
+ configurationInfo: .small(contentType: .text),
+ isSelected: $smallTextButtonIsSelected,
+ action: {
+ smallTextButtonIsSelected.toggle()
+ }
+ )
+
+ OutlinedStyleButtonTestItem(
+ title: "Small Image",
+ configurationInfo: .small(contentType: .image(type: .remote(url: "https://static.wikia.nocookie.net/wallaceandgromit/images/3/38/Gromit-3.png/revision/latest/scale-to-width/360?cb=20191228190308"))),
+ isSelected: $smallImageButtonIsSelected,
+ action: {
+ smallImageButtonIsSelected.toggle()
+ }
+ )
+
+ OutlinedStyleButtonTestItem(
+ title: "Medium Text",
+ configurationInfo: .medium(contentType: .text),
+ isSelected: $mediumTextButtonIsSelected,
+ action: {
+ mediumTextButtonIsSelected.toggle()
+ }
+ )
+
+ OutlinedStyleButtonTestItem(
+ title: "Medium Image",
+ configurationInfo: .medium(contentType: .image(type: .remote(url: "https://static.wikia.nocookie.net/wallaceandgromit/images/3/38/Gromit-3.png/revision/latest/scale-to-width/360?cb=20191228190308"))),
+ isSelected: $mediumImageButtonIsSelected,
+ action: {
+ mediumImageButtonIsSelected.toggle()
+ }
+ )
+
+ OutlinedStyleButtonTestItem(
+ title: "large",
+ configurationInfo: .large,
+ isSelected: $largeButtonIsSelected,
+ action: {
+ largeButtonIsSelected.toggle()
+ }
+ )
+ }
+ }
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Example/Sources/SubViews/RobotoStyleTextTestView.swift b/Projects/Shared/DesignSystem/Example/Sources/SubViews/RobotoStyleTextTestView.swift
new file mode 100644
index 00000000..e5d8110c
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Example/Sources/SubViews/RobotoStyleTextTestView.swift
@@ -0,0 +1,36 @@
+//
+// RobotoStyleTextTestView.swift
+// SharedDesignSystem
+//
+// Created by JongHoon on 7/27/24.
+//
+
+import SwiftUI
+
+import SharedDesignSystem
+
+struct RobotoStyleTextTestView: View {
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 8.0) {
+ ForEach(
+ Array(zip(
+ 0..,
+ padding: Binding,
+ sizeType: SolidButton.SizeType
+ ) {
+ self._isDisabled = isDisabled
+ self._padding = padding
+ self.title = title
+ self.sizeType = sizeType
+ }
+
+ var body: some View {
+ VStack(spacing: 10) {
+ Text("\(sizeType) Button")
+ .font(to: .wantedSans(.subTitle1))
+
+ Slider(value: $padding, in: 0.0...200)
+ .padding(.horizontal, 100)
+
+ Text("horizontal Padding = \(Int(padding))")
+
+ Toggle(isOn: $isDisabled) {
+ Text(title)
+ .font(to: .wantedSans(.body))
+ }
+ .padding(.horizontal, 100)
+
+ SolidButton(
+ title: title,
+ sizeType: sizeType,
+ buttonType: .normal,
+ action: { print("\(title) Button clicked") }
+ )
+ .disabled(isDisabled)
+ .padding(.horizontal, padding)
+
+ Divider()
+ .background(.black)
+ .padding(.horizontal, 20)
+ }
+ }
+}
+
+#Preview {
+ SolidButtonTestView()
+}
diff --git a/Projects/Shared/DesignSystem/Example/Sources/SubViews/StopCardTest/StopCardTestView.swift b/Projects/Shared/DesignSystem/Example/Sources/SubViews/StopCardTest/StopCardTestView.swift
new file mode 100644
index 00000000..2cb01754
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Example/Sources/SubViews/StopCardTest/StopCardTestView.swift
@@ -0,0 +1,21 @@
+//
+// StopCardTestView.swift
+// DesignSystemExample
+//
+// Created by 임현규 on 8/7/24.
+//
+
+import SwiftUI
+
+import SharedDesignSystem
+
+struct StopCardTestView: View {
+ var body: some View {
+ StopCardView(userName: "임현규")
+ .padding(.sm)
+ }
+}
+
+#Preview {
+ StopCardTestView()
+}
diff --git a/Projects/Shared/DesignSystem/Example/Sources/SubViews/ToastTestView.swift b/Projects/Shared/DesignSystem/Example/Sources/SubViews/ToastTestView.swift
new file mode 100644
index 00000000..f5aa8713
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Example/Sources/SubViews/ToastTestView.swift
@@ -0,0 +1,36 @@
+//
+// ToastTestView.swift
+// DesignSystemExample
+//
+// Created by JongHoon on 8/4/24.
+//
+
+import SwiftUI
+import SharedDesignSystem
+
+struct ToastTestView: View {
+ @State private var presentSheet = false
+
+ var body: some View {
+ VStack(spacing: 8.0) {
+ Text("Present Text")
+ .asButton {
+ ToastManager.shared.present(message: "test", durationSecond: 3.0)
+ }
+
+ Text("present sheet")
+ .asButton {
+ presentSheet = true
+ }
+ }
+ .sheet(
+ isPresented: $presentSheet,
+ content: {
+ Text("Present Text")
+ .asButton {
+ ToastManager.shared.present(message: "test", durationSecond: 3.0)
+ }
+ }
+ )
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Example/Sources/SubViews/UserProfileTest/UserProfileTestView.swift b/Projects/Shared/DesignSystem/Example/Sources/SubViews/UserProfileTest/UserProfileTestView.swift
new file mode 100644
index 00000000..86ef0c45
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Example/Sources/SubViews/UserProfileTest/UserProfileTestView.swift
@@ -0,0 +1,35 @@
+//
+// UserProfileTestView.swift
+// DesignSystemExample
+//
+// Created by 임현규 on 8/7/24.
+//
+
+import SwiftUI
+import SharedDesignSystem
+
+public struct UserProfileTestView: View {
+ public init() {}
+ public var body: some View {
+
+ VStack(spacing: .xl) {
+ UserProfileView(
+ imageURL: "https://static.wikia.nocookie.net/wallaceandgromit/images/3/38/Gromit-3.png/revision/latest/scale-to-width/360?cb=20191228190308",
+ userName: "임현규",
+ userAge: 26
+ )
+ .border(.red)
+
+ UserProfileView(
+ imageURL: "",
+ userName: "임현규",
+ userAge: 26
+ )
+ .border(.red)
+ }
+ }
+}
+
+#Preview {
+ UserProfileTestView()
+}
diff --git a/Projects/Shared/DesignSystem/Example/Sources/SubViews/WantedSansStyleTextTestView.swift b/Projects/Shared/DesignSystem/Example/Sources/SubViews/WantedSansStyleTextTestView.swift
new file mode 100644
index 00000000..a2985b4e
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Example/Sources/SubViews/WantedSansStyleTextTestView.swift
@@ -0,0 +1,36 @@
+//
+// WantedSansStyleTextTestView.swift
+// SharedDesignSystem
+//
+// Created by JongHoon on 7/27/24.
+//
+
+import SwiftUI
+
+import SharedDesignSystem
+
+struct WantedSansStyleTextTestView: View {
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 8.0) {
+ ForEach(
+ Array(zip(
+ 0..
+
+
+
+
+
+
+
+
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_arrow_left.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_arrow_left.imageset/Contents.json
new file mode 100644
index 00000000..8d531ae7
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_arrow_left.imageset/Contents.json
@@ -0,0 +1,24 @@
+{
+ "images" : [
+ {
+ "filename" : "icon_arrow_left.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_arrow_left.imageset/icon_arrow_left.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_arrow_left.imageset/icon_arrow_left.svg
new file mode 100644
index 00000000..5911d3d0
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_arrow_left.imageset/icon_arrow_left.svg
@@ -0,0 +1,8 @@
+
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_bottleStorage.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_bottleStorage.imageset/Contents.json
new file mode 100644
index 00000000..20f180b7
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_bottleStorage.imageset/Contents.json
@@ -0,0 +1,24 @@
+{
+ "images" : [
+ {
+ "filename" : "icon_bottleStorage.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_bottleStorage.imageset/icon_bottleStorage.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_bottleStorage.imageset/icon_bottleStorage.svg
new file mode 100644
index 00000000..90fbe85e
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_bottleStorage.imageset/icon_bottleStorage.svg
@@ -0,0 +1,9 @@
+
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_clearDelete.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_clearDelete.imageset/Contents.json
new file mode 100644
index 00000000..5ac15dbe
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_clearDelete.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "icon_clearDelete.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_clearDelete.imageset/icon_clearDelete.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_clearDelete.imageset/icon_clearDelete.svg
new file mode 100644
index 00000000..d8654076
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_clearDelete.imageset/icon_clearDelete.svg
@@ -0,0 +1,3 @@
+
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_delete.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_delete.imageset/Contents.json
new file mode 100644
index 00000000..563a2fe1
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_delete.imageset/Contents.json
@@ -0,0 +1,24 @@
+{
+ "images" : [
+ {
+ "filename" : "icon_delete.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_delete.imageset/icon_delete.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_delete.imageset/icon_delete.svg
new file mode 100644
index 00000000..f5f694eb
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_delete.imageset/icon_delete.svg
@@ -0,0 +1,8 @@
+
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_down.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_down.imageset/Contents.json
new file mode 100644
index 00000000..bed45688
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_down.imageset/Contents.json
@@ -0,0 +1,24 @@
+{
+ "images" : [
+ {
+ "filename" : "icon_down.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_down.imageset/icon_down.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_down.imageset/icon_down.svg
new file mode 100644
index 00000000..c1545173
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_down.imageset/icon_down.svg
@@ -0,0 +1,8 @@
+
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_kakaoLogo.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_kakaoLogo.imageset/Contents.json
new file mode 100644
index 00000000..63a2f6f2
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_kakaoLogo.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "icon_kakaoLogo.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_kakaoLogo.imageset/icon_kakaoLogo.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_kakaoLogo.imageset/icon_kakaoLogo.svg
new file mode 100644
index 00000000..4d378804
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_kakaoLogo.imageset/icon_kakaoLogo.svg
@@ -0,0 +1,3 @@
+
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_myPage.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_myPage.imageset/Contents.json
new file mode 100644
index 00000000..528aa2fe
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_myPage.imageset/Contents.json
@@ -0,0 +1,24 @@
+{
+ "images" : [
+ {
+ "filename" : "icon_myPage.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_myPage.imageset/icon_myPage.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_myPage.imageset/icon_myPage.svg
new file mode 100644
index 00000000..e4c08d6a
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_myPage.imageset/icon_myPage.svg
@@ -0,0 +1,8 @@
+
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_plus.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_plus.imageset/Contents.json
new file mode 100644
index 00000000..1d44c3bf
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_plus.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "icon_plus.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_plus.imageset/icon_plus.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_plus.imageset/icon_plus.svg
new file mode 100644
index 00000000..59985450
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_plus.imageset/icon_plus.svg
@@ -0,0 +1,8 @@
+
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_right.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_right.imageset/Contents.json
new file mode 100644
index 00000000..18d23c9d
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_right.imageset/Contents.json
@@ -0,0 +1,24 @@
+{
+ "images" : [
+ {
+ "filename" : "icon_right.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_right.imageset/icon_right.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_right.imageset/icon_right.svg
new file mode 100644
index 00000000..9e9ba664
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_right.imageset/icon_right.svg
@@ -0,0 +1,8 @@
+
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_sandBeach.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_sandBeach.imageset/Contents.json
new file mode 100644
index 00000000..4a55ec09
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_sandBeach.imageset/Contents.json
@@ -0,0 +1,24 @@
+{
+ "images" : [
+ {
+ "filename" : "icon_sandBeach.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_sandBeach.imageset/icon_sandBeach.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_sandBeach.imageset/icon_sandBeach.svg
new file mode 100644
index 00000000..ef847111
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_sandBeach.imageset/icon_sandBeach.svg
@@ -0,0 +1,12 @@
+
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_share.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_share.imageset/Contents.json
new file mode 100644
index 00000000..ebbeaacd
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_share.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "icon_share.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_share.imageset/icon_share.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_share.imageset/icon_share.svg
new file mode 100644
index 00000000..63ecffd5
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_share.imageset/icon_share.svg
@@ -0,0 +1,4 @@
+
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_siren.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_siren.imageset/Contents.json
new file mode 100644
index 00000000..7e450a36
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_siren.imageset/Contents.json
@@ -0,0 +1,24 @@
+{
+ "images" : [
+ {
+ "filename" : "icon_siren.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_siren.imageset/icon_siren.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_siren.imageset/icon_siren.svg
new file mode 100644
index 00000000..ca693e73
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_siren.imageset/icon_siren.svg
@@ -0,0 +1,8 @@
+
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_up.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_up.imageset/Contents.json
new file mode 100644
index 00000000..6f147b9b
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_up.imageset/Contents.json
@@ -0,0 +1,24 @@
+{
+ "images" : [
+ {
+ "filename" : "icon_up.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_up.imageset/icon_up.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_up.imageset/icon_up.svg
new file mode 100644
index 00000000..f3e9a64a
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_up.imageset/icon_up.svg
@@ -0,0 +1,8 @@
+
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_verticalLine.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_verticalLine.imageset/Contents.json
new file mode 100644
index 00000000..b4e2563b
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_verticalLine.imageset/Contents.json
@@ -0,0 +1,24 @@
+{
+ "images" : [
+ {
+ "filename" : "icon_verticalLine.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_verticalLine.imageset/icon_verticalLine.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_verticalLine.imageset/icon_verticalLine.svg
new file mode 100644
index 00000000..d6164d14
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_verticalLine.imageset/icon_verticalLine.svg
@@ -0,0 +1,3 @@
+
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/Contents.json
new file mode 100644
index 00000000..73c00596
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_basket.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_basket.imageset/Contents.json
new file mode 100644
index 00000000..c09e5140
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_basket.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "illustraition_basket.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_basket.imageset/illustraition_basket.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_basket.imageset/illustraition_basket.svg
new file mode 100644
index 00000000..311e8073
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_basket.imageset/illustraition_basket.svg
@@ -0,0 +1,9 @@
+
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_bottle1.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_bottle1.imageset/Contents.json
new file mode 100644
index 00000000..356dec5b
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_bottle1.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "illustraition_bottle1.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_bottle1.imageset/illustraition_bottle1.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_bottle1.imageset/illustraition_bottle1.svg
new file mode 100644
index 00000000..c48cb6aa
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_bottle1.imageset/illustraition_bottle1.svg
@@ -0,0 +1,9 @@
+
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_bottle2.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_bottle2.imageset/Contents.json
new file mode 100644
index 00000000..bda36ac7
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_bottle2.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "illustraition_bottle2.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_bottle2.imageset/illustraition_bottle2.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_bottle2.imageset/illustraition_bottle2.svg
new file mode 100644
index 00000000..b63ad9e8
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_bottle2.imageset/illustraition_bottle2.svg
@@ -0,0 +1,9 @@
+
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_boy.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_boy.imageset/Contents.json
new file mode 100644
index 00000000..f2f4351b
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_boy.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "illustraition_boy.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_boy.imageset/illustraition_boy.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_boy.imageset/illustraition_boy.svg
new file mode 100644
index 00000000..18c20a9c
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_boy.imageset/illustraition_boy.svg
@@ -0,0 +1,9 @@
+
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_girl.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_girl.imageset/Contents.json
new file mode 100644
index 00000000..09bf2ae9
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_girl.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "illustraition_girl.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_girl.imageset/illustraition_girl.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_girl.imageset/illustraition_girl.svg
new file mode 100644
index 00000000..eeb61585
--- /dev/null
+++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_girl.imageset/illustraition_girl.svg
@@ -0,0 +1,9 @@
+