├── .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 |
--------------------------------------------------------------------------------