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 @@ + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_islandEmptyBottle.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_islandEmptyBottle.imageset/Contents.json new file mode 100644 index 00000000..69936498 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_islandEmptyBottle.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "illustraition_islandEmptyBottle.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_islandEmptyBottle.imageset/illustraition_islandEmptyBottle.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_islandEmptyBottle.imageset/illustraition_islandEmptyBottle.svg new file mode 100644 index 00000000..a4c675bd --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_islandEmptyBottle.imageset/illustraition_islandEmptyBottle.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_islandHasBottle.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_islandHasBottle.imageset/Contents.json new file mode 100644 index 00000000..b3ec18bd --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_islandHasBottle.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "illustraition_islandHasBottle.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_islandHasBottle.imageset/illustraition_islandHasBottle.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_islandHasBottle.imageset/illustraition_islandHasBottle.svg new file mode 100644 index 00000000..03a2945a --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_islandHasBottle.imageset/illustraition_islandHasBottle.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_loginBackground.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_loginBackground.imageset/Contents.json new file mode 100644 index 00000000..e505b9f3 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_loginBackground.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "illustraition_loginBackground.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_loginBackground.imageset/illustraition_loginBackground.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_loginBackground.imageset/illustraition_loginBackground.svg new file mode 100644 index 00000000..2f573079 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_loginBackground.imageset/illustraition_loginBackground.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_logo.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_logo.imageset/Contents.json new file mode 100644 index 00000000..8b41b939 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "illustraition_logo.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_logo.imageset/illustraition_logo.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_logo.imageset/illustraition_logo.svg new file mode 100644 index 00000000..0ef8509c --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_logo.imageset/illustraition_logo.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_loudspeaker.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_loudspeaker.imageset/Contents.json new file mode 100644 index 00000000..d2028108 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_loudspeaker.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "illustraition_loudspeaker.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_loudspeaker.imageset/illustraition_loudspeaker.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_loudspeaker.imageset/illustraition_loudspeaker.svg new file mode 100644 index 00000000..49674e5f --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_loudspeaker.imageset/illustraition_loudspeaker.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_no.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_no.imageset/Contents.json new file mode 100644 index 00000000..4cfb7251 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_no.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "illustraition_no.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_no.imageset/illustraition_no.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_no.imageset/illustraition_no.svg new file mode 100644 index 00000000..4479ad5d --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_no.imageset/illustraition_no.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_phone.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_phone.imageset/Contents.json new file mode 100644 index 00000000..d5c5dd1c --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_phone.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "illustraition_phone.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_phone.imageset/illustraition_phone.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_phone.imageset/illustraition_phone.svg new file mode 100644 index 00000000..bb4ac452 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_phone.imageset/illustraition_phone.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_telescope.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_telescope.imageset/Contents.json new file mode 100644 index 00000000..c62e6313 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_telescope.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "illustraition_telescope.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_telescope.imageset/illustraition_telescope.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_telescope.imageset/illustraition_telescope.svg new file mode 100644 index 00000000..6a796c0f --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_telescope.imageset/illustraition_telescope.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_whiteLogo.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_whiteLogo.imageset/Contents.json new file mode 100644 index 00000000..d8c32ad9 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_whiteLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "illustraition_whiteLogo.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/illustration/illustraition_whiteLogo.imageset/illustraition_whiteLogo.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_whiteLogo.imageset/illustraition_whiteLogo.svg new file mode 100644 index 00000000..42a4831d --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_whiteLogo.imageset/illustraition_whiteLogo.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_yes.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_yes.imageset/Contents.json new file mode 100644 index 00000000..48c5f6da --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_yes.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "illustraition_yes.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_yes.imageset/illustraition_yes.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_yes.imageset/illustraition_yes.svg new file mode 100644 index 00000000..5793e7cd --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustraition_yes.imageset/illustraition_yes.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustration_sandBeachBackground.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustration_sandBeachBackground.imageset/Contents.json new file mode 100644 index 00000000..007bb905 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustration_sandBeachBackground.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "illustration_sandBeachBackground.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustration_sandBeachBackground.imageset/illustration_sandBeachBackground.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustration_sandBeachBackground.imageset/illustration_sandBeachBackground.svg new file mode 100644 index 00000000..228f2105 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/illustration/illustration_sandBeachBackground.imageset/illustration_sandBeachBackground.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/image/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/image/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/image/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/image_splash.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/image_splash.imageset/Contents.json new file mode 100644 index 00000000..71533294 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/image_splash.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "icon_splash.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/image_splash.imageset/icon_splash.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/image_splash.imageset/icon_splash.svg new file mode 100644 index 00000000..eb848180 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/image_splash.imageset/icon_splash.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/Lotties.xcassets/Contents.json b/Projects/Shared/DesignSystem/Resources/Lotties.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Lotties.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/Lotties.xcassets/ProgressIndicator.dataset/Contents.json b/Projects/Shared/DesignSystem/Resources/Lotties.xcassets/ProgressIndicator.dataset/Contents.json new file mode 100644 index 00000000..74fb6963 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Lotties.xcassets/ProgressIndicator.dataset/Contents.json @@ -0,0 +1,12 @@ +{ + "data" : [ + { + "filename" : "ProgressIndicator.lottie", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/Lotties.xcassets/ProgressIndicator.dataset/ProgressIndicator.lottie b/Projects/Shared/DesignSystem/Resources/Lotties.xcassets/ProgressIndicator.dataset/ProgressIndicator.lottie new file mode 100644 index 00000000..5632421c --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Lotties.xcassets/ProgressIndicator.dataset/ProgressIndicator.lottie @@ -0,0 +1 @@ +{"v":"5.7.11","fr":60,"ip":0,"op":81,"w":1920,"h":1080,"nm":"Loading Dots","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Dot4","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":25,"s":[25]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":39,"s":[100]},{"t":55,"s":[25]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":25,"s":[1142,540,0],"to":null,"ti":null},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":39,"s":[1142,500,0],"to":null,"ti":null},{"t":55,"s":[1142,540,0]}],"ix":2,"l":2},"a":{"a":0,"k":[-284,92,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":25,"s":[50,50,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":39,"s":[75,75,100]},{"t":55,"s":[50,50,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[120,120],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false,"_render":true},{"ty":"fl","c":{"a":0,"k":[0.7608,0.7608,0.7608,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false,"_render":true},{"ty":"tr","p":{"a":0,"k":[-284,92],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform","_render":true}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false,"_render":true}],"ip":0,"op":360,"st":0,"bm":0,"completed":true},{"ddd":0,"ind":2,"ty":4,"nm":"Dot3","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":17,"s":[25]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":31,"s":[100]},{"t":47,"s":[25]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":17,"s":[1022,540,0],"to":null,"ti":null},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":31,"s":[1022,500,0],"to":null,"ti":null},{"t":47,"s":[1022,540,0]}],"ix":2,"l":2},"a":{"a":0,"k":[-284,92,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":17,"s":[50,50,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":31,"s":[75,75,100]},{"t":47,"s":[50,50,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[120,120],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false,"_render":true},{"ty":"fl","c":{"a":0,"k":[0.7608,0.7608,0.7608,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false,"_render":true},{"ty":"tr","p":{"a":0,"k":[-284,92],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform","_render":true}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false,"_render":true}],"ip":0,"op":360,"st":0,"bm":0,"completed":true},{"ddd":0,"ind":3,"ty":4,"nm":"Dot2","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":9,"s":[25]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":23,"s":[100]},{"t":39,"s":[25]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":9,"s":[902,540,0],"to":null,"ti":null},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":23,"s":[902,500,0],"to":null,"ti":null},{"t":39,"s":[902,540,0]}],"ix":2,"l":2},"a":{"a":0,"k":[-284,92,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":9,"s":[50,50,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":23,"s":[75,75,100]},{"t":39,"s":[50,50,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[120,120],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false,"_render":true},{"ty":"fl","c":{"a":0,"k":[0.7608,0.7608,0.7608,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false,"_render":true},{"ty":"tr","p":{"a":0,"k":[-284,92],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform","_render":true}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false,"_render":true}],"ip":0,"op":360,"st":0,"bm":0,"completed":true},{"ddd":0,"ind":4,"ty":4,"nm":"Dot1","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[25]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":14,"s":[100]},{"t":30,"s":[25]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[782,540,0],"to":null,"ti":null},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":14,"s":[782,500,0],"to":null,"ti":null},{"t":30,"s":[782,540,0]}],"ix":2,"l":2},"a":{"a":0,"k":[-284,92,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":0,"s":[50,50,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":14,"s":[75,75,100]},{"t":30,"s":[50,50,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[120,120],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false,"_render":true},{"ty":"fl","c":{"a":0,"k":[0.7608,0.7608,0.7608,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false,"_render":true},{"ty":"tr","p":{"a":0,"k":[-284,92],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform","_render":true}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false,"_render":true}],"ip":0,"op":360,"st":0,"bm":0,"completed":true}],"markers":[],"__complete":true} \ No newline at end of file diff --git a/Projects/Shared/DesignSystem/Sources/Color/BottleColorSystem+Neutral.swift b/Projects/Shared/DesignSystem/Sources/Color/BottleColorSystem+Neutral.swift new file mode 100644 index 00000000..63394ec4 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Color/BottleColorSystem+Neutral.swift @@ -0,0 +1,46 @@ +// +// BottleColorSystem+Neutral.swift +// SharedDesignSystem +// +// Created by 임현규 on 7/26/24. +// + +import SwiftUI + +extension Color.BottleColorSystem { + enum Neutral: Colorable { + case neutral100 + case neutral200 + case neutral300 + case neutral400 + case neutral500 + case neutral600 + case neutral700 + case neutral800 + case neutral900 + } +} +extension Color.BottleColorSystem.Neutral { + var color: Color { + switch self { + case .neutral100: + Color(asset: SharedDesignSystemAsset.Colors.neutral100) + case .neutral200: + Color(asset: SharedDesignSystemAsset.Colors.neutral200) + case .neutral300: + Color(asset: SharedDesignSystemAsset.Colors.neutral300) + case .neutral400: + Color(asset: SharedDesignSystemAsset.Colors.neutral400) + case .neutral500: + Color(asset: SharedDesignSystemAsset.Colors.neutral500) + case .neutral600: + Color(asset: SharedDesignSystemAsset.Colors.neutral600) + case .neutral700: + Color(asset: SharedDesignSystemAsset.Colors.neutral700) + case .neutral800: + Color(asset: SharedDesignSystemAsset.Colors.neutral800) + case .neutral900: + Color(asset: SharedDesignSystemAsset.Colors.neutral900) + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Color/BottleColorSystem+Primary.swift b/Projects/Shared/DesignSystem/Sources/Color/BottleColorSystem+Primary.swift new file mode 100644 index 00000000..0847501d --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Color/BottleColorSystem+Primary.swift @@ -0,0 +1,35 @@ +// +// BottleColorSystem+Primary.swift +// SharedDesignSystem +// +// Created by 임현규 on 7/26/24. +// + +import SwiftUI + +extension Color.BottleColorSystem { + enum Primary: Colorable { + case purple100 + case purple200 + case purple300 + case purple400 + case purple500 + } +} + +extension Color.BottleColorSystem.Primary { + var color: Color { + switch self { + case .purple100: + return Color(asset: SharedDesignSystemAsset.Colors.primaryPurple100) + case .purple200: + return Color(asset: SharedDesignSystemAsset.Colors.primaryPurple200) + case .purple300: + return Color(asset: SharedDesignSystemAsset.Colors.primaryPurple300) + case .purple400: + return Color(asset: SharedDesignSystemAsset.Colors.primaryPurple400) + case .purple500: + return Color(asset: SharedDesignSystemAsset.Colors.primaryPurple500) + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Color/BottleColorSystem+Sub.swift b/Projects/Shared/DesignSystem/Sources/Color/BottleColorSystem+Sub.swift new file mode 100644 index 00000000..536161e3 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Color/BottleColorSystem+Sub.swift @@ -0,0 +1,35 @@ +// +// BottleColorSystem+Sub.swift +// SharedDesignSystem +// +// Created by 임현규 on 7/26/24. +// + +import SwiftUI + +extension Color.BottleColorSystem { + enum Sub: Colorable { + case black + case gradient + case red + case white + case kakao + } +} + +extension Color.BottleColorSystem.Sub { + var color: Color { + switch self { + case .black: + return Color(asset: SharedDesignSystemAsset.Colors.black100) + case .gradient: + return Color(asset: SharedDesignSystemAsset.Colors.gradient) + case .red: + return Color(asset: SharedDesignSystemAsset.Colors.red) + case .white: + return Color(asset: SharedDesignSystemAsset.Colors.white100) + case .kakao: + return Color(asset: SharedDesignSystemAsset.Colors.kakao) + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Color/Color+Extensions.swift b/Projects/Shared/DesignSystem/Sources/Color/Color+Extensions.swift new file mode 100644 index 00000000..5eb0eab1 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Color/Color+Extensions.swift @@ -0,0 +1,27 @@ +// +// Color+Extensions.swift +// SharedDesignSystem +// +// Created by 임현규 on 7/26/24. +// + +import SwiftUI + +extension Color { + enum BottleColorSystem: Colorable { + case primary(Primary) + case neutral(Neutral) + case sub(Sub) + + var color: Color { + switch self { + case .primary(let primary): + return primary.color + case .neutral(let neutral): + return neutral.color + case .sub(let sub): + return sub.color + } + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Color/Colorable.swift b/Projects/Shared/DesignSystem/Sources/Color/Colorable.swift new file mode 100644 index 00000000..03f146d8 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Color/Colorable.swift @@ -0,0 +1,12 @@ +// +// Colorable.swift +// SharedDesignSystem +// +// Created by 임현규 on 7/26/24. +// + +import SwiftUI + +public protocol Colorable { + var color: Color { get } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Button/ButtonStateType.swift b/Projects/Shared/DesignSystem/Sources/Components/Button/ButtonStateType.swift new file mode 100644 index 00000000..31bb6e8f --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Button/ButtonStateType.swift @@ -0,0 +1,12 @@ +// +// ButtonStateType.swift +// DesignSystemExample +// +// Created by JongHoon on 7/27/24. +// + +enum ButtonStateType { + case enabled + case selected + case disabled +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Button/ButtonType.swift b/Projects/Shared/DesignSystem/Sources/Components/Button/ButtonType.swift new file mode 100644 index 00000000..d1b321d3 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Button/ButtonType.swift @@ -0,0 +1,12 @@ +// +// ButtonType.swift +// CoreUtil +// +// Created by JongHoon on 7/27/24. +// + +public enum ButtonType { + case normal + case throttle + case debounce +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Button/OutlinedButton/OutlinedButtonStyle.swift b/Projects/Shared/DesignSystem/Sources/Components/Button/OutlinedButton/OutlinedButtonStyle.swift new file mode 100644 index 00000000..2b4fc3ef --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Button/OutlinedButton/OutlinedButtonStyle.swift @@ -0,0 +1,195 @@ +// +// OutlinedButtonStyle.swift +// SharedDesignSystem +// +// Created by JongHoon on 7/28/24. +// + +import SwiftUI + +public struct OutlinedButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var isEnabled: Bool + private let configurationInfo: OutlinedStyleButton.ConfigurationInfo + private let isSelected: Bool? + + public init( + configurationInfo: OutlinedStyleButton.ConfigurationInfo, + isSelected: Bool? = nil + ) { + self.configurationInfo = configurationInfo + self.isSelected = isSelected + } + + public func makeBody(configuration: Configuration) -> some View { + let buttonState = buttonState(configuration: configuration) + + return configuration.label + .padding(.horizontal, horizontalPadding) + .padding(.vertical, verticalPadding) + .frame(maxWidth: width) + .frame(height: height) + .foregroundStyle(foregroundColor(buttonState: buttonState)) + .background(backgroundColor(buttonState: buttonState)) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius) + .strokeBorder( + borderColor(buttonState: buttonState), + lineWidth: 1.0 + ) + ) + } + + private func baseButtonLabel(configuration: Configuration) -> some View { + let buttonState = buttonState(configuration: configuration) + + return configuration.label + .padding(.horizontal, horizontalPadding) + .padding(.vertical, verticalPadding) + .overlay( + RoundedRectangle(cornerRadius: BottleRadiusType.xs.value) + .strokeBorder( + borderColor(buttonState: buttonState), + lineWidth: 1.0 + ) + ) + .frame(width: width, height: height) + .foregroundStyle(foregroundColor(buttonState: buttonState)) + .background(backgroundColor(buttonState: buttonState)) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + } +} + +// MARK: - Private Method + +private extension OutlinedButtonStyle { + func buttonState(configuration: Configuration) -> ButtonStateType { + if !isEnabled { return .disabled } + + if configuration.isPressed || isSelected == true { return .selected } + + return .enabled + } + + func borderColor(buttonState: ButtonStateType) -> Color { + switch buttonState { + case .enabled: + return ColorToken.border(.enabled).color + + case .selected: + return ColorToken.border(.selected).color + + case .disabled: + return ColorToken.border(.enabled).color + } + } + + func backgroundColor(buttonState: ButtonStateType) -> Color { + switch buttonState { + case .enabled: + return ColorToken.container(.enablePrimary).color + + case .selected: + return ColorToken.container(.selected).color + + case .disabled: + return ColorToken.container(.disablePrimary).color + } + } + + func foregroundColor(buttonState: ButtonStateType) -> Color { + switch buttonState { + case .enabled: + return ColorToken.text(.secondary).color + + case .selected: + return ColorToken.text(.selectPrimary).color + + case .disabled: + return ColorToken.text(.disableSecondary).color + } + } + + var horizontalPadding: CGFloat? { + switch configurationInfo { + case .small: + return 12.0 + + case .medium: + return 21.0 + + case .large: + return 0.0 + } + } + + var verticalPadding: CGFloat? { + switch configurationInfo { + case .small: + return nil + + case let .medium(contentType): + switch contentType { + case .text: + return nil + + case .image: + return 23.5 + } + + case .large: + return nil + } + } + + var height: CGFloat { + switch configurationInfo { + case .small: + return 36.0 + + // TODO: 논의 후 수정 필요 + case let .medium(contentType): + switch contentType { + case .text: + return 56.0 + + case .image: + return 180.0 + } + + case .large: + return 56.0 + } + } + + var width: CGFloat? { + switch configurationInfo { + case .small: + return nil + + case .medium: + return .infinity + + case .large: + return .infinity + } + } + + var cornerRadius: CGFloat { + switch configurationInfo { + case let .small(contentType): + switch contentType { + case .text: + return BottleRadiusType.xs.value + case .image: + return BottleRadiusType.sm.value + } + + case .medium: + return BottleRadiusType.sm.value + + case .large: + return BottleRadiusType.sm.value + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Button/OutlinedButton/OutlinedStyleButton.swift b/Projects/Shared/DesignSystem/Sources/Components/Button/OutlinedButton/OutlinedStyleButton.swift new file mode 100644 index 00000000..bfa51416 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Button/OutlinedButton/OutlinedStyleButton.swift @@ -0,0 +1,158 @@ +// +// OutlinedStyleButton.swift.swift +// DesignSystemExample +// +// Created by JongHoon on 7/27/24. +// + +import SwiftUI + +public struct OutlinedStyleButton: View { + private let configurationInfo: ConfigurationInfo + private let title: String + private let buttonType: ButtonType + private let buttonStyle: OutlinedButtonStyle + /// Toggle Button 구현시 필요 + private let isSelected: Bool? + private let action: () -> Void + + public init( + _ configurationInfo: ConfigurationInfo, + title: String, + buttonType: ButtonType, + isSelected: Bool? = nil, + action: @escaping () -> Void + ) { + self.configurationInfo = configurationInfo + self.title = title + self.buttonType = buttonType + self.isSelected = isSelected + self.buttonStyle = OutlinedButtonStyle( + configurationInfo: configurationInfo, + isSelected: isSelected + ) + self.action = action + } + + public var body: some View { + outlinedStyleButton + .buttonStyle(buttonStyle) + } +} + +// MARK: - Public Extension + +public extension OutlinedStyleButton { + enum ConfigurationInfo { + case small(contentType: ContentType) + case medium(contentType: ContentType) + case large + + public enum ContentType { + case text + case image(type: ImageType) + + public enum ImageType { + case remote(url: String) + case local(bottleImageSystem: Image.BottleImageSystem) + } + } + } +} + +// MARK: - Private Views + +private extension OutlinedStyleButton { + var titleText: some View { + Text(title) + .font(to: .wantedSans(.body)) + } + + @ViewBuilder + var sizeBasedButtonLabel: some View { + switch configurationInfo { + case let .small(contentType): + switch contentType { + case .text: + titleText + + case let .image(imageType): + HStack(spacing: .xs) { + imageView(imageType: imageType) + .frame(width: 16.0, height: 16.0) + + titleText + } + } + + case let .medium(contentType): + switch contentType { + case .text: + titleText + + case let .image(imageType): + VStack(spacing: .sm) { + imageView(imageType: imageType) + .frame(width: 100, height: 100) + titleText + } + } + + case .large: + titleText + } + } + + @ViewBuilder + var outlinedStyleButton: some View { + switch buttonType { + case .normal: + sizeBasedButtonLabel.asButton(action: action) + + case .throttle: + sizeBasedButtonLabel.asThrottleButton(action: action) + + case .debounce: + sizeBasedButtonLabel.asDebounceButton(action: action) + } + } + + // TODO: Image 정해지면 corner radius 등 확인 필요 + @ViewBuilder + func imageView(imageType: ConfigurationInfo.ContentType.ImageType) -> some View { + var imageViewType: ImageViewType { + switch imageType { + case let .remote(url): + switch configurationInfo { + case .small: + return .remote(url: url, downsamplingWidth: 20.0, downsamplingHeight: 20.0) + + case .medium: + return .remote(url: url, downsamplingWidth: 152.0, downsamplingHeight: 152.0) + + default: + assertionFailure("Wrong Image Configuration") + return .local(bottleImageSystem: .icom(.siren)) + } + + case let .local(bottleImageSystem): + return .local(bottleImageSystem: bottleImageSystem) + } + } + + BottleImageView(type: imageViewType) + } +} + + + +#Preview { + OutlinedStyleButton( + .small(contentType: .text), + title: "test", + buttonType: .normal, + action: { + print("teste") + } + ) +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Button/SolidButton/SolidButton.swift b/Projects/Shared/DesignSystem/Sources/Components/Button/SolidButton/SolidButton.swift new file mode 100644 index 00000000..3720a565 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Button/SolidButton/SolidButton.swift @@ -0,0 +1,101 @@ +// +// SolidButton.swift +// DesignSystemExample +// +// Created by 임현규 on 7/27/24. +// + +import SwiftUI + +public struct SolidButton: View { + private let title: String + private let sizeType: SizeType + private let buttonType: ButtonType + private let buttonApperance: ButtonAppearanceType + private let action: () -> Void + + public init( + title: String, + sizeType: SizeType, + buttonType: ButtonType, + buttonApperance: ButtonAppearanceType = .solid, + action: @escaping () -> Void + ) { + self.title = title + self.sizeType = sizeType + self.buttonType = buttonType + self.buttonApperance = buttonApperance + self.action = action + } + + public var body: some View { + solidStyleButton + .buttonStyle( + SolidButtonStyle( + sizeType: sizeType, + buttonApperance: buttonApperance + ) + ) + .overlay(alignment: .leading) { + image + .padding(.leading, .md) + } + } +} + +// MARK: - Public Extension + +public extension SolidButton { + enum SizeType { + case extraSmall + case small + case medium + case large + case full + } +} + + +// MARK: - Views + +extension SolidButton { + var titleText: some View { + switch sizeType { + case .extraSmall, .small: + Text(title) + .font(to: .wantedSans(.body)) + + default: + Text(title) + .font(to: .wantedSans(.subTitle1)) + } + } + + @ViewBuilder + var solidStyleButton: some View { + switch buttonType { + case .normal: + titleText.asButton(action: action) + case .throttle: + titleText.asThrottleButton(action: action) + case .debounce: + titleText.asDebounceButton(action: action) + } + } + + @ViewBuilder + var image: some View { + switch buttonApperance { + case .kakao: + BottleImageView(type: .local(bottleImageSystem: .icom(.kakaoLogo))) + + case .apple: + BottleImageView(type: .local(bottleImageSystem: .icom(.appleLogo))) + case .solid: + EmptyView() + case .generalSignIn: + EmptyView() + } + } + +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Button/SolidButton/SolidButtonStyle.swift b/Projects/Shared/DesignSystem/Sources/Components/Button/SolidButton/SolidButtonStyle.swift new file mode 100644 index 00000000..3dd064e5 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Button/SolidButton/SolidButtonStyle.swift @@ -0,0 +1,121 @@ +// +// SolidButtonStyle.swift +// DesignSystemExample +// +// Created by 임현규 on 7/27/24. +// + +import SwiftUI + +public enum ButtonAppearanceType { + case solid + case kakao + case apple + case generalSignIn +} + +struct SolidButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var isEnabled: Bool + private let sizeType: SolidButton.SizeType + private let buttonApperance: ButtonAppearanceType + + public init( + sizeType: SolidButton.SizeType, + buttonApperance: ButtonAppearanceType = .solid + ) { + self.sizeType = sizeType + self.buttonApperance = buttonApperance + } + + func makeBody(configuration: Configuration) -> some View { + let buttonState = makeButtonState(configuration) + + return configuration.label + .padding(.horizontal, .sm) + .frame(height: height) + .frame(maxWidth: width) + .foregroundStyle(foregroundColor(buttonState)) + .background { + RoundedRectangle(cornerRadius: cornerRadius.value) + .fill(backgroundColor(buttonState)) + } + } +} + +// MARK: - Private Extension + +private extension SolidButtonStyle { + var height: CGFloat { + switch sizeType { + case .extraSmall: return 36 + case .small: return 36 + case .medium: return 56 + case .large: return 64 + case .full: return 64 + } + } + + var width: CGFloat? { + switch sizeType { + case .extraSmall: return nil + case .small: return .infinity + case .medium: return .infinity + case .large: return .infinity + case .full: return .infinity + } + } + + var cornerRadius: BottleRadiusType { + switch sizeType { + case .extraSmall: return .xs + case .small: return .sm + case .medium: return .md + case .large: return .md + case .full: return .md + } + } + + func makeButtonState(_ configuration: Configuration) -> ButtonStateType { + return !isEnabled ? .disabled : configuration.isPressed ? .selected : .enabled + } + + func backgroundColor(_ buttonState: ButtonStateType) -> Color { + switch buttonApperance { + case .solid: + switch buttonState { + case .enabled: return ColorToken.container(.enableSecondary).color + case .selected: return ColorToken.container(.pressed).color + case .disabled: return ColorToken.container(.disableSecondary).color + } + case .kakao: + return ColorToken.container(.kakao).color + case .apple: + return ColorToken.container(.primary).color + + case .generalSignIn: + return Color.white + } + } + + func foregroundColor(_ buttonState: ButtonStateType) -> Color { + + switch buttonApperance { + case .solid: + switch buttonState { + case .enabled: return ColorToken.text(.enablePrimary).color + case .selected: return ColorToken.text(.pressed).color + case .disabled: return ColorToken.text(.disablePrimary).color + } + + case .kakao: + return ColorToken.text(.primary).color + + case .apple: + return ColorToken.text(.primary).color + + case .generalSignIn: + return ColorToken.text(.primary).color + } + } +} + diff --git a/Projects/Shared/DesignSystem/Sources/Components/Card/ClipList/ChipListLayout.swift b/Projects/Shared/DesignSystem/Sources/Components/Card/ClipList/ChipListLayout.swift new file mode 100644 index 00000000..5fa96537 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Card/ClipList/ChipListLayout.swift @@ -0,0 +1,95 @@ +// +// ChipListLayout.swift +// SharedDesignSystem +// +// Created by 임현규 on 8/2/24. +// + +import SwiftUI + +struct ClipListLayout: Layout { + var spacing: Spacer.BottleSpacingType = .xs + var rowSpacing: Spacer.BottleSpacingType = .sm + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let maxWidth = proposal.width ?? 0 + var height: CGFloat = 0 + let rows = generateRows(maxWidth, proposal, subviews) + + // rows들로 height 계산 + for (index, row) in rows.enumerated() { + if index == (rows.count - 1) { + height += row.maxHeight(proposal) + } else { + height += row.maxHeight(proposal) + rowSpacing.minLength + } + } + + return .init(width: maxWidth, height: height) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + var origin = bounds.origin + let maxWidth = bounds.width + let rows = generateRows(maxWidth, proposal, subviews) + + // 첫 번째 열부터 view 배치 + for row in rows { + origin.x = bounds.minX + + for view in row { + let viewSize = view.sizeThatFits(proposal) + view.place(at: origin, proposal: proposal) + origin.x += (viewSize.width + spacing.minLength) + } + + origin.y += row.maxHeight(proposal) + rowSpacing.minLength + } + } + + func generateRows(_ maxWidth: CGFloat, _ proposal: ProposedViewSize, _ subviews: Subviews) -> [[LayoutSubviews.Element]] { + var row = [LayoutSubviews.Element]() + var rows = [[LayoutSubviews.Element]]() + + var origin = CGRect.zero.origin + + for view in subviews { + let viewSize = view.sizeThatFits(proposal) + + // view를 배치했을 떄 최대 너비보다 큰 경우 + if (origin.x + viewSize.width + spacing.minLength) > maxWidth { + // 현재까지 row에 담겨있는 view를 rows에 추가한 뒤 row 초기화 + rows.append(row) + row.removeAll() + + origin.x = 0 + // row에 view 추가 -> 새로운 row + row.append(view) + + origin.x += (viewSize.width + spacing.minLength) + } else { + // view 배치했을 때 최대 너비보다 작은 경우 + // row에 view 추가 + row.append(view) + origin.x += (viewSize.width + spacing.minLength) + } + } + + // 마지막 row 처리 + if !row.isEmpty { + rows.append(row) + row.removeAll() + } + + return rows + } +} + +// MARK: - Private Extension +private extension [LayoutSubviews.Element] { + func maxHeight(_ proposal: ProposedViewSize) -> CGFloat { + return self.compactMap { view in + return view.sizeThatFits(proposal).height + }.max() ?? 0 + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Card/ClipList/ClipItem.swift b/Projects/Shared/DesignSystem/Sources/Components/Card/ClipList/ClipItem.swift new file mode 100644 index 00000000..656eb60b --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Card/ClipList/ClipItem.swift @@ -0,0 +1,18 @@ +// +// ClipItem.swift +// SharedDesignSystem +// +// Created by 임현규 on 8/2/24. +// + +import Foundation + +public struct ClipItem: Hashable { + var title: String + var list: [String] + + public init(title: String, list: [String]) { + self.title = title + self.list = list + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Card/ClipList/ClipListContainerView.swift b/Projects/Shared/DesignSystem/Sources/Components/Card/ClipList/ClipListContainerView.swift new file mode 100644 index 00000000..73252d9f --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Card/ClipList/ClipListContainerView.swift @@ -0,0 +1,33 @@ +// +// ClipListContainerView.swift +// SharedDesignSystem +// +// Created by 임현규 on 8/2/24. +// + +import SwiftUI + +public struct ClipListContainerView: View { + private let clipItemList: [ClipItem] + + public init(clipItemList: [ClipItem]) { + self.clipItemList = clipItemList + } + + public var body: some View { + VStack(spacing: .xl) { + ForEach(clipItemList, id: \.self) { clipItem in + ClipListView(clipItem: clipItem) + } + } + .padding(.horizontal, .md) + .padding(.vertical, .xl) + .overlay( + RoundedRectangle(cornerRadius: BottleRadiusType.xl.value) + .strokeBorder( + ColorToken.border(.primary).color, + lineWidth: 1 + ) + ) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Card/ClipList/ClipListView.swift b/Projects/Shared/DesignSystem/Sources/Components/Card/ClipList/ClipListView.swift new file mode 100644 index 00000000..25c897b8 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Card/ClipList/ClipListView.swift @@ -0,0 +1,58 @@ +// +// ClipListView.swift +// DesignSystemExample +// +// Created by 임현규 on 8/2/24. +// + +import SwiftUI + +public struct ClipListView: View { + private let clipItem: ClipItem + + public init(clipItem: ClipItem) { + self.clipItem = clipItem + } + + public var body: some View { + VStack(spacing: .sm) { + HStack(spacing: 0) { + titleText + Spacer() + } + clipList + } + } +} + +private extension ClipListView { + var titleText: some View { + WantedSansStyleText( + clipItem.title, + style: .subTitle2, + color: .tertiary + ) + } + + var clipList: some View { + ClipListLayout() { + ForEach(clipItem.list, id: \.self) { item in + WantedSansStyleText( + item, + style: .body, + color: .secondary + ) + .frame(height: 36) + .padding(.horizontal, .sm) + .overlay( + RoundedRectangle(cornerRadius: BottleRadiusType.xs.value) + .strokeBorder( + ColorToken.border(.primary).color, + lineWidth: 1 + ) + ) + } + } + } +} + diff --git a/Projects/Shared/DesignSystem/Sources/Components/Card/Letter/LettetCardView.swift b/Projects/Shared/DesignSystem/Sources/Components/Card/Letter/LettetCardView.swift new file mode 100644 index 00000000..e1211bc7 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Card/Letter/LettetCardView.swift @@ -0,0 +1,69 @@ +// +// LettetCardView.swift +// SharedDesignSystem +// +// Created by 임현규 on 8/7/24. +// + +import SwiftUI + +public struct LettetCardView: View { + private let title: String + private let letterContent: String + + public init(title: String, letterContent: String) { + self.title = title + self.letterContent = letterContent + } + + public var body: some View { + VStack(spacing: .xl) { + titleText + letterText + } + .padding(.horizontal, .md) + .padding(.vertical, .xl) + .overlay(roundedRectangle) + } +} + +// MARK: - Views +private extension LettetCardView { + var roundedRectangle: some View { + RoundedRectangle(cornerRadius: BottleRadiusType.xl.value) + .strokeBorder( + ColorToken.border(.primary).color, + lineWidth: 1 + ) + } + + var titleText: some View { + HStack(spacing: 0) { + WantedSansStyleText( + "\(title)", + style: .subTitle1, + color: .primary + ) + Spacer() + } + } + + var letterText: some View { + HStack(spacing: 0) { + WantedSansStyleText( + letterContent, + style: .body, + color: .secondary + ) + .multilineTextAlignment(.leading) + + Spacer() + } + .padding(.md) + .lineSpacing(5) + .background { + RoundedRectangle(cornerRadius: BottleRadiusType.xs.value) + .fill(ColorToken.onContainer(.primary).color) + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Card/PingPong/FinalSelectStateType.swift b/Projects/Shared/DesignSystem/Sources/Components/Card/PingPong/FinalSelectStateType.swift new file mode 100644 index 00000000..07c8fa51 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Card/PingPong/FinalSelectStateType.swift @@ -0,0 +1,17 @@ +// +// FinalSelectStateType.swift +// SharedDesignSystem +// +// Created by 임현규 on 8/9/24. +// + +import Foundation + +public enum FinalSelectStateType: Equatable { + /// 아직 선택 전 + case notSelected + /// 상대방 선택 X, 본인 선택 O + case waitingForPeer + /// 상대방 선택 O, 본인 선택 O + case bothSelected +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Card/PingPong/PhotoShareStateType.swift b/Projects/Shared/DesignSystem/Sources/Components/Card/PingPong/PhotoShareStateType.swift new file mode 100644 index 00000000..384d4ecb --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Card/PingPong/PhotoShareStateType.swift @@ -0,0 +1,40 @@ +// +// PhotoShareStateType.swift +// DesignSystemExample +// +// Created by 임현규 on 8/9/24. +// + +import Foundation + +public enum PhotoShareStateType: Equatable { + /// 아직 선택 전 + case notSelected + + /// 상대방 공개 O, 본인 공개 O + case bothPublic(peerProfileImageURL: String, myProfileImageURL: String) + + /// 상대방 공개 X or 본인 공개 X + case eitherPrivate + + /// 상대방 아직 선택 X, 본인 공개 O + case waitingForPeer + + public var peerProfileImageURL: String? { + switch self { + case .bothPublic(let peerProfileImageURL, _): + return peerProfileImageURL + default: + return nil + } + } + + public var myProfileImageURL: String? { + switch self { + case .bothPublic(_, let myProfileImageURL): + return myProfileImageURL + default: + return nil + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Card/PingPong/PingPongBubble.swift b/Projects/Shared/DesignSystem/Sources/Components/Card/PingPong/PingPongBubble.swift new file mode 100644 index 00000000..e2b2e8db --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Card/PingPong/PingPongBubble.swift @@ -0,0 +1,68 @@ +// +// PingPongBubble.swift +// DesignSystemExample +// +// Created by 임현규 on 8/8/24. +// + +import SwiftUI + +public struct PingPongBubble: View { + private let content: String + private let isRight: Bool + + public init( + content: String, + isRight: Bool + ) { + self.content = content + self.isRight = isRight + } + + public var body: some View { + text + } +} + +// MARK: - Views +private extension PingPongBubble { + var roundedRectangle: some View { + Rectangle() + .fill(backgroundColor) + .cornerRadius(.xs, corenrs: rectCorner) + } + + var text: some View { + HStack(spacing: 0) { + + if isRight { + Spacer() + } + + WantedSansStyleText( + content, + style: .body, + color: .secondary + ) + .lineSpacing(5) + .padding(.horizontal, .md) + .padding(.vertical, .sm) + .background { + roundedRectangle + } + + if !isRight { + Spacer() + } + } + .frame(maxWidth: 240) + } + + var backgroundColor: Color { + return isRight ? ColorToken.onContainer(.primary).color : ColorToken.onContainer(.secondary).color + } + + var rectCorner: UIRectCorner { + return isRight ? [.topLeft, .topRight, .bottomLeft] : [.topLeft, .topRight, .bottomRight] + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Card/PingPong/PingPongContainerView.swift b/Projects/Shared/DesignSystem/Sources/Components/Card/PingPong/PingPongContainerView.swift new file mode 100644 index 00000000..f77e13ea --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Card/PingPong/PingPongContainerView.swift @@ -0,0 +1,88 @@ +// +// PingPongContainerView.swift +// SharedDesignSystem +// +// Created by 임현규 on 8/9/24. +// + +import SwiftUI + +public struct PingPongContainer: View { + @State private var isHidden: Bool = false + + private let isActive: Bool + private var pingpongTitle: String + private var content: Content + + public init( + isActive: Bool, + pingpongTitle: String, + @ViewBuilder content: () -> Content + ) { + self.isActive = isActive + self.pingpongTitle = pingpongTitle + self.content = content() + } + public var body: some View { + VStack(spacing: 0) { + title + + if isHidden { + Divider() + .padding(.horizontal, .md) + content + .transition(.move(edge: .top)) + } + } + .clipped() + .background(.white) + .overlay( + roundedRectangle + ) + } +} + +// MARK: - Views +private extension PingPongContainer { + var roundedRectangle: some View { + RoundedRectangle(cornerRadius: BottleRadiusType.xl.value) + .strokeBorder( + ColorToken.border(.primary).color, + lineWidth: 1 + ) + } + + var title: some View { + HStack(spacing: 0) { + WantedSansStyleText( + pingpongTitle, + style: .subTitle1, + color: titleColor + ) + + Spacer() + + LocalImageView(.icom(.up)) + .rotationEffect(.degrees((isHidden ? -180 : 0))) + .asButton { + withAnimation(.snappy) { + isHidden.toggle() + } + } + .tint(to: arrowButtonColor) + .disabled(!isActive) + } + .padding(.horizontal, .md) + .frame(height: 72) + .background(.white) + .zIndex(2) + } + + var titleColor: ColorToken.TextToken { + isActive ? .focusePrimary : .disableSecondary + } + + var arrowButtonColor: ColorToken.IconToken { + isActive ? .primary : .disabled + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Card/PingPong/QuestionStateType.swift b/Projects/Shared/DesignSystem/Sources/Components/Card/PingPong/QuestionStateType.swift new file mode 100644 index 00000000..eb1463d6 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Card/PingPong/QuestionStateType.swift @@ -0,0 +1,42 @@ +// +// QuestionStateType.swift +// SharedDesignSystem +// +// Created by 임현규 on 8/9/24. +// + +import Foundation + +public enum QuestionStateType: Equatable { + /// 상대방 답변 O, 본인 답변 X + case peerAnswered(peer: String) + /// 상대방 답변 O, 본인 답변 O + case bothAnswered(peer: String, mySelf: String) + /// 상대방 답변 X, 본인 답변 O + case selfAnswered(mySelf: String) + /// 상대방 답변 X, 본인 답변 X + case noAnswer + case none + + public var peerAnswer: String? { + switch self { + case .peerAnswered(let peer): + return peer + case .bothAnswered(let peer, _): + return peer + default: + return nil + } + } + + public var myAnswer: String? { + switch self { + case .bothAnswered(_, let mySelf): + return mySelf + case .selfAnswered(let mySelf): + return mySelf + default: + return nil + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Card/Stop/StopCardView.swift b/Projects/Shared/DesignSystem/Sources/Components/Card/Stop/StopCardView.swift new file mode 100644 index 00000000..558957cb --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Card/Stop/StopCardView.swift @@ -0,0 +1,69 @@ +// +// StopCardView.swift +// SharedDesignSystem +// +// Created by 임현규 on 8/7/24. +// + +import SwiftUI + +public struct StopCardView: View { + private let userName: String + + public init(userName: String) { + self.userName = userName + } + + public var body: some View { + VStack(spacing: .xl) { + VStack(spacing: .xs) { + titleText + captionText + } + image + } + .padding(.horizontal, .md) + .padding(.vertical, .xl) + .overlay(roundedRectangle) + } +} + +// MARK: - Views +private extension StopCardView { + var roundedRectangle: some View { + RoundedRectangle(cornerRadius: BottleRadiusType.xl.value) + .strokeBorder( + ColorToken.border(.primary).color, + lineWidth: 1 + ) + } + + var titleText: some View { + HStack(spacing: 0) { + WantedSansStyleText( + "\(userName)님이 대화를 중단했어요", + style: .subTitle1, + color: .primary + ) + Spacer() + } + } + + var captionText: some View { + HStack(spacing: 0) { + WantedSansStyleText( + "대화는 3일 후 완전히 삭제돼요", + style: .body, + color: .tertiary + ) + Spacer() + } + } + + // TODO: - 아직 디자인 안나옴 나오면 수정 + var image: some View { + LocalImageView(.illustraition(.loudspeark)) + .frame(width: 120) + .frame(height: 120) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Card/UserProfile/UserProfileView.swift b/Projects/Shared/DesignSystem/Sources/Components/Card/UserProfile/UserProfileView.swift new file mode 100644 index 00000000..999eb710 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Card/UserProfile/UserProfileView.swift @@ -0,0 +1,66 @@ +// +// UserProfileView.swift +// SharedDesignSystem +// +// Created by 임현규 on 8/7/24. +// + +import SwiftUI + +public struct UserProfileView: View { + private let imageURL: String + private let userName: String + private let userAge: Int + + public init( + imageURL: String, + userName: String, + userAge: Int + ) { + self.imageURL = imageURL + self.userName = userName + self.userAge = userAge + } + + public var body: some View { + VStack(spacing: .sm) { + profileImage + HStack(spacing: .xs) { + userNameText + verticalLine + userAgeText + } + } + } +} + +private extension UserProfileView { + var profileImage: some View { + RemoteImageView( + imageURL: imageURL, + downsamplingWidth: 80.0, + downsamplingHeight: 80.0 + ) + .clipShape(Circle()) + .frame(width: 80, height: 80) + } + + var userNameText: some View { + WantedSansStyleText( + userName, + style: .subTitle1, + color: .secondary) + } + + var verticalLine: some View { + LocalImageView(.icom(.verticalLine)) + .foregroundStyle(to: ColorToken.border(.secondary)) + } + + var userAgeText: some View { + WantedSansStyleText( + "\(userAge)세", + style: .body, + color: .secondary) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/ETC/ImagePickerButton.swift b/Projects/Shared/DesignSystem/Sources/Components/ETC/ImagePickerButton.swift new file mode 100644 index 00000000..8f06031c --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/ETC/ImagePickerButton.swift @@ -0,0 +1,80 @@ +// +// ImagePickerButton.swift +// DesignSystemExample +// +// Created by 임현규 on 8/3/24. +// + +import SwiftUI + +public struct ImagePickerButton: View { + @Binding private var selectedImage: [UIImage] + private var action: () -> Void + + public init( + selectedImage: Binding<[UIImage]>, + action: @escaping () -> Void + ) { + self._selectedImage = selectedImage + self.action = action + } + + public var body: some View { + GeometryReader { geometry in + let width = geometry.size.width + let height = width + + RoundedRectangle(cornerRadius: BottleRadiusType.md.value) + .strokeBorder( + ColorToken.border(.enabled).color, + lineWidth: 1 + ) + .frame(width: width) + .frame(height: height) + .overlay(alignment: .center) { + image.clipShape(RoundedRectangle(cornerRadius: BottleRadiusType.md.value)) + } + } + } +} + +private extension ImagePickerButton { + @ViewBuilder + var image: some View { + GeometryReader { geomtry in + let width = geomtry.size.width + + if let selectedImage = selectedImage.first { + let size = selectedImage.size + let aspect = size.width / size.height + Image(uiImage: selectedImage) + .resizable() + .aspectRatio(aspect, contentMode: .fill) + .frame(width: width, height: width, alignment: .center) + .overlay(alignment: .topTrailing) { + deleteButton + } + } else { + LocalImageView(.icom(.plus)) + .frame(width: width, height: width, alignment: .center) + } + } + } + + var deleteButton: some View { + RoundedRectangle(cornerRadius: BottleRadiusType.xs.value) + .strokeBorder(ColorToken.border(.enabled).color, lineWidth: 1) + .background(to: ColorToken.container(.enablePrimary)) + .clipShape(RoundedRectangle(cornerRadius: BottleRadiusType.xs.value)) + .frame(width: 36, height: 36) + .overlay { + LocalImageView(.icom(.clearDelete)) + .asThrottleButton { + self.selectedImage.removeAll() + action() + } + } + .padding(.top, .md) + .padding(.trailing, .md) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/ETC/PageIndicatorView.swift b/Projects/Shared/DesignSystem/Sources/Components/ETC/PageIndicatorView.swift new file mode 100644 index 00000000..a93502ba --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/ETC/PageIndicatorView.swift @@ -0,0 +1,45 @@ +// +// PageIndicatorView.swift +// DesignSystemExample +// +// Created by 임현규 on 8/3/24. +// + +import SwiftUI + +public struct PageIndicatorView: View { + private var pageInfo: PageInfo + + public init(pageInfo: PageInfo) { + self.pageInfo = pageInfo + } + + public var body: some View { + HStack(spacing: .xxs) { + WantedSansStyleText( + "\(pageInfo.nowPage)", + style: .subTitle2, + color: .quinary + ) + .padding(.leading, .xs) + + WantedSansStyleText( + "/", + style: .subTitle2, + color: .senary + ) + + WantedSansStyleText( + "\(pageInfo.totalCount)", + style: .subTitle2, + color: .senary + ) + .padding(.trailing, .xs) + } + .frame(height: 26) + .background { + RoundedRectangle(cornerRadius: BottleRadiusType.xs.value) + .fill(ColorToken.container(.secondary).color) + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/ETC/TitleView.swift b/Projects/Shared/DesignSystem/Sources/Components/ETC/TitleView.swift new file mode 100644 index 00000000..89f7ce4b --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/ETC/TitleView.swift @@ -0,0 +1,86 @@ +// +// TitleView.swift +// DesignSystemExample +// +// Created by 임현규 on 8/3/24. +// + +import SwiftUI + +public struct PageInfo { + var nowPage: Int + var totalCount: Int + + public init(nowPage: Int, totalCount: Int) { + self.nowPage = nowPage + self.totalCount = totalCount + } +} + +public struct TitleView: View { + private let pageInfo: PageInfo? + private let title: String + private let caption: String? + + public init( + pageInfo: PageInfo? = nil, + title: String, + caption: String? = nil + ) { + self.pageInfo = pageInfo + self.title = title + self.caption = caption + } + + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + pageIndicator + .padding(.bottom, .xl) + + HStack(spacing: 0) { + titleText + Spacer() + } + + HStack(spacing: 0) { + captionText + .padding(.top, .sm) + Spacer() + } + } + } +} + +private extension TitleView { + var titleText: some View { + WantedSansStyleText( + title, + style: .title1, + color: .primary + ) + .multilineTextAlignment(.leading) + } + + @ViewBuilder + var captionText: some View { + if let caption { + WantedSansStyleText( + caption, + style: .body, + color: .tertiary + ) + .multilineTextAlignment(.leading) + } else { + EmptyView() + } + } + + @ViewBuilder + var pageIndicator: some View { + if let pageInfo { + PageIndicatorView(pageInfo: pageInfo) + } else { + EmptyView() + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/ImageView/BlurImageView.swift b/Projects/Shared/DesignSystem/Sources/Components/ImageView/BlurImageView.swift new file mode 100644 index 00000000..0d9f71b1 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/ImageView/BlurImageView.swift @@ -0,0 +1,56 @@ +// +// BlurImageView.swift +// SharedDesignSystem +// +// Created by JongHoon on 8/17/24. +// + +import SwiftUI + +import Kingfisher + +public struct BlurImageView: View { + private let imageURL: String + private let downsamplingWidth: Double + private let downsamplingHeight: Double + + public init( + imageURL: String, + downsamplingWidth: Double, + downsamplingHeight: Double + ) { + self.imageURL = imageURL + self.downsamplingWidth = downsamplingWidth + self.downsamplingHeight = downsamplingHeight + } + + public var body: some View { + KFImage(URL(string: imageURL)) + .cancelOnDisappear(true) + .placeholder { + ColorToken.icon(.secondary).color + } + .downsampling(size: .init( + width: downsamplingWidth, + height: downsamplingHeight + )) + .blur(radius: 3.0) + .scaleFactor(UIScreen.main.scale) + .cacheOriginalImage() + .retry(maxCount: 3, interval: .seconds(2)) + .resizable() + .scaledToFill() + } +} + +struct BlurView: UIViewRepresentable { + var style: UIBlurEffect.Style + + func makeUIView(context: Context) -> UIVisualEffectView { + return UIVisualEffectView(effect: UIBlurEffect(style: style)) + } + + func updateUIView(_ uiView: UIVisualEffectView, context: Context) { + uiView.effect = UIBlurEffect(style: style) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/ImageView/BottleImageView.swift b/Projects/Shared/DesignSystem/Sources/Components/ImageView/BottleImageView.swift new file mode 100644 index 00000000..1cce12da --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/ImageView/BottleImageView.swift @@ -0,0 +1,30 @@ +// +// BottleImageView.swift +// DesignSystemExample +// +// Created by JongHoon on 7/27/24. +// + +import SwiftUI + +public struct BottleImageView: View { + private let type: ImageViewType + + public init(type: ImageViewType) { + self.type = type + } + + public var body: some View { + switch type { + case let .local(bottleImageSystem): + LocalImageView(bottleImageSystem) + + case let .remote(url, downsamplingWidth, downsamplingHeight): + RemoteImageView( + imageURL: url, + downsamplingWidth: downsamplingWidth, + downsamplingHeight: downsamplingHeight + ) + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/ImageView/ImageViewType.swift b/Projects/Shared/DesignSystem/Sources/Components/ImageView/ImageViewType.swift new file mode 100644 index 00000000..cd7ffea9 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/ImageView/ImageViewType.swift @@ -0,0 +1,13 @@ +// +// ImageViewType.swift +// DesignSystemExample +// +// Created by JongHoon on 7/27/24. +// + +import SwiftUI + +public enum ImageViewType { + case local(bottleImageSystem: Image.BottleImageSystem) + case remote(url: String, downsamplingWidth: Double, downsamplingHeight: Double) +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/ImageView/LocalImageView.swift b/Projects/Shared/DesignSystem/Sources/Components/ImageView/LocalImageView.swift new file mode 100644 index 00000000..5e93a634 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/ImageView/LocalImageView.swift @@ -0,0 +1,28 @@ +// +// LocalImageView.swift +// SharedDesignSystem +// +// Created by JongHoon on 7/28/24. +// + +import SwiftUI + +public struct LocalImageView: View { + // TODO: 추후 이미지 관련 네임스페이스 처리 후 개선 필요 + private let bottleImageSystem: Image.BottleImageSystem + + public init( + _ bottleImageSystem: Image.BottleImageSystem + ) { + self.bottleImageSystem = bottleImageSystem + } + + public var body: some View { + if bottleImageSystem.description == "icon" { + bottleImageSystem.image + } else { + bottleImageSystem.image + .resizable() + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/ImageView/RemoteImageView.swift b/Projects/Shared/DesignSystem/Sources/Components/ImageView/RemoteImageView.swift new file mode 100644 index 00000000..b1ecc2ed --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/ImageView/RemoteImageView.swift @@ -0,0 +1,41 @@ +// +// RemoteImageView.swift +// SharedDesignSystem +// +// Created by JongHoon on 7/28/24. +// + +import SwiftUI + +import Kingfisher + +public struct RemoteImageView: View { + private let imageURL: String + private let downsamplingSize: CGSize + + public init( + imageURL: String, + downsamplingWidth: Double, + downsamplingHeight: Double + ) { + self.imageURL = imageURL + self.downsamplingSize = CGSize( + width: downsamplingWidth, + height: downsamplingHeight + ) + } + + public var body: some View { + KFImage(URL(string: imageURL)) + .cancelOnDisappear(true) + .placeholder { + ColorToken.icon(.secondary).color + } + .downsampling(size: downsamplingSize) + .scaleFactor(UIScreen.main.scale) + .cacheOriginalImage() + .retry(maxCount: 3, interval: .seconds(2)) + .resizable() + .scaledToFill() + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/ListItem/BottleStorageItem.swift b/Projects/Shared/DesignSystem/Sources/Components/ListItem/BottleStorageItem.swift new file mode 100644 index 00000000..85fe6d91 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/ListItem/BottleStorageItem.swift @@ -0,0 +1,121 @@ +// +// BottleStorageItem.swift +// DesignSystemExample +// +// Created by JongHoon on 8/8/24. +// + +import SwiftUI + +public struct BottleStorageItem: View { + private let userName: String + private let age: Int + private let mbti: String + private let imageURL: String + private let keywords: [String] + private let isRead: Bool + + public init( + userName: String, + age: Int, + mbti: String, + keywords: [String], + imageURL: String, + isRead: Bool + ) { + self.userName = userName + self.age = age + self.mbti = mbti + self.keywords = keywords + self.imageURL = imageURL + self.isRead = isRead + } + + public var body: some View { + HStack(spacing: 0.0) { + VStack( + alignment: .leading, + spacing: .xs + ) { + HStack(spacing: .xxs) { + WantedSansStyleText( + userName, + style: .title2, + color: .secondary + ) + + if isRead == false { + ColorToken.icon(.update).color + .frame(width: 4.0, height: 4.0) + .clipShape(Circle()) + } + } + + infos + } + + Spacer() + + BlurImageView( + imageURL: imageURL, + downsamplingWidth: 60.0, + downsamplingHeight: 60.0 + ) + .frame(width: 48.0, height: 48.0) + .clipShape(Circle()) + } + .padding(.md) + .overlay( + RoundedRectangle(cornerRadius: 20.0) + .strokeBorder( + ColorToken.border(.primary).color, + lineWidth: 1.0 + ) + ) + } +} + +// MARK: - Private Views + +private extension BottleStorageItem { + var infos: some View { + HStack(spacing: .xs) { + WantedSansStyleText( + "\(age)세", + style: .caption, + color: .tertiary + ) + + verticalSeparator + + WantedSansStyleText( + mbti, + style: .caption, + color: .tertiary + ) + + verticalSeparator + + WantedSansStyleText( + trimKeywords(keywords), + style: .caption, + color: .tertiary + ) + } + } + + var verticalSeparator: some View { + ColorToken.border(.secondary).color + .frame(width: 1.0, height: 12.0) + .padding(1.0) + } +} + +// MARK: - Private Method + +private extension BottleStorageItem { + func trimKeywords(_ keywords: [String]) -> String { + let keywords = keywords.prefix(3) + return keywords.joined(separator: ", ") + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Popup/CountLabel.swift b/Projects/Shared/DesignSystem/Sources/Components/Popup/CountLabel.swift new file mode 100644 index 00000000..785c92cc --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Popup/CountLabel.swift @@ -0,0 +1,31 @@ +// +// CountLabel.swift +// SharedDesignSystem +// +// Created by 임현규 on 7/30/24. +// + +import SwiftUI + +public struct CountLabel: View { + private let radius: CGFloat = 24 + private let text: String + + public init(text: String) { + self.text = text + } + + public var body: some View { + WantedSansStyleText( + text, + style: .body, + color: .quaternary + ) + .padding(4) + .frame(minWidth: radius) + .frame(height: radius) + .frame(maxWidth: nil) + .background(to: ColorToken.icon(.update)) + .clipShape(RoundedRectangle(cornerRadius: radius / 2)) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Popup/PopupType.swift b/Projects/Shared/DesignSystem/Sources/Components/Popup/PopupType.swift new file mode 100644 index 00000000..0306e841 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Popup/PopupType.swift @@ -0,0 +1,14 @@ +// +// PopupType.swift +// SharedDesignSystem +// +// Created by 임현규 on 7/29/24. +// + +import Foundation + +public enum PopupType { + case text(content: String) + case button(content: String, buttonTitle: String) + +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Popup/PopupView.swift b/Projects/Shared/DesignSystem/Sources/Components/Popup/PopupView.swift new file mode 100644 index 00000000..b7b03e54 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Popup/PopupView.swift @@ -0,0 +1,91 @@ +// +// PopupView.swift +// SharedDesignSystem +// +// Created by 임현규 on 7/28/24. +// + +import SwiftUI + +public struct PopupView: View { + private var popupType: PopupType + private let _action: (() -> Void)? + private var action: () -> Void { + return _action ?? {} + } + + public init(popupType: PopupType, action: (() -> Void)? = nil) { + self.popupType = popupType + self._action = action + } + + public var body: some View { + popupItem + .overlay( + TriangleView( + triangleWidth: triangleWidth, + triangleHeight: triangleHeight + ) + ) + .background { + RoundedRectangle(cornerRadius: BottleRadiusType.lg.value) + .fill(ColorToken.container(.primary).color) + } + } +} + +// MARK: Views +private extension PopupView { + var popupText: some View { + switch popupType { + case .text(let content): + WantedSansStyleText(content, style: .subTitle2, color: .secondary) + case .button(let content, _): + WantedSansStyleText(content, style: .subTitle2, color: .secondary) + } + } + + var popupRectangle: some View { + RoundedRectangle(cornerRadius: BottleRadiusType.md.value) + .strokeBorder( + ColorToken.border(.primary).color, + lineWidth: 1 + ) + } + + @ViewBuilder + var popupItem: some View { + switch popupType { + case .text: + popupText + .padding(.sm) + case .button(_, let buttonTitle): + VStack(spacing: .sm) { + popupText + SolidButton(title: buttonTitle, + sizeType: .small, + buttonType: .normal, + action: action) + .frame(width: 227) + } + .padding(.lg) + } + } +} + +// MARK: - Private Extension +private extension PopupView { + var triangleWidth: CGFloat { + return 10.39 + } + var triangleHeight: CGFloat { + return 6 + } + + var height: CGFloat { + switch popupType { + case .button: return 106 + case .text: return 42 + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Popup/TriangleShape.swift b/Projects/Shared/DesignSystem/Sources/Components/Popup/TriangleShape.swift new file mode 100644 index 00000000..3ec31523 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Popup/TriangleShape.swift @@ -0,0 +1,30 @@ +// +// TriangleShape.swift +// SharedDesignSystem +// +// Created by 임현규 on 8/3/24. +// + +import SwiftUI + +struct TriangleShape: Shape { + private let width: CGFloat + private let height: CGFloat + + init(width: CGFloat, height: CGFloat) { + self.width = width + self.height = height + } + + func path(in rect: CGRect) -> Path { + let xPos = rect.midX - width / 2 + let yPos = rect.maxY + var path = Path() + path.move(to: CGPoint(x: xPos, y: yPos)) + path.addLine(to: CGPoint(x: xPos + width, y: yPos)) + path.addLine(to: CGPoint(x: xPos + width / 2, y: yPos + height)) + path.addLine(to: CGPoint(x: xPos, y: yPos)) + path.closeSubpath() + return path + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Popup/TriangleView.swift b/Projects/Shared/DesignSystem/Sources/Components/Popup/TriangleView.swift new file mode 100644 index 00000000..a5af6be2 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Popup/TriangleView.swift @@ -0,0 +1,30 @@ +// +// TriangleView.swift +// SharedDesignSystem +// +// Created by 임현규 on 7/28/24. +// + +import SwiftUI + +struct TriangleView: View { + private let triangleWidth: CGFloat + private let triangleHeight: CGFloat + + init(triangleWidth: CGFloat, triangleHeight: CGFloat) { + self.triangleWidth = triangleWidth + self.triangleHeight = triangleHeight + } + + var body: some View { + TriangleShape(width: triangleWidth, height: triangleHeight) + .fill(ColorToken.container(.primary).color) + .overlay( + TriangleShape(width: triangleWidth, height: triangleHeight) + .stroke( + ColorToken.container(.primary).color, + style: StrokeStyle(lineCap: .round, lineJoin: .round) + ) + ) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/TextField/LineTextField/LineTextField.swift b/Projects/Shared/DesignSystem/Sources/Components/TextField/LineTextField/LineTextField.swift new file mode 100644 index 00000000..8065f33b --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/TextField/LineTextField/LineTextField.swift @@ -0,0 +1,69 @@ +// +// LineTextField.swift +// SharedDesignSystem +// +// Created by 임현규 on 8/12/24. +// + +import SwiftUI + +public struct LineTextField: View { + @Binding private var text: String + @Binding private var textFieldState: TextFieldState + private let placeHolder: String + + public init( + textFieldState: Binding, + text: Binding, + placeHolder: String + ) { + self._textFieldState = textFieldState + self._text = text + self.placeHolder = placeHolder + } + + public var body: some View { + textField + } +} + +// MARK: - Views +private extension LineTextField { + var textField: some View { + TextField(placeHolder, text: $text) + .padding(.horizontal, .md) + .padding(.vertical, .lg) + .overlay( + RoundedRectangle(cornerRadius: BottleRadiusType.sm.value) + .strokeBorder(ColorToken.border(.enabled).color, lineWidth: 1.0) + ) + .background(to: containerColor) + .overlay(alignment: .trailing) { + clearButton + } + .font(to: .wantedSans(.body)) + } + + @ViewBuilder + var clearButton: some View { + if text.isEmpty { + EmptyView() + } else { + BottleImageView(type: .local(bottleImageSystem: .icom(.delete))) + .foregroundStyle(to: ColorToken.icon(.primary)) + .asThrottleButton { + self.text = "" + } + .padding(.trailing, .md) + } + } + + var containerColor: ColorToken.Container { + switch textFieldState { + case .enabled: return .enablePrimary + case .active: return .Active + case .focused: return .focusePrimary + case .error: return .erroPrimary + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/TextField/LinesTextField/LinesTextField.swift b/Projects/Shared/DesignSystem/Sources/Components/TextField/LinesTextField/LinesTextField.swift new file mode 100644 index 00000000..a8dd57db --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/TextField/LinesTextField/LinesTextField.swift @@ -0,0 +1,207 @@ +// +// LinesTextField.swift +// SharedDesignSystem +// +// Created by 임현규 on 7/28/24. +// + +import SwiftUI +import Combine + +public struct LinesTextField: View { + @Binding private var text: String + @Binding private var textFieldState: TextFieldState + private let _action: (() -> Void)? + private var action: () -> Void { + return _action ?? {} + } + private let textFieldType: LinesTextFieldType + private let errorMessage: String? + private let buttonTitle: String? + private let placeHolder: String + private let textLimit: Int + + public init( + textFieldType: LinesTextFieldType, + textFieldState: Binding, + text: Binding, + placeHolder: String, + buttonTitle: String? = nil, + errorMessage: String? = nil, + textLimit: Int, + action: (() -> Void)? = nil + ) { + self.textFieldType = textFieldType + self._textFieldState = textFieldState + self._text = text + self.placeHolder = placeHolder + self.buttonTitle = buttonTitle + self.errorMessage = errorMessage + self.textLimit = textLimit + self._action = action + } + + public var body: some View { + VStack(spacing: .xxs) { + RoundedRectangle(cornerRadius: BottleRadiusType.xl.value) + .strokeBorder( + borderColor.color, + lineWidth: 1 + ) + .frame(height: height) + .background(to: containerColor) + .overlay(alignment: .center) { + linesTextFieldForm + .padding(.md) + } + + errorText + } + } +} + +// MARK: - Public Extension + +public extension LinesTextField { + enum LinesTextFieldType { + case introduction + case letter + } +} + +// MARK: - Views + +private extension LinesTextField { + var textCounter: some View { + Text("\(text.count) / \(textLimit)") + .font(to: .wantedSans(.body)) + .foregroundStyle(to: textColor) + .padding(.md) + } + + var placeHolderText: some View { + Text(placeHolder) + .lineSpacing(6) + .padding(.leading, 5) + .padding(.top, 8) + .font(to: .wantedSans(.body)) + .foregroundStyle(to: textColor) + } + + var textEditor: some View { + GeometryReader { geometry in + let height = geometry.size.height + TextEditor(text: $text) + .lineSpacing(6) + .background(alignment: .topLeading) { + if text.isEmpty && textFieldState == .enabled { placeHolderText } + } + .padding(.md) + .background(to: textEditorColor) + .clipShape(RoundedRectangle(cornerRadius: BottleRadiusType.sm.value)) + .scrollContentBackground(.hidden) + .font(to: .wantedSans(.body)) + .frame(height: height) + .overlay(alignment: .bottomTrailing) { + textCounter + } + .onReceive(Just(text)) { newValue in + if newValue.count >= textLimit { + text = String(text.prefix(textLimit)) + } + } + } + } + + @ViewBuilder + var linesTextFieldForm: some View { + switch textFieldType { + case .introduction: + textEditor + case .letter: + VStack(spacing: .md) { + textEditor + SolidButton( + title: buttonTitle ?? "", + sizeType: .medium, + buttonType: .normal, + action: { action() } + ) + .disabled(isDisabledButton) + } + } + } + + @ViewBuilder + var errorText: some View { + if let errorMessage { + if textFieldState == .error { + HStack(spacing: 0) { + WantedSansStyleText( + errorMessage, + style: .caption, + color: .errorPrimary) + Spacer() + } + } + } else { + EmptyView() + } + } +} + +// MARK: - Private Extension + +private extension LinesTextField { + var height: CGFloat { + switch textFieldType { + case .introduction: return 301 + case .letter: return 240 + } + } + + var textColor: ColorToken.TextToken { + switch textFieldState { + case .enabled: return .enableTertiary + case .active: return .activePrimary + case .focused: return .focusePrimary + case .error: return .errorSecondary + } + } + + var borderColor: ColorToken.Border { + switch textFieldState { + case .enabled: return .enabled + case .active: return .active + case .focused: return .focused + case .error: return .error + } + } + + var textEditorColor: ColorToken.OnContainer { + switch textFieldState { + case .enabled: return .enablePrimary + case .active: return .active + case .focused: return .focuse + case .error: return .error + } + } + + var containerColor: ColorToken.Container { + switch textFieldState { + case .enabled: return .enablePrimary + case .active: return .Active + case .focused: return .focuseSecondary + case .error: return .errorSecondary + } + } + + var isDisabledButton: Bool { + switch textFieldState { + case .enabled: return true + case .active: return false + case .focused: return false + case .error: return true + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/TextField/LinesTextField/TextFieldState.swift b/Projects/Shared/DesignSystem/Sources/Components/TextField/LinesTextField/TextFieldState.swift new file mode 100644 index 00000000..d2dea030 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/TextField/LinesTextField/TextFieldState.swift @@ -0,0 +1,15 @@ +// +// TextFieldState.swift +// DesignSystemExample +// +// Created by 임현규 on 7/28/24. +// + +import Foundation + +public enum TextFieldState { + case enabled + case active + case focused + case error +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Toast/PassthroughWindow.swift b/Projects/Shared/DesignSystem/Sources/Components/Toast/PassthroughWindow.swift new file mode 100644 index 00000000..ffd8d2ca --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Toast/PassthroughWindow.swift @@ -0,0 +1,18 @@ +// +// PassthroughWindow.swift +// DesignSystemExample +// +// Created by JongHoon on 8/4/24. +// + +import UIKit + +class PassthroughWindow: UIWindow { + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard let view = super.hitTest(point, with: event) + else { + return nil + } + return rootViewController?.view == view ? nil : view + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Toast/RootView.swift b/Projects/Shared/DesignSystem/Sources/Components/Toast/RootView.swift new file mode 100644 index 00000000..e59a7176 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Toast/RootView.swift @@ -0,0 +1,40 @@ +// +// RootView.swift +// DesignSystemExample +// +// Created by JongHoon on 8/4/24. +// + +import SwiftUI + +public struct RootView: View { + @ViewBuilder private var content: Content + @State private var overlayWindow: UIWindow? + + public init( + @ViewBuilder content: () -> Content, + overlayWindow: UIWindow? = nil + ) { + self.content = content() + self.overlayWindow = overlayWindow + } + + public var body: some View { + content + .onAppear { + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + overlayWindow == nil { + let window = PassthroughWindow(windowScene: windowScene) + let rootViewController = UIHostingController(rootView: ToastGroup()) + rootViewController.view.frame = windowScene.keyWindow?.frame ?? .zero + rootViewController.view.backgroundColor = .clear + window.rootViewController = rootViewController + window.backgroundColor = .clear + window.isHidden = false + window.isUserInteractionEnabled = true + window.tag = 2525 + overlayWindow = window + } + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Toast/ToastGroupView.swift b/Projects/Shared/DesignSystem/Sources/Components/Toast/ToastGroupView.swift new file mode 100644 index 00000000..268bacea --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Toast/ToastGroupView.swift @@ -0,0 +1,30 @@ +// +// ToastGroupView.swift +// DesignSystemExample +// +// Created by JongHoon on 8/4/24. +// + +import SwiftUI + +struct ToastGroup: View { + @StateObject var toastManager = ToastManager.shared + var body: some View { + GeometryReader { + let size = $0.size + let safeArea = $0.safeAreaInsets + + ZStack { + ForEach(toastManager.toasts) { toast in + ToastItemView(size: size, item: toast) + } + } + .padding(.bottom, safeArea.top == .zero ? 16.0 : 12.0) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .bottom + ) + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Toast/ToastItem.swift b/Projects/Shared/DesignSystem/Sources/Components/Toast/ToastItem.swift new file mode 100644 index 00000000..90de8613 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Toast/ToastItem.swift @@ -0,0 +1,22 @@ +// +// ToastItem.swift +// DesignSystemExample +// +// Created by JongHoon on 8/4/24. +// + +import Foundation + +public struct ToastItem: Identifiable { + public let id: UUID = .init() + public let message: String + public let durationSecond: Double + + public init( + message: String, + durationSecond: Double = 2.0 + ) { + self.message = message + self.durationSecond = durationSecond + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Toast/ToastItemView.swift b/Projects/Shared/DesignSystem/Sources/Components/Toast/ToastItemView.swift new file mode 100644 index 00000000..cf9f71a1 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Toast/ToastItemView.swift @@ -0,0 +1,54 @@ +// +// ToastItemView.swift +// DesignSystemExample +// +// Created by JongHoon on 8/4/24. +// + +import SwiftUI + +struct ToastItemView: View { + @State private var animateIn: Bool = false + @State private var animateOut: Bool = false + private let size: CGSize + private let item: ToastItem + + init( + size: CGSize, + item: ToastItem + ) { + self.size = size + self.item = item + } + + var body: some View { + WantedSansStyleText( + item.message, + style: .body, + color: .quaternary + ) + .padding(.horizontal, 12.0) + .padding(.vertical, 7.5) + .background(to: ColorToken.container(.tertiary)) + .clipShape(RoundedRectangle(cornerRadius: BottleRadiusType.sm.value)) + .offset(y: animateIn ? 0 : 152.0) + .offset(y: !animateOut ? 0 : 152.0) + .task { + guard !animateIn else { return } + withAnimation(.snappy) { + animateIn = true + } + + try? await Task.sleep(nanoseconds: UInt64(item.durationSecond * 1000_000_000)) + + removeToast() + } + } + + func removeToast() { + guard !animateOut else { return } + withAnimation { + animateOut = true + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Toast/ToastManager.swift b/Projects/Shared/DesignSystem/Sources/Components/Toast/ToastManager.swift new file mode 100644 index 00000000..26e497a1 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Toast/ToastManager.swift @@ -0,0 +1,24 @@ +// +// ToastManager.swift +// DesignSystemExample +// +// Created by JongHoon on 8/4/24. +// + +import Foundation + +public class ToastManager: ObservableObject { + public static let shared = ToastManager() + @Published var toasts: [ToastItem] = [] + + public func present( + message: String, + isUserInteractionEnable: Bool = false, + durationSecond: Double + ) { + toasts.append(.init( + message: message, + durationSecond: durationSecond + )) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Extension/PreferenceKey/CGSizePreferenceKey.swift b/Projects/Shared/DesignSystem/Sources/Extension/PreferenceKey/CGSizePreferenceKey.swift new file mode 100644 index 00000000..3290bf07 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Extension/PreferenceKey/CGSizePreferenceKey.swift @@ -0,0 +1,13 @@ +// +// File.swift +// DesignSystemExample +// +// Created by JongHoon on 8/10/24. +// + +import SwiftUI + +public struct CGSizePreferenceKey: PreferenceKey { + public static var defaultValue: CGSize = .zero + public static func reduce(value: inout CGSize, nextValue: () -> CGSize) {} +} diff --git a/Projects/Shared/DesignSystem/Sources/Extension/Text+Font.swift b/Projects/Shared/DesignSystem/Sources/Extension/Text+Font.swift new file mode 100644 index 00000000..b07de3dc --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Extension/Text+Font.swift @@ -0,0 +1,14 @@ +// +// Text+Font.swift +// SharedDesignSystem +// +// Created by 임현규 on 7/28/24. +// + +import SwiftUI + +public extension Text { + func font(to fontType: Font.BottleFontSystem) -> Text { + self.font(fontType.font) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Extension/UIImage+Extensions.swift b/Projects/Shared/DesignSystem/Sources/Extension/UIImage+Extensions.swift new file mode 100644 index 00000000..8fd863eb --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Extension/UIImage+Extensions.swift @@ -0,0 +1,41 @@ +// +// UIImage+Extensions.swift +// SharedDesignSystem +// +// Created by 임현규 on 8/13/24. +// + +import UIKit + +public extension UIImage { + func compressImageData(targetMB: Double = 1.0) -> Data? { + guard var imageSize = self.jpegData(compressionQuality: 0.8)?.count else { return nil } + let resizeLength = 2048 + var compressedImage: UIImage? = self + var resizeScale = 1.0 + while Double(imageSize) > targetMB * pow(2, 20) { + compressedImage = self.resized(toLength: Double(resizeLength) * resizeScale) + imageSize = compressedImage?.jpegData(compressionQuality: 0.8)?.count ?? 0 + resizeScale = 0.75 + } + + return compressedImage?.jpegData(compressionQuality: 0.8) + } + + func resized(toLength length: CGFloat) -> UIImage? { + let canvasSize = size.width > size.height + ? CGSize( + width: length, + height: CGFloat(ceil(length/size.width * size.height)) + ) + : CGSize( + width: CGFloat(ceil(length/size.height * size.width)), + height: length + ) + + UIGraphicsBeginImageContextWithOptions(canvasSize, false, scale) + defer { UIGraphicsEndImageContext() } + draw(in: CGRect(origin: .zero, size: canvasSize)) + return UIGraphicsGetImageFromCurrentImageContext() + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Extension/View+Extensions.swift b/Projects/Shared/DesignSystem/Sources/Extension/View+Extensions.swift new file mode 100644 index 00000000..0df03bbd --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Extension/View+Extensions.swift @@ -0,0 +1,65 @@ +// +// View+Extensions.swift +// SharedDesignSystem +// +// Created by 임현규 on 7/26/24. +// + +import SwiftUI + +public extension View { + + // MARK: - colorToken + func background(to colorToken: Colorable) -> some View { + self.background(colorToken.color) + } + + func tint(to colorToken: Colorable) -> some View { + self.tint(colorToken.color) + } + + func foregroundStyle(to colorToken: Colorable) -> some View { + self.foregroundStyle(colorToken.color) + } + + func colorMultiply(to colorToken: Colorable) -> some View { + self.colorMultiply(colorToken.color) + } +} + +// MARK: - public Methods +extension View { + public func makeLeftBubbleText(text: String) -> some View { + HStack(spacing: 0) { + PingPongBubble( + content: text, + isRight: false + ) + Spacer() + } + } + + public func makeRightBubbleText(text: String) -> some View { + HStack(spacing: 0) { + Spacer() + PingPongBubble( + content: text, + isRight: true + ) + } + } +} + +// MARK: - View Layout + +extension View { + public func readSize(onChange: @escaping (CGSize) -> Void) -> some View { + background( + GeometryReader { geometry in + Color.clear + .preference(key: CGSizePreferenceKey.self, value: geometry.size) + } + ) + .onPreferenceChange(CGSizePreferenceKey.self, perform: onChange) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Extension/View+Font.swift b/Projects/Shared/DesignSystem/Sources/Extension/View+Font.swift new file mode 100644 index 00000000..21dc423e --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Extension/View+Font.swift @@ -0,0 +1,14 @@ +// +// View+Font.swift +// SharedDesignSystem +// +// Created by 임현규 on 7/26/24. +// + +import SwiftUI + +public extension View { + func font(to fontType: Font.BottleFontSystem) -> some View { + self.font(fontType.font) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Extension/View+RoundedCorner.swift b/Projects/Shared/DesignSystem/Sources/Extension/View+RoundedCorner.swift new file mode 100644 index 00000000..70e20c87 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Extension/View+RoundedCorner.swift @@ -0,0 +1,36 @@ +// +// View+RoundedCorner.swift +// DesignSystemExample +// +// Created by 임현규 on 8/8/24. +// + +import SwiftUI + +struct RoundedCorner: Shape { + public var radius: CGFloat = .infinity + public var corners: UIRectCorner = .allCorners + + init( + radius: BottleRadiusType, + corners: UIRectCorner + ) { + self.radius = radius.value + self.corners = corners + } + + func path(in rect: CGRect) -> Path { + let path = UIBezierPath( + roundedRect: rect, + byRoundingCorners: corners, + cornerRadii: CGSize(width: radius, height: radius) + ) + return Path(path.cgPath) + } +} + +extension View { + public func cornerRadius(_ radius: BottleRadiusType, corenrs: UIRectCorner) -> some View { + clipShape(RoundedCorner(radius: radius, corners: corenrs)) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Font/BottleFontSystem+LaundryGothic.swift b/Projects/Shared/DesignSystem/Sources/Font/BottleFontSystem+LaundryGothic.swift new file mode 100644 index 00000000..e347c61c --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Font/BottleFontSystem+LaundryGothic.swift @@ -0,0 +1,25 @@ +// +// BottleFontSystem+LaundryGothic.swift +// SharedDesignSystem +// +// Created by 임현규 on 7/26/24. +// + +import SwiftUI + +public extension Font.BottleFontSystem { + enum LaundryGothic: Fontable { + case title1 + } +} + +public extension Font.BottleFontSystem.LaundryGothic { + var font: Font { + switch self { + case .title1: + return SharedDesignSystemFontFamily.LaundryGothicOTF.bold.swiftUIFont(size: 24) + } + } +} + +extension Font.BottleFontSystem.LaundryGothic: CaseIterable { } diff --git a/Projects/Shared/DesignSystem/Sources/Font/BottleFontSystem+Rotobo.swift b/Projects/Shared/DesignSystem/Sources/Font/BottleFontSystem+Rotobo.swift new file mode 100644 index 00000000..3965584b --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Font/BottleFontSystem+Rotobo.swift @@ -0,0 +1,25 @@ +// +// BottleFontSystem+Rotobo.swift +// SharedDesignSystem +// +// Created by 임현규 on 7/26/24. +// + +import SwiftUI + +public extension Font.BottleFontSystem { + enum Roboto: Fontable { + case kakao + } +} + +public extension Font.BottleFontSystem.Roboto { + var font: Font { + switch self { + case .kakao: + return SharedDesignSystemFontFamily.Roboto.medium.swiftUIFont(size: 14) + } + } +} + +extension Font.BottleFontSystem.Roboto: CaseIterable { } diff --git a/Projects/Shared/DesignSystem/Sources/Font/BottleFontSystem+WantedSans.swift b/Projects/Shared/DesignSystem/Sources/Font/BottleFontSystem+WantedSans.swift new file mode 100644 index 00000000..84339409 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Font/BottleFontSystem+WantedSans.swift @@ -0,0 +1,40 @@ +// +// BottleFontSystem+WantedSans.swift +// SharedDesignSystem +// +// Created by 임현규 on 7/26/24. +// + +import SwiftUI + +public extension Font.BottleFontSystem { + enum WantedSans: Fontable { + case title1 + case title2 + case subTitle1 + case subTitle2 + case body + case caption + } +} + +public extension Font.BottleFontSystem.WantedSans { + var font: Font { + switch self { + case .title1: + return SharedDesignSystemFontFamily.WantedSans.bold.swiftUIFont(size: 24) + case .title2: + return SharedDesignSystemFontFamily.WantedSans.bold.swiftUIFont(size: 20) + case .subTitle1: + return SharedDesignSystemFontFamily.WantedSans.semiBold.swiftUIFont(size: 16) + case .subTitle2: + return SharedDesignSystemFontFamily.WantedSans.semiBold.swiftUIFont(size: 14) + case .body: + return SharedDesignSystemFontFamily.WantedSans.medium.swiftUIFont(size: 14) + case .caption: + return SharedDesignSystemFontFamily.WantedSans.medium.swiftUIFont(size: 12) + } + } +} + +extension Font.BottleFontSystem.WantedSans: CaseIterable { } diff --git a/Projects/Shared/DesignSystem/Sources/Font/Font+Extensions.swift b/Projects/Shared/DesignSystem/Sources/Font/Font+Extensions.swift new file mode 100644 index 00000000..3f25d567 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Font/Font+Extensions.swift @@ -0,0 +1,28 @@ +// +// Font+Extensions.swift +// SharedDesignSystem +// +// Created by 임현규 on 7/26/24. +// + +import SwiftUI + + +public extension Font { + enum BottleFontSystem: Fontable { + case wantedSans(WantedSans) + case roboto(Roboto) + case laundryGothic(LaundryGothic) + + public var font: Font { + switch self { + case .wantedSans(let wantedSans): + return wantedSans.font + case .roboto(let roboto): + return roboto.font + case .laundryGothic(let laundryGothic): + return laundryGothic.font + } + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Font/Fontable.swift b/Projects/Shared/DesignSystem/Sources/Font/Fontable.swift new file mode 100644 index 00000000..abcbd4ed --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Font/Fontable.swift @@ -0,0 +1,12 @@ +// +// Fontable.swift +// SharedDesignSystem +// +// Created by 임현규 on 7/26/24. +// + +import SwiftUI + +public protocol Fontable { + var font: Font { get } +} diff --git a/Projects/Shared/DesignSystem/Sources/Image/BottleImageSystem+Icon.swift b/Projects/Shared/DesignSystem/Sources/Image/BottleImageSystem+Icon.swift new file mode 100644 index 00000000..999d320f --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Image/BottleImageSystem+Icon.swift @@ -0,0 +1,75 @@ +// +// BottleImageSystem+Icon.swift +// SharedDesignSystem +// +// Created by 임현규 on 8/3/24. +// + +import SwiftUI + +public extension Image.BottleImageSystem { + enum Icon: Imageable { + case leftArrow + case down + case right + case up + case delete + case clearDelete + case share + case siren + case plus + case verticalLine + case kakaoLogo + case sandBeach + case bottleStorage + case myPage + case appleLogo + } +} + +public extension Image.BottleImageSystem.Icon { + var image: Image { + switch self { + case .leftArrow: + return SharedDesignSystemAsset.Images.iconArrowLeft.swiftUIImage + case .down: + return SharedDesignSystemAsset.Images.iconDown.swiftUIImage + case .right: + return SharedDesignSystemAsset.Images.iconRight.swiftUIImage + case .up: + return SharedDesignSystemAsset.Images.iconUp.swiftUIImage + case .delete: + return SharedDesignSystemAsset.Images.iconDelete.swiftUIImage + case .clearDelete: + return SharedDesignSystemAsset.Images.iconClearDelete.swiftUIImage + case .share: + return SharedDesignSystemAsset.Images.iconShare.swiftUIImage + case .siren: + return SharedDesignSystemAsset.Images.iconSiren.swiftUIImage + case .plus: + return SharedDesignSystemAsset.Images.iconPlus.swiftUIImage + case .verticalLine: + return SharedDesignSystemAsset.Images.iconVerticalLine.swiftUIImage + + case .kakaoLogo: + return SharedDesignSystemAsset.Images.iconKakaoLogo.swiftUIImage + + case .sandBeach: + return SharedDesignSystemAsset.Images.iconSandBeach.swiftUIImage + + case .bottleStorage: + return SharedDesignSystemAsset.Images.iconBottleStorage.swiftUIImage + + case .myPage: + return SharedDesignSystemAsset.Images.iconMyPage.swiftUIImage + + case .appleLogo: + return SharedDesignSystemAsset.Images.iconAppleLogo.swiftUIImage + } + } +} + + + + + diff --git a/Projects/Shared/DesignSystem/Sources/Image/BottleImageSystem+Illustraition.swift b/Projects/Shared/DesignSystem/Sources/Image/BottleImageSystem+Illustraition.swift new file mode 100644 index 00000000..2ec540a4 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Image/BottleImageSystem+Illustraition.swift @@ -0,0 +1,71 @@ +// +// BottleImageSystem+Illustraition.swift +// SharedDesignSystem +// +// Created by 임현규 on 8/9/24. +// + +import SwiftUI + +public extension Image.BottleImageSystem { + enum Illustraition: Imageable { + case logo + case whiteLogo + case boy + case girl + case loginBackground + case sandBeachBackground + case telescope + case bottle1 + case bottle2 + case islandHasBottle + case islandEmptyBottle + case yes + case no + case loudspeark + case phone + case basket + case splash + } +} + +public extension Image.BottleImageSystem.Illustraition { + var image: Image { + switch self { + case .logo: + return SharedDesignSystemAsset.Images.illustraitionLogo.swiftUIImage + case .whiteLogo: + return SharedDesignSystemAsset.Images.illustraitionWhiteLogo.swiftUIImage + case .boy: + return SharedDesignSystemAsset.Images.illustraitionBoy.swiftUIImage + case .girl: + return SharedDesignSystemAsset.Images.illustraitionGirl.swiftUIImage + case .loginBackground: + return SharedDesignSystemAsset.Images.illustraitionLoginBackground.swiftUIImage + case .sandBeachBackground: + return SharedDesignSystemAsset.Images.illustrationSandBeachBackground.swiftUIImage + case .telescope: + return SharedDesignSystemAsset.Images.illustraitionTelescope.swiftUIImage + case .bottle1: + return SharedDesignSystemAsset.Images.illustraitionBottle1.swiftUIImage + case .bottle2: + return SharedDesignSystemAsset.Images.illustraitionBottle2.swiftUIImage + case .islandHasBottle: + return SharedDesignSystemAsset.Images.illustraitionIslandHasBottle.swiftUIImage + case .islandEmptyBottle: + return SharedDesignSystemAsset.Images.illustraitionIslandEmptyBottle.swiftUIImage + case .yes: + return SharedDesignSystemAsset.Images.illustraitionYes.swiftUIImage + case .no: + return SharedDesignSystemAsset.Images.illustraitionNo.swiftUIImage + case .loudspeark: + return SharedDesignSystemAsset.Images.illustraitionLoudspeaker.swiftUIImage + case .phone: + return SharedDesignSystemAsset.Images.illustraitionPhone.swiftUIImage + case .basket: + return SharedDesignSystemAsset.Images.illustraitionBasket.swiftUIImage + case .splash: + return SharedDesignSystemAsset.Images.imageSplash.swiftUIImage + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Image/Image+Extensions.swift b/Projects/Shared/DesignSystem/Sources/Image/Image+Extensions.swift new file mode 100644 index 00000000..28d6c5f6 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Image/Image+Extensions.swift @@ -0,0 +1,33 @@ +// +// Image+Extensions.swift +// SharedDesignSystem +// +// Created by 임현규 on 8/3/24. +// + +import SwiftUI + +public extension Image { + enum BottleImageSystem: Imageable { + case icom(Icon) + case illustraition(Illustraition) + + public var image: Image { + switch self { + case .icom(let icon): + return icon.image + case .illustraition(let illustraition): + return illustraition.image + } + } + + public var description: String { + switch self { + case .icom: + return "icon" + case .illustraition: + return "illustraition" + } + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Image/Imageable.swift b/Projects/Shared/DesignSystem/Sources/Image/Imageable.swift new file mode 100644 index 00000000..6032d4fa --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Image/Imageable.swift @@ -0,0 +1,12 @@ +// +// Imageable.swift +// SharedDesignSystem +// +// Created by 임현규 on 8/3/24. +// + +import SwiftUI + +public protocol Imageable { + var image: Image { get } +} diff --git a/Projects/Shared/DesignSystem/Sources/Modifiers/AsButtonModifier.swift b/Projects/Shared/DesignSystem/Sources/Modifiers/AsButtonModifier.swift new file mode 100644 index 00000000..7cfcb8b6 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Modifiers/AsButtonModifier.swift @@ -0,0 +1,28 @@ +// +// AsButtonModifier.swift +// CoreUtil +// +// Created by JongHoon on 7/27/24. +// + +import SwiftUI + +private struct AsButtonModifier: ViewModifier { + private let action: () -> Void + + init(action: @escaping () -> Void) { + self.action = action + } + + func body(content: Content) -> some View { + Button(action: action) { + content + } + } +} + +public extension View { + func asButton(action: @escaping () -> Void) -> some View { + modifier(AsButtonModifier(action: action)) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Modifiers/AsDebounceButtonModifier.swift b/Projects/Shared/DesignSystem/Sources/Modifiers/AsDebounceButtonModifier.swift new file mode 100644 index 00000000..0190708c --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Modifiers/AsDebounceButtonModifier.swift @@ -0,0 +1,29 @@ +// +// AsDebounceButtonModifier.swift +// CoreUtil +// +// Created by JongHoon on 7/27/24. +// + +import SwiftUI + +private struct AsDebounceButtonModifier: ViewModifier { + private let action: () -> Void + + init(action: @escaping () -> Void) { + self.action = action + } + + func body(content: Content) -> some View { + content + .asButton { + action() + } + } +} + +public extension View { + func asDebounceButton(action: @escaping () -> Void) -> some View { + modifier(AsDebounceButtonModifier(action: action)) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Modifiers/AsThrottleButtonModifier.swift b/Projects/Shared/DesignSystem/Sources/Modifiers/AsThrottleButtonModifier.swift new file mode 100644 index 00000000..cb89c56e --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Modifiers/AsThrottleButtonModifier.swift @@ -0,0 +1,29 @@ +// +// AsThrottleButtonModifier.swift +// CoreUtil +// +// Created by JongHoon on 7/27/24. +// + +import SwiftUI + +private struct AsThrottleButtonModifier: ViewModifier { + private let action: () -> Void + + init(action: @escaping () -> Void) { + self.action = action + } + + func body(content: Content) -> some View { + content + .asButton { + action() + } + } +} + +public extension View { + func asThrottleButton(action: @escaping () -> Void) -> some View { + modifier(AsThrottleButtonModifier(action: action)) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Modifiers/EndEditingModifier.swift b/Projects/Shared/DesignSystem/Sources/Modifiers/EndEditingModifier.swift new file mode 100644 index 00000000..cc04fafe --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Modifiers/EndEditingModifier.swift @@ -0,0 +1,33 @@ +// +// EndEditingModifier.swift +// DesignSystemExample +// +// Created by 임현규 on 8/12/24. +// + +import Foundation +import SwiftUI + +private struct OnTapEndEditingModifier: ViewModifier { + func body(content: Content) -> some View { + content + .contentShape(Rectangle()) + .onTapGesture { + content.endTextEditing() + } + } +} + +// MARK: - public extension +public extension View { + func onTapEndEditing() -> some View { + modifier(OnTapEndEditingModifier()) + } +} + +// MARK: - private extension +private extension View { + func endTextEditing() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Modifiers/NavigationBarModifier.swift b/Projects/Shared/DesignSystem/Sources/Modifiers/NavigationBarModifier.swift new file mode 100644 index 00000000..aacd17ba --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Modifiers/NavigationBarModifier.swift @@ -0,0 +1,116 @@ +// +// NavigationBarModifier.swift +// SharedDesignSystem +// +// Created by 임현규 on 8/11/24. +// + +import Foundation +import SwiftUI + +private struct NavigationBarModifier: ViewModifier where L: View, C: View, R: View { + private let leftView: (() -> L)? + private let centerView: (() -> C)? + private let rightView: (() -> R)? + + init( + leftView: (() -> L)? = nil, + centerView: (() -> C)? = nil, + rightView: (() -> R)? = nil + ) { + self.leftView = leftView + self.centerView = centerView + self.rightView = rightView + } + + func body(content: Content) -> some View { + content + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + leftView?() + } + } + .toolbar { + ToolbarItem(placement: .principal) { + centerView?() + } + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + rightView?() + } + } + } +} + +// MARK: - View Modifier +public extension View { + func setNavigationBar( + leftView: @escaping (() -> L), + centerView: @escaping (() -> C), + rightView: @escaping (() -> R) + ) -> some View where L: View, C: View, R: View { + modifier( + NavigationBarModifier( + leftView: leftView, + centerView: centerView, + rightView: rightView + ) + ) + } + + func setNavigationBar( + leftView: @escaping (() -> L), + centerView: @escaping (() -> C) + ) -> some View where L: View, C: View { + modifier( + NavigationBarModifier( + leftView: leftView, + centerView: centerView, + rightView: {EmptyView()} + ) + ) + } + + func setNavigationBar( + leftView: @escaping (() -> L), + rightView: @escaping (() -> R) + ) -> some View where L: View, R: View { + modifier( + NavigationBarModifier( + leftView: leftView, + centerView: { EmptyView() }, + rightView: rightView + ) + ) + } + + func setNavigationBar( + leftView: @escaping (() -> L) + ) -> some View where L: View { + modifier( + NavigationBarModifier( + leftView: leftView, + centerView: { EmptyView() }, + rightView: { EmptyView() } + ) + ) + } +} + +// MARK: - NavigationBar Item + +public extension View { + func makeNaivgationleftButton(action: (() -> Void)? = nil) -> some View { + BottleImageView(type: .local(bottleImageSystem: .icom(.leftArrow))) + .foregroundStyle(to: ColorToken.icon(.primary)) + .asThrottleButton(action: action ?? {}) + } + + func makeNavigationReportButton(action: (() -> Void)? = nil) -> some View { + BottleImageView(type: .local(bottleImageSystem: .icom(.siren))) + .foregroundStyle(to: ColorToken.icon(.primary)) + .asThrottleButton(action: action ?? {}) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Modifiers/OnLoadModifier.swift b/Projects/Shared/DesignSystem/Sources/Modifiers/OnLoadModifier.swift new file mode 100644 index 00000000..eb573635 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Modifiers/OnLoadModifier.swift @@ -0,0 +1,32 @@ +// +// OnLoadModifier.swift +// CoreUtil +// +// Created by JongHoon on 7/21/24. +// + +import SwiftUI + +private struct OnLoadModifier: ViewModifier { + @State private var didLoad = false + private let action: () -> Void + + init(perform action: @escaping () -> Void) { + self.action = action + } + + func body(content: Content) -> some View { + content.onAppear { + if didLoad == false { + didLoad = true + action() + } + } + } +} + +public extension View { + func onLoad(perform action: @escaping () -> Void) -> some View { + modifier(OnLoadModifier(perform: action)) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Modifiers/OnLoadTaskModifier.swift b/Projects/Shared/DesignSystem/Sources/Modifiers/OnLoadTaskModifier.swift new file mode 100644 index 00000000..c115be77 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Modifiers/OnLoadTaskModifier.swift @@ -0,0 +1,32 @@ +// +// OnLoadTaskModifier.swift +// CoreUtil +// +// Created by JongHoon on 7/21/24. +// + +import SwiftUI + +private struct OnLoadTaskModifier: ViewModifier { + @State private var didLoad = false + private let action: () -> Void + + init(perform action: @escaping () -> Void) { + self.action = action + } + + func body(content: Content) -> some View { + content.task { + if didLoad == false { + didLoad = true + action() + } + } + } +} + +public extension View { + func onLoadTask(perform action: @escaping () -> Void) -> some View { + modifier(OnLoadTaskModifier(perform: action)) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Padding/BottlePaddingType.swift b/Projects/Shared/DesignSystem/Sources/Padding/BottlePaddingType.swift new file mode 100644 index 00000000..4c3a3fbe --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Padding/BottlePaddingType.swift @@ -0,0 +1,31 @@ +// +// BottlePaddingType.swift +// DesignSystemExample +// +// Created by 임현규 on 7/27/24. +// + +import SwiftUI + +public enum BottlePaddingType { + /// 24.0 + case xl + /// 20.0 + case lg + /// 16.0 + case md + /// 12.0 + case sm + /// 8.0 + case xs + + public var length: CGFloat { + switch self { + case .xl: return 24 + case .lg: return 20 + case .md: return 16 + case .sm: return 12 + case .xs: return 8 + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Padding/View+Padding.swift b/Projects/Shared/DesignSystem/Sources/Padding/View+Padding.swift new file mode 100644 index 00000000..b73957b8 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Padding/View+Padding.swift @@ -0,0 +1,18 @@ +// +// View+Padding.swift +// DesignSystemExample +// +// Created by 임현규 on 7/27/24. +// + +import SwiftUI + +public extension View { + func padding(_ edges: Edge.Set = .all, _ paddingType: BottlePaddingType? = nil) -> some View { + self.padding(edges, paddingType?.length) + } + + func padding(_ paddingType: BottlePaddingType) -> some View { + self.padding(paddingType.length) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/ProgressIndicator/LoadingIndicator.swift b/Projects/Shared/DesignSystem/Sources/ProgressIndicator/LoadingIndicator.swift new file mode 100644 index 00000000..ff001aba --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/ProgressIndicator/LoadingIndicator.swift @@ -0,0 +1,31 @@ +// +// LoadingIndicator.swift +// DesignSystemExample +// +// Created by JongHoon on 8/7/24. +// + +import SwiftUI + +import Lottie + +public struct LoadingIndicator: View { + + public init() {} + + public var body: some View { + ZStack { + Color(.black) + .opacity(0.5) + + LottieView(animation: try? .from(data: SharedDesignSystemAsset.Lotties.progressIndicator.data.data)) + .looping() + .frame(width: 150.0, height: 84.0) + } + .ignoresSafeArea() + } +} + +#Preview { + LoadingIndicator() +} diff --git a/Projects/Shared/DesignSystem/Sources/Radius/BottleRadiusType.swift b/Projects/Shared/DesignSystem/Sources/Radius/BottleRadiusType.swift new file mode 100644 index 00000000..1d3844f8 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Radius/BottleRadiusType.swift @@ -0,0 +1,32 @@ +// +// BottleRadiusType.swift +// DesignSystemExample +// +// Created by 임현규 on 7/27/24. +// + +import Foundation + +public enum BottleRadiusType { + + /// 24.0 + case xl + /// 2.0 + case lg + /// 16.0 + case md + /// 12.0 + case sm + /// 8.0 + case xs + + public var value: CGFloat { + switch self { + case .xl: return 24 + case .lg: return 20 + case .md: return 16 + case .sm: return 12 + case .xs: return 8 + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Spacing/HStack+Spacing.swift b/Projects/Shared/DesignSystem/Sources/Spacing/HStack+Spacing.swift new file mode 100644 index 00000000..0ee7d6b0 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Spacing/HStack+Spacing.swift @@ -0,0 +1,14 @@ +// +// HStack+Spacing.swift +// DesignSystemExample +// +// Created by 임현규 on 7/27/24. +// + +import SwiftUI + +public extension HStack { + init(alignment: VerticalAlignment = .center, spacing: Spacer.BottleSpacingType? = nil, @ViewBuilder content: () -> Content) { + self.init(alignment: alignment, spacing: spacing?.minLength, content: content) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Spacing/Spacable.swift b/Projects/Shared/DesignSystem/Sources/Spacing/Spacable.swift new file mode 100644 index 00000000..a398506b --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Spacing/Spacable.swift @@ -0,0 +1,12 @@ +// +// Spacable.swift +// DesignSystemExample +// +// Created by 임현규 on 7/27/24. +// + +import Foundation + +protocol Spacable { + var minLength: CGFloat { get } +} diff --git a/Projects/Shared/DesignSystem/Sources/Spacing/Spacer+Extensions.swift b/Projects/Shared/DesignSystem/Sources/Spacing/Spacer+Extensions.swift new file mode 100644 index 00000000..62a04c00 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Spacing/Spacer+Extensions.swift @@ -0,0 +1,45 @@ +// +// Spacer+Extensions.swift +// DesignSystemExample +// +// Created by 임현규 on 7/27/24. +// + +import SwiftUI + +public extension Spacer { + enum BottleSpacingType: Spacable { + /// 32.0 + case xxl + /// 24.0 + case xl + /// 20.0 + case lg + /// 16.0 + case md + /// 12.0 + case sm + /// 8.0 + case xs + /// 4.0 + case xxs + + var minLength: CGFloat { + switch self { + case .xxl: return 32 + case .xl: return 24 + case .lg: return 20 + case .md: return 16 + case .sm: return 12 + case .xs: return 8 + case .xxs: return 4 + } + } + } +} + +public extension Spacer { + init(_ bottleSpacingType: BottleSpacingType) { + self.init(minLength: bottleSpacingType.minLength) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Spacing/VStack+Spacing.swift b/Projects/Shared/DesignSystem/Sources/Spacing/VStack+Spacing.swift new file mode 100644 index 00000000..35a135cb --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Spacing/VStack+Spacing.swift @@ -0,0 +1,14 @@ +// +// VStack+Spacing.swift +// DesignSystemExample +// +// Created by 임현규 on 7/27/24. +// + +import SwiftUI + +public extension VStack { + init(alignment: HorizontalAlignment = .center, spacing: Spacer.BottleSpacingType? = nil, @ViewBuilder content: () -> Content) { + self.init(alignment: alignment, spacing: spacing?.minLength, content: content) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Text/CustomStyleText.swift b/Projects/Shared/DesignSystem/Sources/Text/CustomStyleText.swift new file mode 100644 index 00000000..12cb0887 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Text/CustomStyleText.swift @@ -0,0 +1,33 @@ +// +// CustomStyleText.swift +// SharedDesignSystem +// +// Created by JongHoon on 7/27/24. +// + +import SwiftUI + +public struct CustomStyleText: View { + private let content: String + private let style: F + private let color: ColorToken.TextToken + private var font: Font { + style.font + } + + public init( + _ content: String, + style: F, + color: ColorToken.TextToken + ) { + self.content = content + self.style = style + self.color = color + } + + public var body: some View { + Text(content) + .font(font) + .foregroundStyle(to: color) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Text/LaundryGothicStyleText.swift b/Projects/Shared/DesignSystem/Sources/Text/LaundryGothicStyleText.swift new file mode 100644 index 00000000..c327d74a --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Text/LaundryGothicStyleText.swift @@ -0,0 +1,35 @@ +// +// LaundryGothicStyleText.swift +// SharedDesignSystem +// +// Created by JongHoon on 7/27/24. +// + +import SwiftUI + +public struct LaundryGothicStyleText: View { + private let content: String + private let style: Font.BottleFontSystem.LaundryGothic + private let color: ColorToken.TextToken + private var font: Font { + style.font + } + + public init( + _ content: String, + style: Font.BottleFontSystem.LaundryGothic, + color: ColorToken.TextToken + ) { + self.content = content + self.style = style + self.color = color + } + + public var body: some View { + CustomStyleText( + content, + style: style, + color: color + ) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Text/RobotoStyleText.swift b/Projects/Shared/DesignSystem/Sources/Text/RobotoStyleText.swift new file mode 100644 index 00000000..916f1d3c --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Text/RobotoStyleText.swift @@ -0,0 +1,35 @@ +// +// RobotoStyleText.swift +// SharedDesignSystem +// +// Created by JongHoon on 7/27/24. +// + +import SwiftUI + +public struct RobotoStyleText: View { + private let content: String + private let style: Font.BottleFontSystem.Roboto + private let color: ColorToken.TextToken + private var font: Font { + style.font + } + + public init( + _ content: String, + style: Font.BottleFontSystem.Roboto, + color: ColorToken.TextToken + ) { + self.content = content + self.style = style + self.color = color + } + + public var body: some View { + CustomStyleText( + content, + style: style, + color: color + ) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Text/WantedSansStyleText.swift b/Projects/Shared/DesignSystem/Sources/Text/WantedSansStyleText.swift new file mode 100644 index 00000000..9cd35b53 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Text/WantedSansStyleText.swift @@ -0,0 +1,35 @@ +// +// WantedSansStyleText.swift +// SharedDesignSystem +// +// Created by JongHoon on 7/27/24. +// + +import SwiftUI + +public struct WantedSansStyleText: View { + private let content: String + private let style: Font.BottleFontSystem.WantedSans + private let color: ColorToken.TextToken + private var font: Font { + style.font + } + + public init( + _ content: String, + style: Font.BottleFontSystem.WantedSans, + color: ColorToken.TextToken + ) { + self.content = content + self.style = style + self.color = color + } + + public var body: some View { + CustomStyleText( + content, + style: style, + color: color + ) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Token/ColorToken+Background.swift b/Projects/Shared/DesignSystem/Sources/Token/ColorToken+Background.swift new file mode 100644 index 00000000..adfba854 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Token/ColorToken+Background.swift @@ -0,0 +1,29 @@ +// +// ColorToken+Background.swift +// SharedDesignSystem +// +// Created by 임현규 on 7/27/24. +// + +import SwiftUI + +public extension ColorToken { + enum Background: Colorable { + case primary + case secondary + case tertiary + } +} + +public extension ColorToken.Background { + var color: Color { + switch self { + case .primary: + return Color.BottleColorSystem.sub(.gradient).color + case .secondary: + return Color.BottleColorSystem.sub(.white).color + case .tertiary: + return Color.BottleColorSystem.sub(.gradient).color + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Token/ColorToken+Border.swift b/Projects/Shared/DesignSystem/Sources/Token/ColorToken+Border.swift new file mode 100644 index 00000000..357f9bad --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Token/ColorToken+Border.swift @@ -0,0 +1,50 @@ +// +// ColorToken+Border.swift +// SharedDesignSystem +// +// Created by 임현규 on 7/27/24. +// + +import SwiftUI + +public extension ColorToken { + enum Border: Colorable { + case primary + case secondary + case enabled + case disabled + case selected + case focused + case active + case error + } +} + +public extension ColorToken.Border { + var color: Color { + switch self { + case .primary: + return Color.BottleColorSystem.neutral(.neutral300).color + case .secondary: + return Color.BottleColorSystem.neutral(.neutral200).color + + case .enabled: + return Color.BottleColorSystem.neutral(.neutral300).color + + case .disabled: + return Color.BottleColorSystem.neutral(.neutral300).color + + case .selected: + return Color.BottleColorSystem.primary(.purple500).color + + case .focused: + return Color.BottleColorSystem.neutral(.neutral300).color + + case .active: + return Color.BottleColorSystem.neutral(.neutral300).color + + case .error: + return Color.BottleColorSystem.sub(.red).color + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Token/ColorToken+Brand.swift b/Projects/Shared/DesignSystem/Sources/Token/ColorToken+Brand.swift new file mode 100644 index 00000000..8c1a8a89 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Token/ColorToken+Brand.swift @@ -0,0 +1,32 @@ +// +// ColorToken+Brand.swift +// SharedDesignSystem +// +// Created by 임현규 on 7/27/24. +// + +import SwiftUI + +public extension ColorToken { + enum Brand: Colorable { + case primary + case secondary + case tertiary + case quaternary + } +} + +public extension ColorToken.Brand { + var color: Color { + switch self { + case .primary: + return Color.BottleColorSystem.primary(.purple400).color + case .secondary: + return Color.BottleColorSystem.primary(.purple500).color + case .tertiary: + return Color.BottleColorSystem.primary(.purple300).color + case .quaternary: + return Color.BottleColorSystem.primary(.purple100).color + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Token/ColorToken+Container.swift b/Projects/Shared/DesignSystem/Sources/Token/ColorToken+Container.swift new file mode 100644 index 00000000..00bb7287 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Token/ColorToken+Container.swift @@ -0,0 +1,78 @@ +// +// ColorToken+Container.swift +// SharedDesignSystem +// +// Created by 임현규 on 7/27/24. +// + +import SwiftUI + +public extension ColorToken { + enum Container: Colorable { + case primary + case secondary + case tertiary + case enablePrimary + case enableSecondary + case disablePrimary + case disableSecondary + case pressed + case selected + case focusePrimary + case focuseSecondary + case Active + case erroPrimary + case errorSecondary + case kakao + } +} + +public extension ColorToken.Container { + var color: Color { + switch self { + case .primary: + return Color.BottleColorSystem.sub(.white).color + case .secondary: + return Color.BottleColorSystem.primary(.purple100).color + + case .tertiary: + return Color.BottleColorSystem.neutral(.neutral600).color + + case .enablePrimary: + return Color.BottleColorSystem.sub(.white).color + + case .enableSecondary: + return Color.BottleColorSystem.primary(.purple400).color + + case .disablePrimary: + return Color.BottleColorSystem.sub(.white).color + + case .disableSecondary: + return Color.BottleColorSystem.neutral(.neutral400).color + + case .pressed: + return Color.BottleColorSystem.primary(.purple500).color + + case .selected: + return Color.BottleColorSystem.primary(.purple100).color + + case .focusePrimary: + return Color.BottleColorSystem.neutral(.neutral100).color + + case .focuseSecondary: + return Color.BottleColorSystem.sub(.white).color + + case .Active: + return Color.BottleColorSystem.sub(.white).color + + case .erroPrimary: + return Color.BottleColorSystem.neutral(.neutral100).color + + case .errorSecondary: + return Color.BottleColorSystem.sub(.white).color + + case .kakao: + return Color.BottleColorSystem.sub(.kakao).color + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Token/ColorToken+IconToken.swift b/Projects/Shared/DesignSystem/Sources/Token/ColorToken+IconToken.swift new file mode 100644 index 00000000..3b272f8e --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Token/ColorToken+IconToken.swift @@ -0,0 +1,36 @@ +// +// ColorToken+IconToken.swift +// SharedDesignSystem +// +// Created by 임현규 on 7/27/24. +// + +import SwiftUI + +public extension ColorToken { + enum IconToken: Colorable { + case primary + case secondary + case disabled + case update + } +} + +public extension ColorToken.IconToken { + var color: Color { + switch self { + case .primary: + return Color.BottleColorSystem.neutral(.neutral500).color + + case .secondary: + return Color.BottleColorSystem.neutral(.neutral200).color + + case .disabled: + return Color.BottleColorSystem.neutral(.neutral200).color + + case .update: + return Color.BottleColorSystem.sub(.red).color + + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Token/ColorToken+OnContainer.swift b/Projects/Shared/DesignSystem/Sources/Token/ColorToken+OnContainer.swift new file mode 100644 index 00000000..9faccf3f --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Token/ColorToken+OnContainer.swift @@ -0,0 +1,50 @@ +// +// ColorToken+OnContainer.swift +// SharedDesignSystem +// +// Created by 임현규 on 7/27/24. +// + +import SwiftUI + +public extension ColorToken { + enum OnContainer: Colorable { + case primary + case secondary + case enablePrimary + case enableSecondary + case disable + case focuse + case active + case error + } +} + +public extension ColorToken.OnContainer { + var color: Color { + switch self { + case .primary: + return Color.BottleColorSystem.primary(.purple100).color + case .secondary: + return Color.BottleColorSystem.neutral(.neutral100).color + + case .enablePrimary: + return Color.BottleColorSystem.neutral(.neutral100).color + + case .enableSecondary: + return Color.BottleColorSystem.sub(.white).color + + case .disable: + return Color.BottleColorSystem.neutral(.neutral100).color + + case .focuse: + return Color.BottleColorSystem.neutral(.neutral100).color + + case .active: + return Color.BottleColorSystem.neutral(.neutral100).color + + case .error: + return Color.BottleColorSystem.neutral(.neutral100).color + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Token/ColorToken+TextToken.swift b/Projects/Shared/DesignSystem/Sources/Token/ColorToken+TextToken.swift new file mode 100644 index 00000000..5fc691af --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Token/ColorToken+TextToken.swift @@ -0,0 +1,107 @@ +// +// ColorToken+TextToken.swift +// SharedDesignSystem +// +// Created by 임현규 on 7/27/24. +// + +import SwiftUI + +public extension ColorToken { + enum TextToken: Colorable { + case primary + case secondary + case tertiary + case quaternary + case quinary + case senary + case enablePrimary + case enableSecondary + case enableTertiary + case enableQuaternary + case disablePrimary + case disableSecondary + case pressed + case selectPrimary + case selectSecondary + case focusePrimary + case focuseSecondary + case activePrimary + case activeSecondary + case errorPrimary + case errorSecondary + case errorTertiary + } +} + +public extension ColorToken.TextToken { + var color: Color { + switch self { + case .primary: + return Color.BottleColorSystem.sub(.black).color + + case .secondary: + return Color.BottleColorSystem.neutral(.neutral900).color + + case .tertiary: + return Color.BottleColorSystem.neutral(.neutral600).color + + case .quaternary: + return Color.BottleColorSystem.sub(.white).color + + case .quinary: + return Color.BottleColorSystem.primary(.purple500).color + + case .senary: + return Color.BottleColorSystem.primary(.purple300).color + + case .enablePrimary: + return Color.BottleColorSystem.sub(.white).color + + case .enableSecondary: + return Color.BottleColorSystem.neutral(.neutral900).color + + case .enableTertiary: + return Color.BottleColorSystem.neutral(.neutral400).color + + case .enableQuaternary: + return Color.BottleColorSystem.neutral(.neutral600).color + + case .disablePrimary: + return Color.BottleColorSystem.sub(.white).color + + case .disableSecondary: + return Color.BottleColorSystem.neutral(.neutral400).color + + case .pressed: + return Color.BottleColorSystem.sub(.white).color + + case .selectPrimary: + return Color.BottleColorSystem.primary(.purple500).color + + case .selectSecondary: + return Color.BottleColorSystem.sub(.black).color + + case .focusePrimary: + return Color.BottleColorSystem.neutral(.neutral900).color + + case .focuseSecondary: + return Color.BottleColorSystem.neutral(.neutral600).color + + case .activePrimary: + return Color.BottleColorSystem.neutral(.neutral900).color + + case .activeSecondary: + return Color.BottleColorSystem.neutral(.neutral600).color + + case .errorPrimary: + return Color.BottleColorSystem.sub(.red).color + + case .errorSecondary: + return Color.BottleColorSystem.neutral(.neutral900).color + + case .errorTertiary: + return Color.BottleColorSystem.neutral(.neutral600).color + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Token/ColorToken.swift b/Projects/Shared/DesignSystem/Sources/Token/ColorToken.swift new file mode 100644 index 00000000..7b3bb1c0 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Token/ColorToken.swift @@ -0,0 +1,37 @@ +// +// ColorToken.swift +// SharedDesignSystem +// +// Created by 임현규 on 7/27/24. +// + +import SwiftUI + +public enum ColorToken: Colorable { + case brand(Brand) + case background(Background) + case container(Container) + case onContainer(OnContainer) + case text(TextToken) + case border(Border) + case icon(IconToken) + + public var color: Color { + switch self { + case .brand(let brand): + return brand.color + case .background(let background): + return background.color + case .container(let container): + return container.color + case .onContainer(let onContainer): + return onContainer.color + case .text(let textToken): + return textToken.color + case .border(let border): + return border.color + case .icon(let iconToken): + return iconToken.color + } + } +} diff --git a/Projects/Shared/DesignSystemThirdPartyLib/Interface/Sources/DesignSystemThirdPartyLibInterface.swift b/Projects/Shared/DesignSystemThirdPartyLib/Interface/Sources/DesignSystemThirdPartyLibInterface.swift new file mode 100644 index 00000000..c0a824a4 --- /dev/null +++ b/Projects/Shared/DesignSystemThirdPartyLib/Interface/Sources/DesignSystemThirdPartyLibInterface.swift @@ -0,0 +1,5 @@ +// This is for Tuist + +public protocol DesignSystemThirdPartyLibInterface { + +} diff --git a/Projects/Shared/DesignSystemThirdPartyLib/Project.swift b/Projects/Shared/DesignSystemThirdPartyLib/Project.swift new file mode 100644 index 00000000..f896a148 --- /dev/null +++ b/Projects/Shared/DesignSystemThirdPartyLib/Project.swift @@ -0,0 +1,18 @@ +import ProjectDescription +import ProjectDescriptionHelpers +import DependencyPlugin + +let project = Project.makeModule( + name: ModulePath.Shared.name+ModulePath.Shared.DesignSystemThirdPartyLib.rawValue, + targets: [ + .shared( + implements: .DesignSystemThirdPartyLib, + factory: .init( + dependencies: [ + .SPM.Kingfisher, + .SPM.Lottie + ] + ) + ), + ] +) diff --git a/Projects/Shared/DesignSystemThirdPartyLib/Sources/Source.swift b/Projects/Shared/DesignSystemThirdPartyLib/Sources/Source.swift new file mode 100644 index 00000000..b1853ce6 --- /dev/null +++ b/Projects/Shared/DesignSystemThirdPartyLib/Sources/Source.swift @@ -0,0 +1 @@ +// This is for Tuist diff --git a/Projects/Shared/Project.swift b/Projects/Shared/Project.swift new file mode 100644 index 00000000..2bbb90dc --- /dev/null +++ b/Projects/Shared/Project.swift @@ -0,0 +1,26 @@ +// +// Project.swift +// AppManifests +// +// Created by 임현규 on 7/19/24. +// + +import ProjectDescription +import ProjectDescriptionHelpers +import DependencyPlugin + +let targets: [Target] = [ + .shared(factory: .init( + product: .staticFramework, + sources: nil, + dependencies: [ + .shared(implements: .DesignSystem), + .shared(implements: .Util) + ] + )) +] + +let project = Project.makeModule( + name: "Shared", + targets: targets +) diff --git a/Projects/Shared/ThirdPartyLib/Interface/Sources/ThirdPartyLibInterface.swift b/Projects/Shared/ThirdPartyLib/Interface/Sources/ThirdPartyLibInterface.swift new file mode 100644 index 00000000..6b456196 --- /dev/null +++ b/Projects/Shared/ThirdPartyLib/Interface/Sources/ThirdPartyLibInterface.swift @@ -0,0 +1,5 @@ +// This is for Tuist + +public protocol ThirdPartyLibInterface { + +} diff --git a/Projects/Shared/ThirdPartyLib/Project.swift b/Projects/Shared/ThirdPartyLib/Project.swift new file mode 100644 index 00000000..d5534d77 --- /dev/null +++ b/Projects/Shared/ThirdPartyLib/Project.swift @@ -0,0 +1,24 @@ +import ProjectDescription +import ProjectDescriptionHelpers +import DependencyPlugin + +let project = Project.makeModule( + name: ModulePath.Shared.name+ModulePath.Shared.ThirdPartyLib.rawValue, + targets: [ + .shared( + implements: .ThirdPartyLib, + factory: .init( + dependencies: [ + .SPM.ComposableArchitecture, + .SPM.Moya, + .SPM.KakaoSDKAuth, + .SPM.KakaoSDKUser, + .SPM.Alamofire, + .SPM.FirebaseAnalytics, + .SPM.FirebaseCrashlytics, + .SPM.FirebaseMessaging + ] + ) + ), + ] +) diff --git a/Projects/Shared/ThirdPartyLib/Sources/Source.swift b/Projects/Shared/ThirdPartyLib/Sources/Source.swift new file mode 100644 index 00000000..b1853ce6 --- /dev/null +++ b/Projects/Shared/ThirdPartyLib/Sources/Source.swift @@ -0,0 +1 @@ +// This is for Tuist diff --git a/Projects/Shared/Util/Interface/Sources/Date+Extensions.swift b/Projects/Shared/Util/Interface/Sources/Date+Extensions.swift new file mode 100644 index 00000000..18fbe4b2 --- /dev/null +++ b/Projects/Shared/Util/Interface/Sources/Date+Extensions.swift @@ -0,0 +1,38 @@ +// +// Date+Extensions.swift +// SharedUtilInterface +// +// Created by 임현규 on 8/13/24. +// + +import Foundation + +extension Date { + + /// 현재 시간을 기준으로 보틀 도착 시간과의 시간 차이를 계산 + public func newBottleArrivaleTime() -> Int { + let current = self + let calendar = Calendar.current + + let today12pm = calendar.date(bySettingHour: 12, minute: 0, second: 0, of: current)! + let today18pm = calendar.date(bySettingHour: 18, minute: 0, second: 0, of: current)! + + let arrivalTime: Date + + if current < today12pm { + // 현재 시간이 12:00 이전인 경우 + arrivalTime = today12pm + } else if current < today18pm { + // 현재 시간이 12:00, 18:00 사이 인 경우 + arrivalTime = today18pm + } else { + // 현재 시간이 18:00 이후인 경우 + arrivalTime = calendar.date(byAdding: .day, value: 1, to: today12pm)! + } + + // 도착 시간과 현재 시간의 시간 차이 + let timeDifference = calendar.dateComponents([.hour], from: current, to: arrivalTime) + + return timeDifference.hour ?? 0 + } +} diff --git a/Projects/Shared/Util/Interface/Sources/UtilInterface.swift b/Projects/Shared/Util/Interface/Sources/UtilInterface.swift new file mode 100644 index 00000000..b5477d2b --- /dev/null +++ b/Projects/Shared/Util/Interface/Sources/UtilInterface.swift @@ -0,0 +1,5 @@ +// This is for Tuist + +public protocol UtilInterface { + +} diff --git a/Projects/Shared/Util/Project.swift b/Projects/Shared/Util/Project.swift new file mode 100644 index 00000000..158d4759 --- /dev/null +++ b/Projects/Shared/Util/Project.swift @@ -0,0 +1,22 @@ +import ProjectDescription +import ProjectDescriptionHelpers +import DependencyPlugin + +let project = Project.makeModule( + name: ModulePath.Shared.name+ModulePath.Shared.Util.rawValue, + targets: [ + .shared( + interface: .Util, + factory: .init() + ), + .shared( + implements: .Util, + factory: .init( + dependencies: [ + .shared(interface: .Util) + ] + ) + ), + + ] +) diff --git a/Projects/Shared/Util/Sources/Source.swift b/Projects/Shared/Util/Sources/Source.swift new file mode 100644 index 00000000..b1853ce6 --- /dev/null +++ b/Projects/Shared/Util/Sources/Source.swift @@ -0,0 +1 @@ +// This is for Tuist diff --git a/Scripts/GenerateModule.swift b/Scripts/GenerateModule.swift new file mode 100644 index 00000000..fcbc3753 --- /dev/null +++ b/Scripts/GenerateModule.swift @@ -0,0 +1,270 @@ +#!/usr/bin/swift +import Foundation + +enum LayerType: String { + case feature = "Feature" + case domain = "Domain" + case core = "Core" + case shared = "Shared" +} + +public enum MicroTargetType: String, CaseIterable { + case Example = "Example" + case Sources = "Sources" + case Tests = "Tests" + case Testing = "Testing" + case Interface = "Interface" +} + +let fileManager = FileManager.default +let currentPath = "./" +let bash = Bash() + +func registerModuleDependency() { + registerModulePath() + makeProjectDirectory() + + var targetString = "[" + + makeScaffold(target: .Interface) + targetString += """ + + .\(layer.rawValue.lowercased())( + interface: .\(moduleName), + factory: .init() + ), + .\(layer.rawValue.lowercased())( + implements: .\(moduleName), + factory: .init( + dependencies: [ + .\(layer.rawValue.lowercased())(interface: .\(moduleName)) + ] + ) + ), + + """ + + + if hasUnitTests { + makeScaffold(target: .Testing) + makeScaffold(target: .Tests) + targetString += """ + + .\(layer.rawValue.lowercased())( + testing: .\(moduleName), + factory: .init( + dependencies: [ + .\(layer.rawValue.lowercased())(interface: .\(moduleName)) + ] + ) + ), + .\(layer.rawValue.lowercased())( + tests: .\(moduleName), + factory: .init( + dependencies: [ + .\(layer.rawValue.lowercased())(testing: .\(moduleName)), + .\(layer.rawValue.lowercased())(implements: .\(moduleName)) + ] + ) + ), + + """ + } + + if hasExample { + makeScaffold(target: .Example) + targetString += """ + + .\(layer.rawValue.lowercased())( + example: .\(moduleName), + factory: .init( + dependencies: [ + .\(layer.rawValue.lowercased())(testing: .\(moduleName)), + .\(layer.rawValue.lowercased())(implements: .\(moduleName)) + ] + ) + ) + + """ + } + + if targetString.hasSuffix(", ") { + targetString.removeLast(2) + } + + targetString += """ + + ] + """ + + makeProjectSwift(targetString: targetString) + makeProjectScaffold(targetString: targetString) +} + +func registerModulePath() { + updateFileContent( + filePath: currentPath + "Plugins/DependencyPlugin/ProjectDescriptionHelpers/Modules.swift", + finding: "enum \(layer.rawValue): String, CaseIterable {\n", + inserting: " case \(moduleName)\n" + ) + print("Register \(layer.rawValue)Layer's \(moduleName)Module to Modules.swift") +} + + +func makeDirectory(path: String) { + do { + try fileManager.createDirectory(atPath: path, withIntermediateDirectories: false, attributes: nil) + } catch { + fatalError("❌ failed to create directory: \(path)") + } +} + +func makeDirectories(_ paths: [String]) { + paths.forEach(makeDirectory(path:)) +} + +func makeProjectSwift(targetString: String) { + let projectSwift = """ +import ProjectDescription +import ProjectDescriptionHelpers +import DependencyPlugin + +let project = Project.makeModule( + name: ModulePath.\(layer.rawValue).name+ModulePath.\(layer.rawValue).\(moduleName).rawValue, + targets: \(targetString) +) + +""" + writeContentInFile( + path: currentPath + "Projects/\(layer.rawValue)/\(moduleName)/Project.swift", + content: projectSwift + ) +} + +func makeProjectDirectory() { + makeDirectory(path: currentPath + "Projects/\(layer.rawValue)/\(moduleName)") +} + +func makeProjectScaffold(targetString: String) { + _ = try? bash.run( + commandName: "tuist", + arguments: ["scaffold", "Module", "--name", "\(moduleName)", "--layer", "\(layer.rawValue)", "--target", "\(targetString)"] + ) +} + +func makeScaffold(target: MicroTargetType) { + _ = try? bash.run( + commandName: "tuist", + arguments: ["scaffold", "\(target.rawValue)", "--name", "\(moduleName)", "--layer", "\(layer.rawValue)"] + ) +} + +func writeContentInFile(path: String, content: String) { + let fileURL = URL(fileURLWithPath: path) + let data = Data(content.utf8) + try? data.write(to: fileURL) +} + +func updateFileContent( + filePath: String, + finding findingString: String, + inserting insertString: String +) { + let fileURL = URL(fileURLWithPath: filePath) + guard let readHandle = try? FileHandle(forReadingFrom: fileURL) else { + fatalError("❌ Failed to find \(filePath)") + } + guard let readData = try? readHandle.readToEnd() else { + fatalError("❌ Failed to find \(filePath)") + } + try? readHandle.close() + + guard var fileString = String(data: readData, encoding: .utf8) else { fatalError() } + fileString.insert(contentsOf: insertString, at: fileString.range(of: findingString)?.upperBound ?? fileString.endIndex) + + guard let writeHandle = try? FileHandle(forWritingTo: fileURL) else { + fatalError("❌ Failed to find \(filePath)") + } + writeHandle.seek(toFileOffset: 0) + try? writeHandle.write(contentsOf: Data(fileString.utf8)) + try? writeHandle.close() +} + + +// MARK: - Starting point + +print("Enter layer name\n(Feature | Domain | Core | Shared)", terminator: " : ") +let layerInput = readLine() +guard let layerInput, !layerInput.isEmpty, let layerUnwrapping = LayerType(rawValue: layerInput) else { + print("Layer is empty or invalid") + exit(1) +} +let layer = layerUnwrapping +print("Layer: \(layer.rawValue)\n") + +print("Enter module name", terminator: " : ") +let moduleInput = readLine() +guard let moduleNameUnwrapping = moduleInput, !moduleNameUnwrapping.isEmpty else { + print("Module name is empty") + exit(1) +} +var moduleName = moduleNameUnwrapping +print("Module name: \(moduleName)\n") + +print("This module has a 'Tests' Target? (y\\n, default = n)", terminator: " : ") +let hasUnitTests = readLine()?.lowercased() == "y" + +var hasExample = false +if layer.rawValue == "Feature" { + print("This module has a 'Example' Target? (y\\n, default = n)", terminator: " : ") + hasExample = readLine()?.lowercased() == "y" +} + +print("") + +registerModuleDependency() + +print("") +print("------------------------------------------------------------------------------------------------------------------------") +print("Layer: \(layer.rawValue)") +print("Module name: \(moduleName)") +print("unitTests: \(hasUnitTests), example: \(hasExample)") +print("------------------------------------------------------------------------------------------------------------------------") +print("✅ Module is created successfully!") + + + +// MARK: - Bash +protocol CommandExecuting { + func run(commandName: String, arguments: [String]) throws -> String +} + +enum BashError: Error { + case commandNotFound(name: String) +} + +struct Bash: CommandExecuting { + func run(commandName: String, arguments: [String] = []) throws -> String { + return try run(resolve(commandName), with: arguments) + } + + private func resolve(_ command: String) throws -> String { + guard var bashCommand = try? run("/bin/bash" , with: ["-l", "-c", "which \(command)"]) else { + throw BashError.commandNotFound(name: command) + } + bashCommand = bashCommand.trimmingCharacters(in: NSCharacterSet.whitespacesAndNewlines) + return bashCommand + } + + private func run(_ command: String, with arguments: [String] = []) throws -> String { + let process = Process() + process.launchPath = command + process.arguments = arguments + let outputPipe = Pipe() + process.standardOutput = outputPipe + process.launch() + let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() + let output = String(decoding: outputData, as: UTF8.self) + return output + } +} diff --git a/Tuist/Config.swift b/Tuist/Config.swift new file mode 100644 index 00000000..dbeb5f2e --- /dev/null +++ b/Tuist/Config.swift @@ -0,0 +1,15 @@ +// +// Config.swift +// Packages +// +// Created by 임현규 on 7/18/24. +// + +import ProjectDescription + +let config = Config( + plugins: [ + .local(path: .relativeToRoot("Plugins/DependencyPlugin")), + .local(path: .relativeToRoot("Plugins/ConfigurationPlugin")) + ] +) diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved new file mode 100644 index 00000000..aee39621 --- /dev/null +++ b/Tuist/Package.resolved @@ -0,0 +1,302 @@ +{ + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "194a6706acbd25e4ef639bcaddea16e8758a3e27", + "version" : "1.2024011602.0" + } + }, + { + "identity" : "alamofire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/Alamofire", + "state" : { + "revision" : "f455c2975872ccd2d9c81594c658af65716e9b9a", + "version" : "5.9.1" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "21fe1af9be463a359aaf8d96789ef73fc3760d09", + "version" : "11.0.1" + } + }, + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb", + "version" : "1.0.0" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "9118aca998dbe2ceac45d64b21a91c6376928df7", + "version" : "11.1.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "07a2f57d147d2bf368a0d2dcb5579ff082d9e44f", + "version" : "11.1.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "53156c7ec267db846e6b64c9f4c4e31ba4cf75eb", + "version" : "8.0.2" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "f56d8fc3162de9a498377c7b6cea43431f4f5083", + "version" : "1.65.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "a2ab612cb980066ee56d90d60d8462992c07f24b", + "version" : "3.5.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "2d12673670417654f08f5f90fdd62926dc3a2648", + "version" : "100.0.0" + } + }, + { + "identity" : "kakao-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kakao/kakao-ios-sdk.git", + "state" : { + "revision" : "66b3bddc2657e8ccb7a16fa0264aac99c57be09b", + "version" : "2.22.5" + } + }, + { + "identity" : "kingfisher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/onevcat/Kingfisher", + "state" : { + "revision" : "2ef543ee21d63734e1c004ad6c870255e8716c50", + "version" : "7.12.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "lottie-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/airbnb/lottie-ios", + "state" : { + "revision" : "fe4c6fe3a0aa66cdeb51d549623c82ca9704b9a5", + "version" : "4.5.0" + } + }, + { + "identity" : "moya", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Moya/Moya.git", + "state" : { + "revision" : "c263811c1f3dbf002be9bd83107f7cdc38992b26", + "version" : "15.0.3" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version" : "2.30910.0" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version" : "2.4.0" + } + }, + { + "identity" : "reactiveswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ReactiveCocoa/ReactiveSwift.git", + "state" : { + "revision" : "c43bae3dac73fdd3cb906bd5a1914686ca71ed3c", + "version" : "6.7.0" + } + }, + { + "identity" : "rxswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ReactiveX/RxSwift.git", + "state" : { + "revision" : "b06a8c8596e4c3e8e7788e08e720e3248563ce6a", + "version" : "6.7.1" + } + }, + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths", + "state" : { + "revision" : "12bc5b9191b62ee62cafecbfed953fbb1e1554cd", + "version" : "1.5.2" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "a8421d68068d8f45fbceb418fbf22c5dad4afd33", + "version" : "1.0.2" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", + "version" : "1.1.2" + } + }, + { + "identity" : "swift-composable-architecture", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-composable-architecture", + "state" : { + "revision" : "1f952d8c69ace5e53bb69a218e6ed00e03a4695c", + "version" : "1.11.2" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "f01efb26f3a192a0e88dcdb7c3c391ec2fc25d9c", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "d80613633e76d1ef86f41926e72fbef6a2f77d9c", + "version" : "1.3.3" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "2f5ab6e091dd032b63dacbda052405756010dc3b", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-perception", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-perception", + "state" : { + "revision" : "68901eac31c13c7d1ffef8e1bd8c3870ca2eaa95", + "version" : "1.3.2" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "e17d61f26df0f0e06f58f6977ba05a097a720106", + "version" : "1.27.1" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax", + "state" : { + "revision" : "303e5c5c36d6a558407d364878df131c3546fad8", + "version" : "510.0.2" + } + }, + { + "identity" : "swiftui-navigation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swiftui-navigation", + "state" : { + "revision" : "b7c9a79f6f6b1fefb87d3e5a83a9c2fe7cdc9720", + "version" : "1.5.0" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "6f30bdba373bbd7fbfe241dddd732651f2fbd1e2", + "version" : "1.1.2" + } + } + ], + "version" : 2 +} diff --git a/Tuist/Package.swift b/Tuist/Package.swift new file mode 100644 index 00000000..95dcb0b3 --- /dev/null +++ b/Tuist/Package.swift @@ -0,0 +1,36 @@ +// swift-tools-version: 5.9 +import PackageDescription + +#if TUIST + import ProjectDescription + import ProjectDescriptionHelpers + import ConfiguratipnPlugin + + let packageSettings = PackageSettings( + productTypes: [ + "ComposableArchitecture": .staticFramework, + "Kingfisher": .framework, + "Alamofire": .framework, + "Moya": .framework, + "KakaoSDK": .framework, + "Lottie": .framework, + "FirebaseAnalytics": .staticFramework, + "FirebaseMessaging": .staticFramework, + "FirebaseCrashlytics": .staticFramework, + ], + baseSettings: .packageSettings + ) +#endif + +let package = Package( + name: "Bottles_iOS", + dependencies: [ + .package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.11.2"), + .package(url: "https://github.com/onevcat/Kingfisher", from: "7.12.0"), + .package(url: "https://github.com/Alamofire/Alamofire", from: "5.9.1"), + .package(url: "https://github.com/Moya/Moya.git", exact: "15.0.3"), + .package(url: "https://github.com/kakao/kakao-ios-sdk.git", exact: "2.22.5"), + .package(url: "https://github.com/airbnb/lottie-ios", from: "4.5.0"), + .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "11.1.0") + ] +) diff --git a/Tuist/ProjectDescriptionHelpers/InfoPlist+Templates.swift b/Tuist/ProjectDescriptionHelpers/InfoPlist+Templates.swift new file mode 100644 index 00000000..8663c1d0 --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/InfoPlist+Templates.swift @@ -0,0 +1,63 @@ +// +// InfoPlist+Templates.swift +// ProjectDescriptionHelpers +// +// Created by 임현규 on 7/21/24. +// + +import ProjectDescription + +public extension InfoPlist { + static var app: InfoPlist { + return .extendingDefault(with: [ + "CFBundleShortVersionString": "1.0.0", + "CFBundleVersion": "17", + "UIUserInterfaceStyle": "Light", + "CFBundleName": "보틀", + "UILaunchScreen": [ + "UIImageName": "splashImage", + "UIColorName": "splashColor" + ], + "CFBundleIconName": "AppIcon", + "UISupportedInterfaceOrientations": [ + "UIInterfaceOrientationPortrait" + ], + "BASE_URL": "$(BASE_URL)", + "WEB_VIEW_BASE_URL": "$(WEB_VIEW_BASE_URL)", + "WEB_VIEW_MESSAGE_HANDLER_DEFAULT_NAME": "$(WEB_VIEW_MESSAGE_HANDLER_DEFAULT_NAME)", + "LSApplicationQueriesSchemes": ["kakaokompassauth"], + "CFBundleURLTypes": [ + [ + "CFBundleTypeRole": "Editor", + "CFBundleURLSchemes": ["kakao$(KAKAO_APP_KEY)"] + ] + ], + "KAKAO_APP_KEY": "$(KAKAO_APP_KEY)", + "UIBackgroundModes": ["remote-notification"] + ]) + } + + static var example: InfoPlist { + return .extendingDefault(with: [ + "CFBundleShortVersionString": "1.0.0", + "CFBundleVersion": "17", + "UIUserInterfaceStyle": "Light", + "UILaunchScreen": [:], + "UISupportedInterfaceOrientations": [ + "UIInterfaceOrientationPortrait" + ], + "BASE_URL": "$(BASE_URL)", + "WEB_VIEW_BASE_URL": "$(WEB_VIEW_BASE_URL)", + "WEB_VIEW_MESSAGE_HANDLER_DEFAULT_NAME": "$(WEB_VIEW_MESSAGE_HANDLER_DEFAULT_NAME)", + "LSApplicationQueriesSchemes": ["kakaokompassauth"], + "CFBundleURLTypes": [ + [ + "CFBundleTypeRole": "Editor", + "CFBundleURLSchemes": ["kakao$(KAKAO_APP_KEY)"] + ] + ], + "KAKAO_APP_KEY": "$(KAKAO_APP_KEY)", + "UIBackgroundModes": ["remote-notification"] + ]) + } +} diff --git a/Tuist/ProjectDescriptionHelpers/Project+Templates.swift b/Tuist/ProjectDescriptionHelpers/Project+Templates.swift new file mode 100644 index 00000000..046a31a6 --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/Project+Templates.swift @@ -0,0 +1,39 @@ +// +// Project+Templates.swift +// Bottles_iOSManifests +// +// Created by 임현규 on 7/17/24. +// + +import ProjectDescription + +public extension Project { + static func makeModule(name: String, targets: [Target], schemes: [Scheme] = [], resourceSynthesizers: [ResourceSynthesizer] = []) -> Self { + let name: String = name + let organizationName: String? = nil + let options: Project.Options = .options( + defaultKnownRegions: ["en", "ko"], + developmentRegion: "ko", + textSettings: .textSettings(indentWidth: 2, tabWidth: 2) + ) + let packages: [Package] = [] + let settings: Settings? = .projectSettings + let targets: [Target] = targets + let schemes: [Scheme] = schemes + let fileHeaderTemplate: FileHeaderTemplate? = nil + let additionalFiles: [FileElement] = [] + + return .init( + name: name, + organizationName: organizationName, + options: options, + packages: packages, + settings: settings, + targets: targets, + schemes: schemes, + fileHeaderTemplate: fileHeaderTemplate, + additionalFiles: additionalFiles, + resourceSynthesizers: resourceSynthesizers + ) + } +} diff --git a/Tuist/ProjectDescriptionHelpers/Scheme+Templates.swift b/Tuist/ProjectDescriptionHelpers/Scheme+Templates.swift new file mode 100644 index 00000000..a856419c --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/Scheme+Templates.swift @@ -0,0 +1,47 @@ +// +// Scheme+Templates.swift +// ProjectDescriptionHelpers +// +// Created by 임현규 on 7/19/24. +// + +import ProjectDescription +import ConfiguratipnPlugin + +public extension Scheme { + static func makeScheme(_ type: ProjectDeployTarget, name: String) -> Self { + let buildName = type.rawValue + switch type { + case .dev: + return .scheme( + name: "\(name)-\(buildName)", + shared: true, + buildAction: .buildAction(targets: ["\(name)"]), + runAction: .runAction(configuration: .dev), + archiveAction: .archiveAction(configuration: .dev), + profileAction: .profileAction(configuration: .dev), + analyzeAction: .analyzeAction(configuration: .dev) + ) + case .prod: + return .scheme( + name: "\(name)-\(buildName)", + shared: true, + buildAction: .buildAction(targets: ["\(name)"]), + runAction: .runAction(configuration: .prod), + archiveAction: .archiveAction(configuration: .prod), + profileAction: .profileAction(configuration: .prod), + analyzeAction: .analyzeAction(configuration: .prod) + ) + case .test: + return .scheme( + name: "\(name)-\(buildName)", + shared: true, + buildAction: .buildAction(targets: ["\(name)"]), + runAction: .runAction(configuration: .test), + archiveAction: .archiveAction(configuration: .test), + profileAction: .profileAction(configuration: .test), + analyzeAction: .analyzeAction(configuration: .test) + ) + } + } +} diff --git a/Tuist/ProjectDescriptionHelpers/Settings+Templates.swift b/Tuist/ProjectDescriptionHelpers/Settings+Templates.swift new file mode 100644 index 00000000..10e57d5b --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/Settings+Templates.swift @@ -0,0 +1,41 @@ +// +// Settings+Templates.swift +// ProjectDescriptionHelpers +// +// Created by 임현규 on 7/19/24. +// + +import ProjectDescription + +public extension ProjectDescription.Settings { + static var projectSettings: Self { + return .settings( + base: ["OTHER_LDFLAGS":["-all_load -Objc"]], + configurations: [ + .debug(name: .dev, xcconfig: .relativeToRoot("XCConfig/App/DEV.xcconfig")), + .debug(name: .test, xcconfig: .relativeToRoot("XCConfig/App/TEST.xcconfig")), + .release(name: .prod, xcconfig: .relativeToRoot("XCConfig/App/PROD.xcconfig")), + ] + ) + } + + static var targetSettings: Self { + return .settings( + configurations: [ + .debug(name: .dev, xcconfig: .relativeToRoot("XCConfig/App/DEV.xcconfig")), + .debug(name: .test, xcconfig: .relativeToRoot("XCConfig/App/TEST.xcconfig")), + .release(name: .prod, xcconfig: .relativeToRoot("XCConfig/App/PROD.xcconfig")), + ] + ) + } + + static var packageSettings: Self { + return .settings( + configurations: [ + .debug(name: .dev), + .debug(name: .test), + .release(name: .prod), + ] + ) + } +} diff --git a/Tuist/ProjectDescriptionHelpers/SoureceFileList+Templates.swift b/Tuist/ProjectDescriptionHelpers/SoureceFileList+Templates.swift new file mode 100644 index 00000000..2b9a0211 --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/SoureceFileList+Templates.swift @@ -0,0 +1,16 @@ +// +// SoureceFileList+Templates.swift +// ProjectDescriptionHelpers +// +// Created by 임현규 on 7/17/24. +// + +import ProjectDescription + +public extension SourceFilesList { + static let exampleSources: SourceFilesList = "Example/Sources/**" + static let interface: SourceFilesList = "Interface/Sources/**" + static let sources: SourceFilesList = "Sources/**" + static let testing: SourceFilesList = "Testing/Sources/**" + static let tests: SourceFilesList = "Tests/Sources/**" +} diff --git a/Tuist/ProjectDescriptionHelpers/Target+Templates.swift b/Tuist/ProjectDescriptionHelpers/Target+Templates.swift new file mode 100644 index 00000000..7c39d002 --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/Target+Templates.swift @@ -0,0 +1,320 @@ +// +// Target+Templates.swift +// ProjectDescriptionHelpers +// +// Created by 임현규 on 7/17/24. +// + +import ProjectDescription +import DependencyPlugin +import ConfiguratipnPlugin + +// MARK: - Target + Template + +public struct TargetFactory { + var name: String + var destinations: Destinations + var product: Product + var productName: String? + var bundleId: String? + var deploymentTargets: DeploymentTargets? + var infoPlist: InfoPlist? + var sources: SourceFilesList? + var resources: ResourceFileElements? + var copyFiles: [CopyFilesAction]? + var headers: Headers? + var entitlements: Entitlements? + var scripts: [TargetScript] + var dependencies: [TargetDependency] + var settings: Settings? + var coreDataModels: [CoreDataModel] + var environmentVariables: [String: EnvironmentVariable] + var launchArguments: [LaunchArgument] + var additionalFiles: [FileElement] + var bulidRules: [BuildRule] + var mergeBinaryType: MergedBinaryType + var mergable: Bool + + public init( + name: String = "", + destinations: Destinations = [.iPhone], + product: Product = .staticLibrary, + productName: String? = nil, + bundleId: String? = nil, + deploymentTargets: DeploymentTargets? = Project.Environment.deploymentTarget, + infoPlist: InfoPlist? = .default, + sources: SourceFilesList? = .sources, + resources: ResourceFileElements? = nil, + copyFiles: [CopyFilesAction]? = nil, + headers: Headers? = nil, + entitlements: Entitlements? = nil, + scripts: [TargetScript] = [], + dependencies: [TargetDependency] = [], + settings: Settings? = nil, + coreDataModels: [CoreDataModel] = [], + environmentVariables: [String : EnvironmentVariable] = [:], + launchArguments: [LaunchArgument] = [], + additionalFiles: [FileElement] = [], + bulidRules: [BuildRule] = [], + mergeBinaryType: MergedBinaryType = .disabled, + mergable: Bool = false + ) { + self.name = name + self.destinations = destinations + self.product = product + self.productName = productName + self.bundleId = bundleId + self.deploymentTargets = deploymentTargets + self.infoPlist = infoPlist + self.sources = sources + self.resources = resources + self.copyFiles = copyFiles + self.headers = headers + self.entitlements = entitlements + self.scripts = scripts + self.dependencies = dependencies + self.settings = settings + self.coreDataModels = coreDataModels + self.environmentVariables = environmentVariables + self.launchArguments = launchArguments + self.additionalFiles = additionalFiles + self.bulidRules = bulidRules + self.mergeBinaryType = mergeBinaryType + self.mergable = mergable + } +} + +public extension Target { + private static func make(factory: TargetFactory) -> Self { + return .target( + name: factory.name, + destinations: factory.destinations, + product: factory.product, + productName: factory.productName, + bundleId: factory.bundleId ?? Project.Environment.bundlePrefix + ".\(factory.name)", + deploymentTargets: factory.deploymentTargets, + infoPlist: factory.infoPlist, + sources: factory.sources, + resources: factory.resources, + copyFiles: factory.copyFiles, + headers: factory.headers, + entitlements: factory.entitlements, + scripts: factory.scripts, + dependencies: factory.dependencies, + settings: factory.settings, + coreDataModels: factory.coreDataModels, + environmentVariables: factory.environmentVariables, + launchArguments: factory.launchArguments, + additionalFiles: factory.additionalFiles, + buildRules: factory.bulidRules, + mergedBinaryType: factory.mergeBinaryType, + mergeable: factory.mergable + ) + } +} + +// MARK: - Target + App + +public extension Target { + static func app(implements module: ModulePath.App, factory: TargetFactory) -> Self { + var newFactory = factory + newFactory.name = ModulePath.App.name + module.rawValue + + switch module { + case .iOS: + newFactory.destinations = .iOS + newFactory.product = .app + newFactory.name = Project.Environment.appName + newFactory.bundleId = Project.Environment.bundlePrefix + newFactory.settings = .targetSettings + newFactory.resources = ["Resources/**"] + newFactory.sources = ["Sources/**"] + newFactory.productName = "Bottle" + newFactory.infoPlist = .app + newFactory.destinations = [.iPhone] + return make(factory: newFactory) + } + } +} + + +// MARK: - Target + Feature + +public extension Target { + static func feature(factory: TargetFactory) -> Self { + var newFactory = factory + newFactory.name = ModulePath.Feature.name + + return make(factory: newFactory) + } + + static func feature(implements module: ModulePath.Feature, factory: TargetFactory) -> Self { + var newFactory = factory + newFactory.name = ModulePath.Feature.name + module.rawValue + + return make(factory: newFactory) + } + + static func feature(tests module: ModulePath.Feature, factory: TargetFactory) -> Self { + var newFactory = factory + newFactory.name = ModulePath.Feature.name + module.rawValue + "Tests" + newFactory.sources = .tests + newFactory.product = .unitTests + + return make(factory: newFactory) + } + + static func feature(testing module: ModulePath.Feature, factory: TargetFactory) -> Self { + var newFactory = factory + newFactory.name = ModulePath.Feature.name + module.rawValue + "Testing" + newFactory.sources = .testing + + return make(factory: newFactory) + } + + static func feature(interface module: ModulePath.Feature, factory: TargetFactory) -> Self { + var newFactory = factory + newFactory.name = ModulePath.Feature.name + module.rawValue + "Interface" + newFactory.sources = .interface + + return make(factory: newFactory) + } + + static func feature(example module: ModulePath.Feature, factory: TargetFactory) -> Self { + var newFactory = factory + newFactory.name = ModulePath.Feature.name + module.rawValue + "Example" + newFactory.sources = .exampleSources + newFactory.product = .app + newFactory.infoPlist = .example + + return make(factory: newFactory) + } +} + +// MARK: - Target + Domain + +public extension Target { + static func domain(factory: TargetFactory) -> Self { + var newFactory = factory + newFactory.name = ModulePath.Domain.name + + return make(factory: newFactory) + } + + static func domain(implements module: ModulePath.Domain, factory: TargetFactory) -> Self { + var newFactory = factory + newFactory.name = ModulePath.Domain.name + module.rawValue + + return make(factory: newFactory) + } + + static func domain(tests module: ModulePath.Domain, factory: TargetFactory) -> Self { + var newFactory = factory + newFactory.name = ModulePath.Domain.name + module.rawValue + "Tests" + newFactory.product = .unitTests + newFactory.sources = .tests + + return make(factory: newFactory) + } + + static func domain(testing module: ModulePath.Domain, factory: TargetFactory) -> Self { + var newFactory = factory + newFactory.name = ModulePath.Domain.name + module.rawValue + "Testing" + newFactory.sources = .testing + + return make(factory: newFactory) + } + + static func domain(interface module: ModulePath.Domain, factory: TargetFactory) -> Self { + var newFactory = factory + newFactory.name = ModulePath.Domain.name + module.rawValue + "Interface" + newFactory.sources = .interface + + return make(factory: newFactory) + } +} + +// MARK: - Target + Core + +public extension Target { + static func core(factory: TargetFactory) -> Self { + var newFactory = factory + newFactory.name = ModulePath.Core.name + + return make(factory: newFactory) + } + + static func core(implements module: ModulePath.Core, factory: TargetFactory) -> Self { + var newFactory = factory + newFactory.name = ModulePath.Core.name + module.rawValue + + return make(factory: newFactory) + } + + static func core(tests module: ModulePath.Core, factory: TargetFactory) -> Self { + var newFactory = factory + newFactory.name = ModulePath.Core.name + module.rawValue + "Tests" + newFactory.product = .unitTests + newFactory.sources = .tests + + return make(factory: newFactory) + } + + static func core(testing module: ModulePath.Core, factory: TargetFactory) -> Self { + var newFactory = factory + newFactory.name = ModulePath.Core.name + module.rawValue + "Testing" + newFactory.sources = .testing + + return make(factory: newFactory) + } + + static func core(interface module: ModulePath.Core, factory: TargetFactory) -> Self { + var newFactory = factory + newFactory.name = ModulePath.Core.name + module.rawValue + "Interface" + newFactory.sources = .interface + + return make(factory: newFactory) + } +} + +// MARK: - Target + Shared + +public extension Target { + static func shared(factory: TargetFactory) -> Self { + var newFactory = factory + newFactory.name = ModulePath.Shared.name + + return make(factory: newFactory) + } + + static func shared(implements module: ModulePath.Shared, factory: TargetFactory) -> Self { + var newFactory = factory + newFactory.name = ModulePath.Shared.name + module.rawValue + + if module == .DesignSystem { + newFactory.sources = .sources + newFactory.resources = ["Resources/**"] + newFactory.product = .staticFramework + } + + return make(factory: newFactory) + } + + static func shared(interface module: ModulePath.Shared, factory: TargetFactory) -> Self { + var newFactory = factory + newFactory.name = ModulePath.Shared.name + module.rawValue + "Interface" + newFactory.sources = .interface + + return make(factory: newFactory) + } + + static func shared(example module: ModulePath.Shared, factory: TargetFactory) -> Self { + var newFactory = factory + newFactory.name = module.rawValue + "Example" + newFactory.sources = .exampleSources + newFactory.product = .app + newFactory.infoPlist = .example + + return make(factory: newFactory) + } +} diff --git a/Tuist/ResourceSynthesizers/Lottie.stencil b/Tuist/ResourceSynthesizers/Lottie.stencil new file mode 100644 index 00000000..aabb75cb --- /dev/null +++ b/Tuist/ResourceSynthesizers/Lottie.stencil @@ -0,0 +1,56 @@ +// swiftformat:disable all +// swiftlint:disable all +{% if files %} +{% set accessModifier %}{% if param.publicAccess %}public{% else %}internal{% endif %}{% endset %} +{% set documentPrefix %}{{param.documentName|default:"Document"}}{% endset %} +import Foundation +#if canImport(Lottie) +import Lottie +// MARK: - Animations Assets +{{accessModifier}} extension AnimationAsset { + {% for file in files %} + static let {{file.name|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}} = Self(named: "{{file.name}}") + {% endfor %} +} +// MARK: - Animation Helpers +{{accessModifier}} extension AnimationAsset { + /// All the available animation. Can be used to preload them + static let allAnimations: [Self] = [ + {% for file in files %} + Self.{{file.name|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}}, + {% endfor %} + ] +} +// MARK: - Structures +{{accessModifier}} struct AnimationAsset: Hashable { + {{accessModifier}} fileprivate(set) var name: String + {{accessModifier}} let animation: LottieAnimation? + {{accessModifier}} init(named name: String) { + self.name = name + if let url = Bundle.module.url(forResource: name, withExtension: "lottie") { + self.animation = LottieAnimation.filepath(url.path) + } else { + self.animation = nil + } + } + // MARK: Hashable Conformance + {{accessModifier}} static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.name == rhs.name + } + {{accessModifier}} func hash(into hasher: inout Hasher) { + hasher.combine(self.name) + } +} +// MARK: - Preload Helpers +{{accessModifier}} extension AnimationAsset { + /// Preloads all the Lottie Animations to avoid performance issues when loading them + static func preload() -> Void { + for animationAsset in Self.allAnimations { + _ = animationAsset.animation + } + } +} +#endif +{% else %} +// No files found +{% endif %} \ No newline at end of file diff --git a/Tuist/Templates/Example/Example.swift b/Tuist/Templates/Example/Example.swift new file mode 100644 index 00000000..04b18384 --- /dev/null +++ b/Tuist/Templates/Example/Example.swift @@ -0,0 +1,18 @@ +import ProjectDescription + +private let layerAttribute = Template.Attribute.required("layer") +private let nameAttribute = Template.Attribute.required("name") + +private let template = Template( + description: "A template for a new module's demo target", + attributes: [ + layerAttribute, + nameAttribute + ], + items: [ + .file( + path: "Projects/\(layerAttribute)/\(nameAttribute)/Example/Sources/AppView.swift", + templatePath: "ExampleSources.stencil" + ) + ] +) diff --git a/Tuist/Templates/Example/ExampleSources.stencil b/Tuist/Templates/Example/ExampleSources.stencil new file mode 100644 index 00000000..5411b88e --- /dev/null +++ b/Tuist/Templates/Example/ExampleSources.stencil @@ -0,0 +1,11 @@ +import SwiftUI + +@main +struct AppView: App { + var body: some Scene { + WindowGroup { + Text("Hello Tuist!") + } + } +} + diff --git a/Tuist/Templates/Interface/Interface.stencil b/Tuist/Templates/Interface/Interface.stencil new file mode 100644 index 00000000..7ecf1418 --- /dev/null +++ b/Tuist/Templates/Interface/Interface.stencil @@ -0,0 +1,5 @@ +// This is for Tuist + +public protocol {{ name }}Interface { + +} diff --git a/Tuist/Templates/Interface/Interface.swift b/Tuist/Templates/Interface/Interface.swift new file mode 100644 index 00000000..70d479ce --- /dev/null +++ b/Tuist/Templates/Interface/Interface.swift @@ -0,0 +1,18 @@ +import ProjectDescription + +private let layerAttribute = Template.Attribute.required("layer") +private let nameAttribute = Template.Attribute.required("name") + +private let template = Template( + description: "A template for a new module's interface target", + attributes: [ + layerAttribute, + nameAttribute + ], + items: [ + .file( + path: "Projects/\(layerAttribute)/\(nameAttribute)/Interface/Sources/\(nameAttribute)Interface.swift", + templatePath: "Interface.stencil" + ) + ] +) diff --git a/Tuist/Templates/Module/Module.swift b/Tuist/Templates/Module/Module.swift new file mode 100644 index 00000000..285b33ab --- /dev/null +++ b/Tuist/Templates/Module/Module.swift @@ -0,0 +1,24 @@ +import ProjectDescription + +private let layerAttribute = Template.Attribute.required("layer") +private let nameAttribute = Template.Attribute.required("name") +private let targetAttribute = Template.Attribute.required("target") + +private let template = Template( + description: "A template for a new module", + attributes: [ + layerAttribute, + nameAttribute, + targetAttribute + ], + items: [ + .file( + path: "Projects/\(layerAttribute)/\(nameAttribute)/Sources/Source.swift", + templatePath: "Sources.stencil" + ), + .file( + path: "Projects/\(layerAttribute)/\(nameAttribute)/Project.swift", + templatePath: "Project.stencil" + ) + ] +) diff --git a/Tuist/Templates/Module/Project.stencil b/Tuist/Templates/Module/Project.stencil new file mode 100644 index 00000000..37877d1a --- /dev/null +++ b/Tuist/Templates/Module/Project.stencil @@ -0,0 +1,8 @@ +import ProjectDescription +import ProjectDescriptionHelpers +import DependencyPlugin + +let project = Project.makeModule( + name: ModulePath.{{ layer }}.name+ModulePath.{{ layer }}.{{ name }}.rawValue, + targets: {{ target }} +) diff --git a/Tuist/Templates/Module/Sources.stencil b/Tuist/Templates/Module/Sources.stencil new file mode 100644 index 00000000..b1853ce6 --- /dev/null +++ b/Tuist/Templates/Module/Sources.stencil @@ -0,0 +1 @@ +// This is for Tuist diff --git a/Tuist/Templates/Sources/Sources.stencil b/Tuist/Templates/Sources/Sources.stencil new file mode 100644 index 00000000..b1853ce6 --- /dev/null +++ b/Tuist/Templates/Sources/Sources.stencil @@ -0,0 +1 @@ +// This is for Tuist diff --git a/Tuist/Templates/Sources/Sources.swift b/Tuist/Templates/Sources/Sources.swift new file mode 100644 index 00000000..dcb8bf2b --- /dev/null +++ b/Tuist/Templates/Sources/Sources.swift @@ -0,0 +1,18 @@ +import ProjectDescription + +private let layerAttribute = Template.Attribute.required("layer") +private let nameAttribute = Template.Attribute.required("name") + +private let template = Template( + description: "A template for a new module's sources target", + attributes: [ + layerAttribute, + nameAttribute + ], + items: [ + .file( + path: "Projects/\(layerAttribute)/\(nameAttribute)/Sources/Sources.swift", + templatePath: "Sources.stencil" + ) + ] +) diff --git a/Tuist/Templates/Testing/Testing.stencil b/Tuist/Templates/Testing/Testing.stencil new file mode 100644 index 00000000..b1853ce6 --- /dev/null +++ b/Tuist/Templates/Testing/Testing.stencil @@ -0,0 +1 @@ +// This is for Tuist diff --git a/Tuist/Templates/Testing/Testing.swift b/Tuist/Templates/Testing/Testing.swift new file mode 100644 index 00000000..29dbb685 --- /dev/null +++ b/Tuist/Templates/Testing/Testing.swift @@ -0,0 +1,18 @@ +import ProjectDescription + +private let layerAttribute = Template.Attribute.required("layer") +private let nameAttribute = Template.Attribute.required("name") + +private let template = Template( + description: "A template for a new module's testing target", + attributes: [ + layerAttribute, + nameAttribute + ], + items: [ + .file( + path: "Projects/\(layerAttribute)/\(nameAttribute)/Testing/Sources/\(nameAttribute)Testing.swift", + templatePath: "Testing.stencil" + ) + ] +) diff --git a/Tuist/Templates/Tests/Tests.stencil b/Tuist/Templates/Tests/Tests.stencil new file mode 100644 index 00000000..60bdc667 --- /dev/null +++ b/Tuist/Templates/Tests/Tests.stencil @@ -0,0 +1,11 @@ +import XCTest + +final class {{ name }}Tests: XCTestCase { + override func setUpWithError() throws {} + + override func tearDownWithError() throws {} + + func testExample() { + XCTAssertEqual(1, 1) + } +} diff --git a/Tuist/Templates/Tests/Tests.swift b/Tuist/Templates/Tests/Tests.swift new file mode 100644 index 00000000..6a65249a --- /dev/null +++ b/Tuist/Templates/Tests/Tests.swift @@ -0,0 +1,18 @@ +import ProjectDescription + +private let layerAttribute = Template.Attribute.required("layer") +private let nameAttribute = Template.Attribute.required("name") + +private let template = Template( + description: "A template for a new module's unit test target", + attributes: [ + layerAttribute, + nameAttribute + ], + items: [ + .file( + path: "Projects/\(layerAttribute)/\(nameAttribute)/Tests/Sources/\(nameAttribute)Test.swift", + templatePath: "Tests.stencil" + ), + ] +) diff --git a/Workspace.swift b/Workspace.swift new file mode 100644 index 00000000..32d62694 --- /dev/null +++ b/Workspace.swift @@ -0,0 +1,12 @@ +// +// Workspace.swift +// AppManifests +// +// Created by 임현규 on 7/18/24. +// + +import ProjectDescription +import DependencyPlugin + +let workspace = Workspace(name: "Bottle", projects: ["Projects/*"]) + diff --git a/graph.png b/graph.png new file mode 100644 index 00000000..47720e66 Binary files /dev/null and b/graph.png differ