Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Crypto List): Load more data #6

Merged
merged 11 commits into from
Jun 8, 2024
28 changes: 24 additions & 4 deletions CryptoWidgetKitApp.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
4D72664C2C049D800035E348 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D72664B2C049D800035E348 /* ContentView.swift */; };
4D72664E2C049D810035E348 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4D72664D2C049D810035E348 /* Assets.xcassets */; };
4D7266512C049D810035E348 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4D7266502C049D810035E348 /* Preview Assets.xcassets */; };
4D72665B2C049D820035E348 /* CryptoWidgetKitAppTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D72665A2C049D820035E348 /* CryptoWidgetKitAppTests.swift */; };
4D8251B32C05CDDB00DD32A3 /* CryptoModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251B22C05CDDB00DD32A3 /* CryptoModel.swift */; };
4D8251B62C05D21D00DD32A3 /* CryptoAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251B52C05D21D00DD32A3 /* CryptoAPIService.swift */; };
4D8251B82C05D2A200DD32A3 /* CryptoAPIEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251B72C05D2A100DD32A3 /* CryptoAPIEndpoint.swift */; };
Expand All @@ -21,6 +20,8 @@
4D8251C62C05EAD700DD32A3 /* HTTPMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251C52C05EAD700DD32A3 /* HTTPMethod.swift */; };
4D8251C82C05EBBB00DD32A3 /* MockURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251C72C05EBBB00DD32A3 /* MockURLSession.swift */; };
4D8251CA2C05EC4500DD32A3 /* URLSessionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251C92C05EC4500DD32A3 /* URLSessionProtocol.swift */; };
4D8251D12C0879E800DD32A3 /* View+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251D02C0879E800DD32A3 /* View+.swift */; };
4D8251D32C0DAD5000DD32A3 /* CryptoViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251D22C0DAD5000DD32A3 /* CryptoViewModelTests.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand All @@ -40,7 +41,6 @@
4D72664D2C049D810035E348 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
4D7266502C049D810035E348 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
4D7266562C049D820035E348 /* CryptoWidgetKitAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CryptoWidgetKitAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
4D72665A2C049D820035E348 /* CryptoWidgetKitAppTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoWidgetKitAppTests.swift; sourceTree = "<group>"; };
4D8251B22C05CDDB00DD32A3 /* CryptoModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoModel.swift; sourceTree = "<group>"; };
4D8251B52C05D21D00DD32A3 /* CryptoAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoAPIService.swift; sourceTree = "<group>"; };
4D8251B72C05D2A100DD32A3 /* CryptoAPIEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoAPIEndpoint.swift; sourceTree = "<group>"; };
Expand All @@ -50,6 +50,8 @@
4D8251C52C05EAD700DD32A3 /* HTTPMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPMethod.swift; sourceTree = "<group>"; };
4D8251C72C05EBBB00DD32A3 /* MockURLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLSession.swift; sourceTree = "<group>"; };
4D8251C92C05EC4500DD32A3 /* URLSessionProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionProtocol.swift; sourceTree = "<group>"; };
4D8251D02C0879E800DD32A3 /* View+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+.swift"; sourceTree = "<group>"; };
4D8251D22C0DAD5000DD32A3 /* CryptoViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoViewModelTests.swift; sourceTree = "<group>"; };
4D8251E42C0F7B0300DD32A3 /* CryptoWidgetKitApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = CryptoWidgetKitApp.xctestplan; sourceTree = "<group>"; };
/* End PBXFileReference section */

Expand Down Expand Up @@ -96,6 +98,7 @@
4D72664B2C049D800035E348 /* ContentView.swift */,
4D8251B12C05CDC400DD32A3 /* Models */,
4D8251B92C05DA6000DD32A3 /* ViewModels */,
4D8251CC2C0878B900DD32A3 /* Views */,
4D8251B42C05D20B00DD32A3 /* Network */,
4D7266732C049D930035E348 /* Resources */,
4D72664F2C049D810035E348 /* Preview Content */,
Expand All @@ -117,7 +120,7 @@
4D8251E42C0F7B0300DD32A3 /* CryptoWidgetKitApp.xctestplan */,
4D8251C02C05E9F100DD32A3 /* Mocks */,
4D8251C12C05E9FE00DD32A3 /* Network */,
4D72665A2C049D820035E348 /* CryptoWidgetKitAppTests.swift */,
4D8251D22C0DAD5000DD32A3 /* CryptoViewModelTests.swift */,
);
path = CryptoWidgetKitAppTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -182,6 +185,22 @@
path = Utils;
sourceTree = "<group>";
};
4D8251CC2C0878B900DD32A3 /* Views */ = {
isa = PBXGroup;
children = (
4D8251CF2C0879D400DD32A3 /* Extensions */,
);
path = Views;
sourceTree = "<group>";
};
4D8251CF2C0879D400DD32A3 /* Extensions */ = {
isa = PBXGroup;
children = (
4D8251D02C0879E800DD32A3 /* View+.swift */,
);
path = Extensions;
sourceTree = "<group>";
};
/* End PBXGroup section */

/* Begin PBXNativeTarget section */
Expand Down Expand Up @@ -286,6 +305,7 @@
4D8251B32C05CDDB00DD32A3 /* CryptoModel.swift in Sources */,
4D8251B62C05D21D00DD32A3 /* CryptoAPIService.swift in Sources */,
4D72664C2C049D800035E348 /* ContentView.swift in Sources */,
4D8251D12C0879E800DD32A3 /* View+.swift in Sources */,
4D8251B82C05D2A200DD32A3 /* CryptoAPIEndpoint.swift in Sources */,
4D8251C62C05EAD700DD32A3 /* HTTPMethod.swift in Sources */,
4D72664A2C049D800035E348 /* CryptoWidgetKitApp.swift in Sources */,
Expand All @@ -299,7 +319,7 @@
buildActionMask = 2147483647;
files = (
4D8251C82C05EBBB00DD32A3 /* MockURLSession.swift in Sources */,
4D72665B2C049D820035E348 /* CryptoWidgetKitAppTests.swift in Sources */,
4D8251D32C0DAD5000DD32A3 /* CryptoViewModelTests.swift in Sources */,
4D8251BF2C05E9BB00DD32A3 /* CryptoAPIServiceTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
46 changes: 29 additions & 17 deletions CryptoWidgetKitApp/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,38 @@
var body: some View {
@Bindable var cryptoViewModel = cryptoViewModel
NavigationStack {
List(cryptoViewModel.cryptos, id: \.coinInfo.id) { crypto in
HStack {
Text(crypto.coinInfo.fullName)
Spacer()
Text(crypto.display?.usd.price ?? "0")
List {
ForEach(cryptoViewModel.cryptos, id: \.coinInfo.id){ crypto in
HStack {
Text(crypto.coinInfo.fullName)
Spacer()
Text(crypto.display?.usd.price ?? "0")

Check warning on line 22 in CryptoWidgetKitApp/ContentView.swift

View check run for this annotation

Codecov / codecov/patch

CryptoWidgetKitApp/ContentView.swift#L17-L22

Added lines #L17 - L22 were not covered by tests
}
}
}
.redacted(reason: cryptoViewModel.isLoading ? .placeholder : [])
.task { await cryptoViewModel.fetch() }
.refreshable { await cryptoViewModel.fetch() }
.alert(isPresented: $cryptoViewModel.showError, content: {
Alert(title: Text("An error occurred, try again later"),
dismissButton: .default(
Text("Retry"),
action: {
if !cryptoViewModel.showSkeleton {
Section {
Button(action: {

Check warning on line 27 in CryptoWidgetKitApp/ContentView.swift

View check run for this annotation

Codecov / codecov/patch

CryptoWidgetKitApp/ContentView.swift#L25-L27

Added lines #L25 - L27 were not covered by tests
Task {
await cryptoViewModel.fetch()
await cryptoViewModel.loadMoreData()

Check warning on line 29 in CryptoWidgetKitApp/ContentView.swift

View check run for this annotation

Codecov / codecov/patch

CryptoWidgetKitApp/ContentView.swift#L29

Added line #L29 was not covered by tests
}
}, label: {
if cryptoViewModel.isLoading {
ProgressView()
} else {
Text("Load more")

Check warning on line 35 in CryptoWidgetKitApp/ContentView.swift

View check run for this annotation

Codecov / codecov/patch

CryptoWidgetKitApp/ContentView.swift#L31-L35

Added lines #L31 - L35 were not covered by tests
}
}))
})
})

Check warning on line 37 in CryptoWidgetKitApp/ContentView.swift

View check run for this annotation

Codecov / codecov/patch

CryptoWidgetKitApp/ContentView.swift#L37

Added line #L37 was not covered by tests
}
}
}
.redacted(reason: cryptoViewModel.showSkeleton ? .placeholder : [])
.task { await cryptoViewModel.fetchInitialData() }
.refreshable { await cryptoViewModel.refreshData() }
.errorAlert(isPresented: $cryptoViewModel.showError) {
Task {
await cryptoViewModel.refreshData()

Check warning on line 46 in CryptoWidgetKitApp/ContentView.swift

View check run for this annotation

Codecov / codecov/patch

CryptoWidgetKitApp/ContentView.swift#L41-L46

Added lines #L41 - L46 were not covered by tests
}
}
.toolbar {
ToolbarItemGroup(placement: .bottomBar) {
Text("API Cache 120 seconds")
Expand Down
25 changes: 24 additions & 1 deletion CryptoWidgetKitApp/CryptoWidgetKitApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,23 @@
import SwiftUI

@main
struct CryptoWidgetKitApp: App {
struct CryptoWidgetKitApp {

static func main() {
guard isProduction() else {
TestApp.main()
return
}
ProductionApp.main()

Check warning on line 18 in CryptoWidgetKitApp/CryptoWidgetKitApp.swift

View check run for this annotation

Codecov / codecov/patch

CryptoWidgetKitApp/CryptoWidgetKitApp.swift#L18

Added line #L18 was not covered by tests
}

private static func isProduction() -> Bool {
return NSClassFromString("XCTestCase") == nil
}
}

// MARK: - ProductionApp
struct ProductionApp: App {

@State var cryptoViewModel = CryptoViewModel()

Expand All @@ -19,3 +35,10 @@
}
}
}

// MARK: - TestApp
struct TestApp: App {
var body: some Scene {
WindowGroup {}
}
}
13 changes: 11 additions & 2 deletions CryptoWidgetKitApp/Network/CryptoAPIService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,20 @@
//

import Foundation
import os

// MARK: - CryptoAPIService
class CryptoAPIService {

// MARK: - Dependencies
private let session: URLSessionProtocol

// MARK: - Properties
private static let logger = Logger(
subsystem: Bundle.main.bundleIdentifier!,
category: String(describing: CryptoAPIService.self)
)

// MARK: - Init
init(session: URLSessionProtocol) {
self.session = session
Expand All @@ -28,9 +35,11 @@ extension CryptoAPIService {

/// Fetches the list of cryptocurrencies
/// - Returns: An array of `Crypto` objects.
func fetchRetrieveFullList() async throws -> Cryptos {
let request: URLRequest = .init(endpoint: .fullList, method: .get)
func fetchRetrieveTopList(page: Int? = nil) async throws -> Cryptos {
let request: URLRequest = .init(endpoint: .topList(page: page), method: .get)
Self.logger.debug("Fetching top list with request: \(request)")
let (data, _) = try await session.data(for: request)
Self.logger.debug("Received response with data: \(data)")
return try JSONDecoder().decode(Cryptos.self, from: data)
}
}
12 changes: 9 additions & 3 deletions CryptoWidgetKitApp/Network/Utils/CryptoAPIEndpoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,21 @@ import Foundation

// MARK: - CryptoAPIEndpoint
enum CryptoAPIEndpoint {
case fullList
case topList(page: Int?)
}

extension CryptoAPIEndpoint {

var url: URL {
switch self {
case .fullList:
URL(string: "https://min-api.cryptocompare.com/data/top/totalvolfull?tsym=USD")!
case .topList(let page):
var components = URLComponents(string: "https://min-api.cryptocompare.com/data/top/totalvolfull")!
var queryItems = [URLQueryItem(name: "tsym", value: "USD")]
if let page = page {
queryItems.append(URLQueryItem(name: "page", value: String(page)))
}
components.queryItems = queryItems
return components.url!
}
}
}
74 changes: 66 additions & 8 deletions CryptoWidgetKitApp/ViewModels/CryptoViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,21 @@ import Foundation
@Observable
final class CryptoViewModel {

// MARK: - Properties
// MARK: - Published Properties
private(set) var cryptos: [Crypto] = []
private(set) var isLoading: Bool = true
private(set) var showSkeleton: Bool = false
private(set) var isLoading: Bool = false
var showError: Bool = false

// MARK: - Private Properties
private var page: Int = 0 {
didSet {
if page < 0 {
page = 0
}
}
}

// MARK: - Dependencies
private let apiService: CryptoAPIService

Expand All @@ -31,15 +41,63 @@ final class CryptoViewModel {
extension CryptoViewModel {

/// Fetch the cryptos from API and update the cryptos array
func fetch() async {
isLoading = true
func fetchInitialData() async {
showSkeleton = true
defer {
if !showError {
showSkeleton = false
}
}
await fetchData(page: nil)
}

/// Load more cryptos from API and append to the cryptos array
func loadMoreData() async {
page += 1
await fetchData(page: page)
}

/// Refresh all data from API and update the cryptos array
func refreshData() async {
showSkeleton = true
defer {
if !showError {
showSkeleton = false
}
}
var allData: [Crypto] = []
for p in 0...page {
do {
let data = try await apiService.fetchRetrieveTopList(page: p).data
allData.append(contentsOf: data)
} catch {
showError = true
return
}
}
cryptos = allData
}
}

// MARK: - Private Methods
private extension CryptoViewModel {

/// Method to fetch data from API and update the cryptos array
func fetchData(page: Int?) async {
if page != nil {
isLoading = true
}
defer { isLoading = false }
do {
sleep(3)
cryptos = try await apiService.fetchRetrieveFullList().data
isLoading = false
let data = try await apiService.fetchRetrieveTopList(page: page).data
if page != nil && page! > 0 {
cryptos.append(contentsOf: data)
} else {
cryptos = data
}
} catch {
print(error)
showError = true
self.page = (page ?? 0) - 1
}
}
}
Expand Down
20 changes: 20 additions & 0 deletions CryptoWidgetKitApp/Views/Extensions/View+.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//
// View+.swift
// CryptoWidgetKitApp
//
// Created by Jose Jesus Torronteras Hernandez on 30/5/24.
//

import SwiftUI

extension View {

func errorAlert(isPresented: Binding<Bool>, retryAction: @escaping () -> Void) -> some View {
alert(isPresented: isPresented) {
Alert(
title: Text("An error occurred, try again later"),
dismissButton: .default(Text("Retry"), action: retryAction)
)

Check warning on line 17 in CryptoWidgetKitApp/Views/Extensions/View+.swift

View check run for this annotation

Codecov / codecov/patch

CryptoWidgetKitApp/Views/Extensions/View+.swift#L12-L17

Added lines #L12 - L17 were not covered by tests
}
}
}
Loading
Loading