(
66 | presenter: presenter,
67 | rankingType: rankingType,
68 | navigationTitle: navigationTitle,
69 | detailDestination: detailDestination
70 | )
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/Modules/Anime/Sources/Anime/Data/Remote/GetAnimeRankingRemoteDataSource.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetAnimeRankingRemoteDataSource.swift
3 | //
4 | //
5 | // Created by Bryan on 06/01/23.
6 | //
7 |
8 | import Alamofire
9 | import Core
10 | import Combine
11 | import Foundation
12 |
13 | public struct GetAnimeRankingRemoteDataSource: DataSource {
14 |
15 | public typealias Request = AnimeRankingRequest
16 | public typealias Response = [AnimeDataResponse]
17 |
18 | private let _endpoint: String
19 | private let _encoder: ParameterEncoder
20 | private let _headers: HTTPHeaders
21 |
22 | public init(
23 | endpoint: String,
24 | encoder: ParameterEncoder,
25 | headers: HTTPHeaders
26 | ) {
27 | _endpoint = endpoint
28 | _encoder = encoder
29 | _headers = headers
30 | }
31 |
32 | public func execute(request: AnimeRankingRequest?) -> AnyPublisher<[AnimeDataResponse], Error> {
33 | return Future<[AnimeDataResponse], Error> { completion in
34 | guard let request = request else {
35 | return completion(.failure(URLError.invalidRequest))
36 | }
37 |
38 | let remoteRequest = AnimeRankingRemoteRequest(
39 | type: request.rankingType.name,
40 | limit: request.limit,
41 | offset: request.offset,
42 | fields: request.fields,
43 | nsfw: request.nsfw
44 | )
45 |
46 | if let url = URL(string: _endpoint) {
47 | AF.request(
48 | url,
49 | parameters: remoteRequest,
50 | encoder: _encoder,
51 | headers: _headers
52 | )
53 | .validate()
54 | .responseDecodable(of: AnimesResponse.self) { response in
55 | switch response.result {
56 | case .success(let value):
57 | completion(.success(value.animes))
58 | case .failure(let error):
59 | if let error = error.underlyingError as? Foundation.URLError, error.code == .notConnectedToInternet {
60 | // No internet connection
61 | completion(.failure(URLError.notConnectedToInternet))
62 | } else {
63 | completion(.failure(URLError.invalidResponse))
64 | }
65 | }
66 | }
67 | }
68 | }.eraseToAnyPublisher()
69 | }
70 |
71 | }
72 |
--------------------------------------------------------------------------------
/Modules/Common/Sources/Common/Supporting Files/Assets.xcassets/UsersOutlinedIcon.imageset/users.svg:
--------------------------------------------------------------------------------
1 |
14 |
--------------------------------------------------------------------------------
/Modules/Profile/Sources/Profile/Presentation/View/ProfileView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ProfileView.swift
3 | //
4 | //
5 | // Created by Bryan on 08/01/23.
6 | //
7 |
8 | import Common
9 | import Core
10 | import SwiftUI
11 |
12 | public struct ProfileView: View {
13 | @State var scrollOffset: CGFloat
14 |
15 | public init(scrollOffset: CGFloat = CGFloat.zero) {
16 | self.scrollOffset = scrollOffset
17 | }
18 |
19 | public var body: some View {
20 | ZStack(alignment: .top) {
21 | ObservableScrollView(scrollOffset: $scrollOffset, showsIndicators: false) { _ in
22 | VStack(spacing: Space.large) {
23 | profile
24 | }
25 | .padding(
26 | EdgeInsets(
27 | top: 0,
28 | leading: Space.none,
29 | bottom: Space.medium,
30 | trailing: Space.none)
31 | )
32 | }.background(YumeColor.background)
33 |
34 | AppBar(scrollOffset: scrollOffset, label: "profile_title".localized(bundle: .common))
35 | }
36 | }
37 | }
38 |
39 | extension ProfileView {
40 | var profile: some View {
41 | VStack(spacing: Space.none) {
42 | profileBackground
43 | HStack {
44 | VStack(alignment: .leading, spacing: Space.small) {
45 | profilePicture
46 |
47 | VStack(alignment: .leading, spacing: Space.tiny) {
48 | Text("Bryan")
49 | .typography(.title2(weight: .bold))
50 | Text(verbatim: "bryan001@student.ciputra.ac.id")
51 | .typography(.caption(color: YumeColor.onSurfaceVariant))
52 | } }
53 | Spacer()
54 | }
55 | .padding(.horizontal, 16)
56 | }
57 | }
58 |
59 | var profileBackground: some View {
60 | Rectangle()
61 | .fill(Formatter.rgbToColor(red: 201, green: 203, blue: 202))
62 | .frame(height: 100)
63 | }
64 |
65 | var profilePicture: some View {
66 | CircleImage(image: Image("ProfilePicture", bundle: .module))
67 | .frame(width: 80, height: 80)
68 | .overlay {
69 | Circle().stroke(.white, lineWidth: 2)
70 | }
71 | .offset(y: -40)
72 | .padding(.bottom, -40)
73 | }
74 | }
75 |
76 | struct ProfileView_Previews: PreviewProvider {
77 | static var previews: some View {
78 | ProfileView()
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | # Yume
4 | Discover anime anytime, anywhere
5 |
6 | Get to know various information about anime such as season, score, rank, adaptation source, synopsis, and more. Find any anime by title using either English or Rōmaji. Create your own favorite list for easy access.
7 |
8 | Yume is available in English and Bahasa Indonesia.
9 | > Support for Bahasa Indonesia is limited to some features and can only be used by changing device's system language
10 |
11 | ## Features
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | ### Home
21 | - Top airing anime
22 | - Top upcoming anime
23 | - Most popular anime
24 | - Top rated anime
25 |
26 | ### Search
27 | - Top favorite anime
28 | - Search anime by title using either English or Rōmaji
29 |
30 | ### Favorite
31 | - List of anime added to favorite
32 |
33 | ### Profile
34 |
35 | ## Dependency Diagram
36 | 
37 |
38 | ## Getting Started
39 | ### Prerequisites
40 | #### OS & Software
41 | > Requirements might be lower, the app is developed using the system listed below
42 | * macOS Ventura 13.1
43 | * Xcode 14.2
44 | * iOS 16.2
45 |
46 | #### App
47 | * Client ID (API token) from [MyAnimeList](https://myanimelist.net/apiconfig)
48 |
49 | ### Installation
50 | 1. Download the repository
51 | 2. Open the project by using Xcode
52 | 3. Build the project and a `Keys.plist` file should be created automatically at `Yume/Supporting Files/`
53 | > If it isn't created automatically, copy `Keys-Example.plist` at `Yume/Supporting Files/` and paste it as `Keys.plist` at `Yume/Supporting Files/`
54 | 4. Replace the value of key `API_KEY` with your Client ID (API token) at Keys.plist
55 |
56 | ## License
57 | This project is licensed under the MIT License - see the [LICENSE.md](https://github.com/bryanless/Yume-Swift/blob/main/LICENSE) file for details
58 |
59 | ## Acknowledgments
60 | * [MyAnimeList API](https://myanimelist.net/apiconfig/references/api/v2) by [MyAnimeList](https://myanimelist.net)
61 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | ## Custom
6 | Keys.plist
7 |
8 | ## User settings
9 | xcuserdata/
10 |
11 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
12 | *.xcscmblueprint
13 | *.xccheckout
14 |
15 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
16 | build/
17 | DerivedData/
18 | *.moved-aside
19 | *.pbxuser
20 | !default.pbxuser
21 | *.mode1v3
22 | !default.mode1v3
23 | *.mode2v3
24 | !default.mode2v3
25 | *.perspectivev3
26 | !default.perspectivev3
27 |
28 | ## Obj-C/Swift specific
29 | *.hmap
30 |
31 | ## App packaging
32 | *.ipa
33 | *.dSYM.zip
34 | *.dSYM
35 |
36 | ## Playgrounds
37 | timeline.xctimeline
38 | playground.xcworkspace
39 |
40 | # Swift Package Manager
41 | #
42 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
43 | # Packages/
44 | # Package.pins
45 | # Package.resolved
46 | # *.xcodeproj
47 | #
48 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
49 | # hence it is not needed unless you have added a package configuration file to your project
50 | # .swiftpm
51 |
52 | .build/
53 |
54 | # CocoaPods
55 | #
56 | # We recommend against adding the Pods directory to your .gitignore. However
57 | # you should judge for yourself, the pros and cons are mentioned at:
58 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
59 | #
60 | # Pods/
61 | #
62 | # Add this line if you want to avoid checking in source code from the Xcode workspace
63 | # *.xcworkspace
64 |
65 | # Carthage
66 | #
67 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
68 | # Carthage/Checkouts
69 |
70 | Carthage/Build/
71 |
72 | # Accio dependency management
73 | Dependencies/
74 | .accio/
75 |
76 | # fastlane
77 | #
78 | # It is recommended to not store the screenshots in the git repo.
79 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
80 | # For more information about the recommended setup visit:
81 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
82 |
83 | fastlane/report.xml
84 | fastlane/Preview.html
85 | fastlane/screenshots/**/*.png
86 | fastlane/test_output
87 |
88 | # Code Injection
89 | #
90 | # After new code Injection tools there's a generated folder /iOSInjectionProject
91 | # https://github.com/johnno1962/injectionforxcode
92 |
93 | iOSInjectionProject/
94 |
--------------------------------------------------------------------------------
/Modules/Anime/Sources/Anime/Presentation/AnimePresenter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AnimePresenter.swift
3 | //
4 | //
5 | // Created by Bryan on 07/01/23.
6 | //
7 |
8 | import Combine
9 | import Core
10 | import Foundation
11 |
12 | public class AnimePresenter<
13 | AnimeUseCase: UseCase,
14 | FavoriteUseCase: UseCase>: ObservableObject
15 | where AnimeUseCase.Request == AnimeRequest,
16 | AnimeUseCase.Response == AnimeDomainModel,
17 | FavoriteUseCase.Request == Int,
18 | FavoriteUseCase.Response == AnimeDomainModel {
19 |
20 | private var cancellables: Set = []
21 |
22 | private let _animeUseCase: AnimeUseCase
23 | private let _favoriteUseCase: FavoriteUseCase
24 |
25 | @Published public var item: AnimeDomainModel?
26 | @Published public var errorMessage: String = ""
27 | @Published public var isLoading: Bool = false
28 | @Published public var isError: Bool = false
29 |
30 | public init(
31 | animeUseCase: AnimeUseCase,
32 | favoriteUseCase: FavoriteUseCase
33 | ) {
34 | _animeUseCase = animeUseCase
35 | _favoriteUseCase = favoriteUseCase
36 | }
37 |
38 | public func getAnime(request: AnimeUseCase.Request) {
39 | isLoading = true
40 | _animeUseCase.execute(request: request)
41 | .receive(on: RunLoop.main)
42 | .sink(receiveCompletion: { completion in
43 | switch completion {
44 | case .failure(let error):
45 | self.errorMessage = error.localizedDescription
46 | self.isError = true
47 | self.isLoading = false
48 | case .finished:
49 | self.isLoading = false
50 | }
51 | }, receiveValue: { item in
52 | self.isError = false
53 |
54 | var anime = item
55 |
56 | anime.startDate = item.startDate.apiFullStringDateToFullStringDate()
57 | anime.endDate = item.endDate.apiFullStringDateToFullStringDate()
58 |
59 | self.item = anime
60 | })
61 | .store(in: &cancellables)
62 | }
63 |
64 | public func updateFavoriteAnime(request: FavoriteUseCase.Request) {
65 | _favoriteUseCase.execute(request: request)
66 | .receive(on: RunLoop.main)
67 | .sink(receiveCompletion: { completion in
68 | switch completion {
69 | case .failure(let error):
70 | self.errorMessage = error.localizedDescription
71 | self.isError = true
72 | self.isLoading = false
73 | case .finished:
74 | self.isLoading = false
75 | }
76 | }, receiveValue: { item in
77 | self.isError = false
78 | self.item = item
79 | })
80 | .store(in: &cancellables)
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/Yume/App/YumeApp.swift:
--------------------------------------------------------------------------------
1 | //
2 | // YumeApp.swift
3 | // Yume
4 | //
5 | // Created by Bryan on 25/12/22.
6 | //
7 |
8 | import Anime
9 | import Core
10 | import Home
11 | import Search
12 | import SwiftUI
13 |
14 | let injection = Injection.init()
15 |
16 | // Home
17 | let topAiringAnimeUseCase: Interactor<
18 | AnimeRankingRequest,
19 | [AnimeDomainModel],
20 | GetAnimeRankingRepository<
21 | GetAnimeRankingLocaleDataSource,
22 | GetAnimeRankingRemoteDataSource,
23 | AnimesTransformer>> = injection.provideAnimeRanking()
24 | let topUpcomingAnimeUseCase: Interactor<
25 | AnimeRankingRequest,
26 | [AnimeDomainModel],
27 | GetAnimeRankingRepository<
28 | GetAnimeRankingLocaleDataSource,
29 | GetAnimeRankingRemoteDataSource,
30 | AnimesTransformer>> = injection.provideAnimeRanking()
31 | let popularAnimeUseCase: Interactor<
32 | AnimeRankingRequest,
33 | [AnimeDomainModel],
34 | GetAnimeRankingRepository<
35 | GetAnimeRankingLocaleDataSource,
36 | GetAnimeRankingRemoteDataSource,
37 | AnimesTransformer>> = injection.provideAnimeRanking()
38 | let topAllAnimeUseCase: Interactor<
39 | AnimeRankingRequest,
40 | [AnimeDomainModel],
41 | GetAnimeRankingRepository<
42 | GetAnimeRankingLocaleDataSource,
43 | GetAnimeRankingRemoteDataSource,
44 | AnimesTransformer>> = injection.provideAnimeRanking()
45 |
46 | // Search
47 | let searchAnimeUseCase: Interactor<
48 | AnimeListRequest,
49 | [AnimeDomainModel],
50 | SearchAnimeRepository<
51 | GetAnimeListLocaleDataSource,
52 | GetAnimeListRemoteDataSource,
53 | AnimesTransformer>> = injection.provideSearchAnime()
54 | let topFavoriteAnimeUseCase: Interactor<
55 | AnimeRankingRequest,
56 | [AnimeDomainModel],
57 | GetAnimeRankingRepository<
58 | GetAnimeRankingLocaleDataSource,
59 | GetAnimeRankingRemoteDataSource,
60 | AnimesTransformer>> = injection.provideAnimeRanking()
61 |
62 | // Favorite
63 | let favoriteAnimeUseCase: Interactor<
64 | Int,
65 | [AnimeDomainModel],
66 | GetFavoriteAnimesRepository<
67 | GetFavoriteAnimeLocaleDataSource,
68 | AnimesTransformer>> = injection.provideFavoriteAnime()
69 |
70 | @main
71 | struct YumeApp: App {
72 |
73 | let homePresenter = HomePresenter(
74 | topAiringAnimeUseCase: topAiringAnimeUseCase,
75 | topUpcomingAnimeUseCase: topUpcomingAnimeUseCase,
76 | popularAnimeUseCase: popularAnimeUseCase,
77 | topAllAnimeUseCase: topAllAnimeUseCase)
78 | let searchPresenter = SearchPresenter(
79 | searchAnimeUseCase: searchAnimeUseCase,
80 | topFavoriteAnimeUseCase: topFavoriteAnimeUseCase)
81 | let favoritePresenter = GetListPresenter(useCase: favoriteAnimeUseCase)
82 |
83 | var body: some Scene {
84 | WindowGroup {
85 | ContentView()
86 | .environmentObject(homePresenter)
87 | .environmentObject(searchPresenter)
88 | .environmentObject(favoritePresenter)
89 | }
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/Modules/Anime/Sources/Anime/Data/GetAnimeRankingRepository.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetAnimeRankingRepository.swift
3 | //
4 | //
5 | // Created by Bryan on 06/01/23.
6 | //
7 |
8 | import Combine
9 | import Core
10 |
11 | public struct GetAnimeRankingRepository<
12 | AnimeLocaleDataSource: LocaleDataSource,
13 | RemoteDataSource: DataSource,
14 | Transformer: Mapper>: Repository
15 | where AnimeLocaleDataSource.Request == AnimeRankingRequest,
16 | AnimeLocaleDataSource.Response == AnimeModuleEntity,
17 | RemoteDataSource.Request == AnimeRankingRequest,
18 | RemoteDataSource.Response == [AnimeDataResponse],
19 | Transformer.Request == Any,
20 | Transformer.Response == [AnimeDataResponse],
21 | Transformer.Entity == [AnimeModuleEntity],
22 | Transformer.Domain == [AnimeDomainModel] {
23 |
24 | public typealias Request = AnimeRankingRequest
25 | public typealias Response = [AnimeDomainModel]
26 |
27 | private let _localeDataSource: AnimeLocaleDataSource
28 | private let _remoteDataSource: RemoteDataSource
29 | private let _mapper: Transformer
30 |
31 | public init(
32 | localeDataSource: AnimeLocaleDataSource,
33 | remoteDataSource: RemoteDataSource,
34 | mapper: Transformer) {
35 | _localeDataSource = localeDataSource
36 | _remoteDataSource = remoteDataSource
37 | _mapper = mapper
38 | }
39 |
40 | public func execute(request: AnimeRankingRequest?) -> AnyPublisher<[AnimeDomainModel], Error> {
41 | guard let request = request else {
42 | fatalError("Request cannot be empty")
43 | }
44 |
45 | return _localeDataSource.list(request: request)
46 | .flatMap { result -> AnyPublisher<[AnimeDomainModel], Error> in
47 | if result.isEmpty || request.refresh {
48 | return _remoteDataSource.execute(request: request)
49 | .map { _mapper.transformResponseToEntity(request: request, response: $0) }
50 | .flatMap { _localeDataSource.add(entities: $0) }
51 | .filter { $0 }
52 | .flatMap { _ in _localeDataSource.list(request: request)
53 | .map { _mapper.transformEntityToDomain(entity: $0) }
54 | }
55 | .catch { error in
56 | if result.isEmpty {
57 | // First time request and no cache
58 | return Fail<[AnimeDomainModel], Error>(error: error)
59 | .eraseToAnyPublisher()
60 | } else {
61 | // Failed to refresh
62 | return _localeDataSource.list(request: request)
63 | .map { _mapper.transformEntityToDomain(entity: $0) }
64 | .eraseToAnyPublisher()
65 | }
66 | }
67 | .eraseToAnyPublisher()
68 | } else {
69 | return _localeDataSource.list(request: request)
70 | .map { _mapper.transformEntityToDomain(entity: $0) }
71 | .eraseToAnyPublisher()
72 | }
73 | }.eraseToAnyPublisher()
74 | }
75 |
76 | }
77 |
--------------------------------------------------------------------------------
/Modules/SeeAllAnime/Sources/SeeAllAnime/Presentation/View/SeeAllAnimeView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SeeAllAnimeView.swift
3 | //
4 | //
5 | // Created by Bryan on 08/01/23.
6 | //
7 |
8 | import Anime
9 | import Common
10 | import Core
11 | import SwiftUI
12 |
13 | public struct SeeAllAnimeView: View {
14 |
15 | @ObservedObject var presenter: GetListPresenter<
16 | AnimeRankingRequest,
17 | AnimeDomainModel,
18 | Interactor<
19 | AnimeRankingRequest,
20 | [AnimeDomainModel],
21 | GetAnimeRankingRepository<
22 | GetAnimeRankingLocaleDataSource,
23 | GetAnimeRankingRemoteDataSource,
24 | AnimesTransformer>>>
25 | @State var scrollOffset: CGFloat
26 | let rankingType: RankingTypeRequest
27 | let navigationTitle: String
28 | let detailDestination: ((_ anime: AnimeDomainModel) -> DetailDestination)
29 |
30 | public init(
31 | presenter: GetListPresenter<
32 | AnimeRankingRequest,
33 | AnimeDomainModel,
34 | Interactor<
35 | AnimeRankingRequest,
36 | [AnimeDomainModel],
37 | GetAnimeRankingRepository<
38 | GetAnimeRankingLocaleDataSource,
39 | GetAnimeRankingRemoteDataSource,
40 | AnimesTransformer>>>,
41 | scrollOffset: CGFloat = CGFloat.zero,
42 | rankingType: RankingTypeRequest,
43 | navigationTitle: String,
44 | detailDestination: @escaping (_ anime: AnimeDomainModel) -> DetailDestination
45 | ) {
46 | self.presenter = presenter
47 | self.scrollOffset = scrollOffset
48 | self.rankingType = rankingType
49 | self.navigationTitle = navigationTitle
50 | self.detailDestination = detailDestination
51 | }
52 |
53 | public var body: some View {
54 | ZStack {
55 | if presenter.isLoading {
56 | ProgressIndicator()
57 | .background(YumeColor.background)
58 | } else if presenter.isError {
59 | CustomEmptyView(label: presenter.errorMessage)
60 | } else {
61 | content
62 | }
63 | }
64 | .toolbar(.hidden)
65 | .onAppear {
66 | if presenter.list.isEmpty {
67 | presenter.getList(request: AnimeRankingRequest(type: rankingType))
68 | }
69 | }
70 | }
71 | }
72 |
73 | extension SeeAllAnimeView {
74 | var content: some View {
75 | ZStack(alignment: .top) {
76 | ObservableScrollView(scrollOffset: $scrollOffset, showsIndicators: false) { _ in
77 | LazyVStack(spacing: Space.small) {
78 | ForEach(presenter.list.prefix(20)) { anime in
79 | NavigationLink(destination: detailDestination(anime)) {
80 | AnimeCardItem(anime: anime)
81 | }.buttonStyle(.plain)
82 | }
83 | }
84 | .padding(
85 | EdgeInsets(
86 | top: 56,
87 | leading: Space.medium,
88 | bottom: Space.medium,
89 | trailing: Space.medium)
90 | )
91 | }.background(YumeColor.background)
92 |
93 | BackAppBar(scrollOffset: scrollOffset, label: navigationTitle.localized(bundle: .common), alwaysShowLabel: true)
94 | }
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/Modules/Anime/Sources/Anime/Data/GetAnimeRepository.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetAnimeRepository.swift
3 | //
4 | //
5 | // Created by Bryan on 07/01/23.
6 | //
7 |
8 | import Combine
9 | import Core
10 |
11 | public struct GetAnimeRepository<
12 | AnimeLocaleDataSource: LocaleDataSource,
13 | RemoteDataSource: DataSource,
14 | Transformer: Mapper>: Repository
15 | where AnimeLocaleDataSource.Request == Int,
16 | AnimeLocaleDataSource.Response == AnimeModuleEntity,
17 | RemoteDataSource.Request == AnimeRequest,
18 | RemoteDataSource.Response == AnimeResponse,
19 | Transformer.Request == Any,
20 | Transformer.Response == AnimeResponse,
21 | Transformer.Entity == AnimeModuleEntity,
22 | Transformer.Domain == AnimeDomainModel {
23 |
24 | public typealias Request = AnimeRequest
25 | public typealias Response = AnimeDomainModel
26 |
27 | private let _localeDataSource: AnimeLocaleDataSource
28 | private let _remoteDataSource: RemoteDataSource
29 | private let _mapper: Transformer
30 |
31 | public init(
32 | localeDataSource: AnimeLocaleDataSource,
33 | remoteDataSource: RemoteDataSource,
34 | mapper: Transformer) {
35 | _localeDataSource = localeDataSource
36 | _remoteDataSource = remoteDataSource
37 | _mapper = mapper
38 | }
39 |
40 | public func execute(request: AnimeRequest?) -> AnyPublisher {
41 | guard let request = request else {
42 | fatalError("Request cannot be empty")
43 | }
44 |
45 | return _localeDataSource.get(id: request.animeId)
46 | .flatMap { entity -> AnyPublisher in
47 | // Refresh anime if cached more than 24 hours
48 | if entity.updatedAt.isExpired() {
49 | return _remoteDataSource.execute(request: request)
50 | .map { _mapper.transformResponseToEntity(request: request, response: $0) }
51 | .flatMap { _localeDataSource.add(entities: [$0]) }
52 | .filter { $0 }
53 | .flatMap { _ in _localeDataSource.get(id: request.animeId)
54 | .map { _mapper.transformEntityToDomain(entity: $0) }
55 | }
56 | .catch { _ in
57 | return _localeDataSource.get(id: request.animeId)
58 | .map { _mapper.transformEntityToDomain(entity: $0) }
59 | .eraseToAnyPublisher()
60 | }
61 | .eraseToAnyPublisher()
62 | } else {
63 | return _localeDataSource.get(id: request.animeId)
64 | .map { _mapper.transformEntityToDomain(entity: $0) }
65 | .eraseToAnyPublisher()
66 | }
67 | }
68 | .catch { _ in
69 | return _remoteDataSource.execute(request: request)
70 | .map { _mapper.transformResponseToEntity(request: request, response: $0) }
71 | .flatMap { _localeDataSource.add(entities: [$0]) }
72 | .filter { $0 }
73 | .flatMap { _ in _localeDataSource.get(id: request.animeId)
74 | .map { _mapper.transformEntityToDomain(entity: $0) }
75 | }
76 | .eraseToAnyPublisher()
77 | }
78 | .eraseToAnyPublisher()
79 | }
80 |
81 | }
82 |
--------------------------------------------------------------------------------
/Modules/Anime/Sources/Anime/Data/Locale/GetAnimeRankingLocaleDataSource.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetAnimeRankingLocaleDataSource.swift
3 | //
4 | //
5 | // Created by Bryan on 08/01/23.
6 | //
7 |
8 | import Core
9 | import Combine
10 | import Foundation
11 | import RealmSwift
12 |
13 | public struct GetAnimeRankingLocaleDataSource: LocaleDataSource {
14 |
15 | public typealias Request = AnimeRankingRequest
16 | public typealias Response = AnimeModuleEntity
17 |
18 | private let _realm: Realm
19 |
20 | public init(realm: Realm) {
21 | _realm = realm
22 | }
23 |
24 | public func list(request: AnimeRankingRequest?) -> AnyPublisher<[AnimeModuleEntity], Error> {
25 | return Future<[AnimeModuleEntity], Error> { completion in
26 | guard let request = request else {
27 | return completion(.failure(URLError.invalidRequest))
28 | }
29 |
30 | let animes: Results = {
31 | switch request.rankingType {
32 | case RankingTypeRequest.airing:
33 | return _realm.objects(AnimeModuleEntity.self)
34 | .where {
35 | $0.status == Status.currentlyAiring.name
36 | && $0.rank != 0
37 | }
38 | .sorted(byKeyPath: request.rankingType.sortKey)
39 | case .upcoming:
40 | return _realm.objects(AnimeModuleEntity.self)
41 | .where { $0.status == Status.notYetAired.name }
42 | .sorted(byKeyPath: request.rankingType.sortKey)
43 | case .byPopularity:
44 | return _realm.objects(AnimeModuleEntity.self)
45 | .where { $0.popularity != 0 }
46 | .sorted(byKeyPath: request.rankingType.sortKey)
47 | case .favorite:
48 | return _realm.objects(AnimeModuleEntity.self)
49 | .sorted(byKeyPath: request.rankingType.sortKey, ascending: false)
50 | default:
51 | // All
52 | return _realm.objects(AnimeModuleEntity.self)
53 | .where { $0.rank != 0 }
54 | .sorted(byKeyPath: request.rankingType.sortKey)
55 | }
56 | }()
57 | completion(.success(animes.toArray(ofType: AnimeModuleEntity.self)))
58 | }.eraseToAnyPublisher()
59 | }
60 |
61 | public func add(entities: [AnimeModuleEntity]) -> AnyPublisher {
62 | return Future { completion in
63 | do {
64 | try _realm.write {
65 | for anime in entities {
66 | if let animeEntity = _realm.object(ofType: AnimeModuleEntity.self, forPrimaryKey: anime.id) {
67 | anime.isFavorite = animeEntity.isFavorite
68 | _realm.add(anime, update: .all)
69 | } else {
70 | _realm.add(anime)
71 | }
72 | }
73 | completion(.success(true))
74 | }
75 | } catch {
76 | completion(.failure(DatabaseError.requestFailed))
77 | }
78 | }.eraseToAnyPublisher()
79 | }
80 |
81 | public func get(id: Int) -> AnyPublisher {
82 | fatalError()
83 | }
84 |
85 | public func update(id: Int, entity: AnimeModuleEntity) -> AnyPublisher {
86 | fatalError()
87 | }
88 |
89 | }
90 |
--------------------------------------------------------------------------------
/Yume.xcodeproj/xcshareddata/xcschemes/Yume - ID.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
32 |
33 |
44 |
46 |
52 |
53 |
54 |
55 |
61 |
63 |
69 |
70 |
71 |
72 |
74 |
75 |
78 |
79 |
80 |
--------------------------------------------------------------------------------
/Yume/Core/DI/Injection.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Injection.swift
3 | // Yume
4 | //
5 | // Created by Bryan on 28/12/22.
6 | //
7 |
8 | import Anime
9 | import Core
10 |
11 | import Foundation
12 | import RealmSwift
13 |
14 | final class Injection: NSObject {
15 |
16 | private let realm = try? Realm()
17 |
18 | func provideAnimeRanking() -> U
19 | where
20 | U.Request == AnimeRankingRequest,
21 | U.Response == [AnimeDomainModel] {
22 | let locale = GetAnimeRankingLocaleDataSource(realm: realm!)
23 |
24 | let remote = GetAnimeRankingRemoteDataSource(
25 | endpoint: Endpoints.Gets.ranking.url,
26 | encoder: API.encoder,
27 | headers: API.headers
28 | )
29 |
30 | let mapper = AnimesTransformer()
31 |
32 | let repository = GetAnimeRankingRepository(
33 | localeDataSource: locale,
34 | remoteDataSource: remote,
35 | mapper: mapper)
36 |
37 | return Interactor(repository: repository) as! U
38 | }
39 |
40 | func provideSearchAnime() -> U
41 | where
42 | U.Request == AnimeListRequest,
43 | U.Response == [AnimeDomainModel] {
44 | let locale = GetAnimeListLocaleDataSource(realm: realm!)
45 |
46 | let remote = GetAnimeListRemoteDataSource(
47 | endpoint: Endpoints.Gets.search.url,
48 | encoder: API.encoder,
49 | headers: API.headers
50 | )
51 |
52 | let mapper = AnimesTransformer()
53 |
54 | let repository = SearchAnimeRepository(
55 | localeDataSource: locale,
56 | remoteDataSource: remote,
57 | mapper: mapper)
58 |
59 | return Interactor(repository: repository) as! U
60 | }
61 |
62 | func provideAnime() -> U
63 | where
64 | U.Request == AnimeRequest,
65 | U.Response == AnimeDomainModel {
66 | let locale = GetAnimeLocaleDataSource(realm: realm!)
67 |
68 | let remote = GetAnimeRemoteDataSource(
69 | endpoint: Endpoints.Gets.detail.url,
70 | encoder: API.encoder,
71 | headers: API.headers
72 | )
73 |
74 | let mapper = AnimeTransformer()
75 |
76 | let repository = GetAnimeRepository(
77 | localeDataSource: locale,
78 | remoteDataSource: remote,
79 | mapper: mapper
80 | )
81 |
82 | return Interactor(repository: repository) as! U
83 | }
84 |
85 | func provideUpdateFavoriteAnime() -> U
86 | where
87 | U.Request == Int,
88 | U.Response == AnimeDomainModel {
89 | let locale = GetFavoriteAnimeLocaleDataSource(realm: realm!)
90 |
91 | let mapper = AnimeDataTransformer()
92 |
93 | let repository = UpdateFavoriteAnimeRepository(
94 | localeDataSource: locale,
95 | mapper: mapper
96 | )
97 |
98 | return Interactor(repository: repository) as! U
99 | }
100 |
101 | func provideFavoriteAnime() -> U
102 | where
103 | U.Request == Int,
104 | U.Response == [AnimeDomainModel] {
105 | let locale = GetFavoriteAnimeLocaleDataSource(realm: realm!)
106 |
107 | let mapper = AnimesTransformer()
108 |
109 | let repository = GetFavoriteAnimesRepository(
110 | localeDataSource: locale,
111 | mapper: mapper
112 | )
113 |
114 | return Interactor(repository: repository) as! U
115 | }
116 |
117 | }
118 |
--------------------------------------------------------------------------------
/Modules/Favorite/Sources/Favorite/Presentation/View/FavoriteView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FavoriteView.swift
3 | //
4 | //
5 | // Created by Bryan on 08/01/23.
6 | //
7 |
8 | import Anime
9 | import Common
10 | import Core
11 | import SwiftUI
12 |
13 | public struct FavoriteView: View {
14 | @ObservedObject var presenter: GetListPresenter<
15 | Int,
16 | AnimeDomainModel,
17 | Interactor<
18 | Int,
19 | [AnimeDomainModel],
20 | GetFavoriteAnimesRepository<
21 | GetFavoriteAnimeLocaleDataSource,
22 | AnimesTransformer>>>
23 | @State var scrollOffset: CGFloat
24 | let detailDestination: ((_ anime: AnimeDomainModel) -> DetailDestination)
25 |
26 | public init(
27 | presenter: GetListPresenter<
28 | Int,
29 | AnimeDomainModel,
30 | Interactor<
31 | Int,
32 | [AnimeDomainModel],
33 | GetFavoriteAnimesRepository<
34 | GetFavoriteAnimeLocaleDataSource,
35 | AnimesTransformer>>>,
36 | scrollOffset: CGFloat = CGFloat.zero,
37 | detailDestination: @escaping (_ anime: AnimeDomainModel) -> DetailDestination) {
38 | self.presenter = presenter
39 | self.scrollOffset = scrollOffset
40 | self.detailDestination = detailDestination
41 | }
42 |
43 | public var body: some View {
44 | ZStack {
45 | if presenter.isLoading {
46 | ProgressIndicator()
47 | .background(YumeColor.background)
48 | } else if presenter.isError {
49 | Text(presenter.errorMessage)
50 | .background(YumeColor.background)
51 | } else if presenter.list.isEmpty {
52 | empty
53 | } else {
54 | content
55 | }
56 | }.onAppear {
57 | presenter.getList(request: nil)
58 | }
59 | }
60 | }
61 |
62 | extension FavoriteView {
63 | var empty: some View {
64 | VStack(alignment: .leading) {
65 | Text("favorite_title".localized(bundle: .common))
66 | .typography(.largeTitle(weight: .bold))
67 | CustomEmptyView(label: "no_favorite_anime_label".localized(bundle: .module))
68 | }
69 | .padding(
70 | EdgeInsets(
71 | top: 40,
72 | leading: Space.medium,
73 | bottom: Space.medium,
74 | trailing: Space.medium)
75 | )
76 | .background(YumeColor.background)
77 | }
78 |
79 | var content: some View {
80 | ZStack(alignment: .top) {
81 | ObservableScrollView(scrollOffset: $scrollOffset, showsIndicators: false) { _ in
82 | LazyVStack(alignment: .leading, spacing: Space.small) {
83 | Text("favorite_title".localized(bundle: .common))
84 | .typography(.largeTitle(weight: .bold))
85 | ForEach(presenter.list) { anime in
86 | NavigationLink(destination: detailDestination(anime)) {
87 | AnimeCardItem(anime: anime)
88 | }.buttonStyle(.plain)
89 | }
90 | }.padding(
91 | EdgeInsets(
92 | top: 40,
93 | leading: Space.medium,
94 | bottom: Space.medium,
95 | trailing: Space.medium)
96 | )
97 | }
98 | .background(YumeColor.background)
99 |
100 | AppBar(scrollOffset: scrollOffset, label: "favorite_title".localized(bundle: .common))
101 | }
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/Modules/Common/Sources/Common/Utils/View/AnimeCardItem.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AnimeCardItem.swift
3 | // Yume
4 | //
5 | // Created by Bryan on 29/12/22.
6 | //
7 |
8 | import Anime
9 | import Core
10 | import SwiftUI
11 | import SDWebImageSwiftUI
12 |
13 | public struct AnimeCardItem: View {
14 | @State var anime: AnimeDomainModel
15 |
16 | public init(anime: AnimeDomainModel) {
17 | self.anime = anime
18 | }
19 |
20 | public var body: some View {
21 | HStack(spacing: Space.small) {
22 | mainPicture
23 | content
24 | Spacer()
25 | }
26 | .frame(height: 150)
27 | .background(YumeColor.surface)
28 | .overlay(
29 | RoundedRectangle(cornerRadius: 8)
30 | .stroke(YumeColor.outline, lineWidth: 1)
31 | )
32 | }
33 |
34 | }
35 |
36 | extension AnimeCardItem {
37 |
38 | var mainPicture: some View {
39 | WebImage(url: URL(string: anime.mainPicture))
40 | .resizable()
41 | .placeholder {
42 | ImagePlaceholder()
43 | }
44 | .indicator(.activity)
45 | .transition(.fade(duration: 0.5))
46 | .scaledToFill()
47 | .frame(width: 100, height: 150)
48 | .cornerRadius(Shape.small)
49 | }
50 |
51 | var content: some View {
52 | VStack(alignment: .leading, spacing: Space.small) {
53 | overview
54 | Spacer()
55 | tags
56 | }.padding(
57 | EdgeInsets(
58 | top: Space.small,
59 | leading: Space.none,
60 | bottom: Space.small,
61 | trailing: Space.medium
62 | )
63 | )
64 | }
65 |
66 | var overview: some View {
67 | VStack(alignment: .leading, spacing: Space.tiny) {
68 | Text("\(anime.mediaType)"
69 | + " · \(anime.startSeason) \(anime.startSeasonYear)"
70 | + " · \(anime.status)"
71 | ).typography(.caption(color: YumeColor.onSurfaceVariant))
72 |
73 | Text(anime.alternativeTitleEnglish.isEmpty
74 | ? anime.title : anime.alternativeTitleEnglish)
75 | .typography(.body(color: YumeColor.onSurface))
76 | .lineLimit(2)
77 | }
78 | }
79 |
80 | var tags: some View {
81 | VStack(alignment: .leading, spacing: Space.tiny) {
82 | stats
83 | Text(Array(anime.genre.prefix(3))
84 | .joined(separator: " · "))
85 | .lineLimit(1)
86 | }.typography(.caption(color: YumeColor.onSurfaceVariant))
87 | }
88 |
89 | var stats: some View {
90 | HStack(spacing: Space.small) {
91 | HStack(spacing: Space.tiny) {
92 | IconView(
93 | icon: Icons.starOutlined,
94 | color: .yellow,
95 | size: IconSize.small
96 | )
97 | Text(anime.rating.description)
98 | .typography(.caption(color: YumeColor.onSurfaceVariant))
99 | }
100 | HStack(spacing: Space.tiny) {
101 | IconView(
102 | icon: Icons.crownOutlined,
103 | color: .orange,
104 | size: IconSize.small
105 | )
106 | Text("#\(anime.rank.formatNumber())")
107 | .typography(.caption(color: YumeColor.onSurfaceVariant))
108 | }
109 | HStack(spacing: Space.tiny) {
110 | IconView(
111 | icon: Icons.trendingUp,
112 | color: .green,
113 | size: IconSize.small
114 | )
115 | Text("#\(anime.popularity.formatNumber())")
116 | .typography(.caption(color: YumeColor.onSurfaceVariant))
117 | }
118 | }
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/Yume/App/ContentView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ContentView.swift
3 | // Yume
4 | //
5 | // Created by Bryan on 25/12/22.
6 | //
7 |
8 | import Anime
9 | import AnimeDetail
10 | import Core
11 | import Common
12 | import Favorite
13 | import Home
14 | import Profile
15 | import Search
16 | import SeeAllAnime
17 | import SwiftUI
18 |
19 | struct ContentView: View {
20 | @EnvironmentObject var homePresenter: HomePresenter<
21 | Interactor<
22 | AnimeRankingRequest,
23 | [AnimeDomainModel],
24 | GetAnimeRankingRepository<
25 | GetAnimeRankingLocaleDataSource,
26 | GetAnimeRankingRemoteDataSource,
27 | AnimesTransformer>>,
28 | Interactor<
29 | AnimeRankingRequest,
30 | [AnimeDomainModel],
31 | GetAnimeRankingRepository<
32 | GetAnimeRankingLocaleDataSource,
33 | GetAnimeRankingRemoteDataSource,
34 | AnimesTransformer>>,
35 | Interactor<
36 | AnimeRankingRequest,
37 | [AnimeDomainModel],
38 | GetAnimeRankingRepository<
39 | GetAnimeRankingLocaleDataSource,
40 | GetAnimeRankingRemoteDataSource,
41 | AnimesTransformer>>,
42 | Interactor<
43 | AnimeRankingRequest,
44 | [AnimeDomainModel],
45 | GetAnimeRankingRepository<
46 | GetAnimeRankingLocaleDataSource,
47 | GetAnimeRankingRemoteDataSource,
48 | AnimesTransformer>>>
49 | @EnvironmentObject var searchPresenter: SearchPresenter<
50 | Interactor<
51 | AnimeListRequest,
52 | [AnimeDomainModel],
53 | SearchAnimeRepository<
54 | GetAnimeListLocaleDataSource,
55 | GetAnimeListRemoteDataSource,
56 | AnimesTransformer>>,
57 | Interactor<
58 | AnimeRankingRequest,
59 | [AnimeDomainModel],
60 | GetAnimeRankingRepository<
61 | GetAnimeRankingLocaleDataSource,
62 | GetAnimeRankingRemoteDataSource,
63 | AnimesTransformer>>>
64 | @EnvironmentObject var favoritePresenter: GetListPresenter<
65 | Int,
66 | AnimeDomainModel,
67 | Interactor<
68 | Int,
69 | [AnimeDomainModel],
70 | GetFavoriteAnimesRepository<
71 | GetFavoriteAnimeLocaleDataSource,
72 | AnimesTransformer>>>
73 | @State private var selection: Tab = .home
74 |
75 | init() {
76 | NetworkMonitor.shared.startMonitoring()
77 |
78 | UITabBar.appearance().isHidden = true
79 | }
80 |
81 | var body: some View {
82 | VStack(spacing: 0) {
83 | TabView(selection: $selection) {
84 | NavigationStack {
85 | HomeView<
86 | SeeAllAnimeView,
87 | AnimeDetailView>(presenter: homePresenter) { rankingType in
88 | Router().makeSeeAllAnimeView(for: rankingType) { anime in
89 | Router().makeAnimeDetailView(for: anime)
90 | }
91 | } detailDestination: { anime in
92 | Router().makeAnimeDetailView(for: anime)
93 | }
94 | }.tag(Tab.home)
95 | NavigationStack {
96 | SearchView(presenter: searchPresenter) { anime in
97 | Router().makeAnimeDetailView(for: anime)
98 | }
99 | }.tag(Tab.search)
100 | NavigationStack {
101 | FavoriteView(presenter: favoritePresenter) { anime in
102 | Router().makeAnimeDetailView(for: anime)
103 | }
104 | }.tag(Tab.favorite)
105 | NavigationStack {
106 | ProfileView()
107 | }.tag(Tab.profile)
108 | }
109 | TabBar(selection: $selection)
110 | }.ignoresSafeArea(.keyboard)
111 | }
112 |
113 | }
114 |
115 | struct ContentView_Previews: PreviewProvider {
116 | static var previews: some View {
117 | ContentView()
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/Modules/Anime/Sources/Anime/Mapper/AnimeDataTransformer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AnimeDataTransformer.swift
3 | //
4 | //
5 | // Created by Bryan on 07/01/23.
6 | //
7 |
8 | import Core
9 | import Foundation
10 |
11 | public struct AnimeDataTransformer: Mapper {
12 | public typealias Request = Any
13 | public typealias Response = AnimeDataResponse
14 | public typealias Entity = AnimeModuleEntity
15 | public typealias Domain = AnimeDomainModel
16 |
17 | public init() {}
18 |
19 | public func transformResponseToEntity(request: Any?, response: AnimeDataResponse) -> Entity {
20 | let animeEntity = AnimeModuleEntity()
21 | animeEntity.id = response.anime.id
22 | animeEntity.title = response.anime.title
23 | animeEntity.mainPicture = response.anime.mainPicture?.medium ?? "Unknown"
24 | animeEntity.alternativeTitleSynonyms.append(objectsIn: response.anime.alternativeTitles?.synonyms ?? [])
25 | animeEntity.alternativeTitleEnglish = response.anime.alternativeTitles?.english ?? "Unknown"
26 | animeEntity.alternativeTitleJapanese = response.anime.alternativeTitles?.japanese ?? "Unknown"
27 | animeEntity.startDate = response.anime.startDate ?? "Unknown"
28 | animeEntity.endDate = response.anime.endDate ?? "Unknown"
29 | animeEntity.synopsis = response.anime.synopsis ?? "Unknown"
30 | animeEntity.rating = response.anime.rating ?? 0
31 | animeEntity.rank = response.anime.rank ?? 0
32 | animeEntity.popularity = response.anime.popularity ?? 0
33 | animeEntity.userAmount = response.anime.userAmount
34 | animeEntity.favoriteAmount = response.anime.favoriteAmount
35 | animeEntity.nsfw = response.anime.nsfw?.name ?? "Unknown"
36 | animeEntity.genre.append(objectsIn: response.anime.genres?.map { $0.name } ?? [])
37 | animeEntity.mediaType = response.anime.mediaType.name
38 | animeEntity.status = response.anime.status.name
39 | animeEntity.episodeAmount = response.anime.episodeAmount
40 | animeEntity.startSeason = response.anime.startSeason?.season.name ?? "Unknown"
41 | animeEntity.startSeasonYear = response.anime.startSeason?.year.description ?? ""
42 | animeEntity.source = response.anime.source?.name ?? "Unknown"
43 | animeEntity.episodeDuration = response.anime.episodeDuration ?? 0
44 | animeEntity.studios.append(objectsIn: response.anime.studios.map { $0.name })
45 | animeEntity.updatedAt = Date()
46 | return animeEntity
47 | }
48 |
49 | public func transformEntityToDomain(entity: AnimeModuleEntity) -> AnimeDomainModel {
50 | return AnimeDomainModel(
51 | id: entity.id,
52 | title: entity.title,
53 | mainPicture: entity.mainPicture,
54 | alternativeTitleSynonyms: Array(entity.alternativeTitleSynonyms),
55 | alternativeTitleEnglish: entity.alternativeTitleEnglish,
56 | alternativeTitleJapanese: entity.alternativeTitleJapanese,
57 | startDate: entity.startDate,
58 | endDate: entity.endDate,
59 | airedDate: AnimeTransformer.transformToAiredDate(startDate: entity.startDate, endDate: entity.endDate),
60 | synopsis: entity.synopsis,
61 | rating: entity.rating,
62 | rank: entity.rank,
63 | popularity: entity.popularity,
64 | userAmount: entity.userAmount,
65 | favoriteAmount: entity.favoriteAmount,
66 | nsfw: entity.nsfw,
67 | genre: Array(entity.genre),
68 | mediaType: entity.mediaType,
69 | status: entity.status,
70 | episodeAmount: entity.episodeAmount,
71 | startSeason: entity.startSeason,
72 | startSeasonYear: entity.startSeasonYear,
73 | source: entity.source,
74 | episodeDuration: entity.episodeDuration,
75 | episodeDurationText: AnimeTransformer.transformToDurationText(duration: entity.episodeDuration),
76 | studios: Array(entity.studios),
77 | isFavorite: entity.isFavorite
78 | )
79 | }
80 |
81 | }
82 |
--------------------------------------------------------------------------------
/Modules/Common/Sources/Common/Utils/View/Snackbar.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Snackbar.swift
3 | //
4 | //
5 | // Created by Bryan on 03/02/23.
6 | //
7 |
8 | import Core
9 | import SwiftUI
10 |
11 | public struct SnackbarModifier: ViewModifier {
12 | @State private var animate = false
13 | @State private var timer: Timer?
14 |
15 | @Binding var show: Bool
16 | @Binding var restart: Bool
17 | let message: String
18 | var withCloseIcon: Bool
19 |
20 | public func body(content: Content) -> some View {
21 | ZStack {
22 | content
23 |
24 | if show {
25 | VStack {
26 | Spacer()
27 |
28 | HStack(spacing: Space.small) {
29 | Text(message)
30 | .typography(.body(color: YumeColor.inverseOnSurface))
31 |
32 | Spacer()
33 |
34 | if withCloseIcon {
35 | Button {
36 | hideSnackbar()
37 | } label: {
38 | IconView(
39 | icon: Icons.close,
40 | color: YumeColor.inverseOnSurface)
41 | }
42 | }
43 | }
44 | .padding(Space.medium)
45 | .background(YumeColor.inverseSurface)
46 | .cornerRadius(Shape.extraSmall)
47 | }
48 | .padding(
49 | EdgeInsets(
50 | top: Space.small,
51 | leading: Space.medium,
52 | bottom: Space.small,
53 | trailing: Space.medium)
54 | )
55 | .animation(.easeInOut, value: animate)
56 | .transition(.move(edge: .bottom).combined(with: .opacity))
57 | .onAppear {
58 | animate = true
59 | startTimer()
60 | }
61 | .onDisappear {
62 | if restart {
63 | restart = false
64 | showSnackbar()
65 | }
66 | }
67 | .onChange(of: restart) { newRestart in
68 | if newRestart {
69 | hideSnackbar()
70 | }
71 | }
72 | .zIndex(100)
73 | }
74 | }
75 |
76 | }
77 | }
78 |
79 | extension SnackbarModifier {
80 | private func startTimer() {
81 | stopTimer()
82 | guard timer == nil else { return }
83 | timer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: false) { _ in
84 | hideSnackbar()
85 | }
86 | }
87 |
88 | private func stopTimer() {
89 | guard timer != nil else { return }
90 | timer?.invalidate()
91 | timer = nil
92 | }
93 |
94 | private func showSnackbar() {
95 | withAnimation(.easeInOut) {
96 | show = true
97 | }
98 | startTimer()
99 | }
100 |
101 | private func hideSnackbar() {
102 | withAnimation(.easeInOut) {
103 | show = false
104 | }
105 | stopTimer()
106 | }
107 | }
108 |
109 | extension View {
110 | public func snackbar(
111 | message: String,
112 | withCloseIcon: Bool = false,
113 | isShowing: Binding,
114 | restart: Binding) -> some View {
115 | self.modifier(SnackbarModifier(
116 | show: isShowing,
117 | restart: restart,
118 | message: message,
119 | withCloseIcon: withCloseIcon))
120 | }
121 | }
122 |
123 | private struct Snackbar: View {
124 | @State var showSnackbar = false
125 | @State var restartSnackbar = false
126 |
127 | var body: some View {
128 | VStack {
129 | Button("Show snackbar") {
130 | withAnimation(.easeInOut) {
131 | showSnackbar.toggle()
132 | }
133 | }.buttonStyle(.borderedProminent)
134 | }
135 | .snackbar(
136 | message: "Snackbar",
137 | withCloseIcon: true,
138 | isShowing: $showSnackbar,
139 | restart: $restartSnackbar)
140 | }
141 | }
142 |
143 | struct Snackbar_Previews: PreviewProvider {
144 | static var previews: some View {
145 | Snackbar()
146 | }
147 | }
148 |
--------------------------------------------------------------------------------
/Modules/Anime/Sources/Anime/Mapper/AnimesTransformer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AnimesTransformer.swift
3 | //
4 | //
5 | // Created by Bryan on 06/01/23.
6 | //
7 |
8 | import Core
9 | import Foundation
10 |
11 | public struct AnimesTransformer: Mapper {
12 | public typealias Request = Any
13 | public typealias Response = [AnimeDataResponse]
14 | public typealias Entity = [AnimeModuleEntity]
15 | public typealias Domain = [AnimeDomainModel]
16 |
17 | public init() {}
18 |
19 | public func transformResponseToEntity(request: Any?, response: [AnimeDataResponse]) -> Entity {
20 | return response.map { result in
21 | let animeEntity = AnimeModuleEntity()
22 | animeEntity.id = result.anime.id
23 | animeEntity.title = result.anime.title
24 | animeEntity.mainPicture = result.anime.mainPicture?.medium ?? "Unknown"
25 | animeEntity.alternativeTitleSynonyms.append(objectsIn: result.anime.alternativeTitles?.synonyms ?? [])
26 | animeEntity.alternativeTitleEnglish = result.anime.alternativeTitles?.english ?? "Unknown"
27 | animeEntity.alternativeTitleJapanese = result.anime.alternativeTitles?.japanese ?? "Unknown"
28 | animeEntity.startDate = result.anime.startDate ?? "Unknown"
29 | animeEntity.endDate = result.anime.endDate ?? "Unknown"
30 | animeEntity.synopsis = result.anime.synopsis ?? "Unknown"
31 | animeEntity.rating = result.anime.rating ?? 0
32 | animeEntity.rank = result.anime.rank ?? 0
33 | animeEntity.popularity = result.anime.popularity ?? 0
34 | animeEntity.userAmount = result.anime.userAmount
35 | animeEntity.favoriteAmount = result.anime.favoriteAmount
36 | animeEntity.nsfw = result.anime.nsfw?.name ?? "Unknown"
37 | animeEntity.genre.append(objectsIn: result.anime.genres?.map { $0.name } ?? [])
38 | animeEntity.mediaType = result.anime.mediaType.name
39 | animeEntity.status = result.anime.status.name
40 | animeEntity.episodeAmount = result.anime.episodeAmount
41 | animeEntity.startSeason = result.anime.startSeason?.season.name ?? "Unknown"
42 | animeEntity.startSeasonYear = result.anime.startSeason?.year.description ?? ""
43 | animeEntity.source = result.anime.source?.name ?? "Unknown"
44 | animeEntity.episodeDuration = result.anime.episodeDuration ?? 0
45 | animeEntity.studios.append(objectsIn: result.anime.studios.map { $0.name })
46 | animeEntity.updatedAt = Date()
47 | return animeEntity
48 | }
49 | }
50 |
51 | public func transformEntityToDomain(entity: [AnimeModuleEntity]) -> [AnimeDomainModel] {
52 | return entity.map { result in
53 | return AnimeDomainModel(
54 | id: result.id,
55 | title: result.title,
56 | mainPicture: result.mainPicture,
57 | alternativeTitleSynonyms: Array(result.alternativeTitleSynonyms),
58 | alternativeTitleEnglish: result.alternativeTitleEnglish,
59 | alternativeTitleJapanese: result.alternativeTitleJapanese,
60 | startDate: result.startDate,
61 | endDate: result.endDate,
62 | airedDate: AnimeTransformer.transformToAiredDate(startDate: result.startDate, endDate: result.endDate),
63 | synopsis: result.synopsis,
64 | rating: result.rating,
65 | rank: result.rank,
66 | popularity: result.popularity,
67 | userAmount: result.userAmount,
68 | favoriteAmount: result.favoriteAmount,
69 | nsfw: result.nsfw,
70 | genre: Array(result.genre),
71 | mediaType: result.mediaType,
72 | status: result.status,
73 | episodeAmount: result.episodeAmount,
74 | startSeason: result.startSeason,
75 | startSeasonYear: result.startSeasonYear,
76 | source: result.source,
77 | episodeDuration: result.episodeDuration,
78 | episodeDurationText: AnimeTransformer.transformToDurationText(duration: result.episodeDuration),
79 | studios: Array(result.studios),
80 | isFavorite: result.isFavorite
81 | )
82 | }
83 | }
84 |
85 | }
86 |
--------------------------------------------------------------------------------
/Modules/Common/Sources/Common/Utils/View/AppBar.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppBar.swift
3 | // Yume
4 | //
5 | // Created by Bryan on 30/12/22.
6 | //
7 |
8 | import Core
9 | import SwiftUI
10 |
11 | public struct AppBar: View {
12 | var scrollOffset: CGFloat
13 | let label: String
14 | let alwaysShowLabel: Bool
15 | let leading: () -> Content?
16 | let trailing: () -> Content?
17 |
18 | public init(
19 | scrollOffset: CGFloat,
20 | label: String,
21 | alwaysShowLabel: Bool = false,
22 | @ViewBuilder leading: @escaping () -> Content? = { Text("") },
23 | @ViewBuilder trailing: @escaping () -> Content? = { Text("") }
24 | ) {
25 | self.scrollOffset = scrollOffset
26 | self.label = label
27 | self.alwaysShowLabel = alwaysShowLabel
28 | self.leading = leading
29 | self.trailing = trailing
30 | }
31 |
32 | public var body: some View {
33 | GeometryReader { geo in
34 | VStack(spacing: Space.small) {
35 | HStack(spacing: Space.small) {
36 | leading()
37 | .frame(maxWidth: geo.size.width / 4, alignment: .leading)
38 | Text(label)
39 | .typography(.title3(weight: .bold, color: .black))
40 | .lineLimit(1)
41 | .opacity(alwaysShowLabel ? 1 : scrollOffset / 100)
42 | .frame(maxWidth: geo.size.width / 2, alignment: .center)
43 | trailing()
44 | .frame(maxWidth: geo.size.width / 4, alignment: .trailing)
45 | }
46 | }
47 | .padding(
48 | EdgeInsets(
49 | top: Space.none,
50 | leading: Space.medium,
51 | bottom: Space.small,
52 | trailing: Space.medium)
53 | )
54 | .frame(width: geo.size.width)
55 | .background(scrollOffset > 1 ? YumeColor.surface2 : Color.black.opacity(0))
56 | }
57 | }
58 | }
59 |
60 | public struct BackAppBar: View {
61 | @Environment(\.presentationMode) var presentationMode: Binding
62 | var scrollOffset: CGFloat
63 | let label: String
64 | let alwaysShowLabel: Bool
65 | let trailing: () -> Content?
66 |
67 | public init(
68 | scrollOffset: CGFloat,
69 | label: String,
70 | alwaysShowLabel: Bool = false,
71 | @ViewBuilder trailing: @escaping () -> Content? = { Text("") }
72 | ) {
73 | self.scrollOffset = scrollOffset
74 | self.label = label
75 | self.alwaysShowLabel = alwaysShowLabel
76 | self.trailing = trailing
77 | }
78 |
79 | public var body: some View {
80 | GeometryReader { geo in
81 | VStack(spacing: Space.small) {
82 | HStack(spacing: Space.small) {
83 | Button {
84 | presentationMode.wrappedValue.dismiss()
85 | } label: {
86 | IconView(icon: Icons.caretLeft)
87 | }.frame(maxWidth: geo.size.width / 4, alignment: .leading)
88 | Text(label)
89 | .typography(.title3(weight: .bold, color: .black))
90 | .lineLimit(1)
91 | .opacity(alwaysShowLabel ? 1 : scrollOffset / 100)
92 | .frame(maxWidth: geo.size.width / 2, alignment: .center)
93 | trailing()
94 | .frame(maxWidth: geo.size.width / 4, alignment: .trailing)
95 | }
96 | }
97 | .padding(
98 | EdgeInsets(
99 | top: Space.none,
100 | leading: Space.medium,
101 | bottom: Space.small,
102 | trailing: Space.medium)
103 | )
104 | .frame(width: geo.size.width)
105 | .background(scrollOffset > 1 ? YumeColor.surface2 : Color.black.opacity(0))
106 | }
107 | }
108 | }
109 |
110 | struct AppBar_Previews: PreviewProvider {
111 | static var previews: some View {
112 | Group {
113 | AppBar(scrollOffset: 500, label: "Title")
114 | .previewDisplayName("App Bar")
115 |
116 | BackAppBar(scrollOffset: 500, label: "Title")
117 | .previewDisplayName("App Bar with Back")
118 | }
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/Yume.xcodeproj/xcshareddata/xcschemes/Yume.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
34 |
40 |
41 |
42 |
45 |
51 |
52 |
53 |
54 |
55 |
65 |
67 |
73 |
74 |
75 |
76 |
82 |
84 |
90 |
91 |
92 |
93 |
95 |
96 |
99 |
100 |
101 |
--------------------------------------------------------------------------------
/Modules/Anime/Sources/Anime/Mapper/AnimeTransformer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AnimeTransformer.swift
3 | //
4 | //
5 | // Created by Bryan on 09/01/23.
6 | //
7 |
8 | import Core
9 | import Foundation
10 |
11 | public struct AnimeTransformer: Mapper {
12 | public typealias Request = Any
13 | public typealias Response = AnimeResponse
14 | public typealias Entity = AnimeModuleEntity
15 | public typealias Domain = AnimeDomainModel
16 |
17 | public init() {}
18 |
19 | public func transformResponseToEntity(request: Any?, response: AnimeResponse) -> Entity {
20 | let animeEntity = AnimeModuleEntity()
21 | animeEntity.id = response.id
22 | animeEntity.title = response.title
23 | animeEntity.mainPicture = response.mainPicture?.medium ?? "Unknown"
24 | animeEntity.alternativeTitleSynonyms.append(objectsIn: response.alternativeTitles?.synonyms ?? [])
25 | animeEntity.alternativeTitleEnglish = response.alternativeTitles?.english ?? "Unknown"
26 | animeEntity.alternativeTitleJapanese = response.alternativeTitles?.japanese ?? "Unknown"
27 | animeEntity.startDate = response.startDate ?? "Unknown"
28 | animeEntity.endDate = response.endDate ?? "Unknown"
29 | animeEntity.synopsis = response.synopsis ?? "Unknown"
30 | animeEntity.rating = response.rating ?? 0
31 | animeEntity.rank = response.rank ?? 0
32 | animeEntity.popularity = response.popularity ?? 0
33 | animeEntity.userAmount = response.userAmount
34 | animeEntity.favoriteAmount = response.favoriteAmount
35 | animeEntity.nsfw = response.nsfw?.name ?? "Unknown"
36 | animeEntity.genre.append(objectsIn: response.genres?.map { $0.name } ?? [])
37 | animeEntity.mediaType = response.mediaType.name
38 | animeEntity.status = response.status.name
39 | animeEntity.episodeAmount = response.episodeAmount
40 | animeEntity.startSeason = response.startSeason?.season.name ?? "Unknown"
41 | animeEntity.startSeasonYear = response.startSeason?.year.description ?? ""
42 | animeEntity.source = response.source?.name ?? "Unknown"
43 | animeEntity.episodeDuration = response.episodeDuration ?? 0
44 | animeEntity.studios.append(objectsIn: response.studios.map { $0.name })
45 | animeEntity.updatedAt = Date()
46 | return animeEntity
47 | }
48 |
49 | public func transformEntityToDomain(entity: AnimeModuleEntity) -> AnimeDomainModel {
50 | return AnimeDomainModel(
51 | id: entity.id,
52 | title: entity.title,
53 | mainPicture: entity.mainPicture,
54 | alternativeTitleSynonyms: Array(entity.alternativeTitleSynonyms),
55 | alternativeTitleEnglish: entity.alternativeTitleEnglish,
56 | alternativeTitleJapanese: entity.alternativeTitleJapanese,
57 | startDate: entity.startDate,
58 | endDate: entity.endDate,
59 | airedDate: AnimeTransformer.transformToAiredDate(startDate: entity.startDate, endDate: entity.endDate),
60 | synopsis: entity.synopsis,
61 | rating: entity.rating,
62 | rank: entity.rank,
63 | popularity: entity.popularity,
64 | userAmount: entity.userAmount,
65 | favoriteAmount: entity.favoriteAmount,
66 | nsfw: entity.nsfw,
67 | genre: Array(entity.genre),
68 | mediaType: entity.mediaType,
69 | status: entity.status,
70 | episodeAmount: entity.episodeAmount,
71 | startSeason: entity.startSeason,
72 | startSeasonYear: entity.startSeasonYear,
73 | source: entity.source,
74 | episodeDuration: entity.episodeDuration,
75 | episodeDurationText: AnimeTransformer.transformToDurationText(duration: entity.episodeDuration),
76 | studios: Array(entity.studios),
77 | isFavorite: entity.isFavorite
78 | )
79 | }
80 |
81 | public static func transformToAiredDate(startDate: String, endDate: String) -> String {
82 | // Start & end date unknown
83 | if startDate == "Unknown" && endDate == "Unknown" {
84 | return "unknown_label".localized(bundle: .module)
85 | }
86 |
87 | // Start or end date unknown
88 | let start = startDate == "Unknown"
89 | ? "unknown_label".localized(bundle: .module)
90 | : startDate.apiFullStringDateToFullStringDate()
91 | let end = endDate == "Unknown"
92 | ? "unknown_label".localized(bundle: .module)
93 | : endDate.apiFullStringDateToFullStringDate()
94 |
95 | // Start & end date same (mostly for movie)
96 | if start == end {
97 | return start
98 | }
99 |
100 | return "\(start) - \(end)"
101 | }
102 |
103 | public static func transformToDurationText(duration: Int) -> String {
104 | let (hours, minutes, _) = duration.secondsToHoursMinutesSeconds()
105 | return String(
106 | localized: "number_duration_label \(hours) \(minutes) \(0)",
107 | bundle: .module)
108 | }
109 |
110 | }
111 |
--------------------------------------------------------------------------------