├── .gitignore ├── .tuist-version ├── Entitlements ├── Application.entitlements └── Preview.entitlements ├── Package.resolved ├── Package.swift ├── Plugins └── Boost │ ├── Package.swift │ ├── Plugin.swift │ ├── ProjectDescriptionHelpers │ └── LocalHelper.swift │ └── Sources │ └── tuist-my-cli │ └── main.swift ├── Project ├── Core │ ├── Architecture │ │ ├── .gitignore │ │ ├── Package.swift │ │ ├── README.md │ │ ├── Sources │ │ │ └── Architecture │ │ │ │ ├── Architecture.swift │ │ │ │ ├── Binding │ │ │ │ └── BindingObserver.swift │ │ │ │ ├── Combine │ │ │ │ └── AnyPublisher+Result.swift │ │ │ │ ├── Data │ │ │ │ └── Heap.swift │ │ │ │ ├── Link │ │ │ │ ├── Link+Movie.swift │ │ │ │ └── Link.swift │ │ │ │ ├── State │ │ │ │ └── FetchState.swift │ │ │ │ └── TCA │ │ │ │ └── Effect+Cancel.swift │ │ └── Tests │ │ │ └── ArchitectureTests │ │ │ └── ArchitectureTests.swift │ ├── DesignSystem │ │ ├── .gitignore │ │ ├── Package.swift │ │ ├── README.md │ │ ├── Sources │ │ │ └── DesignSystem │ │ │ │ ├── DesignSystem.swift │ │ │ │ ├── RequestFlighView │ │ │ │ └── View+RequestFlighView.swift │ │ │ │ └── Resource │ │ │ │ ├── Assets.xcassets │ │ │ │ ├── Color │ │ │ │ │ ├── Contents.json │ │ │ │ │ ├── Primary.colorset │ │ │ │ │ │ └── Contents.json │ │ │ │ │ └── Text1.colorset │ │ │ │ │ │ └── Contents.json │ │ │ │ ├── Contents.json │ │ │ │ └── SpongeBob.imageset │ │ │ │ │ ├── Contents.json │ │ │ │ │ └── SpongeBob.png │ │ │ │ └── XCAssets+Generated.swift │ │ └── Tests │ │ │ └── DesignSystemTests │ │ │ └── DesignSystemTests.swift │ ├── Domain │ │ ├── .gitignore │ │ ├── Package.swift │ │ ├── README.md │ │ ├── Sources │ │ │ └── Domain │ │ │ │ ├── Configuration │ │ │ │ ├── ConfigurationDomain+Entity.swift │ │ │ │ └── ConfigurationDomain.swift │ │ │ │ ├── Domain.swift │ │ │ │ ├── Error │ │ │ │ └── CompositeErrorDomain.swift │ │ │ │ ├── Movie │ │ │ │ ├── MovieDomain+MovieList.swift │ │ │ │ └── MovieDomain.swift │ │ │ │ ├── MovieDetail │ │ │ │ ├── MovieDetailDomain+Common.swift │ │ │ │ └── MovieDetailDomain.swift │ │ │ │ ├── Search │ │ │ │ ├── SearchDomain+Common.swift │ │ │ │ ├── SearchDomain+Person.swift │ │ │ │ └── SearchDomain.swift │ │ │ │ └── UseCase │ │ │ │ ├── MovieDetailUseCase.swift │ │ │ │ ├── MovieUseCase.swift │ │ │ │ └── SearchUseCase.swift │ │ └── Tests │ │ │ └── DomainTests │ │ │ └── DomainTests.swift │ └── Platform │ │ ├── .gitignore │ │ ├── Package.swift │ │ ├── README.md │ │ ├── Sources │ │ └── Platform │ │ │ ├── Core │ │ │ ├── Locale │ │ │ │ └── LocaleClient.swift │ │ │ └── Remote │ │ │ │ ├── Endpoint+RequestBuilder.swift │ │ │ │ ├── Endpoint.swift │ │ │ │ ├── HttpContent.swift │ │ │ │ ├── HttpMethod.swift │ │ │ │ └── RemoteClient.swift │ │ │ ├── Platform.swift │ │ │ ├── Resource │ │ │ └── Mock │ │ │ │ ├── JSON+Generated.swift │ │ │ │ ├── movieDetail_card_1.json │ │ │ │ ├── movieDetail_credit_1.json │ │ │ │ ├── movieDetail_recommended_1.json │ │ │ │ ├── movieDetail_review_1.json │ │ │ │ ├── movieDetail_similar_1.json │ │ │ │ ├── now_playing_1.json │ │ │ │ ├── now_playing_2.json │ │ │ │ ├── search_keyword_1.json │ │ │ │ ├── search_movie_1.json │ │ │ │ └── search_person_1.json │ │ │ └── UseCase │ │ │ ├── Mock │ │ │ ├── MovieDetailUseCasePlatformMock.swift │ │ │ ├── MovieUseCasePlatformMock.swift │ │ │ └── SearchUseCasePlatformMock.swift │ │ │ ├── MovieDetailUseCasePlatform.swift │ │ │ └── MovieUseCasePlatform.swift │ │ └── Tests │ │ └── PlatformTests │ │ └── PlatformTests.swift ├── Feature │ └── Movie │ │ ├── .gitignore │ │ ├── Package.swift │ │ ├── Sources │ │ └── Movie │ │ │ ├── Movie.swift │ │ │ ├── MovieRouteBuilderGroup.swift │ │ │ ├── MovieSideEffectGroup.swift │ │ │ ├── MovieSideEffectGroupMock.swift │ │ │ ├── Resource │ │ │ ├── Assets.xcassets │ │ │ │ ├── Contents.json │ │ │ │ └── SpongeBob.imageset │ │ │ │ │ ├── Contents.json │ │ │ │ │ └── SpongeBob.png │ │ │ └── XCAssets+Generated.swift │ │ │ └── Source │ │ │ └── Feature │ │ │ ├── MovieDetailHome │ │ │ ├── MovieDetail │ │ │ │ ├── Env │ │ │ │ │ ├── MovieDetailEnvLive.swift │ │ │ │ │ ├── MovieDetailEnvMock.swift │ │ │ │ │ └── MovieDetailEnvType.swift │ │ │ │ ├── MovieDetailPage.swift │ │ │ │ ├── MovieDetailRouteBuilder.swift │ │ │ │ ├── MovieDetailStore.swift │ │ │ │ └── UIComponent │ │ │ │ │ ├── MovieDetailPage+CastListComponent.swift │ │ │ │ │ ├── MovieDetailPage+CrewListComponent.swift │ │ │ │ │ ├── MovieDetailPage+DirectorComponent.swift │ │ │ │ │ ├── MovieDetailPage+KeywordListComponent.swift │ │ │ │ │ ├── MovieDetailPage+ListButtonComponent.swift │ │ │ │ │ ├── MovieDetailPage+MovieCardComponent.swift │ │ │ │ │ ├── MovieDetailPage+MovieOverviewComponent.swift │ │ │ │ │ ├── MovieDetailPage+MovieReviewComponent.swift │ │ │ │ │ ├── MovieDetailPage+OtherPosterListComponent.swift │ │ │ │ │ ├── MovieDetailPage+RecommendedMovieListComponent.swift │ │ │ │ │ ├── MovieDetailPage+SimilarMovieListComponent.swift │ │ │ │ │ └── MovieDetatilPage+BackdropImageListComponent.swift │ │ │ └── Review │ │ │ │ ├── Env │ │ │ │ ├── ReviewEnvLive.swift │ │ │ │ ├── ReviewEnvMock.swift │ │ │ │ └── ReviewEnvType.swift │ │ │ │ ├── ReviewPage.swift │ │ │ │ ├── ReviewRouteBuilder.swift │ │ │ │ ├── ReviewStore.swift │ │ │ │ └── UIComponent │ │ │ │ └── ReviewPage+ItemListComponent.swift │ │ │ ├── MovieHome │ │ │ ├── Env │ │ │ │ ├── MovieHomeEnvLive.swift │ │ │ │ ├── MovieHomeEnvMock.swift │ │ │ │ └── MovieHomeEnvType.swift │ │ │ ├── MovieHomePage.swift │ │ │ ├── MovieHomeRouteBuilder.swift │ │ │ ├── MovieHomeStore.swift │ │ │ └── UIComponent │ │ │ │ ├── MovieHome+ItemListComponent.swift │ │ │ │ ├── MovieHome+SearchComponent.swift │ │ │ │ ├── MovieHomePage+SearchResultMoviesComponent.swift │ │ │ │ └── MovieHomePage+SearchResultPeopleComponent.swift │ │ │ ├── MyLists │ │ │ ├── Env │ │ │ │ ├── MyListsEnvLive.swift │ │ │ │ ├── MyListsEnvMock.swift │ │ │ │ └── MyListsEnvType.swift │ │ │ ├── MyListsPage.swift │ │ │ ├── MyListsRouteBuilder.swift │ │ │ ├── MyListsStore.swift │ │ │ └── UIComponent │ │ │ │ ├── MyListsPage+CustomListsComponent.swift │ │ │ │ ├── MyListsPage+SeenListsComponent.swift │ │ │ │ └── MyListsPage+WishListsComponent.swift │ │ │ ├── People │ │ │ ├── Cast │ │ │ │ ├── CastPage.swift │ │ │ │ ├── CastRouteBuilder.swift │ │ │ │ ├── CastStore.swift │ │ │ │ ├── Env │ │ │ │ │ ├── CastEnvLive.swift │ │ │ │ │ ├── CastEnvMock.swift │ │ │ │ │ └── CastEnvType.swift │ │ │ │ └── UIComponent │ │ │ │ │ └── CastPage+ItemListComponent.swift │ │ │ └── Crew │ │ │ │ ├── CrewPage.swift │ │ │ │ ├── CrewRouteBuilder.swift │ │ │ │ ├── CrewStore.swift │ │ │ │ ├── Env │ │ │ │ ├── CrewEnvLive.swift │ │ │ │ ├── CrewEnvMock.swift │ │ │ │ └── CrewEnvType.swift │ │ │ │ └── UIComponent │ │ │ │ └── CrewPage+ItemListComponent.swift │ │ │ ├── RecommendedMovie │ │ │ ├── Env │ │ │ │ ├── RecommendedMovieEnvLive.swift │ │ │ │ ├── RecommendedMovieEnvMock.swift │ │ │ │ └── RecommendedMovieEnvType.swift │ │ │ ├── RecommendedMoviePage.swift │ │ │ ├── RecommendedMovieRouteBuilder.swift │ │ │ └── RecommendedMovieStore.swift │ │ │ └── SimilarMovie │ │ │ ├── Env │ │ │ ├── SimilarMovieEnvLive.swift │ │ │ ├── SimilarMovieEnvMock.swift │ │ │ └── SimilarMovieEnvType.swift │ │ │ ├── SimilarMoviePage.swift │ │ │ ├── SimilarMovieRouteBuilder.swift │ │ │ ├── SimilarMovieStore.swift │ │ │ └── UIComponent │ │ │ └── SimilarMovie+ItemListComponent.swift │ │ └── Tests │ │ └── MovieTests │ │ └── MovieTests.swift ├── Previews │ └── MoviePreviews │ │ ├── Project.swift │ │ ├── Resources │ │ ├── Assets.xcassets │ │ │ ├── AppIcon.appiconset │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ └── dummy.json │ │ ├── Sources │ │ ├── AppContainer.swift │ │ ├── AppMain.swift │ │ ├── AppMainViewModel.swift │ │ └── AppSideEffect.swift │ │ └── Tests │ │ └── AuthencationPreviewTests.swift └── ThirdParty │ └── CombineNetwork │ ├── .gitignore │ ├── Package.resolved │ ├── Package.swift │ ├── README.md │ ├── Sources │ └── CombineNetwork │ │ ├── CombineNetwork.swift │ │ └── Core │ │ ├── Component │ │ ├── HTTPMethod.swift │ │ ├── NetworkError.swift │ │ └── RequestData.swift │ │ ├── Endpoint │ │ └── Endpoint.swift │ │ └── Network │ │ └── Network.swift │ └── Tests │ └── CombineNetworkTests │ └── CombineNetworkTests.swift ├── README.md ├── Targets ├── Boost │ ├── Resources │ │ └── LaunchScreen.storyboard │ ├── Sources │ │ └── AppDelegate.swift │ └── Tests │ │ └── AppTests.swift ├── BoostKit │ ├── Sources │ │ └── BoostKit.swift │ └── Tests │ │ └── BoostKitTests.swift └── BoostUI │ ├── Sources │ └── BoostUI.swift │ └── Tests │ └── BoostUITests.swift ├── Tuist ├── Config.swift └── ProjectDescriptionHelpers │ ├── Project+Extension.swift │ └── Project+Templates.swift ├── Workspace.swift └── swiftgen.yml /.gitignore: -------------------------------------------------------------------------------- 1 | ### macOS ### 2 | # General 3 | .DS_Store 4 | .AppleDouble 5 | .LSOverride 6 | 7 | # Icon must end with two 8 | Icon 9 | 10 | # Thumbnails 11 | ._* 12 | 13 | # Files that might appear in the root of a volume 14 | .DocumentRevisions-V100 15 | .fseventsd 16 | .Spotlight-V100 17 | .TemporaryItems 18 | .Trashes 19 | .VolumeIcon.icns 20 | .com.apple.timemachine.donotpresent 21 | 22 | # Directories potentially created on remote AFP share 23 | .AppleDB 24 | .AppleDesktop 25 | Network Trash Folder 26 | Temporary Items 27 | .apdisk 28 | 29 | ### Xcode ### 30 | # Xcode 31 | # 32 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 33 | 34 | ## User settings 35 | xcuserdata/ 36 | 37 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 38 | *.xcscmblueprint 39 | *.xccheckout 40 | 41 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 42 | build/ 43 | DerivedData/ 44 | *.moved-aside 45 | *.pbxuser 46 | !default.pbxuser 47 | *.mode1v3 48 | !default.mode1v3 49 | *.mode2v3 50 | !default.mode2v3 51 | *.perspectivev3 52 | !default.perspectivev3 53 | 54 | ### Xcode Patch ### 55 | *.xcodeproj/* 56 | !*.xcodeproj/project.pbxproj 57 | !*.xcodeproj/xcshareddata/ 58 | !*.xcworkspace/contents.xcworkspacedata 59 | /*.gcno 60 | 61 | ### Projects ### 62 | *.xcodeproj 63 | *.xcworkspace 64 | 65 | ### Tuist derived files ### 66 | graph.dot 67 | Derived/ 68 | 69 | ### Tuist managed dependencies ### 70 | Tuist/Dependencies 71 | 72 | .vscode 73 | 74 | ### Jetbrains 75 | .idea 76 | 77 | ### Local Build Files 78 | .build 79 | 80 | ### Fastlane Files 81 | fastlane/test_output 82 | fastlane/report.xml 83 | fastlane/adhoc.app.dSYM.zip 84 | fastlane/adhoc.ipa 85 | fastlane/appstore.ipa 86 | fastlane/appstore.app.dSYM.zip 87 | 88 | 89 | ### Ruby Env Files 90 | Gemfile.lock 91 | 92 | ### SPM Cache Build Files 93 | SourcePackages 94 | .package.resolved -------------------------------------------------------------------------------- /.tuist-version: -------------------------------------------------------------------------------- 1 | 3.17.0 -------------------------------------------------------------------------------- /Entitlements/Application.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | development 7 | com.apple.developer.applesignin 8 | 9 | Default 10 | 11 | com.apple.security.application-groups 12 | 13 | group.MY_COMPANY.com 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /Entitlements/Preview.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.developer.applesignin 6 | 7 | Default 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/airbnb/swift", 7 | "state" : { 8 | "revision" : "b408d36b4f5e73ea75441fb9791b849b0a40f58b", 9 | "version" : "1.0.5" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-argument-parser", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/apple/swift-argument-parser", 16 | "state" : { 17 | "revision" : "8f4d2753f0e4778c76d5f05ad16c74f707390531", 18 | "version" : "1.2.3" 19 | } 20 | } 21 | ], 22 | "version" : 2 23 | } 24 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.8 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "my-app", 6 | dependencies: [ 7 | .package(url: "https://github.com/airbnb/swift", from: "1.0.5"), 8 | ], 9 | targets: [ 10 | ]) 11 | -------------------------------------------------------------------------------- /Plugins/Boost/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.4 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "MyPlugin", 7 | products: [ 8 | .executable(name: "tuist-my-cli", targets: ["tuist-my-cli"]), 9 | ], 10 | targets: [ 11 | .executableTarget( 12 | name: "tuist-my-cli"), 13 | ]) 14 | -------------------------------------------------------------------------------- /Plugins/Boost/Plugin.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | let plugin = Plugin(name: "MyPlugin") 4 | -------------------------------------------------------------------------------- /Plugins/Boost/ProjectDescriptionHelpers/LocalHelper.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct LocalHelper { 4 | let name: String 5 | 6 | public init(name: String) { 7 | self.name = name 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Plugins/Boost/Sources/tuist-my-cli/main.swift: -------------------------------------------------------------------------------- 1 | print("Hello, from your Tuist Task") 2 | -------------------------------------------------------------------------------- /Project/Core/Architecture/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /Project/Core/Architecture/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.8 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Architecture", 8 | platforms: [ 9 | .iOS(.v16), 10 | ], 11 | products: [ 12 | .library( 13 | name: "Architecture", 14 | targets: ["Architecture"]), 15 | ], 16 | dependencies: [ 17 | .package(path: "../Domain"), 18 | .package(path: "../Platform"), 19 | .package( 20 | url: "https://github.com/interactord/LinkNavigator", 21 | branch: "param-refactor"), 22 | .package( 23 | url: "https://github.com/pointfreeco/swift-composable-architecture", 24 | .upToNextMajor(from: "1.2.0")), 25 | ], 26 | targets: [ 27 | .target( 28 | name: "Architecture", 29 | dependencies: [ 30 | "Domain", 31 | "Platform", 32 | "LinkNavigator", 33 | .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), 34 | ]), 35 | .testTarget( 36 | name: "ArchitectureTests", 37 | dependencies: ["Architecture"]), 38 | ]) 39 | -------------------------------------------------------------------------------- /Project/Core/Architecture/README.md: -------------------------------------------------------------------------------- 1 | # Architecture 2 | 3 | A description of this package. 4 | -------------------------------------------------------------------------------- /Project/Core/Architecture/Sources/Architecture/Architecture.swift: -------------------------------------------------------------------------------- 1 | public struct Architecture { 2 | public private(set) var text = "Hello, World!" 3 | 4 | public init() { } 5 | } 6 | -------------------------------------------------------------------------------- /Project/Core/Architecture/Sources/Architecture/Binding/BindingObserver.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | // MARK: - BindingObserver 5 | 6 | public final class BindingObserver: ObservableObject { 7 | 8 | // MARK: Lifecycle 9 | 10 | public init(value: Value) { 11 | self.value = value 12 | } 13 | 14 | // MARK: Public 15 | 16 | @Published public var value: Value 17 | 18 | public func update(value: Value) { 19 | self.value = value 20 | } 21 | } 22 | 23 | extension BindingObserver where Value == String { 24 | public convenience init() { 25 | self.init(value: "") 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Project/Core/Architecture/Sources/Architecture/Combine/AnyPublisher+Result.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | extension Publisher { 4 | public func mapToResult() -> AnyPublisher, Never> { 5 | map(Result.success) 6 | .catch { Just(.failure($0)) } 7 | .eraseToAnyPublisher() 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Project/Core/Architecture/Sources/Architecture/Data/Heap.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - Reference 4 | 5 | private final class Reference: Equatable { 6 | 7 | // MARK: Lifecycle 8 | 9 | init(_ value: T) { 10 | self.value = value 11 | } 12 | 13 | // MARK: Internal 14 | 15 | var value: T 16 | 17 | static func == (lhs: Reference, rhs: Reference) -> Bool { 18 | lhs.value == rhs.value 19 | } 20 | } 21 | 22 | // MARK: - Heap 23 | 24 | @propertyWrapper 25 | public struct Heap: Equatable { 26 | 27 | // MARK: Lifecycle 28 | 29 | public init(_ value: T) { 30 | reference = .init(value) 31 | } 32 | 33 | // MARK: Public 34 | 35 | public var wrappedValue: T { 36 | get { reference.value } 37 | set { 38 | if !isKnownUniquelyReferenced(&reference) { 39 | reference = .init(newValue) 40 | return 41 | } 42 | reference.value = newValue 43 | } 44 | } 45 | 46 | public var projectedValue: Heap { 47 | self 48 | } 49 | 50 | // MARK: Private 51 | 52 | private var reference: Reference 53 | 54 | } 55 | -------------------------------------------------------------------------------- /Project/Core/Architecture/Sources/Architecture/Link/Link+Movie.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - Link.Movie 4 | 5 | extension Link { 6 | public enum Movie { } 7 | } 8 | 9 | // MARK: - Link.Movie.Path 10 | 11 | extension Link.Movie { 12 | public enum Path: String, Equatable { 13 | case home = "movieHome" 14 | case myLists 15 | case movieDetail 16 | case review 17 | case cast 18 | case crew 19 | case similarMovie 20 | case recommendedMovie 21 | } 22 | } 23 | 24 | // MARK: - Link.Movie.DataInjection 25 | 26 | extension Link.Movie { 27 | public enum DataInjection { } 28 | } 29 | -------------------------------------------------------------------------------- /Project/Core/Architecture/Sources/Architecture/Link/Link.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum Link { } 4 | -------------------------------------------------------------------------------- /Project/Core/Architecture/Sources/Architecture/State/FetchState.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - FetchState 4 | 5 | public enum FetchState { } 6 | 7 | // MARK: FetchState.Empty 8 | 9 | extension FetchState { 10 | public struct Empty: Equatable { 11 | public var isLoading = false 12 | 13 | public init(isLoading: Bool) { 14 | self.isLoading = isLoading 15 | } 16 | 17 | public init() { 18 | isLoading = false 19 | } 20 | 21 | public func mutate(isLoading: Bool) -> Self { 22 | .init(isLoading: isLoading) 23 | } 24 | 25 | public static var `default`: Self { 26 | .init(isLoading: false) 27 | } 28 | } 29 | } 30 | 31 | // MARK: FetchState.Data 32 | 33 | extension FetchState { 34 | public struct Data: Equatable { 35 | public var isLoading = false 36 | public var value: V 37 | 38 | public init(isLoading: Bool, value: V) { 39 | self.isLoading = isLoading 40 | self.value = value 41 | } 42 | 43 | public func mutate(isLoading: Bool) -> Self { 44 | .init(isLoading: isLoading, value: value) 45 | } 46 | 47 | public func mutate(value: V) -> Self { 48 | .init(isLoading: isLoading, value: value) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Project/Core/Architecture/Sources/Architecture/TCA/Effect+Cancel.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Foundation 3 | 4 | extension Effect { 5 | public func cancellable(pageID: String, id: some Hashable, cancelInFlight: Bool = false) -> Self { 6 | let newID = "\(id)_\(pageID)_cancel_ID" 7 | return cancellable(id: newID, cancelInFlight: cancelInFlight) 8 | } 9 | } 10 | 11 | extension Effect { 12 | public static func cancel(pageID: String, id: some Hashable) -> Self { 13 | let newID = "\(id)_\(pageID)_cancel_ID" 14 | return cancel(id: newID) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Project/Core/Architecture/Tests/ArchitectureTests/ArchitectureTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Architecture 3 | 4 | final class ArchitectureTests: XCTestCase { 5 | func testExample() throws { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | XCTAssertEqual(Architecture().text, "Hello, World!") 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Project/Core/DesignSystem/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /Project/Core/DesignSystem/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.8 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "DesignSystem", 8 | platforms: [ 9 | .iOS(.v16), 10 | ], 11 | products: [ 12 | .library( 13 | name: "DesignSystem", 14 | targets: ["DesignSystem"]), 15 | ], 16 | dependencies: [ 17 | ], 18 | targets: [ 19 | .target( 20 | name: "DesignSystem", 21 | dependencies: []), 22 | .testTarget( 23 | name: "DesignSystemTests", 24 | dependencies: ["DesignSystem"]), 25 | ]) 26 | -------------------------------------------------------------------------------- /Project/Core/DesignSystem/README.md: -------------------------------------------------------------------------------- 1 | # DesignSystem 2 | 3 | A description of this package. 4 | -------------------------------------------------------------------------------- /Project/Core/DesignSystem/Sources/DesignSystem/DesignSystem.swift: -------------------------------------------------------------------------------- 1 | public struct DesignSystem { 2 | public private(set) var text = "Hello, World!" 3 | 4 | public init() { } 5 | } 6 | -------------------------------------------------------------------------------- /Project/Core/DesignSystem/Sources/DesignSystem/RequestFlighView/View+RequestFlighView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension View { 4 | 5 | @ViewBuilder 6 | public func setRequestFlightView(isLoading: Bool) -> some View { 7 | overlay(alignment: .center) { 8 | if isLoading { 9 | Rectangle() 10 | .fill(.clear) 11 | .background(.black.opacity(0.13)) 12 | .overlay( 13 | ProgressView() 14 | .progressViewStyle(CircularProgressViewStyle.circular)) 15 | } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Project/Core/DesignSystem/Sources/DesignSystem/Resource/Assets.xcassets/Color/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Project/Core/DesignSystem/Sources/DesignSystem/Resource/Assets.xcassets/Color/Primary.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "platform" : "ios", 6 | "reference" : "systemBlueColor" 7 | }, 8 | "idiom" : "universal" 9 | }, 10 | { 11 | "appearances" : [ 12 | { 13 | "appearance" : "luminosity", 14 | "value" : "dark" 15 | } 16 | ], 17 | "color" : { 18 | "color-space" : "srgb", 19 | "components" : { 20 | "alpha" : "1.000", 21 | "blue" : "0.659", 22 | "green" : "0.335", 23 | "red" : "0.022" 24 | } 25 | }, 26 | "idiom" : "universal" 27 | } 28 | ], 29 | "info" : { 30 | "author" : "xcode", 31 | "version" : 1 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Project/Core/DesignSystem/Sources/DesignSystem/Resource/Assets.xcassets/Color/Text1.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "extended-gray", 6 | "components" : { 7 | "alpha" : "0.000", 8 | "white" : "1.000" 9 | } 10 | }, 11 | "idiom" : "universal" 12 | }, 13 | { 14 | "appearances" : [ 15 | { 16 | "appearance" : "luminosity", 17 | "value" : "dark" 18 | } 19 | ], 20 | "color" : { 21 | "platform" : "ios", 22 | "reference" : "darkTextColor" 23 | }, 24 | "idiom" : "universal" 25 | } 26 | ], 27 | "info" : { 28 | "author" : "xcode", 29 | "version" : 1 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Project/Core/DesignSystem/Sources/DesignSystem/Resource/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Project/Core/DesignSystem/Sources/DesignSystem/Resource/Assets.xcassets/SpongeBob.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "SpongeBob.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "original" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Project/Core/DesignSystem/Sources/DesignSystem/Resource/Assets.xcassets/SpongeBob.imageset/SpongeBob.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/interactord/Boost/48141593190459ba75877af44dba030311b9371d/Project/Core/DesignSystem/Sources/DesignSystem/Resource/Assets.xcassets/SpongeBob.imageset/SpongeBob.png -------------------------------------------------------------------------------- /Project/Core/DesignSystem/Tests/DesignSystemTests/DesignSystemTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import DesignSystem 3 | 4 | final class DesignSystemTests: XCTestCase { 5 | func testExample() throws { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | XCTAssertEqual(DesignSystem().text, "Hello, World!") 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Project/Core/Domain/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /Project/Core/Domain/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.8 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Domain", 8 | platforms: [.iOS(.v16)], 9 | products: [ 10 | .library( 11 | name: "Domain", 12 | targets: ["Domain"]), 13 | ], 14 | dependencies: [ 15 | ], 16 | targets: [ 17 | .target( 18 | name: "Domain", 19 | dependencies: []), 20 | .testTarget( 21 | name: "DomainTests", 22 | dependencies: ["Domain"]), 23 | ]) 24 | -------------------------------------------------------------------------------- /Project/Core/Domain/README.md: -------------------------------------------------------------------------------- 1 | # Domain 2 | 3 | A description of this package. 4 | -------------------------------------------------------------------------------- /Project/Core/Domain/Sources/Domain/Configuration/ConfigurationDomain+Entity.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - ConfigurationDomain.Entity 4 | 5 | extension ConfigurationDomain { 6 | public struct Entity { 7 | public let baseURL: BaseURL 8 | 9 | public init(baseURL: BaseURL = .init()) { 10 | self.baseURL = baseURL 11 | } 12 | } 13 | } 14 | 15 | extension ConfigurationDomain.Entity { 16 | public struct BaseURL: Equatable { 17 | public let apiURL: String 18 | public let apiToken: String 19 | public let imageURL: String 20 | 21 | public init(apiURL: String = "", apiToken: String = "", imageURL: String = "") { 22 | self.apiURL = apiURL 23 | self.apiToken = apiToken 24 | self.imageURL = imageURL 25 | } 26 | 27 | public var imageSizeURL: (ImageSizeURL) -> String { 28 | { 29 | $0.make(imageURL: imageURL) 30 | } 31 | } 32 | } 33 | 34 | public enum ImageSizeURL: Equatable { 35 | case small 36 | case medium 37 | case cast 38 | case original 39 | 40 | public func make(imageURL: String) -> String { 41 | var extensionPath: String { 42 | switch self { 43 | case .small: return "w154" 44 | case .medium: return "w500" 45 | case .cast: return "w185" 46 | case .original: return "original" 47 | } 48 | } 49 | return [imageURL, extensionPath].joined(separator: "/") 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Project/Core/Domain/Sources/Domain/Configuration/ConfigurationDomain.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct ConfigurationDomain { 4 | public let entity: Entity 5 | 6 | public init(entity: Entity) { 7 | self.entity = entity 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Project/Core/Domain/Sources/Domain/Domain.swift: -------------------------------------------------------------------------------- 1 | public struct Domain { 2 | public private(set) var text = "Hello, World!" 3 | 4 | public init() { } 5 | } 6 | -------------------------------------------------------------------------------- /Project/Core/Domain/Sources/Domain/Error/CompositeErrorDomain.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - CompositeErrorDomain 4 | 5 | public enum CompositeErrorDomain: Error { 6 | case notInstanceSelf 7 | case invalidCasting 8 | case networkError(Int) 9 | case other(Error) 10 | } 11 | 12 | // MARK: CustomDebugStringConvertible 13 | 14 | extension CompositeErrorDomain: CustomDebugStringConvertible { 15 | public var debugDescription: String { 16 | switch self { 17 | case .notInstanceSelf: return "notInstanceSelf" 18 | case .invalidCasting: return "invalidCasting" 19 | case .networkError(let code): return "networkError : \(code)" 20 | case .other(let error): return error.localizedDescription 21 | } 22 | } 23 | } 24 | 25 | // MARK: Equatable 26 | 27 | extension CompositeErrorDomain: Equatable { 28 | public static func == (lhs: Self, rhs: Self) -> Bool { 29 | lhs.debugDescription == rhs.debugDescription 30 | } 31 | } 32 | 33 | -------------------------------------------------------------------------------- /Project/Core/Domain/Sources/Domain/Movie/MovieDomain+MovieList.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - MovieDomain.MovieList 4 | 5 | extension MovieDomain { 6 | public enum MovieList { 7 | public enum Request { } 8 | public enum Response { } 9 | } 10 | } 11 | 12 | // MARK: - MovieDomain.MovieList.Request.NowPlay 13 | 14 | extension MovieDomain.MovieList.Request { 15 | public struct NowPlay: Equatable { 16 | public let region: String 17 | public let page: Int 18 | 19 | public init(region: String, page: Int) { 20 | self.region = region 21 | self.page = page 22 | } 23 | 24 | public init(locale: Locale, page: Int) { 25 | region = locale.region?.identifier ?? "US" 26 | self.page = page 27 | } 28 | } 29 | } 30 | 31 | extension MovieDomain.MovieList.Response { 32 | public struct NowPlay: Equatable, Codable { 33 | 34 | // MARK: Lifecycle 35 | 36 | public init( 37 | totalPages: Int = .zero, 38 | totalResult: Int = .zero, 39 | page: Int = .zero, 40 | resultList: [ResultItem] = []) 41 | { 42 | self.totalPages = totalPages 43 | self.totalResult = totalResult 44 | self.page = page 45 | self.resultList = resultList 46 | } 47 | 48 | // MARK: Public 49 | 50 | public let totalPages: Int 51 | public let totalResult: Int 52 | public let page: Int 53 | public let resultList: [ResultItem] 54 | 55 | // MARK: Private 56 | 57 | private enum CodingKeys: String, CodingKey { 58 | case totalPages = "total_pages" 59 | case totalResult = "total_results" 60 | case page 61 | case resultList = "results" 62 | } 63 | } 64 | 65 | public struct ResultItem: Equatable, Codable, Identifiable { 66 | public let id: Int 67 | public let posterPath: String 68 | public let overview: String 69 | public let title: String 70 | public let voteAverage: Double 71 | public let releaseDate: String 72 | 73 | private enum CodingKeys: String, CodingKey { 74 | case id 75 | case posterPath = "poster_path" 76 | case overview 77 | case title 78 | case voteAverage = "vote_average" 79 | case releaseDate = "release_date" 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Project/Core/Domain/Sources/Domain/Movie/MovieDomain.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum MovieDomain { } 4 | -------------------------------------------------------------------------------- /Project/Core/Domain/Sources/Domain/MovieDetail/MovieDetailDomain.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum MovieDetailDomain { } 4 | -------------------------------------------------------------------------------- /Project/Core/Domain/Sources/Domain/Search/SearchDomain+Common.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension SearchDomain { 4 | public enum Request { } 5 | public enum Response { } 6 | } 7 | 8 | extension SearchDomain.Request { 9 | public struct KeywordAndPage: Equatable, Codable { 10 | public let language: String 11 | public let query: String 12 | public let page: Int 13 | 14 | public init(language: String, query: String, page: Int) { 15 | self.language = language 16 | self.query = query 17 | self.page = page 18 | } 19 | } 20 | 21 | public struct Keyword: Equatable, Codable { 22 | public let language: String 23 | public let query: String 24 | 25 | public init(language: String, query: String) { 26 | self.language = language 27 | self.query = query 28 | } 29 | } 30 | } 31 | 32 | extension SearchDomain.Response { 33 | public struct MovieResult: Equatable, Codable { 34 | 35 | // MARK: Lifecycle 36 | 37 | public init( 38 | totalPages: Int = .zero, 39 | totalResult: Int = .zero, 40 | page: Int = .zero, 41 | resultList: [MovieResultItem] = []) 42 | { 43 | self.totalPages = totalPages 44 | self.totalResult = totalResult 45 | self.page = page 46 | self.resultList = resultList 47 | } 48 | 49 | // MARK: Public 50 | 51 | public let totalPages: Int 52 | public let totalResult: Int 53 | public let page: Int 54 | public let resultList: [MovieResultItem] 55 | 56 | // MARK: Private 57 | 58 | private enum CodingKeys: String, CodingKey { 59 | case totalPages = "total_pages" 60 | case totalResult = "total_results" 61 | case page 62 | case resultList = "results" 63 | } 64 | } 65 | 66 | // MovieResult -> resultList -> MovieResultItem 67 | public struct MovieResultItem: Equatable, Codable, Identifiable { 68 | public let id: Int 69 | public let posterPath: String? 70 | public let overview: String 71 | public let title: String 72 | public let voteAverage: Double 73 | public let releaseDate: String 74 | 75 | private enum CodingKeys: String, CodingKey { 76 | case id 77 | case posterPath = "poster_path" 78 | case overview 79 | case title 80 | case voteAverage = "vote_average" 81 | case releaseDate = "release_date" 82 | } 83 | } 84 | 85 | public struct KeywordResult: Equatable, Codable { 86 | 87 | // MARK: Lifecycle 88 | 89 | public init( 90 | totalPages: Int = .zero, 91 | totalResult: Int = .zero, 92 | page: Int = .zero, 93 | resultList: [KeywordResultItem] = []) 94 | { 95 | self.totalPages = totalPages 96 | self.totalResult = totalResult 97 | self.page = page 98 | self.resultList = resultList 99 | } 100 | 101 | // MARK: Public 102 | 103 | public let totalPages: Int 104 | public let totalResult: Int 105 | public let page: Int 106 | public let resultList: [KeywordResultItem] 107 | 108 | // MARK: Private 109 | 110 | private enum CodingKeys: String, CodingKey { 111 | case totalPages = "total_pages" 112 | case totalResult = "total_results" 113 | case page 114 | case resultList = "results" 115 | } 116 | } 117 | 118 | public struct KeywordResultItem: Equatable, Codable, Identifiable { 119 | public let id: Int 120 | public let name: String 121 | 122 | private enum CodingKeys: String, CodingKey { 123 | case id 124 | case name 125 | } 126 | } 127 | 128 | // 여기는 사람 129 | public struct PeopleResult: Equatable, Codable { 130 | 131 | // MARK: Lifecycle 132 | 133 | public init( 134 | totalPages: Int = .zero, 135 | totalResult: Int = .zero, 136 | page: Int = .zero, 137 | resultList: [PersonResultItem] = []) 138 | { 139 | self.totalPages = totalPages 140 | self.totalResult = totalResult 141 | self.page = page 142 | self.resultList = resultList 143 | } 144 | 145 | // MARK: Public 146 | 147 | public let totalPages: Int 148 | public let totalResult: Int 149 | public let page: Int 150 | public let resultList: [PersonResultItem] 151 | 152 | // MARK: Private 153 | 154 | private enum CodingKeys: String, CodingKey { 155 | case totalPages = "total_pages" 156 | case totalResult = "total_results" 157 | case page 158 | case resultList = "results" 159 | } 160 | } 161 | 162 | public struct PersonResultItem: Equatable, Codable, Identifiable { 163 | public let id: Int 164 | public let name: String 165 | public let profilePath: String? 166 | public let appearanceList: [Appearance] 167 | 168 | private enum CodingKeys: String, CodingKey { 169 | case id 170 | case name 171 | case profilePath = "profile_path" 172 | case appearanceList = "known_for" 173 | } 174 | } 175 | 176 | public struct Appearance: Equatable, Codable, Identifiable { 177 | public let id: Int 178 | public let title: String? 179 | public let originalTitle: String? 180 | 181 | private enum CodingKeys: String, CodingKey { 182 | case id 183 | case title 184 | case originalTitle = "original_title" 185 | } 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /Project/Core/Domain/Sources/Domain/Search/SearchDomain+Person.swift: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /Project/Core/Domain/Sources/Domain/Search/SearchDomain.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum SearchDomain { } 4 | -------------------------------------------------------------------------------- /Project/Core/Domain/Sources/Domain/UseCase/MovieDetailUseCase.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | public protocol MovieDetailUseCase { 5 | var movieCard: (MovieDetailDomain.Request.MovieCard) 6 | -> AnyPublisher { get } 7 | var movieReview: (MovieDetailDomain.Request.Review) 8 | -> AnyPublisher { get } 9 | var movieCredit: (MovieDetailDomain.Request.Credit) 10 | -> AnyPublisher { get } 11 | var similarMovie: (MovieDetailDomain.Request.SimilarMovie) 12 | -> AnyPublisher { get } 13 | var recommendedMovie: (MovieDetailDomain.Request.RecommendedMovie) 14 | -> AnyPublisher { get } 15 | } 16 | -------------------------------------------------------------------------------- /Project/Core/Domain/Sources/Domain/UseCase/MovieUseCase.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | public protocol MovieUseCase { 5 | var nowPlaying: (MovieDomain.MovieList.Request.NowPlay) 6 | -> AnyPublisher { get } 7 | 8 | } 9 | -------------------------------------------------------------------------------- /Project/Core/Domain/Sources/Domain/UseCase/SearchUseCase.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | public protocol SearchUseCase { 5 | var searchMovie: (SearchDomain.Request.KeywordAndPage) 6 | -> AnyPublisher { get } 7 | var searchKeyword: (SearchDomain.Request.Keyword) 8 | -> AnyPublisher { get } 9 | var searchPeople: (SearchDomain.Request.KeywordAndPage) 10 | -> AnyPublisher { get } 11 | } 12 | -------------------------------------------------------------------------------- /Project/Core/Domain/Tests/DomainTests/DomainTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Domain 3 | 4 | final class DomainTests: XCTestCase { 5 | func testExample() throws { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | XCTAssertEqual(Domain().text, "Hello, World!") 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Project/Core/Platform/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /Project/Core/Platform/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.8 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Platform", 8 | platforms: [.iOS(.v16)], 9 | products: [ 10 | .library( 11 | name: "Platform", 12 | targets: ["Platform"]), 13 | ], 14 | dependencies: [ 15 | .package(path: "../../ThirdParty/CombineNetwork"), 16 | .package(path: "../../Core/Domain"), 17 | ], 18 | targets: [ 19 | .target( 20 | name: "Platform", 21 | dependencies: [ 22 | "Domain", 23 | "CombineNetwork", 24 | ], 25 | resources: [ 26 | .process("Resource/Mock/now_playing_1.json"), 27 | .process("Resource/Mock/now_playing_2.json"), 28 | .process("Resource/Mock/search_movie_1.json"), 29 | .process("Resource/Mock/search_person_1.json"), 30 | .process("Resource/Mock/search_keyword_1.json"), 31 | .process("Resource/Mock/movieDetail_card_1.json"), 32 | .process("Resource/Mock/movieDetail_review_1.json"), 33 | .process("Resource/Mock/movieDetail_credit_1.json"), 34 | .process("Resource/Mock/movieDetail_similar_1.json"), 35 | .process("Resource/Mock/movieDetail_recommended_1.json"), 36 | ]), 37 | .testTarget( 38 | name: "PlatformTests", 39 | dependencies: ["Platform"]), 40 | ]) 41 | -------------------------------------------------------------------------------- /Project/Core/Platform/README.md: -------------------------------------------------------------------------------- 1 | # Platform 2 | 3 | A description of this package. 4 | -------------------------------------------------------------------------------- /Project/Core/Platform/Sources/Platform/Core/Locale/LocaleClient.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - LocaleClient 4 | 5 | struct LocaleClient { 6 | let origin: Locale 7 | 8 | init(origin: Locale = .current) { 9 | self.origin = origin 10 | } 11 | } 12 | 13 | extension LocaleClient { 14 | var language: String { 15 | Locale.preferredLanguages.first?.split(separator: "-").map(String.init).first ?? "en" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Project/Core/Platform/Sources/Platform/Core/Remote/Endpoint+RequestBuilder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import URLEncodedForm 3 | 4 | extension Endpoint { 5 | var request: URLRequest? { 6 | baseURL 7 | .component? 8 | .apply(pathList: pathList) 9 | .apply(content: content) 10 | .request 11 | } 12 | } 13 | 14 | extension String { 15 | var component: URLComponents? { 16 | .init(string: self) 17 | } 18 | } 19 | 20 | extension URLComponents { 21 | fileprivate var request: URLRequest? { 22 | guard let url else { return .none } 23 | return .init(url: url) 24 | } 25 | 26 | fileprivate func apply(pathList: [String]) -> Self { 27 | var new = self 28 | new.path = ([path] + pathList).joined(separator: "/") 29 | return new 30 | } 31 | 32 | fileprivate func apply(content: Endpoint.HttpContent) -> Self { 33 | guard case .queryItemPath(let item) = content 34 | else { return self } 35 | 36 | var new = self 37 | let newQuery = item.encodeString() 38 | new.query = newQuery 39 | 40 | return new 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Project/Core/Platform/Sources/Platform/Core/Remote/Endpoint.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Endpoint { 4 | let baseURL: String 5 | let pathList: [String] 6 | let httpMethod: HttpMethod 7 | let content: HttpContent 8 | 9 | init(baseURL: String, pathList: [String], httpMethod: HttpMethod = .get, content: HttpContent) { 10 | self.baseURL = baseURL 11 | self.pathList = pathList 12 | self.httpMethod = httpMethod 13 | self.content = content 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Project/Core/Platform/Sources/Platform/Core/Remote/HttpContent.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import URLEncodedForm 3 | 4 | extension Endpoint { 5 | enum HttpContent { 6 | case queryItemPath(Encodable) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Project/Core/Platform/Sources/Platform/Core/Remote/HttpMethod.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - HttpMethod 4 | 5 | enum HttpMethod: Equatable { 6 | case get 7 | } 8 | 9 | extension HttpMethod { 10 | var rawValue: String { 11 | switch self { 12 | case .get: return "GET" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Project/Core/Platform/Sources/Platform/Core/Remote/RemoteClient.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Domain 3 | import Foundation 4 | 5 | extension Endpoint { 6 | func fetch(session: URLSession = .shared) -> AnyPublisher { 7 | makeRequest() 8 | .flatMap(session.fetchData) 9 | .decode(type: D.self, decoder: JSONDecoder()) 10 | .catch { Fail(error: $0.serialized()) } 11 | .eraseToAnyPublisher() 12 | } 13 | } 14 | 15 | extension URLSession { 16 | fileprivate var fetchData: (URLRequest) -> AnyPublisher { 17 | { 18 | self.dataTaskPublisher(for: $0) 19 | .tryMap { data, response in 20 | print(response.url?.absoluteString ?? "") 21 | 22 | guard let urlResponse = response as? HTTPURLResponse 23 | else { throw CompositeErrorDomain.invalidCasting } 24 | 25 | guard (200...299).contains(urlResponse.statusCode) 26 | else { throw CompositeErrorDomain.networkError(urlResponse.statusCode) } 27 | 28 | return data 29 | } 30 | .catch { Fail(error: $0.serialized()) } 31 | .eraseToAnyPublisher() 32 | } 33 | } 34 | } 35 | 36 | extension Endpoint { 37 | private var makeRequest: () -> AnyPublisher { 38 | { 39 | Future { promise in 40 | guard let request else { return promise(.failure(.invalidCasting)) } 41 | return promise(.success(request)) 42 | } 43 | .eraseToAnyPublisher() 44 | } 45 | } 46 | } 47 | 48 | extension Error { 49 | fileprivate func serialized() -> CompositeErrorDomain { 50 | guard let error = self as? CompositeErrorDomain else { 51 | return CompositeErrorDomain.other(self) 52 | } 53 | return error 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Project/Core/Platform/Sources/Platform/Platform.swift: -------------------------------------------------------------------------------- 1 | public struct Platform { 2 | public private(set) var text = "Hello, World!" 3 | 4 | public init() { } 5 | } 6 | -------------------------------------------------------------------------------- /Project/Core/Platform/Sources/Platform/Resource/Mock/JSON+Generated.swift: -------------------------------------------------------------------------------- 1 | // swiftlint:disable all 2 | // Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen 3 | 4 | import Foundation 5 | 6 | // MARK: - Files 7 | 8 | // swiftlint:disable superfluous_disable_command file_length line_length implicit_return 9 | 10 | // swiftlint:disable explicit_type_interface identifier_name 11 | // swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces 12 | enum Files { 13 | /// movieDetail_card_1.json 14 | static let movieDetailCard1Json = File(name: "movieDetail_card_1", ext: "json", relativePath: "", mimeType: "application/json") 15 | /// movieDetail_credit_1.json 16 | static let movieDetailCredit1Json = File( 17 | name: "movieDetail_credit_1", 18 | ext: "json", 19 | relativePath: "", 20 | mimeType: "application/json") 21 | /// movieDetail_recommended_1.json 22 | static let movieDetailRecommended1Json = File( 23 | name: "movieDetail_recommended_1", 24 | ext: "json", 25 | relativePath: "", 26 | mimeType: "application/json") 27 | /// movieDetail_review_1.json 28 | static let movieDetailReview1Json = File( 29 | name: "movieDetail_review_1", 30 | ext: "json", 31 | relativePath: "", 32 | mimeType: "application/json") 33 | /// movieDetail_similar_1.json 34 | static let movieDetailSimilar1Json = File( 35 | name: "movieDetail_similar_1", 36 | ext: "json", 37 | relativePath: "", 38 | mimeType: "application/json") 39 | /// now_playing_1.json 40 | static let nowPlaying1Json = File(name: "now_playing_1", ext: "json", relativePath: "", mimeType: "application/json") 41 | /// now_playing_2.json 42 | static let nowPlaying2Json = File(name: "now_playing_2", ext: "json", relativePath: "", mimeType: "application/json") 43 | /// search_keyword_1.json 44 | static let searchKeyword1Json = File(name: "search_keyword_1", ext: "json", relativePath: "", mimeType: "application/json") 45 | /// search_movie_1.json 46 | static let searchMovie1Json = File(name: "search_movie_1", ext: "json", relativePath: "", mimeType: "application/json") 47 | /// search_person_1.json 48 | static let searchPerson1Json = File(name: "search_person_1", ext: "json", relativePath: "", mimeType: "application/json") 49 | } 50 | 51 | // MARK: - File 52 | 53 | // swiftlint:enable explicit_type_interface identifier_name 54 | // swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces 55 | 56 | struct File { 57 | let name: String 58 | let ext: String? 59 | let relativePath: String 60 | let mimeType: String 61 | 62 | var url: URL { 63 | url(locale: nil) 64 | } 65 | 66 | var path: String { 67 | path(locale: nil) 68 | } 69 | 70 | func url(locale: Locale?) -> URL { 71 | let bundle = BundleToken.bundle 72 | let url = bundle.url( 73 | forResource: name, 74 | withExtension: ext, 75 | subdirectory: relativePath, 76 | localization: locale?.identifier) 77 | guard let result = url else { 78 | let file = name + (ext.flatMap { ".\($0)" } ?? "") 79 | fatalError("Could not locate file named \(file)") 80 | } 81 | return result 82 | } 83 | 84 | func path(locale: Locale?) -> String { 85 | url(locale: locale).path 86 | } 87 | } 88 | 89 | // MARK: - BundleToken 90 | 91 | // swiftlint:disable convenience_type explicit_type_interface 92 | private final class BundleToken { 93 | static let bundle: Bundle = { 94 | #if SWIFT_PACKAGE 95 | return Bundle.module 96 | #else 97 | return Bundle(for: BundleToken.self) 98 | #endif 99 | }() 100 | } 101 | 102 | // swiftlint:enable convenience_type explicit_type_interface 103 | -------------------------------------------------------------------------------- /Project/Core/Platform/Sources/Platform/Resource/Mock/movieDetail_review_1.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 615656, 3 | "page": 1, 4 | "results": [ 5 | { 6 | "author": "CinemaSerf", 7 | "author_details": { 8 | "name": "CinemaSerf", 9 | "username": "Geronimo1967", 10 | "avatar_path": "/1kks3YnVkpyQxzw36CObFPvhL5f.jpg", 11 | "rating": 5.0 12 | }, 13 | "content": "Now the \"Meg\" (2018) itself could never be called a good film, but it is a great deal better than this muddled and derivative effort. \"Jonas\" (Jason Statham) is now working as a sort of eco-policeman trying to stop the dumping of toxic waste into the oceans, and after a snappy \"007\" style opening, he is daringly rescued by \"Mac\" (Clff Curtis) and \"Rigas\" (Melissanthu Mahut) and returned to the research centre where he is reunited with \"Jess\" (Skyler Samuels), \"DJ\" (Page Kennedy) and the adrenalin seeking \"Juiming\" (Jing Wu) who are nursing the daughter of the last megalodon! The team now travel to a remote installation where they must investigate some more of the beasties that live below the frozen layer put there by nature to ensure than we don't mix. Thing is, it seems they are not the only folks who've hit on the idea that there might be untapped riches 25,000 feet below the surface, and soon our team are involved in a contretemps with \"Montes\" (Sergio Peris-Mencheta) that introduces treachery, double-dealing and causes explosions galore that release not just megs, but also an enormous octopus into the ocean where they can merrily terrorise the holidaymakers on the nearby resort of \"Fun Island\". Can \"Jonas\" et al manage to take on four of these super-creatures before they've snacked their way through the tourists? This might have been a bit better if they'd just cut all the preamble and gone straight to the rig and to the underwater action, big fish and pyrotechnics. As it is, we spend far too long meandering about on the surface meeting the characters and there's way too much pointless dialogue throughout - though one or two half-hearted witticisms and puns help a little - before an ending that is entirely predictable and really rather processionally so. The acting is just banal, the continuity is all over the place - as is the editing - and the huge chunk of Ali Baba money that's floated this thing ensures that the switches from the English to Chinese languages actually smacks more of keeping everybody happy in the boardroom rather than engaging anyone in the actual cinema. Simply, even the charismatic Statham cannot rescue this from the doldrums of CGI-led mediocrity that churns out an unremarkable hybrid of \"Jaws\" and \"Jurassic Park\". It does need a big screen. If you wait til it's on the telly then you will be even more disappointed. Mind you, is that actually possible?", 14 | "created_at": "2023-08-12T06:21:56.587Z", 15 | "id": "64d72504d100b6011c8180f6", 16 | "updated_at": "2023-08-12T06:21:56.728Z", 17 | "url": "https://www.themoviedb.org/review/64d72504d100b6011c8180f6" 18 | }, 19 | { 20 | "author": "MovieGuys", 21 | "author_details": { 22 | "name": "", 23 | "username": "MovieGuys", 24 | "avatar_path": null, 25 | "rating": 5.0 26 | }, 27 | "content": "Meg 2 doesn't really feel like a follow up film, to its 2018 counterpart. Indeed, the latest instalment feels more like a platform for various action \"stunts\". \r\n\r\nThe giant,prehistoric shark's are essentially window dressing, for a variety of frenetic action sequences, where things blow up, are torn apart, shot at, harpooned, people gobbled up Jaws and Jurassic Park style and what I can only describe as acrobatic, aquatic scenes with jet ski's and the like, all take place. They even throw in a giant squid, to spice things up. \r\n\r\nOr put more simply, this is a messy mash up, of derivative, often dispirit ideas. Unsurprisingly, the results a bit chaotic, with no compass to direct the viewer, in terms of the story, such as it is. Its quite watchable, in its own way but it never really goes anywhere.\r\n\r\nIn summary, watchable on a very visually superficial level. Lots of action but little in the way of an established story, to pull the whole thing together.", 28 | "created_at": "2023-08-26T19:41:42.368Z", 29 | "id": "64ea55765258ae00add50b7d", 30 | "updated_at": "2023-08-26T19:45:44.496Z", 31 | "url": "https://www.themoviedb.org/review/64ea55765258ae00add50b7d" 32 | }, 33 | { 34 | "author": "mohitgupta943", 35 | "author_details": { 36 | "name": "", 37 | "username": "mohitgupta943", 38 | "avatar_path": null, 39 | "rating": null 40 | }, 41 | "content": "Good Movie", 42 | "created_at": "2023-08-26T21:02:26.104Z", 43 | "id": "64ea6862c613ce00c9f25a67", 44 | "updated_at": "2023-08-30T16:03:08.138Z", 45 | "url": "https://www.themoviedb.org/review/64ea6862c613ce00c9f25a67" 46 | } 47 | ], 48 | "total_pages": 1, 49 | "total_results": 3 50 | } 51 | -------------------------------------------------------------------------------- /Project/Core/Platform/Sources/Platform/Resource/Mock/search_keyword_1.json: -------------------------------------------------------------------------------- 1 | { 2 | "page": 1, 3 | "results": [ 4 | { 5 | "id": 273351, 6 | "name": "killer babes" 7 | }, 8 | { 9 | "id": 4725, 10 | "name": "baby-snatching" 11 | }, 12 | { 13 | "id": 249295, 14 | "name": "baby king" 15 | }, 16 | { 17 | "id": 214786, 18 | "name": "juno baby" 19 | }, 20 | { 21 | "id": 214851, 22 | "name": "baby story" 23 | }, 24 | { 25 | "id": 281809, 26 | "name": "chitti babu" 27 | }, 28 | { 29 | "id": 215581, 30 | "name": "baby selling" 31 | }, 32 | { 33 | "id": 252153, 34 | "name": "baba yaga" 35 | }, 36 | { 37 | "id": 283458, 38 | "name": "baby shower" 39 | }, 40 | { 41 | "id": 163213, 42 | "name": "baby contest" 43 | }, 44 | { 45 | "id": 254990, 46 | "name": "babushkas" 47 | }, 48 | { 49 | "id": 256216, 50 | "name": "bábfilm" 51 | }, 52 | { 53 | "id": 256825, 54 | "name": "alice babs" 55 | }, 56 | { 57 | "id": 171244, 58 | "name": "baby powder" 59 | }, 60 | { 61 | "id": 171975, 62 | "name": "red-baby" 63 | }, 64 | { 65 | "id": 257656, 66 | "name": "war baby" 67 | }, 68 | { 69 | "id": 174281, 70 | "name": "baby left on doorstep" 71 | }, 72 | { 73 | "id": 287949, 74 | "name": "babershop" 75 | }, 76 | { 77 | "id": 288078, 78 | "name": "babish" 79 | }, 80 | { 81 | "id": 227634, 82 | "name": "baby elephant" 83 | } 84 | ], 85 | "total_pages": 6, 86 | "total_results": 101 87 | } 88 | -------------------------------------------------------------------------------- /Project/Core/Platform/Sources/Platform/UseCase/Mock/MovieDetailUseCasePlatformMock.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Domain 3 | import Foundation 4 | 5 | // MARK: - MovieDetailUseCasePlatformMock 6 | 7 | public struct MovieDetailUseCasePlatformMock { 8 | private let configurationDomain: ConfigurationDomain 9 | 10 | public init(configurationDomain: ConfigurationDomain) { 11 | self.configurationDomain = configurationDomain 12 | } 13 | } 14 | 15 | extension MovieDetailUseCasePlatformMock { 16 | private var movieDetailCardPage1: MovieDetailDomain.Response.MovieCardResult { 17 | Files.movieDetailCard1Json.url 18 | .mapToData() 19 | .decoded() 20 | } 21 | 22 | private var movieDetailReviewPage1: MovieDetailDomain.Response.MovieReviewResult { 23 | Files.movieDetailReview1Json.url 24 | .mapToData() 25 | .decoded() 26 | } 27 | 28 | private var movieDetailCreditPage1: MovieDetailDomain.Response.MovieCreditResult { 29 | Files.movieDetailCredit1Json.url 30 | .mapToData() 31 | .decoded() 32 | } 33 | 34 | private var movieSimilarPage1: MovieDetailDomain.Response.SimilarMovieResult { 35 | Files.movieDetailSimilar1Json.url 36 | .mapToData() 37 | .decoded() 38 | } 39 | 40 | private var movieRecommendedPage1: MovieDetailDomain.Response.RecommenededMovieResult { 41 | Files.movieDetailRecommended1Json.url 42 | .mapToData() 43 | .decoded() 44 | } 45 | 46 | } 47 | 48 | // MARK: MovieDetailUseCase 49 | 50 | extension MovieDetailUseCasePlatformMock: MovieDetailUseCase { 51 | public var movieCard: (MovieDetailDomain.Request.MovieCard) -> AnyPublisher< 52 | MovieDetailDomain.Response.MovieCardResult, 53 | CompositeErrorDomain 54 | > { 55 | { _ in 56 | Just(movieDetailCardPage1) 57 | .delay(for: .seconds(0.5), scheduler: RunLoop.main) 58 | .setFailureType(to: CompositeErrorDomain.self) 59 | .eraseToAnyPublisher() 60 | } 61 | } 62 | 63 | public var movieReview: (MovieDetailDomain.Request.Review) -> AnyPublisher< 64 | MovieDetailDomain.Response.MovieReviewResult, 65 | CompositeErrorDomain 66 | > { 67 | { _ in 68 | Just(movieDetailReviewPage1) 69 | .delay(for: .seconds(0.1), scheduler: RunLoop.main) 70 | .setFailureType(to: CompositeErrorDomain.self) 71 | .eraseToAnyPublisher() 72 | } 73 | } 74 | 75 | public var movieCredit: (MovieDetailDomain.Request.Credit) -> AnyPublisher< 76 | MovieDetailDomain.Response.MovieCreditResult, 77 | CompositeErrorDomain 78 | > { 79 | { _ in 80 | Just(movieDetailCreditPage1) 81 | .delay(for: .seconds(0.2), scheduler: RunLoop.main) 82 | .setFailureType(to: CompositeErrorDomain.self) 83 | .eraseToAnyPublisher() 84 | } 85 | } 86 | 87 | public var similarMovie: (MovieDetailDomain.Request.SimilarMovie) -> AnyPublisher< 88 | MovieDetailDomain.Response.SimilarMovieResult, 89 | CompositeErrorDomain 90 | > { 91 | { _ in 92 | Just(movieSimilarPage1) 93 | .delay(for: .seconds(0.2), scheduler: RunLoop.main) 94 | .setFailureType(to: CompositeErrorDomain.self) 95 | .eraseToAnyPublisher() 96 | } 97 | } 98 | 99 | public var recommendedMovie: (MovieDetailDomain.Request.RecommendedMovie) -> AnyPublisher< 100 | MovieDetailDomain.Response.RecommenededMovieResult, 101 | CompositeErrorDomain 102 | > { 103 | { _ in 104 | Just(movieRecommendedPage1) 105 | .delay(for: .seconds(0.2), scheduler: RunLoop.main) 106 | .setFailureType(to: CompositeErrorDomain.self) 107 | .eraseToAnyPublisher() 108 | } 109 | } 110 | } 111 | 112 | extension URL { 113 | fileprivate func mapToData() -> Data { 114 | try! Data(contentsOf: self) 115 | } 116 | } 117 | 118 | extension Data { 119 | fileprivate func decoded() -> D { 120 | try! JSONDecoder().decode(D.self, from: self) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Project/Core/Platform/Sources/Platform/UseCase/Mock/MovieUseCasePlatformMock.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Domain 3 | import Foundation 4 | 5 | // MARK: - MovieUseCasePlatformMock 6 | 7 | public struct MovieUseCasePlatformMock { 8 | private let configurationDomain: ConfigurationDomain 9 | 10 | public init(configurationDomain: ConfigurationDomain) { 11 | self.configurationDomain = configurationDomain 12 | } 13 | } 14 | 15 | extension MovieUseCasePlatformMock { 16 | private var nowPlaying1Json: MovieDomain.MovieList.Response.NowPlay { 17 | Files.nowPlaying1Json.url 18 | .mapToData() 19 | .decoded() 20 | } 21 | 22 | private var nowPlaying2Json: MovieDomain.MovieList.Response.NowPlay { 23 | Files.nowPlaying2Json.url 24 | .mapToData() 25 | .decoded() 26 | } 27 | } 28 | 29 | // MARK: MovieUseCase 30 | 31 | extension MovieUseCasePlatformMock: MovieUseCase { 32 | public var nowPlaying: (MovieDomain.MovieList.Request.NowPlay) 33 | -> AnyPublisher 34 | { 35 | { requestModel in 36 | print("AA ", requestModel.page) 37 | var response: MovieDomain.MovieList.Response.NowPlay { 38 | requestModel.page == 1 ? nowPlaying1Json : nowPlaying2Json 39 | } 40 | 41 | return Just(response) 42 | .delay(for: .seconds(1), scheduler: RunLoop.main) 43 | .setFailureType(to: CompositeErrorDomain.self) 44 | .eraseToAnyPublisher() 45 | } 46 | } 47 | } 48 | 49 | extension URL { 50 | fileprivate func mapToData() -> Data { 51 | try! Data(contentsOf: self) 52 | } 53 | } 54 | 55 | extension Data { 56 | fileprivate func decoded() -> D { 57 | try! JSONDecoder().decode(D.self, from: self) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Project/Core/Platform/Sources/Platform/UseCase/Mock/SearchUseCasePlatformMock.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Domain 3 | import Foundation 4 | 5 | // MARK: - SearchUseCasePlatformMock 6 | 7 | public struct SearchUseCasePlatformMock { 8 | private let configurationDomain: ConfigurationDomain 9 | 10 | public init(configurationDomain: ConfigurationDomain) { 11 | self.configurationDomain = configurationDomain 12 | } 13 | } 14 | 15 | extension SearchUseCasePlatformMock { 16 | private var searchMoviePage1: SearchDomain.Response.MovieResult { 17 | Files.searchMovie1Json.url 18 | .mapToData() 19 | .decoded() 20 | } 21 | 22 | private var searchKeywordPage1: SearchDomain.Response.KeywordResult { 23 | Files.searchKeyword1Json.url 24 | .mapToData() 25 | .decoded() 26 | } 27 | 28 | private var searchPeoplePage1: SearchDomain.Response.PeopleResult { 29 | Files.searchPerson1Json.url 30 | .mapToData() 31 | .decoded() 32 | } 33 | 34 | } 35 | 36 | // MARK: SearchUseCase 37 | 38 | extension SearchUseCasePlatformMock: SearchUseCase { 39 | public var searchMovie: (SearchDomain.Request.KeywordAndPage) -> AnyPublisher< 40 | SearchDomain.Response.MovieResult, 41 | CompositeErrorDomain 42 | > { 43 | { _ in 44 | Just(searchMoviePage1) 45 | .delay(for: .seconds(1), scheduler: RunLoop.main) 46 | .setFailureType(to: CompositeErrorDomain.self) 47 | .eraseToAnyPublisher() 48 | } 49 | } 50 | 51 | public var searchKeyword: (SearchDomain.Request.Keyword) -> AnyPublisher< 52 | SearchDomain.Response.KeywordResult, 53 | CompositeErrorDomain 54 | > { 55 | { _ in 56 | Just(searchKeywordPage1) 57 | .delay(for: .seconds(1.2), scheduler: RunLoop.main) 58 | .setFailureType(to: CompositeErrorDomain.self) 59 | .eraseToAnyPublisher() 60 | } 61 | } 62 | 63 | public var searchPeople: (SearchDomain.Request.KeywordAndPage) -> AnyPublisher< 64 | SearchDomain.Response.PeopleResult, 65 | CompositeErrorDomain 66 | > { 67 | { _ in 68 | Just(searchPeoplePage1) 69 | .delay(for: .seconds(1), scheduler: RunLoop.main) 70 | .setFailureType(to: CompositeErrorDomain.self) 71 | .eraseToAnyPublisher() 72 | } 73 | } 74 | 75 | } 76 | 77 | extension URL { 78 | fileprivate func mapToData() -> Data { 79 | try! Data(contentsOf: self) 80 | } 81 | } 82 | 83 | extension Data { 84 | fileprivate func decoded() -> D { 85 | try! JSONDecoder().decode(D.self, from: self) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Project/Core/Platform/Sources/Platform/UseCase/MovieUseCasePlatform.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Domain 3 | import Foundation 4 | 5 | // MARK: - MovieUseCasePlatform 6 | 7 | public struct MovieUseCasePlatform { 8 | private let configurationDomain: ConfigurationDomain 9 | 10 | public init(configurationDomain: ConfigurationDomain) { 11 | self.configurationDomain = configurationDomain 12 | } 13 | } 14 | 15 | // MARK: MovieUseCase 16 | 17 | extension MovieUseCasePlatform: MovieUseCase { 18 | public var nowPlaying: (MovieDomain.MovieList.Request.NowPlay) 19 | -> AnyPublisher 20 | { 21 | { item in 22 | let requestModel = NowPlay( 23 | apiKey: configurationDomain.entity.baseURL.apiToken, 24 | item: item) 25 | let endpoint = Endpoint( 26 | baseURL: configurationDomain.entity.baseURL.apiURL, 27 | pathList: ["movie", "now_playing"], 28 | content: .queryItemPath(requestModel)) 29 | 30 | return endpoint.fetch() 31 | } 32 | } 33 | } 34 | 35 | // MARK: MovieUseCasePlatform.NowPlay 36 | 37 | extension MovieUseCasePlatform { 38 | private struct NowPlay: Equatable, Codable { 39 | let apiKey: String 40 | let language: String 41 | let region: String 42 | let page: Int 43 | 44 | init(apiKey: String, item: MovieDomain.MovieList.Request.NowPlay) { 45 | self.apiKey = apiKey 46 | language = LocaleClient().language 47 | region = item.region 48 | page = item.page 49 | } 50 | 51 | private enum CodingKeys: String, CodingKey { 52 | case apiKey = "api_key" 53 | case language 54 | case region 55 | case page 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Project/Core/Platform/Tests/PlatformTests/PlatformTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Platform 3 | 4 | final class PlatformTests: XCTestCase { 5 | func testExample() throws { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | XCTAssertEqual(Platform().text, "Hello, World!") 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Project/Feature/Movie/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.8 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Movie", 8 | platforms: [ 9 | .iOS(.v16), 10 | ], 11 | products: [ 12 | .library( 13 | name: "Movie", 14 | targets: ["Movie"]), 15 | ], 16 | dependencies: [ 17 | .package(path: "../../Core/Architecture"), 18 | .package(path: "../../Core/DesignSystem"), 19 | ], 20 | targets: [ 21 | .target( 22 | name: "Movie", 23 | dependencies: [ 24 | "Architecture", 25 | "DesignSystem", 26 | ]), 27 | .testTarget( 28 | name: "MovieTests", 29 | dependencies: ["Movie"]), 30 | ]) 31 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Movie.swift: -------------------------------------------------------------------------------- 1 | // The Swift Programming Language 2 | // https://docs.swift.org/swift-book 3 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/MovieRouteBuilderGroup.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import LinkNavigator 3 | 4 | // MARK: - MovieRouteBuilderGroup 5 | 6 | public struct MovieRouteBuilderGroup{ 7 | public init() { } 8 | } 9 | 10 | extension MovieRouteBuilderGroup { 11 | public static var release: [RouteBuilderOf] { 12 | [ 13 | MovieHomeRouteBuilder.generate(), 14 | MyListsRouteBuilder.generate(), 15 | MovieDetailRouteBuilder.generate(), 16 | ReviewRoteBuilder.generate(), 17 | CastRouteBuilder.generate(), 18 | CrewRouteBuilder.generate(), 19 | SimilarMovieRouteBuilder.generate(), 20 | RecommendedMovieRouteBuilder.generate(), 21 | ] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/MovieSideEffectGroup.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import Domain 3 | import LinkNavigator 4 | import Platform 5 | 6 | public protocol MovieSideEffectGroup: DependencyType { 7 | var configurationDomain: ConfigurationDomain { get } 8 | var movieUseCase: MovieUseCase { get } 9 | var searchUseCase: SearchUseCase { get } 10 | var movieDetailUseCase: MovieDetailUseCase { get } 11 | } 12 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/MovieSideEffectGroupMock.swift: -------------------------------------------------------------------------------- 1 | import Domain 2 | import Platform 3 | 4 | struct MovieSideEffectGroupMock: MovieSideEffectGroup { 5 | init() { 6 | configurationDomain = .init(entity: .init()) 7 | movieUseCase = MovieUseCasePlatformMock(configurationDomain: configurationDomain) 8 | searchUseCase = SearchUseCasePlatformMock(configurationDomain: configurationDomain) 9 | movieDetailUseCase = MovieDetailUseCasePlatformMock(configurationDomain: configurationDomain) 10 | } 11 | 12 | let configurationDomain: ConfigurationDomain 13 | let movieUseCase: MovieUseCase 14 | let searchUseCase: SearchUseCase 15 | let movieDetailUseCase: MovieDetailUseCase 16 | } 17 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Resource/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Resource/Assets.xcassets/SpongeBob.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "SpongeBob.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "original" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Resource/Assets.xcassets/SpongeBob.imageset/SpongeBob.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/interactord/Boost/48141593190459ba75877af44dba030311b9371d/Project/Feature/Movie/Sources/Movie/Resource/Assets.xcassets/SpongeBob.imageset/SpongeBob.png -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Resource/XCAssets+Generated.swift: -------------------------------------------------------------------------------- 1 | // swiftlint:disable all 2 | // Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen 3 | 4 | #if os(macOS) 5 | import AppKit 6 | #elseif os(iOS) 7 | import UIKit 8 | #elseif os(tvOS) || os(watchOS) 9 | import UIKit 10 | #endif 11 | #if canImport(SwiftUI) 12 | import SwiftUI 13 | #endif 14 | 15 | // Deprecated typealiases 16 | @available(*, deprecated, renamed: "ImageAsset.Image", message: "This typealias will be removed in SwiftGen 7.0") 17 | typealias AssetImageTypeAlias = ImageAsset.Image 18 | 19 | // MARK: - Asset 20 | 21 | // swiftlint:disable superfluous_disable_command file_length implicit_return 22 | 23 | // swiftlint:disable identifier_name line_length nesting type_body_length type_name 24 | enum Asset { 25 | static let spongeBob = ImageAsset(name: "SpongeBob") 26 | } 27 | 28 | // MARK: - ImageAsset 29 | 30 | // swiftlint:enable identifier_name line_length nesting type_body_length type_name 31 | 32 | struct ImageAsset { 33 | fileprivate(set) var name: String 34 | 35 | #if os(macOS) 36 | typealias Image = NSImage 37 | #elseif os(iOS) || os(tvOS) || os(watchOS) 38 | typealias Image = UIImage 39 | #endif 40 | 41 | @available(iOS 8.0, tvOS 9.0, watchOS 2.0, macOS 10.7, *) 42 | var image: Image { 43 | let bundle = BundleToken.bundle 44 | #if os(iOS) || os(tvOS) 45 | let image = Image(named: name, in: bundle, compatibleWith: nil) 46 | #elseif os(macOS) 47 | let name = NSImage.Name(name) 48 | let image = (bundle == .main) ? NSImage(named: name) : bundle.image(forResource: name) 49 | #elseif os(watchOS) 50 | let image = Image(named: name) 51 | #endif 52 | guard let result = image else { 53 | fatalError("Unable to load image asset named \(name).") 54 | } 55 | return result 56 | } 57 | 58 | #if os(iOS) || os(tvOS) 59 | @available(iOS 8.0, tvOS 9.0, *) 60 | func image(compatibleWith traitCollection: UITraitCollection) -> Image { 61 | let bundle = BundleToken.bundle 62 | guard let result = Image(named: name, in: bundle, compatibleWith: traitCollection) else { 63 | fatalError("Unable to load image asset named \(name).") 64 | } 65 | return result 66 | } 67 | #endif 68 | 69 | #if canImport(SwiftUI) 70 | @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) 71 | var swiftUIImage: SwiftUI.Image { 72 | SwiftUI.Image(asset: self) 73 | } 74 | #endif 75 | } 76 | 77 | extension ImageAsset.Image { 78 | @available(iOS 8.0, tvOS 9.0, watchOS 2.0, *) 79 | @available( 80 | macOS, 81 | deprecated, 82 | message: "This initializer is unsafe on macOS, please use the ImageAsset.image property") 83 | convenience init?(asset: ImageAsset) { 84 | #if os(iOS) || os(tvOS) 85 | let bundle = BundleToken.bundle 86 | self.init(named: asset.name, in: bundle, compatibleWith: nil) 87 | #elseif os(macOS) 88 | self.init(named: NSImage.Name(asset.name)) 89 | #elseif os(watchOS) 90 | self.init(named: asset.name) 91 | #endif 92 | } 93 | } 94 | 95 | #if canImport(SwiftUI) 96 | @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) 97 | extension SwiftUI.Image { 98 | init(asset: ImageAsset) { 99 | let bundle = BundleToken.bundle 100 | self.init(asset.name, bundle: bundle) 101 | } 102 | 103 | init(asset: ImageAsset, label: Text) { 104 | let bundle = BundleToken.bundle 105 | self.init(asset.name, bundle: bundle, label: label) 106 | } 107 | 108 | init(decorative asset: ImageAsset) { 109 | let bundle = BundleToken.bundle 110 | self.init(decorative: asset.name, bundle: bundle) 111 | } 112 | } 113 | #endif 114 | 115 | // MARK: - BundleToken 116 | 117 | // swiftlint:disable convenience_type 118 | private final class BundleToken { 119 | static let bundle: Bundle = { 120 | #if SWIFT_PACKAGE 121 | return Bundle.module 122 | #else 123 | return Bundle(for: BundleToken.self) 124 | #endif 125 | }() 126 | } 127 | 128 | // swiftlint:enable convenience_type 129 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MovieDetailHome/MovieDetail/Env/MovieDetailEnvLive.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import ComposableArchitecture 3 | import Domain 4 | import Foundation 5 | import LinkNavigator 6 | import URLEncodedForm 7 | 8 | // MARK: - MovieDetailEnvLive 9 | 10 | struct MovieDetailEnvLive { 11 | 12 | let mainQueue: AnySchedulerOf 13 | let useCaseGroup: MovieSideEffectGroup 14 | let navigator: LinkNavigatorProtocol 15 | 16 | init( 17 | mainQueue: AnySchedulerOf = .main, 18 | useCaseGroup: MovieSideEffectGroup, 19 | navigator: LinkNavigatorProtocol) 20 | { 21 | self.mainQueue = mainQueue 22 | self.useCaseGroup = useCaseGroup 23 | self.navigator = navigator 24 | } 25 | } 26 | 27 | // MARK: MovieDetailEnvType 28 | 29 | extension MovieDetailEnvLive: MovieDetailEnvType { 30 | var routeToReview: (MovieDetailDomain.Response.MovieReviewResult) -> Void { 31 | { item in 32 | navigator.backOrNext( 33 | linkItem: .init( 34 | path: Link.Movie.Path.review.rawValue, 35 | items: item.encoded()), 36 | isAnimated: true) 37 | } 38 | } 39 | 40 | var routeToCast: (MovieDetailDomain.Response.MovieCreditResult) -> Void { 41 | { item in 42 | navigator.backOrNext( 43 | linkItem: .init( 44 | path: Link.Movie.Path.cast.rawValue, 45 | items: item.encoded()), 46 | isAnimated: true) 47 | } 48 | } 49 | 50 | var routeToCrew: (MovieDetailDomain.Response.MovieCreditResult) -> Void { 51 | { item in 52 | navigator.backOrNext( 53 | linkItem: .init( 54 | path: Link.Movie.Path.crew.rawValue, 55 | items: item.encoded()), 56 | isAnimated: true) 57 | } 58 | } 59 | 60 | var routeToSimilarMovie: (MovieDetailDomain.Response.SimilarMovieResult) -> Void { 61 | { item in 62 | navigator.backOrNext( 63 | linkItem: .init( 64 | path: Link.Movie.Path.similarMovie.rawValue, 65 | items: item.encoded()), 66 | isAnimated: true) 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MovieDetailHome/MovieDetail/Env/MovieDetailEnvMock.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Domain 3 | import Foundation 4 | 5 | // MARK: - MovieDetailEnvMock 6 | 7 | struct MovieDetailEnvMock { 8 | 9 | let mainQueue: AnySchedulerOf 10 | let useCaseGroup: MovieSideEffectGroup 11 | } 12 | 13 | // MARK: MovieDetailEnvType 14 | 15 | extension MovieDetailEnvMock: MovieDetailEnvType { 16 | var routeToReview: (MovieDetailDomain.Response.MovieReviewResult) -> Void { 17 | { _ in Void() } 18 | } 19 | 20 | var routeToCast: (MovieDetailDomain.Response.MovieCreditResult) -> Void { 21 | { _ in Void() } 22 | } 23 | 24 | var routeToCrew: (MovieDetailDomain.Response.MovieCreditResult) -> Void { 25 | { _ in Void() } 26 | } 27 | 28 | var routeToSimilarMovie: (MovieDetailDomain.Response.SimilarMovieResult) -> Void { 29 | { _ in Void() } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MovieDetailHome/MovieDetail/Env/MovieDetailEnvType.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import ComposableArchitecture 3 | import Domain 4 | import Foundation 5 | 6 | // MARK: - MovieDetailEnvType 7 | 8 | protocol MovieDetailEnvType { 9 | var mainQueue: AnySchedulerOf { get } 10 | var useCaseGroup: MovieSideEffectGroup { get } 11 | 12 | var movieCard: (Int) 13 | -> Effect> { get } 14 | 15 | var movieReview: (Int) 16 | -> Effect> { get } 17 | 18 | var movieCredit: (Int) 19 | -> Effect> { get } 20 | 21 | var similarMovie: (Int) 22 | -> Effect> { get } 23 | 24 | var recommendedMovie: (Int) 25 | -> Effect> { get } 26 | 27 | var routeToReview: (MovieDetailDomain.Response.MovieReviewResult) -> Void { get } 28 | 29 | var routeToCast: (MovieDetailDomain.Response.MovieCreditResult) -> Void { get } 30 | 31 | var routeToCrew: (MovieDetailDomain.Response.MovieCreditResult) -> Void { get } 32 | 33 | var routeToSimilarMovie: (MovieDetailDomain.Response.SimilarMovieResult) -> Void { get } 34 | } 35 | 36 | extension MovieDetailEnvType { 37 | public var movieCard: (Int) 38 | -> Effect> 39 | { 40 | { id in 41 | .publisher { 42 | useCaseGroup 43 | .movieDetailUseCase 44 | .movieCard(.init(id: id)) 45 | .map { $0.serialized( 46 | imageURL: useCaseGroup.configurationDomain.entity.baseURL.imageSizeURL(.medium) 47 | )} 48 | .mapToResult() 49 | .receive(on: mainQueue) 50 | } 51 | } 52 | } 53 | 54 | public var movieReview: (Int) 55 | -> Effect> 56 | { 57 | { id in 58 | .publisher { 59 | useCaseGroup 60 | .movieDetailUseCase 61 | .movieReview(.init(id: id)) 62 | .mapToResult() 63 | .receive(on: mainQueue) 64 | } 65 | } 66 | } 67 | 68 | public var movieCredit: (Int) 69 | -> Effect> 70 | { 71 | { id in 72 | .publisher { 73 | useCaseGroup 74 | .movieDetailUseCase 75 | .movieCredit(.init(id: id)) 76 | .mapToResult() 77 | .receive(on: mainQueue) 78 | } 79 | } 80 | } 81 | 82 | public var similarMovie: (Int) 83 | -> Effect> 84 | { 85 | { id in 86 | .publisher { 87 | useCaseGroup 88 | .movieDetailUseCase 89 | .similarMovie(.init(id: id)) 90 | .mapToResult() 91 | .receive(on: mainQueue) 92 | } 93 | } 94 | } 95 | 96 | public var recommendedMovie: (Int) 97 | -> Effect> 98 | { 99 | { id in 100 | .publisher { 101 | useCaseGroup 102 | .movieDetailUseCase 103 | .recommendedMovie(.init(id: id)) 104 | .mapToResult() 105 | .receive(on: mainQueue) 106 | } 107 | } 108 | } 109 | 110 | } 111 | 112 | extension MovieDetailDomain.Response.MovieCardResult { 113 | fileprivate func serialized(imageURL: String) -> MovieDetailStore.MovieCardResultScope { 114 | .init(imageURL: imageURL, item: self) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MovieDetailHome/MovieDetail/MovieDetailRouteBuilder.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import Domain 3 | import LinkNavigator 4 | import URLEncodedForm 5 | 6 | struct MovieDetailRouteBuilder { 7 | 8 | static func generate() -> RouteBuilderOf { 9 | let matchPath = Link.Movie.Path.movieDetail.rawValue 10 | 11 | return .init(matchPath: matchPath) { navigator, item, dependency -> RouteViewController? in 12 | guard 13 | let env: MovieSideEffectGroup = dependency.resolve(), 14 | let query: MovieDomain.MovieList.Response.ResultItem = item.decoded() 15 | else { return .none } 16 | 17 | return WrappingController(matchPath: matchPath) { 18 | MovieDetailPage(store: .init( 19 | initialState: MovieDetailStore.State(movieID: query.id), 20 | reducer: { 21 | MovieDetailStore(env: MovieDetailEnvLive( 22 | useCaseGroup: env, 23 | navigator: navigator)) 24 | })) 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MovieDetailHome/MovieDetail/UIComponent/MovieDetailPage+CastListComponent.swift: -------------------------------------------------------------------------------- 1 | import Domain 2 | import Foundation 3 | import SwiftUI 4 | 5 | // MARK: - MovieDetailPage.CastListComponent 6 | 7 | extension MovieDetailPage { 8 | struct CastListComponent { 9 | let viewState: ViewState 10 | let selectAction: (MovieDetailDomain.Response.MovieCreditResult) -> Void 11 | } 12 | } 13 | 14 | extension MovieDetailPage.CastListComponent { 15 | private var filterItemList: [ViewState.CastItem] { 16 | viewState.itemList.reduce(into: [ViewState.CastItem]()) { curr, next in 17 | if !curr.contains(where: { $0.id == next.id }) { 18 | curr.append(next) 19 | } 20 | } 21 | } 22 | } 23 | 24 | // MARK: - MovieDetailPage.CastListComponent + View 25 | 26 | extension MovieDetailPage.CastListComponent: View { 27 | var body: some View { 28 | VStack { 29 | HStack { 30 | Text("Cast") 31 | Text("See all") 32 | .foregroundColor(.customGreenColor) 33 | 34 | Spacer() 35 | 36 | Image(systemName: "chevron.right") 37 | .resizable() 38 | .frame(width: 8, height: 10) 39 | } 40 | .background(.white) 41 | .onTapGesture { 42 | selectAction(viewState.rawValue) 43 | print("Tapped Cast See all") 44 | } 45 | 46 | ScrollView(.horizontal, showsIndicators: false) { 47 | LazyHStack { 48 | ForEach(filterItemList) { item in 49 | ItemComponent(item: item) 50 | .background(.white) 51 | } 52 | } 53 | } 54 | } 55 | .padding(.vertical) 56 | .padding(.horizontal, 16) 57 | } 58 | } 59 | 60 | // MARK: - MovieDetailPage.CastListComponent.ViewState 61 | 62 | extension MovieDetailPage.CastListComponent { 63 | struct ViewState: Equatable { 64 | let itemList: [CastItem] // 65 | let rawValue: MovieDetailDomain.Response.MovieCreditResult 66 | 67 | init(rawValue: MovieDetailDomain.Response.MovieCreditResult?) { 68 | itemList = (rawValue?.castList ?? []).map(CastItem.init(rawValue:)) 69 | self.rawValue = rawValue ?? MovieDetailDomain.Response.MovieCreditResult() 70 | } 71 | } 72 | } 73 | 74 | // MARK: - MovieDetailPage.CastListComponent.ViewState.CastItem 75 | 76 | extension MovieDetailPage.CastListComponent.ViewState { 77 | struct CastItem: Equatable, Identifiable { 78 | let id: Int // cast id 79 | let name: String 80 | let character: String 81 | let profileImage: String 82 | 83 | init(rawValue: MovieDetailDomain.Response.CastResultItem) { 84 | id = rawValue.id 85 | name = rawValue.name 86 | character = rawValue.character 87 | profileImage = rawValue.profileImage ?? "" 88 | } 89 | } 90 | } 91 | 92 | // MARK: - MovieDetailPage.CastListComponent.ItemComponent 93 | 94 | extension MovieDetailPage.CastListComponent { 95 | fileprivate struct ItemComponent { 96 | let item: ViewState.CastItem 97 | } 98 | } 99 | 100 | // MARK: - MovieDetailPage.CastListComponent.ItemComponent + View 101 | 102 | extension MovieDetailPage.CastListComponent.ItemComponent: View { 103 | var body: some View { 104 | Button(action: { }) { 105 | VStack(alignment: .center) { 106 | // API로 받아오는 데이터가 nil이 아니라 ""(빈문자열)로 표시 될수 있으므로 != nil 대신 이런 방식으로 사용 107 | if !item.profileImage.isEmpty { 108 | Asset.spongeBob.swiftUIImage 109 | .resizable() 110 | .frame(width: 70, height: 90) 111 | .clipShape(RoundedRectangle(cornerRadius: 10)) 112 | .overlay( 113 | RoundedRectangle(cornerRadius: 10) 114 | .stroke(.black, lineWidth: 1)) 115 | } else { 116 | RoundedRectangle(cornerRadius: 10) 117 | .fill(Color.customBgColor) 118 | .frame(width: 70, height: 90) 119 | } 120 | 121 | Text(item.name) 122 | .font(.footnote) 123 | .foregroundColor(Color(.label)) 124 | Text(item.character) 125 | .font(.caption) 126 | .foregroundColor(.gray) 127 | } 128 | } 129 | .frame(width: 90) 130 | .lineLimit(0) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MovieDetailHome/MovieDetail/UIComponent/MovieDetailPage+CrewListComponent.swift: -------------------------------------------------------------------------------- 1 | import Domain 2 | import Foundation 3 | import SwiftUI 4 | 5 | // MARK: - MovieDetailPage.CrewListComponent 6 | 7 | extension MovieDetailPage { 8 | struct CrewListComponent { 9 | let viewState: ViewState 10 | let selectAction: (MovieDetailDomain.Response.MovieCreditResult) -> Void 11 | } 12 | } 13 | 14 | extension MovieDetailPage.CrewListComponent { 15 | private var filterItemList: [ViewState.CrewItem] { 16 | viewState.itemList.reduce(into: [ViewState.CrewItem]()) { curr, next in 17 | if !curr.contains(where: { $0.id == next.id }) { 18 | curr.append(next) 19 | } 20 | } 21 | } 22 | } 23 | 24 | // MARK: - MovieDetailPage.CrewListComponent + View 25 | 26 | extension MovieDetailPage.CrewListComponent: View { 27 | var body: some View { 28 | VStack { 29 | HStack { 30 | Text("Crew") 31 | Text("See all") 32 | .foregroundColor(.customGreenColor) 33 | 34 | Spacer() 35 | 36 | Image(systemName: "chevron.right") 37 | .resizable() 38 | .frame(width: 8, height: 10) 39 | } 40 | .background(.white) 41 | .onTapGesture { 42 | selectAction(viewState.rawValue) 43 | print("Tapped Crew See all") 44 | } 45 | 46 | ScrollView(.horizontal, showsIndicators: false) { 47 | LazyHStack { 48 | ForEach(filterItemList) { item in 49 | ItemComponent(item: item) 50 | } 51 | } 52 | } 53 | } 54 | .padding(.vertical) 55 | .padding(.horizontal, 16) 56 | } 57 | } 58 | 59 | // MARK: - MovieDetailPage.CrewListComponent.ViewState 60 | 61 | extension MovieDetailPage.CrewListComponent { 62 | struct ViewState: Equatable { 63 | let itemList: [CrewItem] 64 | let rawValue: MovieDetailDomain.Response.MovieCreditResult 65 | 66 | init(rawValue: MovieDetailDomain.Response.MovieCreditResult?) { 67 | itemList = (rawValue?.crewList ?? []).map(CrewItem.init(rawValue:)) 68 | self.rawValue = rawValue ?? MovieDetailDomain.Response.MovieCreditResult() 69 | } 70 | } 71 | } 72 | 73 | // MARK: - MovieDetailPage.CrewListComponent.ViewState.CrewItem 74 | 75 | extension MovieDetailPage.CrewListComponent.ViewState { 76 | struct CrewItem: Equatable, Identifiable, Hashable { 77 | let id: Int 78 | let name: String 79 | let department: String 80 | let profileImage: String 81 | 82 | init(rawValue: MovieDetailDomain.Response.CrewResultItem) { 83 | id = rawValue.id 84 | name = rawValue.name 85 | department = rawValue.department 86 | profileImage = rawValue.profileImage ?? "" 87 | } 88 | } 89 | } 90 | 91 | // MARK: - MovieDetailPage.CrewListComponent.ItemComponent 92 | 93 | extension MovieDetailPage.CrewListComponent { 94 | fileprivate struct ItemComponent { 95 | let item: ViewState.CrewItem 96 | } 97 | } 98 | 99 | // MARK: - MovieDetailPage.CrewListComponent.ItemComponent + View 100 | 101 | extension MovieDetailPage.CrewListComponent.ItemComponent: View { 102 | var body: some View { 103 | Button(action: { }) { 104 | VStack(alignment: .center) { 105 | if !item.profileImage.isEmpty { 106 | Asset.spongeBob.swiftUIImage 107 | .resizable() 108 | .frame(width: 70, height: 90) 109 | .clipShape(RoundedRectangle(cornerRadius: 10)) 110 | .overlay( 111 | RoundedRectangle(cornerRadius: 10) 112 | .stroke(.black, lineWidth: 1)) 113 | } else { 114 | RoundedRectangle(cornerRadius: 10) 115 | .fill(Color.customBgColor) 116 | .frame(width: 70, height: 90) 117 | } 118 | 119 | Text(item.name) 120 | .font(.footnote) 121 | .foregroundColor(Color(.label)) 122 | Text(item.department) 123 | .font(.caption) 124 | .foregroundColor(.gray) 125 | } 126 | } 127 | .frame(width: 90) 128 | .lineLimit(0) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MovieDetailHome/MovieDetail/UIComponent/MovieDetailPage+DirectorComponent.swift: -------------------------------------------------------------------------------- 1 | import Domain 2 | import Foundation 3 | import SwiftUI 4 | 5 | // MARK: - MovieDetailPage.DirectorComponent 6 | 7 | extension MovieDetailPage { 8 | struct DirectorComponent { 9 | let viewState: ViewState 10 | } 11 | } 12 | 13 | extension MovieDetailPage.DirectorComponent { } 14 | 15 | // MARK: - MovieDetailPage.DirectorComponent + View 16 | 17 | extension MovieDetailPage.DirectorComponent: View { 18 | var body: some View { 19 | HStack { 20 | Text("Director:") 21 | 22 | Text(viewState.director.first(where: { $0.job == "Director" })?.name ?? "") 23 | .foregroundColor(.gray) 24 | Spacer() 25 | 26 | Image(systemName: "chevron.right") 27 | .resizable() 28 | .frame(width: 8, height: 10) 29 | } 30 | .padding(.horizontal, 16) 31 | } 32 | } 33 | 34 | // MARK: - MovieDetailPage.DirectorComponent.ViewState 35 | 36 | extension MovieDetailPage.DirectorComponent { 37 | struct ViewState: Equatable { 38 | let director: [DirectorItem] 39 | 40 | init(rawValue: MovieDetailDomain.Response.MovieCreditResult?) { 41 | director = (rawValue?.crewList ?? []).map(DirectorItem.init(rawValue:)) 42 | } 43 | } 44 | } 45 | 46 | // MARK: - MovieDetailPage.DirectorComponent.ViewState.DirectorItem 47 | 48 | extension MovieDetailPage.DirectorComponent.ViewState { 49 | struct DirectorItem: Equatable { 50 | let name: String 51 | let job: String 52 | 53 | init(rawValue: MovieDetailDomain.Response.CrewResultItem) { 54 | name = rawValue.name 55 | job = rawValue.job 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MovieDetailHome/MovieDetail/UIComponent/MovieDetailPage+KeywordListComponent.swift: -------------------------------------------------------------------------------- 1 | import Domain 2 | import Foundation 3 | import SwiftUI 4 | 5 | // MARK: - MovieDetailPage.KeywordListComponent 6 | 7 | extension MovieDetailPage { 8 | struct KeywordListComponent { 9 | let viewState: ViewState 10 | } 11 | } 12 | 13 | extension MovieDetailPage.KeywordListComponent { } 14 | 15 | // MARK: - MovieDetailPage.KeywordListComponent + View 16 | 17 | extension MovieDetailPage.KeywordListComponent: View { 18 | var body: some View { 19 | VStack(alignment: .leading) { 20 | Text("Keywords") 21 | 22 | ScrollView(.horizontal, showsIndicators: false) { 23 | LazyHStack { 24 | ForEach(viewState.keywordList, id: \.id) { item in 25 | Button(action: { }) { 26 | HStack { 27 | Text(item.name) 28 | .font(.footnote) 29 | 30 | Image(systemName: "chevron.right") 31 | .resizable() 32 | .frame(width: 6, height: 8) 33 | } 34 | .foregroundColor(Color(.label)) 35 | .padding(4) 36 | .background( 37 | RoundedRectangle(cornerRadius: 10) 38 | .fill(Color.customBgColor)) 39 | } 40 | } 41 | } 42 | } 43 | } 44 | .padding(.vertical) 45 | .padding(.horizontal, 12) 46 | } 47 | } 48 | 49 | // MARK: - MovieDetailPage.KeywordListComponent.ViewState 50 | 51 | extension MovieDetailPage.KeywordListComponent { 52 | struct ViewState: Equatable { 53 | let keywordList: [KeywordItem] 54 | 55 | init(rawValue: MovieDetailDomain.Response.KeywordBucket?) { 56 | keywordList = (rawValue?.keywordList ?? []).map(KeywordItem.init(rawValue:)) 57 | } 58 | } 59 | } 60 | 61 | // MARK: - MovieDetailPage.KeywordListComponent.ViewState.KeywordItem 62 | 63 | extension MovieDetailPage.KeywordListComponent.ViewState { 64 | struct KeywordItem: Equatable, Identifiable { 65 | let id: Int 66 | let name: String 67 | 68 | init(rawValue: MovieDetailDomain.Response.KeywordItem) { 69 | id = rawValue.id 70 | name = rawValue.name 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MovieDetailHome/MovieDetail/UIComponent/MovieDetailPage+ListButtonComponent.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | // MARK: - MovieDetailPage.ListButtonComponent 5 | 6 | extension MovieDetailPage { 7 | struct ListButtonComponent { 8 | let viewState: ViewState 9 | } 10 | } 11 | 12 | extension MovieDetailPage.ListButtonComponent { } 13 | 14 | // MARK: - MovieDetailPage.ListButtonComponent + View 15 | 16 | extension MovieDetailPage.ListButtonComponent: View { 17 | var body: some View { 18 | HStack { 19 | // Text(viewState.text) 20 | Button(action: { }) { 21 | HStack { 22 | Image(systemName: "heart") 23 | .resizable() 24 | .frame(width: 15, height: 15) 25 | 26 | Text("Wishlist") 27 | } 28 | .padding(4) 29 | .background( 30 | RoundedRectangle(cornerRadius: 5).stroke(lineWidth: 1)) 31 | } 32 | 33 | Button(action: { }) { 34 | HStack { 35 | Image(systemName: "eye") 36 | .resizable() 37 | .frame(width: 20, height: 15) 38 | 39 | Text("SeenList") 40 | } 41 | .padding(4) 42 | .background( 43 | RoundedRectangle(cornerRadius: 5).stroke(lineWidth: 1)) 44 | } 45 | 46 | Button(action: { }) { 47 | HStack { 48 | Image(systemName: "pin") 49 | .resizable() 50 | .frame(width: 10, height: 15) 51 | 52 | Text("List") 53 | } 54 | .padding(4) 55 | .background( 56 | RoundedRectangle(cornerRadius: 5) 57 | .stroke(lineWidth: 1)) 58 | } 59 | 60 | Spacer() 61 | } 62 | .padding(.leading, 16) 63 | } 64 | } 65 | 66 | // MARK: - MovieDetailPage.ListButtonComponent.ViewState 67 | 68 | extension MovieDetailPage.ListButtonComponent { 69 | struct ViewState: Equatable { 70 | let text: String 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MovieDetailHome/MovieDetail/UIComponent/MovieDetailPage+MovieOverviewComponent.swift: -------------------------------------------------------------------------------- 1 | import Domain 2 | import Foundation 3 | import SwiftUI 4 | 5 | // MARK: - MovieDetailPage.MovieOverviewComponent 6 | 7 | extension MovieDetailPage { 8 | struct MovieOverviewComponent { 9 | let viewState: ViewState 10 | @State private var isOverview = false 11 | } 12 | } 13 | 14 | extension MovieDetailPage.MovieOverviewComponent { 15 | private var overView: String { 16 | viewState.overView 17 | } 18 | 19 | private var toggleText: String { 20 | isOverview ? "Less" : "Read More" 21 | } 22 | } 23 | 24 | // MARK: - MovieDetailPage.MovieOverviewComponent + View 25 | 26 | extension MovieDetailPage.MovieOverviewComponent: View { 27 | var body: some View { 28 | VStack(alignment: .leading, spacing: 6) { 29 | Text("OverView:") 30 | 31 | Text(overView) 32 | .font(.subheadline) 33 | .foregroundColor(.gray) 34 | .multilineTextAlignment(.leading) 35 | .lineLimit(isOverview ? .max : 3) 36 | 37 | Button(action: { isOverview.toggle() }) { 38 | Text(toggleText) 39 | .foregroundColor(.customGreenColor) 40 | } 41 | } 42 | .padding(.horizontal, 12) 43 | } 44 | } 45 | 46 | // MARK: - MovieDetailPage.MovieOverviewComponent.ViewState 47 | 48 | extension MovieDetailPage.MovieOverviewComponent { 49 | struct ViewState: Equatable { 50 | let overView: String 51 | 52 | init(rawValue: MovieDetailDomain.Response.MovieCardResult?) { 53 | overView = rawValue?.overview ?? "" 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MovieDetailHome/MovieDetail/UIComponent/MovieDetailPage+MovieReviewComponent.swift: -------------------------------------------------------------------------------- 1 | import Domain 2 | import Foundation 3 | import SwiftUI 4 | 5 | // MARK: - MovieDetailPage.MovieReviewComponent 6 | 7 | extension MovieDetailPage { 8 | struct MovieReviewComponent { 9 | let viewState: ViewState 10 | let selectAction: (MovieDetailDomain.Response.MovieReviewResult) -> Void 11 | } 12 | } 13 | 14 | extension MovieDetailPage.MovieReviewComponent { } 15 | 16 | // MARK: - MovieDetailPage.MovieReviewComponent + View 17 | 18 | extension MovieDetailPage.MovieReviewComponent: View { 19 | var body: some View { 20 | HStack { 21 | Text("\(viewState.rawValue.totalResult) reviews") 22 | .foregroundColor(.customGreenColor) 23 | 24 | Spacer() 25 | 26 | Image(systemName: "chevron.right") 27 | .resizable() 28 | .aspectRatio(contentMode: .fit) 29 | .frame(width: 10, height: 10) 30 | } 31 | .background(.white) 32 | .padding(.horizontal, 16) 33 | .onTapGesture { 34 | selectAction(viewState.rawValue) 35 | print(viewState.rawValue.id) 36 | print("Tapped") 37 | } 38 | } 39 | } 40 | 41 | // MARK: - MovieDetailPage.MovieReviewComponent.ViewState 42 | 43 | extension MovieDetailPage.MovieReviewComponent { 44 | struct ViewState: Equatable { 45 | let rawValue: MovieDetailDomain.Response.MovieReviewResult 46 | 47 | init(rawValue: MovieDetailDomain.Response.MovieReviewResult?) { 48 | self.rawValue = rawValue ?? MovieDetailDomain.Response.MovieReviewResult() 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MovieDetailHome/MovieDetail/UIComponent/MovieDetailPage+OtherPosterListComponent.swift: -------------------------------------------------------------------------------- 1 | import Domain 2 | import Foundation 3 | import SwiftUI 4 | 5 | // MARK: - MovieDetailPage.OtherPosterListComponent 6 | 7 | extension MovieDetailPage { 8 | struct OtherPosterListComponent { 9 | let viewState: ViewState 10 | } 11 | } 12 | 13 | extension MovieDetailPage.OtherPosterListComponent { } 14 | 15 | // MARK: - MovieDetailPage.OtherPosterListComponent + View 16 | 17 | extension MovieDetailPage.OtherPosterListComponent: View { 18 | var body: some View { 19 | VStack { 20 | HStack { 21 | Text("Other posters") 22 | Spacer() 23 | } 24 | 25 | ScrollView(.horizontal, showsIndicators: false) { 26 | LazyHStack(spacing: 36) { 27 | ForEach(viewState.itemList.prefix(8), id: \.image) { _ in 28 | Asset.spongeBob.swiftUIImage 29 | .resizable() 30 | .frame(width: 100, height: 160) 31 | .clipShape(RoundedRectangle(cornerRadius: 10)) 32 | .overlay( 33 | RoundedRectangle(cornerRadius: 10) 34 | .stroke(.black, lineWidth: 1)) 35 | .shadow(radius: 10) 36 | } 37 | } 38 | } 39 | } 40 | .padding(.vertical) 41 | .padding(.horizontal, 16) 42 | } 43 | } 44 | 45 | // MARK: - MovieDetailPage.OtherPosterListComponent.ViewState 46 | 47 | extension MovieDetailPage.OtherPosterListComponent { 48 | struct ViewState: Equatable { 49 | let itemList: [ImageItem] 50 | 51 | init(rawValue: MovieDetailDomain.Response.ImageBucket?) { 52 | itemList = (rawValue?.posterList ?? []).map(ImageItem.init(rawValue:)) 53 | } 54 | } 55 | } 56 | 57 | // MARK: - MovieDetailPage.OtherPosterListComponent.ViewState.ImageItem 58 | 59 | extension MovieDetailPage.OtherPosterListComponent.ViewState { 60 | struct ImageItem: Equatable { 61 | let image: String 62 | 63 | init(rawValue: MovieDetailDomain.Response.PosterItem) { 64 | image = rawValue.poster ?? "" 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MovieDetailHome/MovieDetail/UIComponent/MovieDetailPage+RecommendedMovieListComponent.swift: -------------------------------------------------------------------------------- 1 | import Domain 2 | import Foundation 3 | import SwiftUI 4 | 5 | // MARK: - MovieDetailPage.RecommendedMovieListComponent 6 | 7 | extension MovieDetailPage { 8 | struct RecommendedMovieListComponent { 9 | let viewState: ViewState 10 | } 11 | } 12 | 13 | extension MovieDetailPage.RecommendedMovieListComponent { } 14 | 15 | // MARK: - MovieDetailPage.RecommendedMovieListComponent + View 16 | 17 | extension MovieDetailPage.RecommendedMovieListComponent: View { 18 | var body: some View { 19 | VStack { 20 | HStack { 21 | Text("Recommended Movies") 22 | Text("See all") 23 | .foregroundColor(.customGreenColor) 24 | 25 | Spacer() 26 | 27 | Image(systemName: "chevron.right") 28 | .resizable() 29 | .frame(width: 8, height: 10) 30 | } 31 | 32 | ScrollView(.horizontal, showsIndicators: false) { 33 | LazyHStack(spacing: 48) { 34 | ForEach(viewState.itemList, id: \.id) { item in 35 | Button(action: { }) { 36 | VStack { 37 | Asset.spongeBob.swiftUIImage 38 | .resizable() 39 | .frame(width: 100, height: 160) 40 | .clipShape(RoundedRectangle(cornerRadius: 10)) 41 | .overlay( 42 | RoundedRectangle(cornerRadius: 10) 43 | .stroke(.black, lineWidth: 1)) 44 | .shadow(radius: 10) 45 | 46 | Text(item.title) 47 | .font(.footnote) 48 | 49 | Circle() 50 | .trim(from: 0, to: item.voteAverage / 10) 51 | .stroke( 52 | style: StrokeStyle(lineWidth: 2, dash: [1, 1.5])) 53 | .rotationEffect(.degrees(-90)) 54 | .frame(width: 40, height: 40) 55 | .foregroundColor(Color.lineColor(item.voteAverage)) 56 | .shadow(color: Color.lineColor(item.voteAverage), radius: 5, x: 0, y: 0) 57 | .overlay( 58 | Text("\(Int(item.voteAverage * 10))%") 59 | .font(.system(size: 10))) 60 | } 61 | } 62 | .foregroundColor(Color(.label)) 63 | .frame(width: 120) 64 | .lineLimit(0) 65 | } 66 | } 67 | } 68 | } 69 | .padding(.vertical) 70 | .padding(.horizontal, 16) 71 | } 72 | } 73 | 74 | // MARK: - MovieDetailPage.RecommendedMovieListComponent.ViewState 75 | 76 | extension MovieDetailPage.RecommendedMovieListComponent { 77 | struct ViewState: Equatable { 78 | let itemList: [RecommendedMovieItem] 79 | 80 | init(rawValue: MovieDetailDomain.Response.RecommenededMovieResult?) { 81 | itemList = (rawValue?.resultList ?? []).map(RecommendedMovieItem.init(rawValue:)) 82 | } 83 | } 84 | } 85 | 86 | // MARK: - MovieDetailPage.RecommendedMovieListComponent.ViewState.RecommendedMovieItem 87 | 88 | extension MovieDetailPage.RecommendedMovieListComponent.ViewState { 89 | struct RecommendedMovieItem: Equatable, Identifiable { 90 | let id: Int 91 | let title: String 92 | let voteAverage: Double 93 | 94 | init(rawValue: MovieDetailDomain.Response.RecommenededMovieResultItem) { 95 | id = rawValue.id 96 | title = rawValue.title 97 | voteAverage = rawValue.voteAverage 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MovieDetailHome/MovieDetail/UIComponent/MovieDetailPage+SimilarMovieListComponent.swift: -------------------------------------------------------------------------------- 1 | import Domain 2 | import Foundation 3 | import SwiftUI 4 | 5 | // MARK: - MovieDetailPage.SimilarMovieListComponent 6 | 7 | extension MovieDetailPage { 8 | struct SimilarMovieListComponent { 9 | let viewState: ViewState 10 | let selectAction: (MovieDetailDomain.Response.SimilarMovieResult) -> Void 11 | } 12 | } 13 | 14 | extension MovieDetailPage.SimilarMovieListComponent { } 15 | 16 | // MARK: - MovieDetailPage.SimilarMovieListComponent + View 17 | 18 | extension MovieDetailPage.SimilarMovieListComponent: View { 19 | var body: some View { 20 | VStack { 21 | HStack { 22 | Text("Similar Movies") 23 | Text("See all") 24 | .foregroundColor(.customGreenColor) 25 | 26 | Spacer() 27 | 28 | Image(systemName: "chevron.right") 29 | .resizable() 30 | .frame(width: 8, height: 10) 31 | } 32 | .background(.white) 33 | .onTapGesture { 34 | selectAction(viewState.rawValue) 35 | print("Tapped Similar Movie") 36 | } 37 | 38 | ScrollView(.horizontal, showsIndicators: false) { 39 | LazyHStack(spacing: 24) { 40 | ForEach(viewState.itemList) { item in 41 | ItemComponent(item: item) 42 | } 43 | } 44 | } 45 | } 46 | .padding(.vertical) 47 | .padding(.horizontal, 16) 48 | } 49 | } 50 | 51 | // MARK: - MovieDetailPage.SimilarMovieListComponent.ViewState 52 | 53 | extension MovieDetailPage.SimilarMovieListComponent { 54 | struct ViewState: Equatable { 55 | let itemList: [SimilarMovieItem] 56 | let rawValue: MovieDetailDomain.Response.SimilarMovieResult 57 | 58 | init(rawValue: MovieDetailDomain.Response.SimilarMovieResult?) { 59 | itemList = (rawValue?.resultList ?? []).map(SimilarMovieItem.init(rawValue:)) 60 | self.rawValue = rawValue ?? MovieDetailDomain.Response.SimilarMovieResult() 61 | } 62 | } 63 | } 64 | 65 | // MARK: - MovieDetailPage.SimilarMovieListComponent.ViewState.SimilarMovieItem 66 | 67 | extension MovieDetailPage.SimilarMovieListComponent.ViewState { 68 | struct SimilarMovieItem: Equatable, Identifiable { 69 | let id: Int 70 | let title: String 71 | let voteAverage: Double 72 | 73 | init(rawValue: MovieDetailDomain.Response.SimilarMovieResultItem) { 74 | id = rawValue.id 75 | title = rawValue.title 76 | voteAverage = rawValue.voteAverage 77 | } 78 | } 79 | } 80 | 81 | // MARK: - MovieDetailPage.SimilarMovieListComponent.ItemComponent 82 | 83 | extension MovieDetailPage.SimilarMovieListComponent { 84 | fileprivate struct ItemComponent { 85 | let item: ViewState.SimilarMovieItem 86 | } 87 | } 88 | 89 | // MARK: - MovieDetailPage.SimilarMovieListComponent.ItemComponent + View 90 | 91 | extension MovieDetailPage.SimilarMovieListComponent.ItemComponent: View { 92 | var body: some View { 93 | Button(action: { }) { 94 | VStack { 95 | Asset.spongeBob.swiftUIImage 96 | .resizable() 97 | .frame(width: 100, height: 140) 98 | .clipShape(RoundedRectangle(cornerRadius: 10)) 99 | .overlay( 100 | RoundedRectangle(cornerRadius: 10) 101 | .stroke(.black, lineWidth: 1)) 102 | .shadow(radius: 10) 103 | 104 | Text(item.title) 105 | .font(.footnote) 106 | 107 | Circle() 108 | .trim(from: 0, to: item.voteAverage / 10) 109 | .stroke( 110 | style: StrokeStyle(lineWidth: 2, dash: [1, 1.5])) 111 | .rotationEffect(.degrees(-90)) 112 | .frame(width: 40, height: 40) 113 | .foregroundColor(Color.lineColor(item.voteAverage)) 114 | .shadow(color: Color.lineColor(item.voteAverage), radius: 5, x: 0, y: 0) 115 | .overlay( 116 | Text("\(Int(item.voteAverage * 10))%") 117 | .font(.system(size: 10))) 118 | } 119 | } 120 | .foregroundColor(Color(.label)) 121 | .frame(width: 120) 122 | .lineLimit(0) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MovieDetailHome/MovieDetail/UIComponent/MovieDetatilPage+BackdropImageListComponent.swift: -------------------------------------------------------------------------------- 1 | import Domain 2 | import Foundation 3 | import SwiftUI 4 | 5 | // MARK: - MovieDetailPage.BackdropImageListComponent 6 | 7 | extension MovieDetailPage { 8 | struct BackdropImageListComponent { 9 | let viewState: ViewState 10 | } 11 | } 12 | 13 | extension MovieDetailPage.BackdropImageListComponent { } 14 | 15 | // MARK: - MovieDetailPage.BackdropImageListComponent + View 16 | 17 | extension MovieDetailPage.BackdropImageListComponent: View { 18 | var body: some View { 19 | VStack { 20 | HStack { 21 | Text("Images") 22 | Spacer() 23 | } 24 | ScrollView(.horizontal, showsIndicators: false) { 25 | LazyHStack(spacing: 20) { 26 | ForEach(viewState.itemList, id: \.image) { _ in 27 | Asset.spongeBob.swiftUIImage 28 | .resizable() 29 | .frame(width: 240, height: 160) 30 | .overlay( 31 | Rectangle() 32 | .stroke(Color.black, lineWidth: 2)) 33 | } 34 | } 35 | } 36 | } 37 | .padding(.vertical) 38 | .padding(.horizontal, 16) 39 | } 40 | } 41 | 42 | // MARK: - MovieDetailPage.BackdropImageListComponent.ViewState 43 | 44 | extension MovieDetailPage.BackdropImageListComponent { 45 | struct ViewState: Equatable { 46 | let itemList: [ImageItem] 47 | 48 | init(rawValue: MovieDetailDomain.Response.ImageBucket?) { 49 | itemList = (rawValue?.backdropList ?? []).map(ImageItem.init(rawValue:)) 50 | } 51 | } 52 | } 53 | 54 | // MARK: - MovieDetailPage.BackdropImageListComponent.ViewState.ImageItem 55 | 56 | extension MovieDetailPage.BackdropImageListComponent.ViewState { 57 | struct ImageItem: Equatable { 58 | let image: String 59 | 60 | init(rawValue: MovieDetailDomain.Response.BackdropItem) { 61 | image = rawValue.backdropImage ?? "" 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MovieDetailHome/Review/Env/ReviewEnvLive.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import ComposableArchitecture 3 | import Domain 4 | import Foundation 5 | import LinkNavigator 6 | import URLEncodedForm 7 | 8 | // MARK: - ReviewEnvLive 9 | 10 | struct ReviewEnvLive { 11 | 12 | let mainQueue: AnySchedulerOf 13 | let useCaseGroup: MovieSideEffectGroup 14 | let navigator: LinkNavigatorProtocol 15 | 16 | init( 17 | mainQueue: AnySchedulerOf = .main, 18 | useCaseGroup: MovieSideEffectGroup, 19 | navigator: LinkNavigatorProtocol) 20 | { 21 | self.mainQueue = mainQueue 22 | self.useCaseGroup = useCaseGroup 23 | self.navigator = navigator 24 | } 25 | } 26 | 27 | // MARK: ReviewEnvType 28 | 29 | extension ReviewEnvLive: ReviewEnvType { } 30 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MovieDetailHome/Review/Env/ReviewEnvMock.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Domain 3 | import Foundation 4 | 5 | // MARK: - ReviewEnvMock 6 | 7 | struct ReviewEnvMock { 8 | 9 | let mainQueue: AnySchedulerOf 10 | let useCaseGroup: MovieSideEffectGroup 11 | } 12 | 13 | // MARK: ReviewEnvType 14 | 15 | extension ReviewEnvMock: ReviewEnvType { } 16 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MovieDetailHome/Review/Env/ReviewEnvType.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import ComposableArchitecture 3 | import Domain 4 | import Foundation 5 | 6 | // MARK: - ReviewEnvType 7 | 8 | protocol ReviewEnvType { 9 | var mainQueue: AnySchedulerOf { get } 10 | var useCaseGroup: MovieSideEffectGroup { get } 11 | 12 | var movieReview: (Int) 13 | -> Effect> { get } 14 | } 15 | 16 | extension ReviewEnvType { 17 | public var movieReview: (Int) 18 | -> Effect> 19 | { 20 | { id in 21 | .publisher { 22 | useCaseGroup 23 | .movieDetailUseCase 24 | .movieReview(.init(id: id)) 25 | .mapToResult() 26 | .receive(on: mainQueue) 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MovieDetailHome/Review/ReviewPage.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import ComposableArchitecture 3 | import Foundation 4 | import SwiftUI 5 | 6 | // MARK: - ReviewPage 7 | 8 | struct ReviewPage { 9 | 10 | private let store: StoreOf 11 | @ObservedObject private var viewStore: ViewStoreOf 12 | 13 | init(store: StoreOf) { 14 | self.store = store 15 | viewStore = ViewStore(store, observe: { $0 }) 16 | } 17 | } 18 | 19 | extension ReviewPage { 20 | private var itemListComponentViewState: ItemListComponent.ViewState { 21 | .init(rawValue: viewStore.fetchMovieReview.value.resultList) 22 | } 23 | } 24 | 25 | extension ReviewPage { 26 | private var isLoading: Bool { 27 | viewStore.fetchMovieReview.isLoading 28 | } 29 | } 30 | 31 | // MARK: View 32 | 33 | extension ReviewPage: View { 34 | 35 | var body: some View { 36 | ScrollView { 37 | VStack { 38 | ItemListComponent(viewState: itemListComponentViewState) 39 | } 40 | .background(Color.white) 41 | .cornerRadius(10) 42 | .padding(.bottom) 43 | .padding(.horizontal, 16) 44 | } 45 | .background(Color.customBgColor) 46 | .onAppear { 47 | viewStore.send(.getMovieReview) 48 | } 49 | .onDisappear { 50 | viewStore.send(.teardown) 51 | } 52 | } 53 | } 54 | 55 | #Preview { 56 | ReviewPage(store: .init( 57 | initialState: ReviewStore.State(movieID: .zero), 58 | reducer: { 59 | ReviewStore( 60 | env: ReviewEnvMock( 61 | mainQueue: .main, 62 | useCaseGroup: MovieSideEffectGroupMock())) 63 | })) 64 | } 65 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MovieDetailHome/Review/ReviewRouteBuilder.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import Domain 3 | import LinkNavigator 4 | import URLEncodedForm 5 | 6 | struct ReviewRoteBuilder{ 7 | 8 | static func generate() -> RouteBuilderOf { 9 | let matchPath = Link.Movie.Path.review.rawValue 10 | 11 | return .init(matchPath: matchPath) { navigator, item, dependency -> RouteViewController? in 12 | guard 13 | let env: MovieSideEffectGroup = dependency.resolve(), 14 | let query: MovieDetailDomain.Response.MovieReviewResult = item.decoded() 15 | else { return .none } 16 | 17 | return WrappingController(matchPath: matchPath) { 18 | ReviewPage(store: .init( 19 | initialState: ReviewStore.State(movieID: query.id), 20 | reducer: { 21 | ReviewStore(env: ReviewEnvLive( 22 | useCaseGroup: env, 23 | navigator: navigator)) 24 | })) 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MovieDetailHome/Review/ReviewStore.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import ComposableArchitecture 3 | import Domain 4 | import Foundation 5 | 6 | // MARK: - ReviewStore 7 | 8 | public struct ReviewStore { 9 | let pageID: String 10 | let env: ReviewEnvType 11 | 12 | init(pageID: String = UUID().uuidString, env: ReviewEnvType) { 13 | self.pageID = pageID 14 | self.env = env 15 | } 16 | } 17 | 18 | // MARK: ReviewStore.State 19 | 20 | extension ReviewStore { 21 | public struct State: Equatable { 22 | let movieID: Int 23 | 24 | init(movieID: Int) { 25 | self.movieID = movieID 26 | _fetchMovieReview = .init(.init(isLoading: false, value: .init())) 27 | } 28 | 29 | @Heap var fetchMovieReview: FetchState.Data 30 | } 31 | } 32 | 33 | extension ReviewStore.State { } 34 | 35 | // MARK: - ReviewStore.Action 36 | 37 | extension ReviewStore { 38 | public enum Action: BindableAction, Equatable { 39 | case binding(BindingAction) 40 | case teardown 41 | 42 | case getMovieReview 43 | 44 | case fetchMovieReview(Result) 45 | 46 | case throwError(CompositeErrorDomain) 47 | } 48 | } 49 | 50 | // MARK: - ReviewStore.CancelID 51 | 52 | extension ReviewStore { 53 | enum CancelID: Equatable, CaseIterable { 54 | case teardown 55 | case requestMovieReview 56 | } 57 | } 58 | 59 | // MARK: - ReviewStore + Reducer 60 | 61 | extension ReviewStore: Reducer { 62 | public var body: some ReducerOf { 63 | BindingReducer() 64 | Reduce { state, action in 65 | switch action { 66 | case .binding: 67 | return .none 68 | 69 | case .teardown: 70 | return .concatenate( 71 | CancelID.allCases.map { .cancel(pageID: pageID, id: $0) }) 72 | 73 | case .getMovieReview: 74 | state.fetchMovieReview.isLoading = false 75 | return .concatenate( 76 | env.movieReview(state.movieID) 77 | .map(Action.fetchMovieReview) 78 | .cancellable(pageID: pageID, id: CancelID.requestMovieReview, cancelInFlight: true)) 79 | 80 | case .fetchMovieReview(let result): 81 | state.fetchMovieReview.isLoading = false 82 | switch result { 83 | case .success(let content): 84 | state.fetchMovieReview.value = content 85 | return .none 86 | case .failure(let error): 87 | return .run { await $0(.throwError(error)) } 88 | } 89 | 90 | case .throwError(let error): 91 | print(error) 92 | return .none 93 | } 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MovieDetailHome/Review/UIComponent/ReviewPage+ItemListComponent.swift: -------------------------------------------------------------------------------- 1 | import Domain 2 | import Foundation 3 | import SwiftUI 4 | 5 | // MARK: - ReviewPage.ItemListComponent 6 | 7 | extension ReviewPage { 8 | struct ItemListComponent { 9 | let viewState: ViewState 10 | } 11 | } 12 | 13 | // MARK: - ReviewPage.ItemListComponent + View 14 | 15 | extension ReviewPage.ItemListComponent: View { 16 | var body: some View { 17 | ScrollView { 18 | LazyVStack { 19 | ForEach(viewState.itemList) { item in 20 | ItemComponent(item: item) 21 | } 22 | .padding(.horizontal) 23 | .padding(.vertical) 24 | } 25 | .background(Color.white) 26 | } 27 | } 28 | } 29 | 30 | // MARK: - ReviewPage.ItemListComponent.ViewState 31 | 32 | extension ReviewPage.ItemListComponent { 33 | struct ViewState: Equatable { 34 | let itemList: [ReviewItem] 35 | 36 | init(rawValue: [MovieDetailDomain.Response.ReviewResultItem]) { 37 | itemList = rawValue.map(ReviewItem.init(rawValue:)) 38 | } 39 | } 40 | } 41 | 42 | // MARK: - ReviewPage.ItemListComponent.ViewState.ReviewItem 43 | 44 | extension ReviewPage.ItemListComponent.ViewState { 45 | struct ReviewItem: Equatable, Identifiable { 46 | let id: String // author id 47 | let author: String 48 | let content: String 49 | let rawValue: MovieDetailDomain.Response.ReviewResultItem 50 | 51 | init(rawValue: MovieDetailDomain.Response.ReviewResultItem) { 52 | id = rawValue.id 53 | author = rawValue.author 54 | content = rawValue.content 55 | self.rawValue = rawValue 56 | } 57 | } 58 | } 59 | 60 | // MARK: - ReviewPage.ItemListComponent.ItemComponent 61 | 62 | extension ReviewPage.ItemListComponent { 63 | fileprivate struct ItemComponent { 64 | let item: ViewState.ReviewItem 65 | } 66 | } 67 | 68 | // MARK: - ReviewPage.ItemListComponent.ItemComponent + View 69 | 70 | extension ReviewPage.ItemListComponent.ItemComponent: View { 71 | var body: some View { 72 | VStack(alignment: .leading, spacing: 8) { 73 | Text("Review written by \(item.author)") 74 | .font(.headline) 75 | 76 | Text(item.content) 77 | .font(.callout) 78 | 79 | Divider() 80 | .padding(.top, 16) 81 | .padding(.leading, 16) 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MovieHome/Env/MovieHomeEnvLive.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import ComposableArchitecture 3 | import Domain 4 | import Foundation 5 | import LinkNavigator 6 | import URLEncodedForm 7 | 8 | // MARK: - MovieHomeEnvLive 9 | 10 | struct MovieHomeEnvLive { 11 | 12 | let mainQueue: AnySchedulerOf 13 | let useCaseGroup: MovieSideEffectGroup 14 | let navigator: LinkNavigatorProtocol 15 | 16 | init( 17 | mainQueue: AnySchedulerOf = .main, 18 | useCaseGroup: MovieSideEffectGroup, 19 | navigator: LinkNavigatorProtocol) 20 | { 21 | self.mainQueue = mainQueue 22 | self.useCaseGroup = useCaseGroup 23 | self.navigator = navigator 24 | } 25 | } 26 | 27 | // MARK: MovieHomeEnvType 28 | 29 | extension MovieHomeEnvLive: MovieHomeEnvType { 30 | var routeToMovieDetail: (MovieDomain.MovieList.Response.ResultItem) -> Void { 31 | { item in 32 | navigator.backOrNext( 33 | linkItem: .init( 34 | path: Link.Movie.Path.movieDetail.rawValue, 35 | items: item.encoded()), 36 | isAnimated: true) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MovieHome/Env/MovieHomeEnvMock.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Domain 3 | import Foundation 4 | 5 | // MARK: - MovieHomeEnvMock 6 | 7 | struct MovieHomeEnvMock { 8 | 9 | let mainQueue: AnySchedulerOf 10 | let useCaseGroup: MovieSideEffectGroup 11 | } 12 | 13 | // MARK: MovieHomeEnvType 14 | 15 | extension MovieHomeEnvMock: MovieHomeEnvType { 16 | 17 | var routeToMovieDetail: (MovieDomain.MovieList.Response.ResultItem) -> Void { 18 | { _ in Void() } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MovieHome/Env/MovieHomeEnvType.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import ComposableArchitecture 3 | import Domain 4 | import Foundation 5 | 6 | // MARK: - MovieHomeEnvType 7 | 8 | protocol MovieHomeEnvType { 9 | var mainQueue: AnySchedulerOf { get } 10 | var useCaseGroup: MovieSideEffectGroup { get } 11 | 12 | var nowPlaying: (Int) 13 | -> Effect> { get } 14 | var searchKeyword: (String) 15 | -> Effect> { get } 16 | var searchMovie: (String) 17 | -> Effect> { get } 18 | var searchPeople: (String) 19 | -> Effect> { get } 20 | 21 | var routeToMovieDetail: (MovieDomain.MovieList.Response.ResultItem) -> Void { get } 22 | } 23 | 24 | extension MovieHomeEnvType { 25 | public var nowPlaying: (Int) 26 | -> Effect> 27 | { 28 | { pageNumber in 29 | .publisher { 30 | useCaseGroup 31 | .movieUseCase 32 | .nowPlaying(.init(locale: Locale.current, page: pageNumber)) 33 | .map{ $0.serialized( 34 | imageURL: useCaseGroup.configurationDomain.entity.baseURL.imageSizeURL(.medium) 35 | )} 36 | .mapToResult() 37 | .receive(on: mainQueue) 38 | } 39 | } 40 | } 41 | 42 | public var searchKeyword: (String) 43 | -> Effect> 44 | { 45 | { keyword in 46 | .publisher { 47 | useCaseGroup 48 | .searchUseCase 49 | .searchKeyword(.init(language: dummyLanguage, query: keyword)) 50 | .mapToResult() 51 | .receive(on: mainQueue) 52 | } 53 | } 54 | } 55 | 56 | public var searchMovie: (String) 57 | -> Effect> 58 | { 59 | { keyword in 60 | .publisher { 61 | useCaseGroup 62 | .searchUseCase 63 | .searchMovie(.init(language: dummyLanguage, query: keyword, page: 1)) 64 | .mapToResult() 65 | .receive(on: mainQueue) 66 | } 67 | } 68 | } 69 | 70 | public var searchPeople: (String) 71 | -> Effect> 72 | { 73 | { keyword in 74 | .publisher { 75 | useCaseGroup 76 | .searchUseCase 77 | .searchPeople(.init(language: dummyLanguage, query: keyword, page: 1)) 78 | .mapToResult() 79 | .receive(on: mainQueue) 80 | } 81 | } 82 | } 83 | } 84 | 85 | private let dummyLanguage = "ko-kr" 86 | private let dummyRegion = "ko" 87 | 88 | 89 | extension MovieDomain.MovieList.Response.NowPlay { 90 | func serialized(imageURL: String) -> MovieHomeStore.State.NowPlayScope { 91 | .init( 92 | totalPages: totalPages, 93 | totalResult: totalResult, 94 | page: page, 95 | resultList: resultList.map { 96 | .init(imageURL: imageURL, item: $0) 97 | }) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MovieHome/MovieHomePage.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import ComposableArchitecture 3 | import DesignSystem 4 | import Foundation 5 | import SwiftUI 6 | 7 | // MARK: - MovieHomePage 8 | 9 | struct MovieHomePage { 10 | 11 | private let store: StoreOf 12 | @ObservedObject private var viewStore: ViewStoreOf 13 | @FocusState private var isFocus: Bool? 14 | 15 | init(store: StoreOf) { 16 | self.store = store 17 | viewStore = ViewStore(store, observe: { $0 }) 18 | } 19 | } 20 | 21 | extension MovieHomePage { 22 | private var searchComponentViewState: SearchComponent.ViewState { 23 | .init(placeHolder: "Serch any movies or person") 24 | } 25 | 26 | private var itemListComponentViewState: ItemListComponent.ViewState { 27 | .init(rawValue: viewStore.fetchNowPlaying.value.resultList) 28 | } 29 | 30 | private var searchResultMoviesComponentViewState: SearchResultMoviesComponent.ViewState { 31 | .init( 32 | fetchSearchMovie: viewStore.fetchSearchMovie.value, 33 | fetchSearchKeyword: viewStore.fetchSearchKeyword.value) 34 | } 35 | 36 | private var searchResultPeopleComponenetViewState: SearchResultPeopleComponenet.ViewState { 37 | .init(rawValue: viewStore.fetchSearchPeople.value) 38 | } 39 | } 40 | 41 | extension MovieHomePage { 42 | private var isLoading: Bool { 43 | viewStore.fetchNowPlaying.isLoading 44 | || viewStore.fetchSearchMovie.isLoading 45 | || viewStore.fetchSearchKeyword.isLoading 46 | } 47 | } 48 | 49 | // MARK: View 50 | 51 | extension MovieHomePage: View { 52 | 53 | var body: some View { 54 | VStack { 55 | // 서치뷰 56 | SearchComponent( 57 | viewState: searchComponentViewState, 58 | keyword: viewStore.$keyword, 59 | isFocus: $isFocus, 60 | throttleAction: { viewStore.send(.onUpdateKeyword) }, 61 | clearAction: { 62 | viewStore.send(.onClearKeyword) 63 | isFocus = false 64 | }) 65 | .padding(.trailing, 16) 66 | .padding(.bottom, 8) 67 | 68 | Divider() 69 | 70 | // 아이템 리스트 71 | ItemListComponent( 72 | viewState: itemListComponentViewState, 73 | nextPageAction: { viewStore.send(.getNowPlay) }, 74 | selectAction: { viewStore.send(.onSelectMovieItem($0)) }) 75 | .overlay { 76 | if isFocus ?? false { 77 | VStack { 78 | Picker("", selection: viewStore.$searchFocus) { 79 | Text("Movies").tag(MovieHomeStore.State.SearchType.movies) 80 | Text("People").tag(MovieHomeStore.State.SearchType.people) 81 | } 82 | .pickerStyle(SegmentedPickerStyle()) 83 | .padding(.trailing, 16) 84 | 85 | Divider() 86 | 87 | switch viewStore.searchFocus { 88 | case .movies: 89 | // 서치 했을때의 무비 리스트 90 | SearchResultMoviesComponent( 91 | viewState: searchResultMoviesComponentViewState) 92 | 93 | case .people: 94 | // 서치 했을때의 사람 리스트 95 | SearchResultPeopleComponenet( 96 | viewState: searchResultPeopleComponenetViewState) 97 | } 98 | 99 | Spacer() 100 | } 101 | .background(.white) 102 | } 103 | } 104 | 105 | Spacer() 106 | } 107 | .padding(.leading, 16) 108 | .background(.white) 109 | .navigationTitle("Now Playing") 110 | .toolbar { 111 | ToolbarItem(placement: .navigationBarTrailing) { 112 | Button(action: { }) { 113 | Image(systemName: "gearshape") 114 | .resizable() 115 | .foregroundColor(.customYellowColor) 116 | } 117 | .animation(.none, value: viewStore.state) // 특정한 곳에 애니메이션 주기 싫을때 118 | } 119 | } 120 | .animation(.spring(), value: viewStore.state) 121 | .setRequestFlightView(isLoading: isLoading) 122 | // 키보드가 내려 갈대 다른 아이템이 보이지 않게 하기 위해 123 | .ignoresSafeArea(.keyboard, edges: .bottom) 124 | .onAppear { 125 | viewStore.send(.getNowPlay) 126 | } 127 | .onDisappear { 128 | viewStore.send(.teardown) 129 | } 130 | } 131 | } 132 | 133 | #Preview { 134 | MovieHomePage(store: .init( 135 | initialState: MovieHomeStore.State(), 136 | reducer: { 137 | MovieHomeStore( 138 | env: MovieHomeEnvMock( 139 | mainQueue: .main, 140 | useCaseGroup: MovieSideEffectGroupMock())) 141 | })) 142 | } 143 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MovieHome/MovieHomeRouteBuilder.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import LinkNavigator 3 | 4 | struct MovieHomeRouteBuilder{ 5 | 6 | static func generate() -> RouteBuilderOf { 7 | let matchPath = Link.Movie.Path.home.rawValue 8 | 9 | return .init(matchPath: matchPath) { navigator, _, dependency -> RouteViewController? in 10 | guard let env: MovieSideEffectGroup = dependency.resolve() else { return .none } 11 | 12 | return WrappingController(matchPath: matchPath) { 13 | MovieHomePage(store: .init( 14 | initialState: MovieHomeStore.State(), 15 | reducer: { 16 | MovieHomeStore(env: MovieHomeEnvLive( 17 | useCaseGroup: env, 18 | navigator: navigator)) 19 | })) 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MovieHome/UIComponent/MovieHome+ItemListComponent.swift: -------------------------------------------------------------------------------- 1 | import Domain 2 | import Foundation 3 | import SwiftUI 4 | 5 | // MARK: - MovieHomePage.ItemListComponent 6 | 7 | extension MovieHomePage { 8 | struct ItemListComponent { 9 | let viewState: ViewState 10 | let nextPageAction: () -> Void 11 | let selectAction: (MovieDomain.MovieList.Response.ResultItem) -> Void 12 | } 13 | } 14 | 15 | // MARK: - MovieHomePage.ItemListComponent + View 16 | 17 | extension MovieHomePage.ItemListComponent: View { 18 | var body: some View { 19 | ScrollView { 20 | LazyVStack { 21 | ForEach(viewState.itemList) { item in 22 | ItemComponent(item: item) 23 | .background(.white) 24 | .onTapGesture { 25 | selectAction(item.rawValue) 26 | } 27 | .onAppear { 28 | guard viewState.lastID == item.id else { return } 29 | nextPageAction() 30 | } 31 | } 32 | } 33 | } 34 | .onAppear { 35 | print("MovieHomePage.ItemListComponent onAppear") 36 | } 37 | .onDisappear { 38 | print("MovieHomePage.ItemListComponent onDisappear") 39 | } 40 | } 41 | } 42 | 43 | // MARK: - MovieHomePage.ItemListComponent.ViewState 44 | 45 | extension MovieHomePage.ItemListComponent { 46 | struct ViewState: Equatable { 47 | let itemList: [MovieItem] 48 | let lastID: Int 49 | 50 | init(rawValue: [MovieHomeStore.State.ResultItemScope]) { 51 | itemList = rawValue.map(MovieItem.init(rawValue:)) 52 | lastID = rawValue.last?.id ?? .zero 53 | } 54 | } 55 | } 56 | 57 | // MARK: - MovieHomePage.ItemListComponent.ViewState.MovieItem 58 | 59 | extension MovieHomePage.ItemListComponent.ViewState { 60 | struct MovieItem: Equatable, Identifiable { 61 | let id: Int 62 | let title: String 63 | let imageURL: String 64 | let voteAverage: Double 65 | let releaseDate: String 66 | let overView: String 67 | let rawValue: MovieDomain.MovieList.Response.ResultItem 68 | 69 | init(rawValue: MovieHomeStore.State.ResultItemScope) { 70 | id = rawValue.id 71 | imageURL = rawValue.imageURL + rawValue.item.posterPath 72 | title = rawValue.item.title 73 | voteAverage = rawValue.item.voteAverage 74 | releaseDate = rawValue.item.releaseDate 75 | overView = rawValue.item.overview 76 | self.rawValue = rawValue.item 77 | } 78 | } 79 | } 80 | 81 | extension Color { 82 | 83 | // MARK: Public 84 | 85 | public static var customYellowColor = Color(red: 0.75, green: 0.6, blue: 0.2) 86 | 87 | public static var customGreenColor = Color(red: 0.45, green: 0.64, blue: 0.62) 88 | 89 | public static var customBgColor = Color(red: 0.94, green: 0.94, blue: 0.96) 90 | 91 | // MARK: Internal 92 | 93 | static func lineColor(_ voteAverage: Double) -> Color { 94 | if voteAverage >= 7.5 { 95 | return .green 96 | } else if voteAverage >= 5.0 { 97 | return .yellow 98 | } else { 99 | return .red 100 | } 101 | } 102 | } 103 | 104 | // MARK: - MovieHomePage.ItemListComponent.ItemComponent 105 | 106 | extension MovieHomePage.ItemListComponent { 107 | fileprivate struct ItemComponent { 108 | let item: ViewState.MovieItem 109 | } 110 | } 111 | 112 | // MARK: - MovieHomePage.ItemListComponent.ItemComponent + View 113 | 114 | extension MovieHomePage.ItemListComponent.ItemComponent: View { 115 | var body: some View { 116 | VStack { 117 | HStack(spacing: 16) { 118 | AsyncImage( 119 | url: .init(string: item.imageURL), 120 | content: { image in 121 | image 122 | .resizable() 123 | .aspectRatio(contentMode: .fit) 124 | }, placeholder: { 125 | Rectangle() 126 | .fill(.gray) 127 | .frame(width: 90) 128 | }) 129 | .frame(height: 140) 130 | .clipShape(RoundedRectangle(cornerRadius: 10)) 131 | .overlay( 132 | RoundedRectangle(cornerRadius: 10) 133 | .stroke(.black, lineWidth: 1)) 134 | .shadow(radius: 10) 135 | 136 | VStack(alignment: .leading, spacing: 8) { 137 | Text(item.title) 138 | .font(.headline) 139 | .fontWeight(.regular) 140 | .foregroundColor(.customYellowColor) 141 | 142 | HStack { 143 | Circle() 144 | .trim(from: 0, to: item.voteAverage / 10) 145 | .stroke( 146 | style: StrokeStyle(lineWidth: 2, dash: [1, 1.5])) 147 | .rotationEffect(.degrees(-90)) 148 | .frame(width: 40, height: 40) 149 | .foregroundColor(Color.lineColor(item.voteAverage)) 150 | .shadow(color: Color.lineColor(item.voteAverage), radius: 5, x: 0, y: 0) 151 | .overlay( 152 | Text("\(Int(item.voteAverage * 10))%") 153 | .font(.system(size: 10))) 154 | 155 | Text(item.releaseDate.formatDate()) 156 | .font(.subheadline) 157 | } 158 | 159 | Text(item.overView) 160 | .font(.callout) 161 | .foregroundColor(Color.gray) 162 | .multilineTextAlignment(.leading) 163 | .lineLimit(3) 164 | } 165 | 166 | Spacer() 167 | 168 | Image(systemName: "chevron.right") 169 | .resizable() 170 | .frame(width: 8, height: 12) 171 | .foregroundColor(Color(.gray)) 172 | .padding(.trailing, 16) 173 | } // Hstack 174 | .padding(.vertical, 8) 175 | 176 | Divider() 177 | .padding(.leading, 144) 178 | } 179 | } 180 | } 181 | 182 | extension String { 183 | fileprivate func formatDate() -> Self { 184 | let dateFormatter = DateFormatter() 185 | dateFormatter.dateFormat = "yyyy-MM-dd" 186 | 187 | if let date = dateFormatter.date(from: self) { 188 | dateFormatter.dateFormat = "M/d/yy" 189 | return dateFormatter.string(from: date) 190 | } else { 191 | return "날짜 형식 오류" 192 | } 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MovieHome/UIComponent/MovieHome+SearchComponent.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import Combine 3 | import Foundation 4 | import SwiftUI 5 | 6 | // MARK: - MovieHomePage.SearchComponent 7 | 8 | extension MovieHomePage { 9 | struct SearchComponent { 10 | 11 | @StateObject private var textBindingObserver: BindingObserver = .init() 12 | 13 | let viewState: ViewState 14 | let keyword: Binding 15 | let isFocus: FocusState.Binding 16 | let throttleAction: () -> Void 17 | let clearAction: () -> Void 18 | } 19 | } 20 | 21 | // MARK: - MovieHomePage.SearchComponent + View 22 | 23 | extension MovieHomePage.SearchComponent: View { 24 | var body: some View { 25 | HStack(spacing: 8) { 26 | Image(systemName: "magnifyingglass") 27 | .resizable() 28 | .frame(width: 16, height: 16) 29 | .padding(8) 30 | 31 | TextField(viewState.placeHolder, text: keyword) 32 | .textFieldStyle(RoundedBorderTextFieldStyle()) 33 | .focused(isFocus, equals: true) 34 | .padding(.trailing, 12) 35 | 36 | if !keyword.wrappedValue.isEmpty { 37 | Button(action: { 38 | keyword.wrappedValue = "" 39 | isFocus.wrappedValue = .none 40 | }) { 41 | Text("Cancel") 42 | .font(.body) 43 | .foregroundColor(Color(.systemPink)) 44 | } 45 | } 46 | } 47 | .onChange(of: keyword.wrappedValue) { textBindingObserver.update(value: $0) } 48 | .onChange(of: keyword.wrappedValue) { 49 | guard $0.isEmpty else { return } 50 | clearAction() 51 | } 52 | .onReceive( 53 | textBindingObserver.$value 54 | .throttle(for: .milliseconds(500), scheduler: RunLoop.main, latest: true)) 55 | { _ in throttleAction() } 56 | } 57 | } 58 | 59 | // MARK: - MovieHomePage.SearchComponent.ViewState 60 | 61 | extension MovieHomePage.SearchComponent { 62 | struct ViewState: Equatable { 63 | let placeHolder: String 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MovieHome/UIComponent/MovieHomePage+SearchResultPeopleComponent.swift: -------------------------------------------------------------------------------- 1 | import Domain 2 | import Foundation 3 | import SwiftUI 4 | 5 | // MARK: - MovieHomePage.SearchResultPeopleComponenet 6 | 7 | extension MovieHomePage { 8 | struct SearchResultPeopleComponenet { 9 | let viewState: ViewState 10 | } 11 | } 12 | 13 | // MARK: - MovieHomePage.SearchResultPeopleComponenet + View 14 | 15 | extension MovieHomePage.SearchResultPeopleComponenet: View { 16 | var body: some View { 17 | // 검색 했을때 맞는 키워드가 없으면 keywords에는 header만 나오고, Result 부분은 "No results" 가 나오도록 해야됌 (아직 구현 x) 18 | ScrollView { 19 | LazyVStack(alignment: .leading) { 20 | ForEach(viewState.profileList) { profile in 21 | 22 | HStack(spacing: 16) { 23 | Asset.spongeBob.swiftUIImage 24 | .resizable() 25 | .frame(width: 70, height: 90) 26 | .clipShape(RoundedRectangle(cornerRadius: 10)) 27 | .overlay( 28 | RoundedRectangle(cornerRadius: 10) 29 | .stroke(.black, lineWidth: 1)) 30 | 31 | VStack(alignment: .leading) { 32 | Text(profile.name) 33 | .font(.headline) 34 | .foregroundColor(.customYellowColor) 35 | .padding(.top, 8) 36 | 37 | Spacer() 38 | 39 | Text(profile.workList.compactMap { $0 }.joined(separator: ", ")) 40 | .font(.subheadline) 41 | .foregroundColor(Color.gray) 42 | .multilineTextAlignment(.leading) 43 | .lineLimit(2) 44 | Spacer() 45 | } 46 | 47 | Spacer() 48 | 49 | Image(systemName: "chevron.right") 50 | .resizable() 51 | .frame(width: 8, height: 12) 52 | .foregroundColor(Color(.gray)) 53 | .padding(.trailing, 16) 54 | } 55 | .padding(.vertical, 16) 56 | 57 | Divider() 58 | .padding(.leading, 144) 59 | } 60 | } 61 | } 62 | } 63 | } 64 | 65 | // MARK: - MovieHomePage.SearchResultPeopleComponenet.ViewState 66 | 67 | extension MovieHomePage.SearchResultPeopleComponenet { 68 | struct ViewState: Equatable { 69 | let profileList: [ProfileItem] 70 | 71 | init(rawValue: SearchDomain.Response.PeopleResult?) { 72 | profileList = (rawValue?.resultList ?? []).map(ProfileItem.init(rawValue:)) 73 | } 74 | } 75 | } 76 | 77 | // MARK: - MovieHomePage.SearchResultPeopleComponenet.ViewState.ProfileItem 78 | 79 | extension MovieHomePage.SearchResultPeopleComponenet.ViewState { 80 | struct ProfileItem: Equatable, Identifiable { 81 | let id: Int 82 | let name: String 83 | let workList: [String?] 84 | 85 | init(rawValue: SearchDomain.Response.PersonResultItem) { 86 | id = rawValue.id 87 | name = rawValue.name 88 | workList = rawValue.appearanceList.map { $0.title ?? $0.originalTitle } 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MyLists/Env/MyListsEnvLive.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import LinkNavigator 3 | 4 | // MARK: - MyListsEnvLive 5 | 6 | struct MyListsEnvLive { 7 | 8 | let useCaseGroup: MovieSideEffectGroup 9 | let navigator: LinkNavigatorProtocol 10 | } 11 | 12 | // MARK: MyListsEnvType 13 | 14 | extension MyListsEnvLive: MyListsEnvType { } 15 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MyLists/Env/MyListsEnvMock.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - MyListsEnvMock 4 | 5 | struct MyListsEnvMock { 6 | 7 | let useCaseGroup: MovieSideEffectGroup 8 | } 9 | 10 | // MARK: MyListsEnvType 11 | 12 | extension MyListsEnvMock: MyListsEnvType { } 13 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MyLists/Env/MyListsEnvType.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol MyListsEnvType { 4 | var useCaseGroup: MovieSideEffectGroup { get } 5 | } 6 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MyLists/MyListsPage.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import ComposableArchitecture 3 | import Foundation 4 | import SwiftUI 5 | 6 | // MARK: - MyListsPage 7 | 8 | struct MyListsPage { 9 | 10 | private let store: StoreOf 11 | @ObservedObject private var viewStore: ViewStoreOf 12 | 13 | init(store: StoreOf) { 14 | self.store = store 15 | viewStore = ViewStore(store, observe: { $0 }) 16 | } 17 | } 18 | 19 | // MOCK 데이터 생성 20 | extension MyListsPage { 21 | private var customListsComponentViewState: CustomListsComponent.ViewState { 22 | .init(placeHolder: "Create custom list") 23 | } 24 | 25 | private var wishListsComponentViewState: WishListsComponent.ViewState { 26 | .init( 27 | text: "(선택한 무비 개수) MOVIES IN WISHLIST (BY RELEASE DATE)", 28 | seenList: [ 29 | WishListsComponent.ViewState.MovieItem( 30 | title: "더넌 2", 31 | voteAverage: 0.67, 32 | releaseDate: "9/6/23", 33 | overView: "1956년, 프랑스의 한 성당에서 신부가 끔찍하게 살해당한다. 이 사건을 조사하기 위해 파견된 아이린 수녀는 4년 전 자신을 공포에 떨게 했던 악마의 기운을 느낀다. 어두은 밤, 계속해서 일어나는 의문의 사건들 가운데 충격적인 진실이 드러나는데..."), 34 | 35 | WishListsComponent.ViewState.MovieItem( 36 | title: "스트레이즈", 37 | voteAverage: 0.74, 38 | releaseDate: "8/18/23", 39 | overView: ""), 40 | 41 | WishListsComponent.ViewState.MovieItem( 42 | title: "데메테르호의 마지막 항해", 43 | voteAverage: 0.73, 44 | releaseDate: "8/9/23", 45 | overView: "브램 스토커의 소설인 <드라큘라> 중 한 에피소드를 영화화하는 작품으로 드라큘라의 관을 싣고 가던 영국 배의 선원들이 차례로 사라지는 공포스러운 상황에 관한 이야기"), 46 | ]) 47 | } 48 | 49 | private var seenListsComponentViewState: SeenListsComponent.ViewState { 50 | .init( 51 | text: "(선택한 무비 개수) MOVIES IN SEENLIST (BY RELEASE DATE)", 52 | wishtList: []) 53 | } 54 | } 55 | 56 | // MARK: View 57 | 58 | extension MyListsPage: View { 59 | var body: some View { 60 | ScrollView { 61 | VStack { 62 | // CUSTOM Lists 63 | CustomListsComponent(viewState: customListsComponentViewState) 64 | .padding(.vertical, 20) 65 | 66 | // MY Lists 67 | Group { 68 | Picker("", selection: viewStore.$listFocus) { 69 | Text("Wishlist").tag(MyListsStore.State.ListType.wish) 70 | Text("Seenlist").tag(MyListsStore.State.ListType.seen) 71 | } 72 | .pickerStyle(SegmentedPickerStyle()) 73 | .font(.callout) 74 | .foregroundColor(.customGreenColor) 75 | .frame(minHeight: 40) 76 | .frame(maxWidth: .infinity, alignment: .leading) 77 | .padding(.horizontal, 16) 78 | .background(Color.white) 79 | 80 | switch viewStore.listFocus { 81 | case .wish: 82 | WishListsComponent(viewState: wishListsComponentViewState) 83 | 84 | case .seen: 85 | SeenListsComponent(viewState: seenListsComponentViewState) 86 | } 87 | } 88 | 89 | Spacer() 90 | } 91 | .navigationTitle("My Lists") 92 | .toolbar { 93 | ToolbarItem(placement: .navigationBarTrailing) { 94 | Button(action: { }) { 95 | Image(systemName: "line.3.horizontal.decrease.circle") 96 | .resizable() 97 | .foregroundColor(.customYellowColor) 98 | } 99 | } 100 | } 101 | } 102 | .background(Color.customBgColor) 103 | } 104 | } 105 | 106 | #Preview { 107 | MyListsPage(store: .init( 108 | initialState: MyListsStore.State(), 109 | reducer: { 110 | MyListsStore( 111 | env: MyListsEnvMock( 112 | useCaseGroup: MovieSideEffectGroupMock())) 113 | })) 114 | } 115 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MyLists/MyListsRouteBuilder.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import LinkNavigator 3 | 4 | struct MyListsRouteBuilder{ 5 | 6 | static func generate() -> RouteBuilderOf { 7 | let matchPath = Link.Movie.Path.myLists.rawValue 8 | 9 | return .init(matchPath: matchPath) { navigator, _, dependency -> RouteViewController? in 10 | guard let env: MovieSideEffectGroup = dependency.resolve() else { return .none } 11 | 12 | return WrappingController(matchPath: matchPath) { 13 | MyListsPage(store: .init( 14 | initialState: MyListsStore.State(), 15 | reducer: { 16 | MyListsStore(env: MyListsEnvLive( 17 | useCaseGroup: env, 18 | navigator: navigator)) 19 | })) 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MyLists/MyListsStore.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Foundation 3 | 4 | // MARK: - MyListsStore 5 | 6 | public struct MyListsStore { 7 | let pageID: String 8 | let env: MyListsEnvType 9 | init(pageID: String = UUID().uuidString, env: MyListsEnvType) { 10 | self.pageID = pageID 11 | self.env = env 12 | } 13 | } 14 | 15 | // MARK: MyListsStore.State 16 | 17 | extension MyListsStore { 18 | public struct State: Equatable { 19 | @BindingState var listFocus: ListType = .wish 20 | } 21 | } 22 | 23 | // MARK: - MyListsStore.State.ListType 24 | 25 | extension MyListsStore.State { 26 | public enum ListType: Int, Equatable { 27 | case wish 28 | case seen 29 | } 30 | } 31 | 32 | // MARK: - MyListsStore.Action 33 | 34 | extension MyListsStore { 35 | public enum Action: BindableAction, Equatable { 36 | case binding(BindingAction) 37 | case teardown 38 | } 39 | } 40 | 41 | // MARK: - MyListsStore + Reducer 42 | 43 | extension MyListsStore: Reducer { 44 | public var body: some ReducerOf { 45 | BindingReducer() 46 | Reduce { _, action in 47 | switch action { 48 | case .binding: 49 | return .none 50 | 51 | case .teardown: 52 | return .none 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MyLists/UIComponent/MyListsPage+CustomListsComponent.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | // MARK: - MyListsPage.CustomListsComponent 5 | 6 | extension MyListsPage { 7 | struct CustomListsComponent { 8 | let viewState: ViewState 9 | } 10 | } 11 | 12 | // MARK: - MyListsPage.CustomListsComponent + View 13 | 14 | extension MyListsPage.CustomListsComponent: View { 15 | var body: some View { 16 | HStack { 17 | VStack(alignment: .leading, spacing: 8) { 18 | Text("CUSTOM LISTS") 19 | .font(.footnote) 20 | .foregroundColor(Color(.gray)) 21 | .padding(.leading, 16) 22 | 23 | // 나중에 Sheet로 변경 할 것 24 | Text(viewState.placeHolder) 25 | .font(.callout) 26 | .foregroundColor(.customGreenColor) 27 | .frame(minHeight: 40) 28 | .frame(maxWidth: .infinity, alignment: .leading) 29 | .padding(.leading, 16) 30 | .background(Color.white) 31 | } 32 | 33 | Spacer() 34 | } 35 | } 36 | } 37 | 38 | // MARK: - MyListsPage.CustomListsComponent.ViewState 39 | 40 | extension MyListsPage.CustomListsComponent { 41 | struct ViewState: Equatable { 42 | let placeHolder: String 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MyLists/UIComponent/MyListsPage+SeenListsComponent.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | // MARK: - MyListsPage.SeenListsComponent 5 | 6 | extension MyListsPage { 7 | struct SeenListsComponent { 8 | let viewState: ViewState 9 | } 10 | } 11 | 12 | // MARK: - MyListsPage.SeenListsComponent + View 13 | 14 | extension MyListsPage.SeenListsComponent: View { 15 | var body: some View { 16 | ScrollView { 17 | Text(viewState.text) 18 | .font(.footnote) 19 | .foregroundColor(Color(.gray)) 20 | .frame(maxWidth: .infinity, alignment: .leading) 21 | .padding(.leading, 16) 22 | .padding(.top, 20) 23 | 24 | LazyVStack { 25 | ForEach(viewState.wishtList) { item in 26 | HStack(spacing: 16) { 27 | Asset.spongeBob.swiftUIImage 28 | .resizable() 29 | .frame(width: 100, height: 140) 30 | .clipShape(RoundedRectangle(cornerRadius: 10)) 31 | .overlay( 32 | RoundedRectangle(cornerRadius: 10) 33 | .stroke(.black, lineWidth: 1)) 34 | .shadow(radius: 10) 35 | 36 | VStack(alignment: .leading, spacing: 8) { 37 | Text(item.title) 38 | .font(.headline) 39 | .fontWeight(.regular) 40 | .foregroundColor(.customYellowColor) 41 | 42 | HStack { 43 | Circle() 44 | .trim(from: 0, to: 0.75) 45 | .stroke( 46 | style: StrokeStyle(lineWidth: 2, dash: [1, 1.5])) 47 | .rotationEffect(.degrees(-90)) 48 | .frame(width: 40, height: 40) 49 | .foregroundColor(Color.lineColor(item.voteAverage)) 50 | .shadow(color: Color.lineColor(item.voteAverage), radius: 5, x: 0, y: 0) 51 | .overlay( 52 | Text("\(Int(item.voteAverage * 100))%") 53 | .font(.system(size: 10))) 54 | 55 | Text(item.releaseDate) 56 | .font(.subheadline) 57 | } 58 | 59 | Text(item.overView) 60 | .font(.callout) 61 | .foregroundColor(Color.gray) 62 | .multilineTextAlignment(.leading) 63 | .lineLimit(3) 64 | } 65 | 66 | Spacer() 67 | 68 | Image(systemName: "chevron.right") 69 | .resizable() 70 | .frame(width: 8, height: 12) 71 | .foregroundColor(Color(.gray)) 72 | .padding(.trailing, 16) 73 | } // Hstack 74 | .padding(.vertical, 16) 75 | 76 | Divider() 77 | .padding(.leading, 144) 78 | } 79 | } 80 | .padding(.leading, 16) 81 | .background(Color.white) 82 | } 83 | } 84 | } 85 | 86 | // MARK: - MyListsPage.SeenListsComponent.ViewState 87 | 88 | extension MyListsPage.SeenListsComponent { 89 | struct ViewState: Equatable { 90 | let text: String 91 | let wishtList: [MovieItem] 92 | } 93 | } 94 | 95 | // MARK: - MyListsPage.SeenListsComponent.ViewState.MovieItem 96 | 97 | extension MyListsPage.SeenListsComponent.ViewState { 98 | struct MovieItem: Equatable, Identifiable { 99 | let id = UUID() 100 | let title: String 101 | let voteAverage: Double 102 | let releaseDate: String 103 | let overView: String 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/MyLists/UIComponent/MyListsPage+WishListsComponent.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | // MARK: - MyListsPage.WishListsComponent 5 | 6 | extension MyListsPage { 7 | struct WishListsComponent { 8 | let viewState: ViewState 9 | } 10 | } 11 | 12 | // MARK: - MyListsPage.WishListsComponent + View 13 | 14 | extension MyListsPage.WishListsComponent: View { 15 | var body: some View { 16 | ScrollView { 17 | Text(viewState.text) 18 | .font(.footnote) 19 | .foregroundColor(Color(.gray)) 20 | .frame(maxWidth: .infinity, alignment: .leading) 21 | .padding(.leading, 16) 22 | .padding(.top, 20) 23 | 24 | LazyVStack { 25 | ForEach(viewState.seenList) { item in 26 | HStack(spacing: 16) { 27 | Asset.spongeBob.swiftUIImage 28 | .resizable() 29 | .frame(width: 100, height: 140) 30 | .clipShape(RoundedRectangle(cornerRadius: 10)) 31 | .overlay( 32 | RoundedRectangle(cornerRadius: 10) 33 | .stroke(.black, lineWidth: 1)) 34 | .shadow(radius: 10) 35 | 36 | VStack(alignment: .leading, spacing: 8) { 37 | Text(item.title) 38 | .font(.headline) 39 | .fontWeight(.regular) 40 | .foregroundColor(.customYellowColor) 41 | 42 | HStack { 43 | Circle() 44 | .trim(from: 0, to: 0.75) 45 | .stroke( 46 | style: StrokeStyle(lineWidth: 2, dash: [1, 1.5])) 47 | .rotationEffect(.degrees(-90)) 48 | .frame(width: 40, height: 40) 49 | .foregroundColor(Color.lineColor(item.voteAverage)) 50 | .shadow(color: Color.lineColor(item.voteAverage), radius: 5, x: 0, y: 0) 51 | .overlay( 52 | Text("\(Int(item.voteAverage * 100))%") 53 | .font(.system(size: 10))) 54 | 55 | Text(item.releaseDate) 56 | .font(.subheadline) 57 | } 58 | 59 | Text(item.overView) 60 | .font(.callout) 61 | .foregroundColor(Color.gray) 62 | .multilineTextAlignment(.leading) 63 | .lineLimit(3) 64 | } 65 | 66 | Spacer() 67 | 68 | Image(systemName: "chevron.right") 69 | .resizable() 70 | .frame(width: 8, height: 12) 71 | .foregroundColor(Color(.gray)) 72 | .padding(.trailing, 16) 73 | } // Hstack 74 | .padding(.vertical, 16) 75 | 76 | Divider() 77 | .padding(.leading, 144) 78 | } 79 | } 80 | .padding(.leading, 16) 81 | .background(Color.white) 82 | } 83 | } 84 | } 85 | 86 | // MARK: - MyListsPage.WishListsComponent.ViewState 87 | 88 | extension MyListsPage.WishListsComponent { 89 | struct ViewState: Equatable { 90 | let text: String 91 | let seenList: [MovieItem] 92 | } 93 | } 94 | 95 | // MARK: - MyListsPage.WishListsComponent.ViewState.MovieItem 96 | 97 | extension MyListsPage.WishListsComponent.ViewState { 98 | struct MovieItem: Equatable, Identifiable { 99 | let id = UUID() 100 | let title: String 101 | let voteAverage: Double 102 | let releaseDate: String 103 | let overView: String 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/People/Cast/CastPage.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import ComposableArchitecture 3 | import Foundation 4 | import SwiftUI 5 | 6 | // MARK: - CastPage 7 | 8 | struct CastPage { 9 | 10 | private let store: StoreOf 11 | @ObservedObject private var viewStore: ViewStoreOf 12 | 13 | init(store: StoreOf) { 14 | self.store = store 15 | viewStore = ViewStore(store, observe: { $0 }) 16 | } 17 | } 18 | 19 | extension CastPage { 20 | private var itemListComponentViewState: ItemListComponent.ViewState { 21 | .init(rawValue: viewStore.fetchMovieCast.value.castList) 22 | } 23 | } 24 | 25 | extension CastPage { 26 | private var isLoading: Bool { 27 | viewStore.fetchMovieCast.isLoading 28 | } 29 | } 30 | 31 | // MARK: View 32 | 33 | extension CastPage: View { 34 | var body: some View { 35 | ScrollView { 36 | VStack { 37 | ItemListComponent(viewState: itemListComponentViewState) 38 | } 39 | .background(Color.white) 40 | .cornerRadius(10) 41 | .padding(.bottom) 42 | .padding(.horizontal, 16) 43 | } 44 | .background(Color.customBgColor) 45 | .onAppear { 46 | viewStore.send(.getMovieCast) 47 | } 48 | .onDisappear { 49 | viewStore.send(.teardown) 50 | } 51 | } 52 | } 53 | 54 | #Preview { 55 | CastPage(store: .init( 56 | initialState: CastStore.State(movieID: .zero), 57 | reducer: { 58 | CastStore(env: CastEnvMock(mainQueue: .main, useCaseGroup: MovieSideEffectGroupMock())) 59 | })) 60 | } 61 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/People/Cast/CastRouteBuilder.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import Domain 3 | import LinkNavigator 4 | import URLEncodedForm 5 | 6 | struct CastRouteBuilder{ 7 | 8 | static func generate() -> RouteBuilderOf { 9 | let matchPath = Link.Movie.Path.cast.rawValue 10 | 11 | return .init(matchPath: matchPath) { navigator, item, dependency -> RouteViewController? in 12 | guard 13 | let env: MovieSideEffectGroup = dependency.resolve(), 14 | let query: MovieDetailDomain.Response.MovieCreditResult = item.decoded() 15 | else { return .none } 16 | 17 | return WrappingController(matchPath: matchPath) { 18 | CastPage(store: .init( 19 | initialState: CastStore.State(movieID: query.id), 20 | reducer: { 21 | CastStore(env: CastEnvLive( 22 | useCaseGroup: env, 23 | navigator: navigator)) 24 | })) 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/People/Cast/CastStore.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import ComposableArchitecture 3 | import Domain 4 | import Foundation 5 | 6 | // MARK: - CastStore 7 | 8 | public struct CastStore { 9 | let pageID: String 10 | let env: CastEnvType 11 | 12 | init(pageID: String = UUID().uuidString, env: CastEnvType) { 13 | self.pageID = pageID 14 | self.env = env 15 | } 16 | } 17 | 18 | // MARK: CastStore.State 19 | 20 | extension CastStore { 21 | public struct State: Equatable { 22 | let movieID: Int 23 | 24 | init(movieID: Int) { 25 | self.movieID = movieID 26 | _fetchMovieCast = .init(.init(isLoading: false, value: .init())) 27 | } 28 | 29 | @Heap var fetchMovieCast: FetchState.Data 30 | } 31 | } 32 | 33 | extension CastStore.State { } 34 | 35 | // MARK: - CastStore.Action 36 | 37 | extension CastStore { 38 | public enum Action: BindableAction, Equatable { 39 | case binding(BindingAction) 40 | case teardown 41 | 42 | case getMovieCast 43 | 44 | case fetchMovieCast(Result) 45 | 46 | case throwError(CompositeErrorDomain) 47 | } 48 | } 49 | 50 | // MARK: - CastStore.CancelID 51 | 52 | extension CastStore { 53 | enum CancelID: Equatable, CaseIterable { 54 | case teardown 55 | case requestMovieCast 56 | } 57 | } 58 | 59 | // MARK: - CastStore + Reducer 60 | 61 | extension CastStore: Reducer { 62 | public var body: some ReducerOf { 63 | BindingReducer() 64 | Reduce { state, action in 65 | switch action { 66 | case .binding: 67 | return .none 68 | 69 | case .teardown: 70 | return .merge( 71 | CancelID.allCases.map { .cancel(pageID: pageID, id: $0) }) 72 | 73 | case .getMovieCast: 74 | state.fetchMovieCast.isLoading = false 75 | return .merge( 76 | env.movieCredit(state.movieID) 77 | .map(Action.fetchMovieCast) 78 | .cancellable(pageID: pageID, id: CancelID.requestMovieCast, cancelInFlight: true)) 79 | 80 | case .fetchMovieCast(let result): 81 | state.fetchMovieCast.isLoading = false 82 | switch result { 83 | case .success(let content): 84 | state.fetchMovieCast.value = content 85 | return .none 86 | case.failure(let error): 87 | return .run { await $0(.throwError(error)) } 88 | } 89 | 90 | case .throwError(let error): 91 | print(error) 92 | return .none 93 | } 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/People/Cast/Env/CastEnvLive.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import ComposableArchitecture 3 | import Domain 4 | import Foundation 5 | import LinkNavigator 6 | import URLEncodedForm 7 | 8 | // MARK: - CastEnvLive 9 | 10 | struct CastEnvLive { 11 | 12 | let mainQueue: AnySchedulerOf 13 | let useCaseGroup: MovieSideEffectGroup 14 | let navigator: LinkNavigatorProtocol 15 | 16 | init( 17 | mainQueue: AnySchedulerOf = .main, 18 | useCaseGroup: MovieSideEffectGroup, 19 | navigator: LinkNavigatorProtocol) 20 | { 21 | self.mainQueue = mainQueue 22 | self.useCaseGroup = useCaseGroup 23 | self.navigator = navigator 24 | } 25 | } 26 | 27 | // MARK: CastEnvType 28 | 29 | extension CastEnvLive: CastEnvType { } 30 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/People/Cast/Env/CastEnvMock.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Domain 3 | import Foundation 4 | 5 | // MARK: - CastEnvMock 6 | 7 | struct CastEnvMock { 8 | 9 | let mainQueue: AnySchedulerOf 10 | let useCaseGroup: MovieSideEffectGroup 11 | } 12 | 13 | // MARK: CastEnvType 14 | 15 | extension CastEnvMock: CastEnvType { } 16 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/People/Cast/Env/CastEnvType.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import ComposableArchitecture 3 | import Domain 4 | import Foundation 5 | 6 | // MARK: - CastEnvType 7 | 8 | protocol CastEnvType { 9 | var mainQueue: AnySchedulerOf { get } 10 | var useCaseGroup: MovieSideEffectGroup { get } 11 | 12 | var movieCredit: (Int) 13 | -> Effect> { get } 14 | } 15 | 16 | extension CastEnvType { 17 | public var movieCredit: (Int) 18 | -> Effect> 19 | { 20 | { id in 21 | .publisher { 22 | useCaseGroup 23 | .movieDetailUseCase 24 | .movieCredit(.init(id: id)) 25 | .mapToResult() 26 | .receive(on: mainQueue) 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/People/Cast/UIComponent/CastPage+ItemListComponent.swift: -------------------------------------------------------------------------------- 1 | import Domain 2 | import Foundation 3 | import SwiftUI 4 | 5 | // MARK: - CastPage.ItemListComponent 6 | 7 | extension CastPage { 8 | struct ItemListComponent { 9 | let viewState: ViewState 10 | } 11 | } 12 | 13 | extension CastPage.ItemListComponent { 14 | private var filterItemList: [ViewState.CastItem] { 15 | viewState.itemList.reduce(into: [ViewState.CastItem]()) { curr, next in 16 | if !curr.contains(where: { $0.id == next.id }) { 17 | curr.append(next) 18 | } 19 | } 20 | } 21 | } 22 | 23 | // MARK: - CastPage.ItemListComponent + View 24 | 25 | extension CastPage.ItemListComponent: View { 26 | var body: some View { 27 | ScrollView { 28 | LazyVStack(alignment: .leading) { 29 | ForEach(filterItemList) { item in 30 | ItemComponent(item: item) 31 | } 32 | .padding(.horizontal, 16) 33 | } 34 | .background(.white) 35 | } 36 | } 37 | } 38 | 39 | // MARK: - CastPage.ItemListComponent.ViewState 40 | 41 | extension CastPage.ItemListComponent { 42 | struct ViewState: Equatable { 43 | let itemList: [CastItem] 44 | 45 | init(rawValue: [MovieDetailDomain.Response.CastResultItem]) { 46 | itemList = rawValue.map(CastItem.init(rawValue:)) 47 | } 48 | } 49 | } 50 | 51 | // MARK: - CastPage.ItemListComponent.ViewState.CastItem 52 | 53 | extension CastPage.ItemListComponent.ViewState { 54 | struct CastItem: Equatable, Identifiable { 55 | let id: Int // 각 cast의 id 56 | let name: String 57 | let character: String 58 | let rawValue: MovieDetailDomain.Response.CastResultItem 59 | 60 | init(rawValue: MovieDetailDomain.Response.CastResultItem) { 61 | id = rawValue.id 62 | name = rawValue.name 63 | character = rawValue.character 64 | self.rawValue = rawValue 65 | } 66 | } 67 | } 68 | 69 | // MARK: - CastPage.ItemListComponent.ItemComponent 70 | 71 | extension CastPage.ItemListComponent { 72 | fileprivate struct ItemComponent { 73 | let item: ViewState.CastItem 74 | } 75 | } 76 | 77 | // MARK: - CastPage.ItemListComponent.ItemComponent + View 78 | 79 | extension CastPage.ItemListComponent.ItemComponent: View { 80 | var body: some View { 81 | HStack(spacing: 16) { 82 | Asset.spongeBob.swiftUIImage 83 | .resizable() 84 | .frame(width: 70, height: 90) 85 | .clipShape(RoundedRectangle(cornerRadius: 10)) 86 | .overlay( 87 | RoundedRectangle(cornerRadius: 10) 88 | .stroke(.black, lineWidth: 1)) 89 | .padding(.top) 90 | 91 | VStack(alignment: .leading, spacing: 8) { 92 | Text(item.name) 93 | .font(.headline) 94 | 95 | Text(item.character) 96 | .font(.subheadline) 97 | .foregroundColor(Color.gray) 98 | } 99 | 100 | Spacer() 101 | 102 | Image(systemName: "chevron.right") 103 | .resizable() 104 | .frame(width: 8, height: 12) 105 | .foregroundColor(Color(.gray)) 106 | } 107 | 108 | Divider() 109 | .padding(.leading, 64) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/People/Crew/CrewPage.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import ComposableArchitecture 3 | import Foundation 4 | import SwiftUI 5 | 6 | // MARK: - CrewPage 7 | 8 | struct CrewPage { 9 | 10 | private let store: StoreOf 11 | @ObservedObject private var viewStore: ViewStoreOf 12 | 13 | init(store: StoreOf) { 14 | self.store = store 15 | viewStore = ViewStore(store, observe: { $0 }) 16 | } 17 | } 18 | 19 | extension CrewPage { 20 | private var itemListComponentViewState: ItemListComponent.ViewState { 21 | .init(rawValue: viewStore.fetchMovieCrew.value.crewList) 22 | } 23 | } 24 | 25 | extension CrewPage { 26 | private var isLoading: Bool { 27 | viewStore.fetchMovieCrew.isLoading 28 | } 29 | } 30 | 31 | // MARK: View 32 | 33 | extension CrewPage: View { 34 | var body: some View { 35 | ScrollView { 36 | VStack { 37 | ItemListComponent(viewState: itemListComponentViewState) 38 | } 39 | .background(Color.white) 40 | .cornerRadius(10) 41 | .padding(.bottom) 42 | .padding(.horizontal, 16) 43 | } 44 | .background(Color.customBgColor) 45 | .onAppear { 46 | viewStore.send(.getMovieCrew) 47 | } 48 | .onDisappear { 49 | viewStore.send(.teardown) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/People/Crew/CrewRouteBuilder.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import Domain 3 | import LinkNavigator 4 | 5 | struct CrewRouteBuilder{ 6 | 7 | static func generate() -> RouteBuilderOf { 8 | let matchPath = Link.Movie.Path.crew.rawValue 9 | 10 | return .init(matchPath: matchPath) { navigator, item, dependency -> RouteViewController? in 11 | guard 12 | let env: MovieSideEffectGroup = dependency.resolve(), 13 | let query: MovieDetailDomain.Response.MovieCreditResult = item.decoded() 14 | else { return .none } 15 | 16 | return WrappingController(matchPath: matchPath) { 17 | CrewPage(store: .init( 18 | initialState: CrewStore.State(movieID: query.id), 19 | reducer: { 20 | CrewStore(env: CrewEnvLive( 21 | useCaseGroup: env, 22 | navigator: navigator)) 23 | })) 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/People/Crew/CrewStore.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import ComposableArchitecture 3 | import Domain 4 | import Foundation 5 | 6 | // MARK: - CrewStore 7 | 8 | public struct CrewStore { 9 | let pageID: String 10 | let env: CrewEnvType 11 | 12 | init(pageID: String = UUID().uuidString, env: CrewEnvType) { 13 | self.pageID = pageID 14 | self.env = env 15 | } 16 | } 17 | 18 | // MARK: CrewStore.State 19 | 20 | extension CrewStore { 21 | public struct State: Equatable { 22 | let movieID: Int 23 | 24 | init(movieID: Int) { 25 | self.movieID = movieID 26 | _fetchMovieCrew = .init(.init(isLoading: false, value: .init())) 27 | } 28 | 29 | @Heap var fetchMovieCrew: FetchState.Data 30 | } 31 | } 32 | 33 | extension CrewStore.State { } 34 | 35 | // MARK: - CrewStore.Action 36 | 37 | extension CrewStore { 38 | public enum Action: BindableAction, Equatable { 39 | case binding(BindingAction) 40 | case teardown 41 | 42 | case getMovieCrew 43 | 44 | case fetchMovieCrew(Result) 45 | 46 | case throwError(CompositeErrorDomain) 47 | } 48 | } 49 | 50 | // MARK: - CrewStore.CancelID 51 | 52 | extension CrewStore { 53 | enum CancelID: Equatable, CaseIterable { 54 | case teardown 55 | case requestMovieCrew 56 | } 57 | } 58 | 59 | // MARK: - CrewStore + Reducer 60 | 61 | extension CrewStore: Reducer { 62 | public var body: some ReducerOf { 63 | BindingReducer() 64 | Reduce { state, action in 65 | switch action { 66 | case .binding: 67 | return .none 68 | 69 | case .teardown: 70 | return .concatenate( 71 | CancelID.allCases.map { .cancel(pageID: pageID, id: $0) }) 72 | 73 | case .getMovieCrew: 74 | state.fetchMovieCrew.isLoading = false 75 | return .concatenate( 76 | env.movieCredit(state.movieID) 77 | .map(Action.fetchMovieCrew) 78 | .cancellable(pageID: pageID, id: CancelID.requestMovieCrew, cancelInFlight: true)) 79 | 80 | case .fetchMovieCrew(let result): 81 | state.fetchMovieCrew.isLoading = false 82 | switch result { 83 | case .success(let content): 84 | state.fetchMovieCrew.value = content 85 | return .none 86 | case .failure(let error): 87 | return .run { await $0(.throwError(error)) } 88 | } 89 | 90 | case .throwError(let error): 91 | print(error) 92 | return .none 93 | } 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/People/Crew/Env/CrewEnvLive.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import ComposableArchitecture 3 | import Domain 4 | import Foundation 5 | import LinkNavigator 6 | import URLEncodedForm 7 | 8 | // MARK: - CrewEnvLive 9 | 10 | struct CrewEnvLive { 11 | 12 | let mainQueue: AnySchedulerOf 13 | let useCaseGroup: MovieSideEffectGroup 14 | let navigator: LinkNavigatorProtocol 15 | 16 | init( 17 | mainQueue: AnySchedulerOf = .main, 18 | useCaseGroup: MovieSideEffectGroup, 19 | navigator: LinkNavigatorProtocol) 20 | { 21 | self.mainQueue = mainQueue 22 | self.useCaseGroup = useCaseGroup 23 | self.navigator = navigator 24 | } 25 | } 26 | 27 | // MARK: CrewEnvType 28 | 29 | extension CrewEnvLive: CrewEnvType { } 30 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/People/Crew/Env/CrewEnvMock.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Domain 3 | import Foundation 4 | 5 | // MARK: - CrewEnvMocck 6 | 7 | struct CrewEnvMocck { 8 | let mainQueue: AnySchedulerOf 9 | let useCaseGroup: MovieSideEffectGroup 10 | } 11 | 12 | // MARK: CrewEnvType 13 | 14 | extension CrewEnvMocck: CrewEnvType { } 15 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/People/Crew/Env/CrewEnvType.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import ComposableArchitecture 3 | import Domain 4 | import Foundation 5 | 6 | // MARK: - CrewEnvType 7 | 8 | protocol CrewEnvType { 9 | var mainQueue: AnySchedulerOf { get } 10 | var useCaseGroup: MovieSideEffectGroup { get } 11 | 12 | var movieCredit: (Int) 13 | -> Effect> { get } 14 | } 15 | 16 | extension CrewEnvType { 17 | public var movieCredit: (Int) 18 | -> Effect> 19 | { 20 | { id in 21 | .publisher { 22 | useCaseGroup 23 | .movieDetailUseCase 24 | .movieCredit(.init(id: id)) 25 | .mapToResult() 26 | .receive(on: mainQueue) 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/People/Crew/UIComponent/CrewPage+ItemListComponent.swift: -------------------------------------------------------------------------------- 1 | import Domain 2 | import Foundation 3 | import SwiftUI 4 | 5 | // MARK: - CrewPage.ItemListComponent 6 | 7 | extension CrewPage { 8 | struct ItemListComponent { 9 | let viewState: ViewState 10 | } 11 | } 12 | 13 | extension CrewPage.ItemListComponent { 14 | private var filterItemList: [ViewState.CrewItem] { 15 | viewState.itemList.reduce(into: [ViewState.CrewItem]()) { curr, next in 16 | if !curr.contains(where: { $0.id == next.id }) { 17 | curr.append(next) 18 | } 19 | } 20 | } 21 | } 22 | 23 | // MARK: - CrewPage.ItemListComponent + View 24 | 25 | extension CrewPage.ItemListComponent: View { 26 | var body: some View { 27 | ScrollView { 28 | LazyVStack(alignment: .leading) { 29 | ForEach(filterItemList) { item in 30 | ItemComponent(item: item) 31 | } 32 | .padding(.horizontal, 16) 33 | } 34 | .background(.white) 35 | } 36 | } 37 | } 38 | 39 | // MARK: - CrewPage.ItemListComponent.ViewState 40 | 41 | extension CrewPage.ItemListComponent { 42 | struct ViewState: Equatable { 43 | let itemList: [CrewItem] 44 | 45 | init(rawValue: [MovieDetailDomain.Response.CrewResultItem]) { 46 | itemList = rawValue.map(CrewItem.init(rawValue:)) 47 | } 48 | } 49 | } 50 | 51 | // MARK: - CrewPage.ItemListComponent.ViewState.CrewItem 52 | 53 | extension CrewPage.ItemListComponent.ViewState { 54 | struct CrewItem: Equatable, Identifiable { 55 | let id: Int // 각 cast의 id 56 | let name: String 57 | let department: String 58 | let rawValue: MovieDetailDomain.Response.CrewResultItem 59 | 60 | init(rawValue: MovieDetailDomain.Response.CrewResultItem) { 61 | id = rawValue.id 62 | name = rawValue.name 63 | department = rawValue.department 64 | self.rawValue = rawValue 65 | } 66 | } 67 | } 68 | 69 | // MARK: - CrewPage.ItemListComponent.ItemComponent 70 | 71 | extension CrewPage.ItemListComponent { 72 | fileprivate struct ItemComponent { 73 | let item: ViewState.CrewItem 74 | } 75 | } 76 | 77 | // MARK: - CrewPage.ItemListComponent.ItemComponent + View 78 | 79 | extension CrewPage.ItemListComponent.ItemComponent: View { 80 | var body: some View { 81 | HStack(spacing: 16) { 82 | Asset.spongeBob.swiftUIImage 83 | .resizable() 84 | .frame(width: 70, height: 90) 85 | .clipShape(RoundedRectangle(cornerRadius: 10)) 86 | .overlay( 87 | RoundedRectangle(cornerRadius: 10) 88 | .stroke(.black, lineWidth: 1)) 89 | .padding(.top) 90 | 91 | VStack(alignment: .leading, spacing: 8) { 92 | Text(item.name) 93 | .font(.headline) 94 | 95 | Text(item.department) 96 | .font(.subheadline) 97 | .foregroundColor(Color.gray) 98 | } 99 | 100 | Spacer() 101 | 102 | Image(systemName: "chevron.right") 103 | .resizable() 104 | .frame(width: 8, height: 12) 105 | .foregroundColor(Color(.gray)) 106 | } 107 | 108 | Divider() 109 | .padding(.leading, 64) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/RecommendedMovie/Env/RecommendedMovieEnvLive.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import ComposableArchitecture 3 | import Domain 4 | import Foundation 5 | import LinkNavigator 6 | import URLEncodedForm 7 | 8 | // MARK: - RecommendedMovieEnvLive 9 | 10 | struct RecommendedMovieEnvLive { 11 | 12 | let mainQueue: AnySchedulerOf 13 | let useCaseGroup: MovieSideEffectGroup 14 | let navigator: LinkNavigatorProtocol 15 | 16 | init( 17 | mainQueue: AnySchedulerOf = .main, 18 | useCaseGroup: MovieSideEffectGroup, 19 | navigator: LinkNavigatorProtocol) 20 | { 21 | self.mainQueue = mainQueue 22 | self.useCaseGroup = useCaseGroup 23 | self.navigator = navigator 24 | } 25 | } 26 | 27 | // MARK: RecommendedMovieEnvType 28 | 29 | extension RecommendedMovieEnvLive: RecommendedMovieEnvType { } 30 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/RecommendedMovie/Env/RecommendedMovieEnvMock.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Domain 3 | import Foundation 4 | 5 | // MARK: - RecommendedMovieEnvMock 6 | 7 | struct RecommendedMovieEnvMock { 8 | 9 | let mainQueue: AnySchedulerOf 10 | let useCaseGroup: MovieSideEffectGroup 11 | } 12 | 13 | // MARK: RecommendedMovieEnvType 14 | 15 | extension RecommendedMovieEnvMock: RecommendedMovieEnvType { } 16 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/RecommendedMovie/Env/RecommendedMovieEnvType.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import ComposableArchitecture 3 | import Domain 4 | import Foundation 5 | 6 | // MARK: - RecommendedMovieEnvType 7 | 8 | protocol RecommendedMovieEnvType { 9 | var mainQueue: AnySchedulerOf { get } 10 | var useCaseGroup: MovieSideEffectGroup { get } 11 | } 12 | 13 | extension RecommendedMovieEnvType { } 14 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/RecommendedMovie/RecommendedMoviePage.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import ComposableArchitecture 3 | import Foundation 4 | import SwiftUI 5 | 6 | // MARK: - RecommendedMoviePage 7 | 8 | struct RecommendedMoviePage { 9 | private let store: StoreOf 10 | @ObservedObject private var viewStore: ViewStoreOf 11 | 12 | init(store: StoreOf) { 13 | self.store = store 14 | viewStore = ViewStore(store, observe: { $0 }) 15 | } 16 | } 17 | 18 | extension RecommendedMoviePage { } 19 | 20 | // MARK: View 21 | 22 | extension RecommendedMoviePage: View { 23 | var body: some View { 24 | Text("Recommended movie page") 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/RecommendedMovie/RecommendedMovieRouteBuilder.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import Domain 3 | import LinkNavigator 4 | import URLEncodedForm 5 | 6 | struct RecommendedMovieRouteBuilder{ 7 | 8 | static func generate() -> RouteBuilderOf { 9 | let matchPath = Link.Movie.Path.recommendedMovie.rawValue 10 | 11 | return .init(matchPath: matchPath) { navigator, _, dependency -> RouteViewController? in 12 | guard 13 | let env: MovieSideEffectGroup = dependency.resolve() 14 | 15 | else { return .none } 16 | 17 | return WrappingController(matchPath: matchPath) { 18 | RecommendedMoviePage(store: .init( 19 | initialState: RecommendedMovieStore.State(), 20 | reducer: { 21 | RecommendedMovieStore(env: RecommendedMovieEnvLive( 22 | useCaseGroup: env, 23 | navigator: navigator)) 24 | })) 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/RecommendedMovie/RecommendedMovieStore.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import ComposableArchitecture 3 | import Domain 4 | import Foundation 5 | 6 | // MARK: - RecommendedMovieStore 7 | 8 | public struct RecommendedMovieStore { 9 | let pageID: String 10 | let env: RecommendedMovieEnvType 11 | 12 | init(pageID: String = UUID().uuidString, env: RecommendedMovieEnvType) { 13 | self.pageID = pageID 14 | self.env = env 15 | } 16 | } 17 | 18 | // MARK: RecommendedMovieStore.State 19 | 20 | extension RecommendedMovieStore { 21 | public struct State: Equatable { 22 | // let movieID: Int 23 | // 24 | // init(movieID: Int) { 25 | // self.movieId = movieID 26 | // } 27 | // 28 | } 29 | } 30 | 31 | extension RecommendedMovieStore.State { } 32 | 33 | // MARK: - RecommendedMovieStore.Action 34 | 35 | extension RecommendedMovieStore { 36 | public enum Action: BindableAction, Equatable { 37 | case binding(BindingAction) 38 | case teardown 39 | 40 | } 41 | } 42 | 43 | // MARK: - RecommendedMovieStore + Reducer 44 | 45 | extension RecommendedMovieStore: Reducer { 46 | public var body: some ReducerOf { 47 | BindingReducer() 48 | Reduce { _, action in 49 | switch action { 50 | case .binding: 51 | return .none 52 | 53 | case .teardown: 54 | return .none 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/SimilarMovie/Env/SimilarMovieEnvLive.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import ComposableArchitecture 3 | import Domain 4 | import Foundation 5 | import LinkNavigator 6 | import URLEncodedForm 7 | 8 | // MARK: - SimilarMovieEnvLive 9 | 10 | struct SimilarMovieEnvLive { 11 | 12 | let mainQueue: AnySchedulerOf 13 | let useCaseGroup: MovieSideEffectGroup 14 | let navigator: LinkNavigatorProtocol 15 | 16 | init( 17 | mainQueue: AnySchedulerOf = .main, 18 | useCaseGroup: MovieSideEffectGroup, 19 | navigator: LinkNavigatorProtocol) 20 | { 21 | self.mainQueue = mainQueue 22 | self.useCaseGroup = useCaseGroup 23 | self.navigator = navigator 24 | } 25 | } 26 | 27 | // MARK: SimilarMovieEnvType 28 | 29 | extension SimilarMovieEnvLive: SimilarMovieEnvType { } 30 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/SimilarMovie/Env/SimilarMovieEnvMock.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Domain 3 | import Foundation 4 | 5 | // MARK: - SimilarMovieEnvMock 6 | 7 | struct SimilarMovieEnvMock { 8 | 9 | let mainQueue: AnySchedulerOf 10 | let useCaseGroup: MovieSideEffectGroup 11 | } 12 | 13 | // MARK: SimilarMovieEnvType 14 | 15 | extension SimilarMovieEnvMock: SimilarMovieEnvType { } 16 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/SimilarMovie/Env/SimilarMovieEnvType.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import ComposableArchitecture 3 | import Domain 4 | import Foundation 5 | 6 | // MARK: - SimilarMovieEnvType 7 | 8 | protocol SimilarMovieEnvType { 9 | var mainQueue: AnySchedulerOf { get } 10 | var useCaseGroup: MovieSideEffectGroup { get } 11 | 12 | var similarMovie: (Int) 13 | -> Effect> { get } 14 | } 15 | 16 | extension SimilarMovieEnvType { 17 | public var similarMovie: (Int) 18 | -> Effect> 19 | { 20 | { id in 21 | .publisher { 22 | useCaseGroup 23 | .movieDetailUseCase 24 | .similarMovie(.init(id: id)) 25 | .mapToResult() 26 | .receive(on: mainQueue) 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/SimilarMovie/SimilarMoviePage.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import ComposableArchitecture 3 | import Foundation 4 | import SwiftUI 5 | 6 | // MARK: - SimilarMoviePage 7 | 8 | struct SimilarMoviePage { 9 | 10 | private let store: StoreOf 11 | @ObservedObject private var viewStore: ViewStoreOf 12 | 13 | init(store: StoreOf) { 14 | self.store = store 15 | viewStore = ViewStore(store, observe: { $0 }) 16 | } 17 | } 18 | 19 | extension SimilarMoviePage { 20 | private var itemListComponentViewState: ItemListComponent.ViewState { 21 | .init(rawValue: viewStore.fetchSimilarMovie.value.resultList) 22 | } 23 | } 24 | 25 | // MARK: View 26 | 27 | extension SimilarMoviePage: View { 28 | var body: some View { 29 | ScrollView { 30 | VStack { 31 | Text("similarMoviePage") 32 | ItemListComponent(viewState: itemListComponentViewState) 33 | } 34 | .padding(.leading, 12) 35 | } 36 | .onAppear { 37 | viewStore.send(.getSimilarMovie) 38 | } 39 | .onDisappear { 40 | viewStore.send(.teardown) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/SimilarMovie/SimilarMovieRouteBuilder.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import Domain 3 | import LinkNavigator 4 | import URLEncodedForm 5 | 6 | struct SimilarMovieRouteBuilder{ 7 | 8 | static func generate() -> RouteBuilderOf { 9 | let matchPath = Link.Movie.Path.similarMovie.rawValue 10 | 11 | return .init(matchPath: matchPath) { navigator, item, dependency -> RouteViewController? in 12 | guard 13 | let env: MovieSideEffectGroup = dependency.resolve(), 14 | let query: MovieDetailDomain.Response.SimilarMovieResult = item.decoded() 15 | else { return .none } 16 | 17 | return WrappingController(matchPath: matchPath) { 18 | SimilarMoviePage(store: .init( 19 | initialState: SimilarMovieStore.State(movieID: 565770), 20 | reducer: { 21 | SimilarMovieStore(env: SimilarMovieEnvLive( 22 | useCaseGroup: env, 23 | navigator: navigator)) 24 | })) 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/SimilarMovie/SimilarMovieStore.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import ComposableArchitecture 3 | import Domain 4 | import Foundation 5 | 6 | // MARK: - SimilarMovieStore 7 | 8 | public struct SimilarMovieStore { 9 | let pageID: String 10 | let env: SimilarMovieEnvType 11 | init(pageID: String = UUID().uuidString, env: SimilarMovieEnvType) { 12 | self.pageID = pageID 13 | self.env = env 14 | } 15 | } 16 | 17 | // MARK: SimilarMovieStore.State 18 | 19 | extension SimilarMovieStore { 20 | public struct State: Equatable { 21 | let movieID: Int 22 | 23 | init(movieID: Int) { 24 | self.movieID = movieID 25 | _fetchSimilarMovie = .init(.init(isLoading: false, value: .init())) 26 | } 27 | 28 | @Heap var fetchSimilarMovie: FetchState.Data 29 | } 30 | } 31 | 32 | // MARK: - MyListsStore.State.ListType 33 | 34 | extension SimilarMovieStore.State { } 35 | 36 | // MARK: - SimilarMovieStore.Action 37 | 38 | extension SimilarMovieStore { 39 | public enum Action: BindableAction, Equatable { 40 | case binding(BindingAction) 41 | case teardown 42 | 43 | case getSimilarMovie 44 | 45 | case fetchSimilarMovie(Result) 46 | 47 | case throwError(CompositeErrorDomain) 48 | } 49 | } 50 | 51 | // MARK: - SimilarMovieStore.CancelID 52 | 53 | extension SimilarMovieStore { 54 | enum CancelID: Equatable, CaseIterable { 55 | case teardown 56 | case requestSimilarMovie 57 | } 58 | } 59 | 60 | // MARK: - SimilarMovieStore + Reducer 61 | 62 | extension SimilarMovieStore: Reducer { 63 | public var body: some ReducerOf { 64 | BindingReducer() 65 | Reduce { state, action in 66 | switch action { 67 | case .binding: 68 | return .none 69 | 70 | case .teardown: 71 | return .concatenate( 72 | CancelID.allCases.map { .cancel(pageID: pageID, id: $0) }) 73 | 74 | case .getSimilarMovie: 75 | state.fetchSimilarMovie.isLoading = false 76 | return .concatenate( 77 | env.similarMovie(state.movieID) 78 | .map(Action.fetchSimilarMovie) 79 | .cancellable(pageID: pageID, id: CancelID.requestSimilarMovie, cancelInFlight: true)) 80 | 81 | case .fetchSimilarMovie(let result): 82 | state.fetchSimilarMovie.isLoading = false 83 | switch result { 84 | case .success(let content): 85 | state.fetchSimilarMovie.value = content 86 | return .none 87 | case .failure(let error): 88 | return .run { await $0(.throwError(error)) } 89 | } 90 | 91 | case .throwError(let error): 92 | print(error) 93 | return .none 94 | } 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Sources/Movie/Source/Feature/SimilarMovie/UIComponent/SimilarMovie+ItemListComponent.swift: -------------------------------------------------------------------------------- 1 | import Domain 2 | import Foundation 3 | import SwiftUI 4 | 5 | // MARK: - SimilarMoviePage.ItemListComponent 6 | 7 | extension SimilarMoviePage { 8 | struct ItemListComponent { 9 | let viewState: ViewState 10 | } 11 | } 12 | 13 | // MARK: - SimilarMoviePage.ItemListComponent + View 14 | 15 | extension SimilarMoviePage.ItemListComponent: View { 16 | var body: some View { 17 | ScrollView { 18 | LazyVStack { 19 | ForEach(viewState.itemList) { item in 20 | ItemComponent(item: item) 21 | .background(.white) 22 | } 23 | } 24 | } 25 | } 26 | } 27 | 28 | // MARK: - SimilarMoviePage.ItemListComponent.ViewState 29 | 30 | extension SimilarMoviePage.ItemListComponent { 31 | struct ViewState: Equatable { 32 | let itemList: [SimilarMovieItem] 33 | 34 | init(rawValue: [MovieDetailDomain.Response.SimilarMovieResultItem]) { 35 | itemList = rawValue.map(SimilarMovieItem.init(rawValue:)) 36 | } 37 | } 38 | } 39 | 40 | // MARK: - SimilarMoviePage.ItemListComponent.ViewState.SimilarMovieItem 41 | 42 | extension SimilarMoviePage.ItemListComponent.ViewState { 43 | struct SimilarMovieItem: Equatable, Identifiable { 44 | let id: Int 45 | let title: String 46 | let voteAverage: Double 47 | let releaseDate: String 48 | let overView: String 49 | let rawValue: MovieDetailDomain.Response.SimilarMovieResultItem 50 | 51 | init(rawValue: MovieDetailDomain.Response.SimilarMovieResultItem) { 52 | id = rawValue.id 53 | title = rawValue.title 54 | voteAverage = rawValue.voteAverage 55 | releaseDate = rawValue.releaseDate 56 | overView = rawValue.overview 57 | self.rawValue = rawValue 58 | } 59 | } 60 | } 61 | 62 | // MARK: - SimilarMoviePage.ItemListComponent.ItemComponent 63 | 64 | extension SimilarMoviePage.ItemListComponent { 65 | fileprivate struct ItemComponent { 66 | let item: ViewState.SimilarMovieItem 67 | } 68 | } 69 | 70 | // MARK: - SimilarMoviePage.ItemListComponent.ItemComponent + View 71 | 72 | extension SimilarMoviePage.ItemListComponent.ItemComponent: View { 73 | var body: some View { 74 | VStack { 75 | HStack(spacing: 16) { 76 | Asset.spongeBob.swiftUIImage 77 | .resizable() 78 | .frame(width: 100, height: 140) 79 | .clipShape(RoundedRectangle(cornerRadius: 10)) 80 | .overlay( 81 | RoundedRectangle(cornerRadius: 10) 82 | .stroke(.black, lineWidth: 1)) 83 | .shadow(radius: 10) 84 | 85 | VStack(alignment: .leading, spacing: 8) { 86 | Text(item.title) 87 | .font(.headline) 88 | .fontWeight(.regular) 89 | .foregroundColor(.customYellowColor) 90 | 91 | HStack { 92 | Circle() 93 | .trim(from: 0, to: item.voteAverage / 10) 94 | .stroke( 95 | style: StrokeStyle(lineWidth: 2, dash: [1, 1.5])) 96 | .rotationEffect(.degrees(-90)) 97 | .frame(width: 40, height: 40) 98 | .foregroundColor(Color.lineColor(item.voteAverage)) 99 | .shadow(color: Color.lineColor(item.voteAverage), radius: 5, x: 0, y: 0) 100 | .overlay( 101 | Text("\(Int(item.voteAverage * 10))%") 102 | .font(.system(size: 10))) 103 | 104 | Text(item.releaseDate.formatDate()) 105 | .font(.subheadline) 106 | } 107 | 108 | Text(item.overView) 109 | .font(.callout) 110 | .foregroundColor(Color.gray) 111 | .multilineTextAlignment(.leading) 112 | .lineLimit(3) 113 | } 114 | 115 | Spacer() 116 | 117 | Image(systemName: "chevron.right") 118 | .resizable() 119 | .frame(width: 8, height: 12) 120 | .foregroundColor(Color(.gray)) 121 | .padding(.trailing, 16) 122 | } // Hstack 123 | .padding(.vertical, 8) 124 | 125 | Divider() 126 | .padding(.leading, 144) 127 | } 128 | } 129 | } 130 | 131 | extension String { 132 | fileprivate func formatDate() -> Self { 133 | let dateFormatter = DateFormatter() 134 | dateFormatter.dateFormat = "yyyy-MM-dd" 135 | 136 | if let date = dateFormatter.date(from: self) { 137 | dateFormatter.dateFormat = "M/d/yy" 138 | return dateFormatter.string(from: date) 139 | } else { 140 | return "날짜 형식 오류" 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /Project/Feature/Movie/Tests/MovieTests/MovieTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Movie 3 | 4 | final class MovieTests: XCTestCase { 5 | func testExample() throws { 6 | // XCTest Documenation 7 | // https://developer.apple.com/documentation/xctest 8 | 9 | // Defining Test Cases and Test Methods 10 | // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Project/Previews/MoviePreviews/Project.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | import ProjectDescriptionHelpers 3 | 4 | let project: Project = .previewProject( 5 | projectName: "Movie", 6 | packages: [ 7 | .local(path: "../../Feature/Movie"), 8 | ], 9 | dependencies: [ 10 | .package(product: "Movie"), 11 | ]) 12 | -------------------------------------------------------------------------------- /Project/Previews/MoviePreviews/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Project/Previews/MoviePreviews/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Project/Previews/MoviePreviews/Resources/dummy.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /Project/Previews/MoviePreviews/Sources/AppContainer.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import Domain 3 | import LinkNavigator 4 | import Movie 5 | import Platform 6 | 7 | // MARK: - AppContainer 8 | 9 | final class AppContainer { 10 | 11 | // MARK: Lifecycle 12 | 13 | init( 14 | dependency: AppSideEffect, 15 | navigator: SingleLinkNavigator) 16 | { 17 | self.dependency = dependency 18 | self.navigator = navigator 19 | } 20 | 21 | // MARK: Internal 22 | 23 | let dependency: AppSideEffect 24 | let navigator: SingleLinkNavigator 25 | } 26 | 27 | extension AppContainer { 28 | class func build() -> AppContainer { 29 | let configuration = Self.configurationLive 30 | let dependency = AppSideEffect( 31 | configurationDomain: configuration, 32 | movieUseCase: MovieUseCasePlatform( 33 | configurationDomain: configuration), 34 | searchUseCase: SearchUseCasePlatformMock( 35 | configurationDomain: configuration), 36 | movieDetailUseCase: MovieDetailUseCasePlatform( 37 | configurationDomain: configuration)) 38 | return .init( 39 | dependency: dependency, 40 | navigator: .init( 41 | routeBuilderItemList: MovieRouteBuilderGroup.release, 42 | dependency: dependency)) 43 | } 44 | } 45 | 46 | extension AppContainer { 47 | private class var configurationLive: ConfigurationDomain { 48 | .init( 49 | entity: .init( 50 | baseURL: .init( 51 | apiURL: "https://api.themoviedb.org/3", 52 | apiToken: "1d9b898a212ea52e283351e521e17871", 53 | imageURL: "https://image.tmdb.org/t/p"))) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Project/Previews/MoviePreviews/Sources/AppMain.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import LinkNavigator 3 | import SwiftUI 4 | 5 | @main 6 | struct AppMain: App { 7 | 8 | @StateObject var viewModel = AppMainViewModel() 9 | 10 | var body: some Scene { 11 | WindowGroup { 12 | LinkNavigationView( 13 | linkNavigator: viewModel.linkNavigator, 14 | item: .init(path: Link.Movie.Path.home.rawValue, items: "")) 15 | 16 | .ignoresSafeArea() 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Project/Previews/MoviePreviews/Sources/AppMainViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import LinkNavigator 3 | 4 | final class AppMainViewModel: ObservableObject { 5 | 6 | // MARK: Lifecycle 7 | 8 | init() { } 9 | 10 | // MARK: Internal 11 | 12 | let appContainer = AppContainer.build() 13 | 14 | var linkNavigator: SingleLinkNavigator { 15 | appContainer.navigator 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Project/Previews/MoviePreviews/Sources/AppSideEffect.swift: -------------------------------------------------------------------------------- 1 | import Architecture 2 | import Domain 3 | import LinkNavigator 4 | import Movie 5 | 6 | // MARK: - AppSideEffect 7 | 8 | struct AppSideEffect { 9 | init( 10 | configurationDomain: ConfigurationDomain, 11 | movieUseCase: MovieUseCase, 12 | searchUseCase: SearchUseCase, 13 | movieDetailUseCase: MovieDetailUseCase) 14 | { 15 | self.configurationDomain = configurationDomain 16 | self.movieUseCase = movieUseCase 17 | self.searchUseCase = searchUseCase 18 | self.movieDetailUseCase = movieDetailUseCase 19 | } 20 | 21 | let configurationDomain: ConfigurationDomain 22 | let movieUseCase: MovieUseCase 23 | let searchUseCase: SearchUseCase 24 | let movieDetailUseCase: MovieDetailUseCase 25 | } 26 | 27 | // MARK: MovieSideEffectGroup 28 | 29 | extension AppSideEffect: MovieSideEffectGroup { } 30 | -------------------------------------------------------------------------------- /Project/Previews/MoviePreviews/Tests/AuthencationPreviewTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import AuthencationPreview 3 | 4 | final class AuthencationPreviewTests: XCTestCase { 5 | func testExamle() throws { 6 | XCTAssertEqual(echo(), "Hello, World!!") 7 | } 8 | 9 | func echo() -> String { 10 | "Hello, World!!" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Project/ThirdParty/CombineNetwork/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /Project/ThirdParty/CombineNetwork/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "urlencodedform", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/interactord/URLEncodedForm", 7 | "state" : { 8 | "revision" : "643c3e3752464b8f5bd44b79a5d1216b632959bb", 9 | "version" : "1.0.0" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /Project/ThirdParty/CombineNetwork/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.8 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "CombineNetwork", 8 | platforms: [.iOS(.v15)], 9 | products: [ 10 | .library( 11 | name: "CombineNetwork", 12 | targets: ["CombineNetwork"]), 13 | ], 14 | dependencies: [ 15 | .package(url: "https://github.com/interactord/URLEncodedForm", .upToNextMajor(from: "1.0.0")), 16 | ], 17 | targets: [ 18 | .target( 19 | name: "CombineNetwork", 20 | dependencies: [ 21 | "URLEncodedForm", 22 | ]), 23 | .testTarget( 24 | name: "CombineNetworkTests", 25 | dependencies: ["CombineNetwork"]), 26 | ]) 27 | -------------------------------------------------------------------------------- /Project/ThirdParty/CombineNetwork/README.md: -------------------------------------------------------------------------------- 1 | # CombineNetwork 2 | 3 | A description of this package. 4 | -------------------------------------------------------------------------------- /Project/ThirdParty/CombineNetwork/Sources/CombineNetwork/CombineNetwork.swift: -------------------------------------------------------------------------------- 1 | public struct CombineNetwork { 2 | public private(set) var text = "Hello, World!" 3 | 4 | public init() { } 5 | } 6 | -------------------------------------------------------------------------------- /Project/ThirdParty/CombineNetwork/Sources/CombineNetwork/Core/Component/HTTPMethod.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum HTTPMethod: String { 4 | case get = "GET" 5 | case post = "POST" 6 | case put = "PUT" 7 | case patch = "PATCH" 8 | case delete = "DELETE" 9 | } 10 | -------------------------------------------------------------------------------- /Project/ThirdParty/CombineNetwork/Sources/CombineNetwork/Core/Component/NetworkError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - NetworkError 4 | 5 | public struct NetworkError: Error { 6 | let data: Data? 7 | let statusCode: Int? 8 | let error: Error? 9 | let debugDescription: String? 10 | 11 | init( 12 | data: Data?, 13 | statusCode: Int?, 14 | error: Error?, 15 | debugDescription: String?) 16 | { 17 | self.data = data 18 | self.statusCode = statusCode 19 | self.error = error 20 | self.debugDescription = debugDescription 21 | } 22 | 23 | } 24 | 25 | extension NetworkError { 26 | static func other(error: Error) -> Self { 27 | .init(data: .none, statusCode: .none, error: error, debugDescription: .none) 28 | } 29 | 30 | static func flat(error: Error) -> NetworkError { 31 | if let err = error as? Self { return err } 32 | return .other(error: error) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Project/ThirdParty/CombineNetwork/Sources/CombineNetwork/Core/Component/RequestData.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import URLEncodedForm 3 | 4 | // MARK: - RequestData 5 | 6 | public enum RequestData { 7 | case query(Encodable) 8 | case json(Encodable) 9 | // case binaryData(Data) 10 | // case form([String: Any]) 11 | } 12 | 13 | extension RequestData { 14 | var rawData: Data? { 15 | switch self { 16 | case .query(let item): 17 | return try? URLEncodedFormEncoder().encode(item) 18 | case .json(let item): 19 | return try? JSONEncoder().encode(item) 20 | // case .binaryData(<#T##Data#>) 21 | } 22 | } 23 | } 24 | 25 | // MARK: Equatable 26 | 27 | extension RequestData: Equatable { 28 | 29 | // MARK: Public 30 | 31 | public static func == (lhs: RequestData, rhs: RequestData) -> Bool { 32 | lhs.id == rhs.id 33 | } 34 | 35 | // MARK: Private 36 | 37 | private var id: String { 38 | switch self { 39 | case .query(let data): return "\(data)" 40 | case .json(let data): return "\(data)" 41 | // case .binaryData(let data): return "\(data)" 42 | // case .form(let itemList): return "(\(itemList))" 43 | } 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /Project/ThirdParty/CombineNetwork/Sources/CombineNetwork/Core/Endpoint/Endpoint.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import URLEncodedForm 3 | 4 | // MARK: - Endpoint 5 | 6 | public struct Endpoint: Equatable { 7 | 8 | let baseURL: String 9 | let path: String 10 | let query: [String: String] 11 | let httpMethod: HTTPMethod 12 | let header: [String: String] 13 | let requestData: RequestData 14 | 15 | } 16 | 17 | extension Endpoint { 18 | func makeRequest() -> URLRequest? { 19 | guard let url = makeURL() else { return .none } 20 | 21 | var request = URLRequest(url: url) 22 | request.httpMethod = httpMethod.rawValue 23 | 24 | for headerItem in header { 25 | request.setValue(headerItem.value, forHTTPHeaderField: headerItem.key) 26 | } 27 | 28 | request.httpBody = requestData.rawData 29 | return request 30 | } 31 | } 32 | 33 | extension Endpoint { 34 | private func makeURL() -> URL? { 35 | guard 36 | let url = URL(string: [baseURL, path].joined(separator: "/")), 37 | var urlComponent = URLComponents(url: url, resolvingAgainstBaseURL: false) 38 | else { return .none } 39 | 40 | urlComponent.queryItems = query.map { 41 | .init(name: $0.key, value: $0.value) 42 | } 43 | return urlComponent.url 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Project/ThirdParty/CombineNetwork/Sources/CombineNetwork/Core/Network/Network.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | // MARK: - Network 5 | 6 | public struct Network { 7 | let endpoint: Endpoint 8 | let session: URLSession 9 | 10 | public init( 11 | session: URLSession = .shared, 12 | endpoint: Endpoint) 13 | { 14 | self.endpoint = endpoint 15 | self.session = session 16 | } 17 | } 18 | 19 | extension Network { 20 | 21 | func fetch() -> AnyPublisher { 22 | guard let request = endpoint.makeRequest() else { 23 | return Fail(error: NetworkError(data: .none, statusCode: .none, error: .none, debugDescription: "invalidURLRequest")) 24 | .eraseToAnyPublisher() 25 | } 26 | 27 | return session 28 | .dataTaskPublisher(for: request) 29 | .tryMap { data, response -> Data in 30 | guard let res = response as? HTTPURLResponse else { 31 | throw NetworkError( 32 | data: data, 33 | statusCode: .none, 34 | error: .none, 35 | debugDescription: "invalid type casting response as? HTTPURLResponse") 36 | } 37 | guard (200...299).contains(res.statusCode) else { 38 | throw NetworkError(data: data, statusCode: res.statusCode, error: .none, debugDescription: "invalide response") 39 | } 40 | 41 | return data 42 | } 43 | .mapError { NetworkError.flat(error: $0) } 44 | .eraseToAnyPublisher() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Project/ThirdParty/CombineNetwork/Tests/CombineNetworkTests/CombineNetworkTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import CombineNetwork 3 | 4 | final class CombineNetworkTests: XCTestCase { 5 | func testExample() throws { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | XCTAssertEqual(CombineNetwork().text, "Hello, World!") 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Boost 2 | -------------------------------------------------------------------------------- /Targets/Boost/Resources/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Targets/Boost/Sources/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import BoostKit 2 | import BoostUI 3 | import UIKit 4 | 5 | @main 6 | class AppDelegate: UIResponder, UIApplicationDelegate { 7 | 8 | var window: UIWindow? 9 | 10 | func application( 11 | _: UIApplication, 12 | didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) 13 | -> Bool 14 | { 15 | window = UIWindow(frame: UIScreen.main.bounds) 16 | let viewController = UIViewController() 17 | viewController.view.backgroundColor = .white 18 | window?.rootViewController = viewController 19 | window?.makeKeyAndVisible() 20 | 21 | BoostKit.hello() 22 | BoostUI.hello() 23 | 24 | return true 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /Targets/Boost/Tests/AppTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | 4 | final class BoostTests: XCTestCase { 5 | func test_twoPlusTwo_isFour() { 6 | XCTAssertEqual(2 + 2, 4) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Targets/BoostKit/Sources/BoostKit.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum BoostKit { 4 | public static func hello() { 5 | print("Hello, from your Kit framework") 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Targets/BoostKit/Tests/BoostKitTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | 4 | final class BoostKitTests: XCTestCase { 5 | func test_example() { 6 | XCTAssertEqual("BoostKit", "BoostKit") 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Targets/BoostUI/Sources/BoostUI.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum BoostUI { 4 | public static func hello() { 5 | print("Hello, from your UI framework") 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Targets/BoostUI/Tests/BoostUITests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | 4 | final class BoostUITests: XCTestCase { 5 | func test_example() { 6 | XCTAssertEqual("BoostUI", "BoostUI") 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Tuist/Config.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | let config = Config( 4 | plugins: [ 5 | .local(path: .relativeToManifest("../../Plugins/Boost")), 6 | ]) 7 | -------------------------------------------------------------------------------- /Tuist/ProjectDescriptionHelpers/Project+Extension.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | extension DeploymentTarget { 4 | public static let defaultTarget: DeploymentTarget = .iOS(targetVersion: "16.0", devices: [.iphone, .ipad]) 5 | } 6 | 7 | extension String { 8 | public static let marketVersion = "MARKETING_VERSION" 9 | public static let codeSignIdentity = "CODE_SIGN_IDENTITY" 10 | public static let codeSigningStyle = "CODE_SIGNING_STYLE" 11 | public static let codeSigningRequired = "CODE_SIGNING_REQUIRED" 12 | public static let developmentTeam = "DEVELOPMENT_TEAM" 13 | public static let provisioningProfileSpecifier = "PROVISIONING_PROFILE_SPECIFIER" 14 | public static let swiftVersion = "SWIFT_VERSION" 15 | public static let developmentAssetPaths = "DEVELOPMENT_ASSET_PATHS" 16 | public static let enableTestability = "ENABLE_TESTABILITY" 17 | } 18 | -------------------------------------------------------------------------------- /Tuist/ProjectDescriptionHelpers/Project+Templates.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ProjectDescription 3 | 4 | extension Target { 5 | static func previewTarget( 6 | projectName: String, 7 | dependencies: [TargetDependency]) 8 | -> Self 9 | { 10 | .init( 11 | name: "\(projectName)Preview", 12 | platform: .iOS, 13 | product: .app, 14 | bundleId: "com.myCompany.\(projectName.lowercased()).preview", 15 | deploymentTarget: .defaultTarget, 16 | infoPlist: .extendingDefault(with: infoValue), 17 | sources: ["Sources/**"], 18 | resources: ["Resources/**"], 19 | entitlements: .relativeToRoot("Entitlements/Preview.entitlements"), 20 | dependencies: dependencies, 21 | settings: .defaultConfigSettings) 22 | } 23 | 24 | static func previewTestTarget(projectName: String) -> Self { 25 | .init( 26 | name: "\(projectName)PreviewTests", 27 | platform: .iOS, 28 | product: .unitTests, 29 | bundleId: "com.myCompany.\(projectName.lowercased()).preview.tests", 30 | deploymentTarget: .defaultTarget, 31 | sources: ["Tests/**"], 32 | dependencies: [ 33 | .target(name: "\(projectName)Preview"), 34 | ], 35 | settings: .defaultConfigSettings) 36 | } 37 | } 38 | 39 | extension Collection { 40 | 41 | public static func testScheme(previewTestTarget: String) -> [Scheme] { 42 | [ 43 | .init( 44 | name: "\(previewTestTarget)Preview", 45 | shared: true, 46 | hidden: false, 47 | buildAction: .init(targets: ["\(previewTestTarget)Preview"]), 48 | testAction: .targets(["\(previewTestTarget)PreviewTests"])), 49 | ] 50 | } 51 | } 52 | 53 | extension Settings { 54 | public static let defaultConfigSettings: Settings = .settings( 55 | base: [ 56 | "CODE_SIGN_IDENTITY": "iPhone Developer", 57 | "CODE_SIGN_STYLE": "Automatic", 58 | "DEVELOPMENT_TEAM": "DEVELOPMENT_TEAM", 59 | ], configurations: [], defaultSettings: .recommended) 60 | } 61 | 62 | extension Project { 63 | 64 | public static func previewProject( 65 | projectName: String, 66 | packages: [Package], 67 | dependencies: [TargetDependency]) 68 | -> Self 69 | { 70 | .init( 71 | name: "\(projectName)Preview", 72 | organizationName: "myCompany", 73 | packages: packages, 74 | targets: [ 75 | .previewTarget(projectName: projectName, dependencies: dependencies), 76 | .previewTestTarget(projectName: projectName), 77 | ], 78 | schemes: .testScheme(previewTestTarget: projectName)) 79 | } 80 | } 81 | 82 | public var infoValue: [String: InfoPlist.Value] { 83 | defaultInfoValue 84 | .merging(customPropertyInfoValue) { $1 } 85 | } 86 | 87 | var defaultInfoValue: [String: InfoPlist.Value] { 88 | [ 89 | "CFBundleDevelopmentRegion": .string("$(DEVELOPMENT_LANGUAGE)"), 90 | "CFBundleDisplayName": .string("${PRODUCT_NAME}"), 91 | "CFBundleShortVersionString": .string(.appVersion()), 92 | "CFBundleVersion": .string(.appBuildVersion()), 93 | "LSHasLocalizedDisplayName": .boolean(true), 94 | "UIApplicationSupportsMultipleScenes": .boolean(false), 95 | "UISupportedInterfaceOrientations": .array([ 96 | .string("UIInterfaceOrientationPortrait"), 97 | ]), 98 | "LSRequiresIPhoneOS": .boolean(true), 99 | "UIApplicationSceneManifest": .dictionary([ 100 | "UIApplicationSupportsMultipleScenes": .boolean(true), 101 | ]), 102 | "UIApplicationSupportsIndirectInputEvents": .boolean(true), 103 | "UILaunchScreen": .dictionary([:]), 104 | "UISceneConfigurations": .dictionary([ 105 | "UIApplicationSupportsMultipleScenes": .boolean(false), 106 | "UISceneConfigurations": .dictionary([ 107 | "UIWindowSceneSessionRoleApplication": .array([.dictionary([ 108 | "UISceneDelegateClassName": "$(PRODUCT_MODULE_NAME).SceneDelegate", 109 | ])]), 110 | ]), 111 | ]), 112 | "ITSAppUsesNonExemptEncryption": .boolean(false), 113 | "NSAppTransportSecurity": .dictionary([ 114 | "NSAllowsArbitraryLoads": .boolean(true), 115 | ]), 116 | ] 117 | } 118 | 119 | var customPropertyInfoValue: [String: InfoPlist.Value] { 120 | [ 121 | "Mode": .string("$(MODE)"), 122 | ] 123 | } 124 | 125 | extension String { 126 | 127 | public static func appVersion() -> String { 128 | let formatter = DateFormatter() 129 | formatter.dateFormat = "yy.MM.dd" 130 | formatter.locale = Locale(identifier: "ko_KR") 131 | return formatter.string(from: Date()) 132 | } 133 | 134 | public static func appBuildVersion() -> String { 135 | let formatter = DateFormatter() 136 | formatter.dateFormat = "yyyyMMddHHmmsss" 137 | formatter.locale = Locale(identifier: "ko_KR") 138 | return formatter.string(from: Date()) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /Workspace.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | let workspace = Workspace( 4 | name: "MyApp", 5 | projects: [ 6 | "Project/**", 7 | ]) 8 | -------------------------------------------------------------------------------- /swiftgen.yml: -------------------------------------------------------------------------------- 1 | xcassets: 2 | - inputs: >- 3 | Project/Feature/Demo/Sources/Demo/Resource/Assets.xcassets 4 | outputs: 5 | - templateName: swift5 6 | params: 7 | forceProvidesNamespaces: true 8 | output: >- 9 | Project/Feature/Demo/Sources/Demo/Resource/XCAssets+Generated.swift 10 | - inputs: >- 11 | Project/Feature/Movie/Sources/Movie/Resource/Assets.xcassets 12 | outputs: 13 | - templateName: swift5 14 | params: 15 | forceProvidesNamespaces: true 16 | output: >- 17 | Project/Feature/Movie/Sources/Movie/Resource/XCAssets+Generated.swift 18 | - inputs: >- 19 | Project/Core/DesignSystem/Sources/DesignSystem/Resource/Assets.xcassets 20 | outputs: 21 | - templateName: swift5 22 | params: 23 | forceProvidesNamespaces: true 24 | output: >- 25 | Project/Core/DesignSystem/Sources/DesignSystem/Resource/XCAssets+Generated.swift 26 | files: 27 | - inputs: >- 28 | Project/Core/Platform/Sources/Platform/Resource/Mock 29 | filter: .+\.json$ 30 | outputs: 31 | - templateName: structured-swift5 32 | params: 33 | forceProvidesNamespaces: true 34 | output: >- 35 | Project/Core/Platform/Sources/Platform/Resource/Mock/JSON+Generated.swift 36 | --------------------------------------------------------------------------------