diff --git a/EasyCrypto.xcodeproj/project.pbxproj b/EasyCrypto.xcodeproj/project.pbxproj index 2b7af0d..802784e 100644 --- a/EasyCrypto.xcodeproj/project.pbxproj +++ b/EasyCrypto.xcodeproj/project.pbxproj @@ -92,6 +92,7 @@ 459B45D12A0BBCD3001C93BA /* Double + Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 459B45D02A0BBCD3001C93BA /* Double + Extension.swift */; }; 45B8DF022A0A3E8400BF2EB7 /* MockDataAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45B8DF012A0A3E8400BF2EB7 /* MockDataAction.swift */; }; 45B8DF062A0A6C7400BF2EB7 /* CacheStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45B8DF052A0A6C7400BF2EB7 /* CacheStack.swift */; }; + 65239A462BCD0F3B001FC67D /* HandleViewModelStateModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65239A452BCD0F3B001FC67D /* HandleViewModelStateModifier.swift */; }; A803DFC4297E92D000357A7F /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = A803DFC3297E92D000357A7F /* Color.swift */; }; A803DFC9297E93B700357A7F /* FontManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A803DFC5297E93B600357A7F /* FontManager.swift */; }; A803DFD3297E943100357A7F /* Cancelable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A803DFD2297E943100357A7F /* Cancelable.swift */; }; @@ -230,6 +231,7 @@ 459B45D02A0BBCD3001C93BA /* Double + Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double + Extension.swift"; sourceTree = ""; }; 45B8DF012A0A3E8400BF2EB7 /* MockDataAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDataAction.swift; sourceTree = ""; }; 45B8DF052A0A6C7400BF2EB7 /* CacheStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheStack.swift; sourceTree = ""; }; + 65239A452BCD0F3B001FC67D /* HandleViewModelStateModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandleViewModelStateModifier.swift; sourceTree = ""; }; A803DFBE297E91FF00357A7F /* Launch Screen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = "Launch Screen.storyboard"; sourceTree = ""; }; A803DFC3297E92D000357A7F /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = ""; }; A803DFC5297E93B600357A7F /* FontManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FontManager.swift; sourceTree = ""; }; @@ -637,14 +639,6 @@ path = TabItemView; sourceTree = ""; }; - 457B87862A0F7ECE00B04D9E /* ViewDidLoadModifier */ = { - isa = PBXGroup; - children = ( - 457B87872A0F7EE400B04D9E /* ViewDidLoadModifier.swift */, - ); - path = ViewDidLoadModifier; - sourceTree = ""; - }; 457B87902A0F8E5E00B04D9E /* CoinDetail */ = { isa = PBXGroup; children = ( @@ -672,6 +666,15 @@ path = Cache; sourceTree = ""; }; + 65239A482BCD104F001FC67D /* ViewModifier */ = { + isa = PBXGroup; + children = ( + 65239A452BCD0F3B001FC67D /* HandleViewModelStateModifier.swift */, + 457B87872A0F7EE400B04D9E /* ViewDidLoadModifier.swift */, + ); + path = ViewModifier; + sourceTree = ""; + }; A803DFB7297E918900357A7F /* Theme */ = { isa = PBXGroup; children = ( @@ -701,7 +704,6 @@ A803DFBA297E91BF00357A7F /* Core */ = { isa = PBXGroup; children = ( - 457B87862A0F7ECE00B04D9E /* ViewDidLoadModifier */, A803DFB9297E91AF00357A7F /* Components */, 027A1B0629D08301008B10D2 /* Log */, 02D8622229BBAE4500F77BC7 /* WorkScheduler */, @@ -772,6 +774,7 @@ A803DFCD297E93F600357A7F /* Support */ = { isa = PBXGroup; children = ( + 65239A482BCD104F001FC67D /* ViewModifier */, A851A2342989155F007F4CF9 /* MessageHelper */, A851A22F29891214007F4CF9 /* Extension */, ); @@ -1203,6 +1206,7 @@ A851A22B29890C0B007F4CF9 /* Configuration.swift in Sources */, A8D4A10E299E0E1300C11107 /* AppRouter.swift in Sources */, 02BC668029B882F400785196 /* PlaceholderModifier.swift in Sources */, + 65239A462BCD0F3B001FC67D /* HandleViewModelStateModifier.swift in Sources */, A8D4A109299E0A6F00C11107 /* CoinDetailCoordinator.swift in Sources */, 02593C53298E992500ADDA20 /* MarketPriceRepository.swift in Sources */, A851A21029890B75007F4CF9 /* NetworkClientManager.swift in Sources */, diff --git a/EasyCrypto/Base/BaseViewModel.swift b/EasyCrypto/Base/BaseViewModel.swift index 3c35af3..a7b7ce5 100644 --- a/EasyCrypto/Base/BaseViewModel.swift +++ b/EasyCrypto/Base/BaseViewModel.swift @@ -7,11 +7,12 @@ import Foundation import Combine +import SwiftUI enum ViewModelStatus: Equatable { case loadStart case dismissAlert - case emptyStateHandler(title: String, isShow: Bool) + case emptyStateHandler(title: String) } protocol BaseViewModelEventSource: AnyObject { @@ -19,59 +20,46 @@ protocol BaseViewModelEventSource: AnyObject { } protocol ViewModelService: AnyObject { - func callWithProgress(argument: AnyPublisher, - callback: @escaping (_ data: ReturnType?) -> Void) - func callWithoutProgress(argument: AnyPublisher, - callback: @escaping (_ data: ReturnType?) -> Void) + func call(callWithIndicator: Bool, + argument: AnyPublisher, + callback: @escaping (_ data: ReturnType?) -> Void) } typealias BaseViewModel = BaseViewModelEventSource & ViewModelService open class DefaultViewModel: BaseViewModel, ObservableObject { - + var loadingState = CurrentValueSubject(.dismissAlert) let subscriber = Cancelable() - - func callWithProgress(argument: AnyPublisher, - callback: @escaping (_ data: ReturnType?) -> Void) { - self.loadingState.send(.loadStart) - + + func call(callWithIndicator: Bool = true, + argument: AnyPublisher, + callback: @escaping (_ data: ReturnType?) -> Void) { + + if callWithIndicator { + self.loadingState.send(.loadStart) + } + let completionHandler: (Subscribers.Completion) -> Void = { [weak self] completion in switch completion { case .failure(let error): self?.loadingState.send(.dismissAlert) - self?.loadingState.send(.emptyStateHandler(title: error.desc, isShow: true)) + self?.loadingState.send(.emptyStateHandler(title: error.desc)) case .finished: self?.loadingState.send(.dismissAlert) } } - + let resultValueHandler: (ReturnType?) -> Void = { data in callback(data) } - + argument .subscribe(on: WorkScheduler.backgroundWorkScheduler) .receive(on: WorkScheduler.mainScheduler) .sink(receiveCompletion: completionHandler, receiveValue: resultValueHandler) .store(in: subscriber) } - - func callWithoutProgress(argument: AnyPublisher, - callback: @escaping (_ data: ReturnType?) -> Void) { - - let resultValueHandler: (ReturnType?) -> Void = { data in - callback(data) - } - - argument - .subscribe(on: WorkScheduler.backgroundWorkScheduler) - .receive(on: WorkScheduler.mainScheduler) - .sink(receiveCompletion: {_ in }, receiveValue: resultValueHandler) - .store(in: subscriber) - } } diff --git a/EasyCrypto/Core/Components/AlertView/CustomAlertView.swift b/EasyCrypto/Core/Components/AlertView/CustomAlertView.swift index 5e5f43d..2a60815 100644 --- a/EasyCrypto/Core/Components/AlertView/CustomAlertView.swift +++ b/EasyCrypto/Core/Components/AlertView/CustomAlertView.swift @@ -23,7 +23,6 @@ struct CustomAlertView: View { .resizable() .scaledToFit() .frame(width: 80, height: 80) - // swiftlint:disable:next opening_brace } else if let title = title { Text(title) .font(.headline) diff --git a/EasyCrypto/Core/Constants/Constants.swift b/EasyCrypto/Core/Constants/Constants.swift index b097a60..64389c1 100644 --- a/EasyCrypto/Core/Constants/Constants.swift +++ b/EasyCrypto/Core/Constants/Constants.swift @@ -14,6 +14,7 @@ enum Constants { enum Title { static let mainTitle: String = "EasyCrypto" static let detailTitle: String = "Coin Detail" + static let errorTitle: String = "Error" } enum PlaceHolder { static let searchCoins: String = "Search coins" diff --git a/EasyCrypto/Presentation/CoinDetail/View/CoinDetailView.swift b/EasyCrypto/Presentation/CoinDetail/View/CoinDetailView.swift index c9c0592..368c6ee 100644 --- a/EasyCrypto/Presentation/CoinDetail/View/CoinDetailView.swift +++ b/EasyCrypto/Presentation/CoinDetail/View/CoinDetailView.swift @@ -18,6 +18,8 @@ struct CoinDetailView: Coordinatable { @ObservedObject private(set) var viewModel: CoinDetailViewModel @State private var isLoading: Bool = false + @State private var presentAlert = false + @State private var alertMessage: String = "" let subscriber = Cancelable() var id: String? @@ -62,8 +64,12 @@ struct CoinDetailView: Coordinatable { .onAppear { self.viewModel.apply(.onAppear(id: self.id.orWhenNilOrEmpty(""))) } + .handleViewModelState(viewModel: viewModel, + isLoading: $isLoading, + alertMessage: $alertMessage, + presentAlert: $presentAlert) } - }.onAppear(perform: handleState) + } } } @@ -73,23 +79,6 @@ extension CoinDetailView { } } -extension CoinDetailView { - private func handleState() { - self.viewModel.loadingState - .receive(on: WorkScheduler.mainThread) - .sink { state in - switch state { - case .loadStart: - self.isLoading = true - case .dismissAlert: - self.isLoading = false - case .emptyStateHandler(_, _): - self.isLoading = false - } - }.store(in: subscriber) - } -} - struct CoinDetailView_Previews: PreviewProvider { static var previews: some View { CoinDetailView(viewModel: CoinDetailViewModel()) diff --git a/EasyCrypto/Presentation/CoinDetail/ViewModel/CoinDetailViewModel.swift b/EasyCrypto/Presentation/CoinDetail/ViewModel/CoinDetailViewModel.swift index 37a75af..d33653e 100644 --- a/EasyCrypto/Presentation/CoinDetail/ViewModel/CoinDetailViewModel.swift +++ b/EasyCrypto/Presentation/CoinDetail/ViewModel/CoinDetailViewModel.swift @@ -50,7 +50,7 @@ extension CoinDetailViewModel: DataFlowProtocol { func getCoinDetailData(id: String) { guard !String.isNilOrEmpty(string: id) else {return} - self.callWithProgress(argument: self.coinDetailUsecase.execute(id: id)) { [weak self] data in + self.call(argument: self.coinDetailUsecase.execute(id: id)) { [weak self] data in guard let data = data else {return} self?.coinData = data } diff --git a/EasyCrypto/Presentation/Detail/View/DetailView.swift b/EasyCrypto/Presentation/Detail/View/DetailView.swift index 5d12a86..9cccb07 100644 --- a/EasyCrypto/Presentation/Detail/View/DetailView.swift +++ b/EasyCrypto/Presentation/Detail/View/DetailView.swift @@ -22,6 +22,10 @@ struct DetailView: View { } var body: some View { + content + } + + var content: some View { ZStack { Color.darkBlue .edgesIgnoringSafeArea(.all) diff --git a/EasyCrypto/Presentation/Main/Coordinator/MainCoordinator.swift b/EasyCrypto/Presentation/Main/Coordinator/MainCoordinator.swift index d489ec0..d914e64 100644 --- a/EasyCrypto/Presentation/Main/Coordinator/MainCoordinator.swift +++ b/EasyCrypto/Presentation/Main/Coordinator/MainCoordinator.swift @@ -9,7 +9,7 @@ import SwiftUI import Combine struct MainCoordinator: CoordinatorProtocol { - + @StateObject var viewModel: MainViewModel @State var activeRoute: Destination? = Destination(route: .first(item: MarketsPrice())) @@ -38,9 +38,9 @@ struct MainCoordinator: CoordinatorProtocol { extension MainCoordinator { struct Destination: DestinationProtocol { - + var route: MainView.Routes - + @ViewBuilder var content: some View { switch route { diff --git a/EasyCrypto/Presentation/Main/View/MainView.swift b/EasyCrypto/Presentation/Main/View/MainView.swift index 83ad547..7cc2712 100644 --- a/EasyCrypto/Presentation/Main/View/MainView.swift +++ b/EasyCrypto/Presentation/Main/View/MainView.swift @@ -12,7 +12,7 @@ struct MainView: Coordinatable { typealias Route = Routes - @StateObject var viewModel: MainViewModel + @ObservedObject var viewModel: MainViewModel enum Constant { static let searchHeight: CGFloat = 55 @@ -24,12 +24,16 @@ struct MainView: Coordinatable { @State private var shouldShowDropdown = false @State private var searchText: String = .empty @State private var isLoading: Bool = false - @State private var presentAlert = true - @State private var alertMesagee: String = "" + @State private var presentAlert = false + @State private var alertMessage: String = "" let subscriber = Cancelable() var body: some View { + content + } + + var content: some View { NavigationView { ZStack { Color.darkBlue @@ -63,15 +67,15 @@ struct MainView: Coordinatable { .padding(.top, 20) TabView(selection: $tabIndex) { if tabIndex == 0 { - coinsList() + coinsList } else { - whishList() + whishList } } .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) Spacer() - if !presentAlert { - self.showAlert("Error", alertMesagee) + if presentAlert { + self.showAlert(viewModel.errorTitle, alertMessage) } } } @@ -80,10 +84,14 @@ struct MainView: Coordinatable { .onViewDidLoad { self.viewModel.apply(.onAppear) } + .handleViewModelState(viewModel: viewModel, + isLoading: $isLoading, + alertMessage: $alertMessage, + presentAlert: $presentAlert) } - .onAppear(perform: handleState) } - func coinsList() -> some View { + + var coinsList: some View { ScrollView { LazyVStack { ForEach(viewModel.marketData, id: \.id) { item in @@ -111,7 +119,8 @@ struct MainView: Coordinatable { .padding() } } - func whishList() -> some View { + + var whishList: some View { ScrollView { VStack { ForEach(viewModel.wishListData, id: \.symbol) { item in @@ -135,25 +144,6 @@ extension MainView { } } -extension MainView { - private func handleState() { - self.viewModel.loadingState - .receive(on: WorkScheduler.mainThread) - .sink { state in - switch state { - case .loadStart: - self.isLoading = true - case .dismissAlert: - self.isLoading = false - case .emptyStateHandler(let message, _): - self.isLoading = false - self.presentAlert = false - self.alertMesagee = message - } - }.store(in: subscriber) - } -} - extension MainView { func showAlert(_ title: String, _ message: String) -> some View { CustomAlertView(title: title, message: message, primaryButtonLabel: "Retry", primaryButtonAction: { diff --git a/EasyCrypto/Presentation/Main/ViewModel/MainViewModel.swift b/EasyCrypto/Presentation/Main/ViewModel/MainViewModel.swift index 89d6e1f..f08d8c0 100644 --- a/EasyCrypto/Presentation/Main/ViewModel/MainViewModel.swift +++ b/EasyCrypto/Presentation/Main/ViewModel/MainViewModel.swift @@ -19,6 +19,7 @@ protocol DefaultMainViewModel: MainViewModelProtocol { } final class MainViewModel: DefaultViewModel, DefaultMainViewModel { let title: String = Constants.Title.mainTitle + let errorTitle: String = Constants.Title.errorTitle private let marketPriceUsecase: MarketPriceUsecaseProtocol private let searchMarketUsecase: SearchMarketUsecaseProtocol @@ -100,7 +101,13 @@ extension MainViewModel: DataFlowProtocol { func getMarketData(vs_currency: String = "usd", order: String = "market_cap_desc", sparkline: Bool = false) { - self.callWithProgress(argument: self.marketPriceUsecase.execute(vs_currency: vs_currency, + + // Check if the number of market data entries is already 30 to limit service calls + if marketData.count == 30 { + return + } + + self.call(argument: self.marketPriceUsecase.execute(vs_currency: vs_currency, order: order, per_page: self.perPage, page: self.page, @@ -112,7 +119,7 @@ extension MainViewModel: DataFlowProtocol { } func searchMarketData(text: String) { - self.callWithProgress(argument: self.searchMarketUsecase.execute(text: text)) { [weak self] data in + self.call(argument: self.searchMarketUsecase.execute(text: text)) { [weak self] data in let coin = data?.coins ?? [] self?.searchData = [] self?.searchData = coin diff --git a/EasyCrypto/Support/Extension/View + Extension.swift b/EasyCrypto/Support/Extension/View + Extension.swift index 57299da..3225a9b 100644 --- a/EasyCrypto/Support/Extension/View + Extension.swift +++ b/EasyCrypto/Support/Extension/View + Extension.swift @@ -18,3 +18,15 @@ extension View { UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) } } + +extension View { + func handleViewModelState(viewModel: DefaultViewModel, + isLoading: Binding, + alertMessage: Binding, + presentAlert: Binding) -> some View { + self.modifier(HandleViewModelStateModifier(viewModel: viewModel, + isLoading: isLoading, + alertMessage: alertMessage, + presentAlert: presentAlert)) + } +} diff --git a/EasyCrypto/Support/ViewModifier/HandleViewModelStateModifier.swift b/EasyCrypto/Support/ViewModifier/HandleViewModelStateModifier.swift new file mode 100644 index 0000000..94772d2 --- /dev/null +++ b/EasyCrypto/Support/ViewModifier/HandleViewModelStateModifier.swift @@ -0,0 +1,34 @@ +// +// HandleViewModelStateModifier.swift +// EasyCrypto +// +// Created by Kamalifard, Mehran | TDD on 2024/04/15. +// + +import SwiftUI + +struct HandleViewModelStateModifier: ViewModifier { + + @ObservedObject var viewModel: DefaultViewModel + @Binding var isLoading: Bool + @Binding var alertMessage: String + @Binding var presentAlert: Bool + + func body(content: Content) -> some View { + content + .onReceive(viewModel.loadingState.receive(on: DispatchQueue.main)) { state in + switch state { + case .loadStart: + isLoading = true + case .dismissAlert: + isLoading = false + alertMessage = "" + presentAlert = false + case .emptyStateHandler(let message): + isLoading = false + alertMessage = message + presentAlert = true + } + } + } +} diff --git a/EasyCrypto/Core/ViewDidLoadModifier/ViewDidLoadModifier.swift b/EasyCrypto/Support/ViewModifier/ViewDidLoadModifier.swift similarity index 100% rename from EasyCrypto/Core/ViewDidLoadModifier/ViewDidLoadModifier.swift rename to EasyCrypto/Support/ViewModifier/ViewDidLoadModifier.swift diff --git a/EasyCryptoTests/Cases/ViewModel/BaseViewModelTest.swift b/EasyCryptoTests/Cases/ViewModel/BaseViewModelTest.swift index b659110..4d84f3b 100644 --- a/EasyCryptoTests/Cases/ViewModel/BaseViewModelTest.swift +++ b/EasyCryptoTests/Cases/ViewModel/BaseViewModelTest.swift @@ -42,7 +42,7 @@ final class BaseViewModelTest: XCTestCase { XCTAssertEqual(event, .loadStart) }.store(in: &subscriber) - viewModel?.callWithProgress(argument: self.remote.fetch(vs_currency: .empty, + viewModel?.call(argument: self.remote.fetch(vs_currency: .empty, order: .empty, per_page: 1, page: 1, @@ -72,7 +72,7 @@ final class BaseViewModelTest: XCTestCase { let viewModel = self.viewModelToTest // Act - viewModel?.callWithoutProgress(argument: self.remote.fetch(vs_currency: .empty, + viewModel?.call(argument: self.remote.fetch(vs_currency: .empty, order: .empty, per_page: 1, page: 1,