Thanks to visit codestin.com
Credit goes to github.com

Skip to content
This repository was archived by the owner on Jun 5, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions Bookie/UI/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,34 @@
//

import Fashion
import SwiftData
@preconcurrency import Swinject
import Then
import UIKit

let dependenciesContainer = {
let result = Container()
let objectScope: ObjectScope = .container
result.register(AnyCoordinator.self) { _ in
CoordinatorSwiftUI()
}.inObjectScope(.container)
}.inObjectScope(objectScope)
result.register(Stylesheet.self) { _ in
MainStylesheet()
}.inObjectScope(.container)
}.inObjectScope(objectScope)
result.register(RemoteDataSource.self) { _ in
GoogleRemoteDataSource()
}.inObjectScope(objectScope)
result.register(LocalDataSource.self) { _ in
if #available(iOS 17, *), let container = {
let configuration = ModelConfiguration(for: BookSwiftData.self)
let schema = Schema([BookSwiftData.self])
return try? ModelContainer(for: schema, configurations: [configuration])
}() {
return SwiftDataSource(modelContainer: container)
} else {
return RealmDataSource()
}
}
return result
}()

Expand Down
35 changes: 12 additions & 23 deletions Bookie/ViewModel/BooksViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,19 @@ final class BooksViewModel {
}

func reloadData() async {
guard let source = dependenciesContainer.resolve(RemoteDataSource.self),
let localSource = dependenciesContainer.resolve(LocalDataSource.self)
else {
return
}
do {
let books = try await currentData()
let books: BookResponse
if let booksRemote = try? await source.search(text: searchText.value) {
books = booksRemote
} else {
books = try await localSource.search(text: searchText.value)
}
try await localSource.save(books: books.items)

data = books
oldSet = newSet
Expand All @@ -93,28 +104,6 @@ final class BooksViewModel {
}
}

private func currentData() async throws (BooksViewModelError) -> BookResponse {
let provider = MoyaProvider<BooksService>(plugins: [
NetworkLoggerPlugin(configuration: .init(
logOptions: [.requestBody, .successResponseBody, .errorResponseBody]
)),
])
do {
guard let response = try await provider.requestPublisher(.volumes(query: searchText.value)).values.first(
where: { _ in true }
) else {
throw BooksViewModelError.noData
}
do {
return try JSONDecoder().decode(BookResponse.self, from: response.data)
} catch {
throw BooksViewModelError.parseError(error)
}
} catch {
throw BooksViewModelError.requestError(error)
}
}

func numberOfItemsInSection(_ section: Int) -> Int {
newSet[section].elements.count
}
Expand Down
42 changes: 42 additions & 0 deletions Bookie/ViewModel/GoogleRemoteDataSource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//
// GoogleRemoteDataSource.swift
// Bookie
//
// Created by Roman Podymov on 25/04/2025.
// Copyright © 2025 Bookie. All rights reserved.
//

import Foundation
import Moya

protocol RemoteDataSource {
func search(text: String) async throws (BooksViewModelError) -> BookResponse
}

protocol LocalDataSource: RemoteDataSource {
func save(books: [Book]) async throws (BooksViewModelError)
}

struct GoogleRemoteDataSource: RemoteDataSource {
func search(text: String) async throws (BooksViewModelError) -> BookResponse {
let provider = MoyaProvider<BooksService>(plugins: [
NetworkLoggerPlugin(configuration: .init(
logOptions: [.requestBody, .successResponseBody, .errorResponseBody]
)),
])
do {
guard let response = try await provider.requestPublisher(.volumes(query: text)).values.first(
where: { _ in true }
) else {
throw BooksViewModelError.noData
}
do {
return try JSONDecoder().decode(BookResponse.self, from: response.data)
} catch {
throw BooksViewModelError.parseError(error)
}
} catch {
throw BooksViewModelError.requestError(error)
}
}
}
78 changes: 78 additions & 0 deletions Bookie/ViewModel/RealmDataSource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
//
// RealmDataSource.swift
// Bookie
//
// Created by Roman Podymov on 25/04/2025.
// Copyright © 2025 Bookie. All rights reserved.
//

import Foundation
import RealmSwift

final class BookRealm: Object, Identifiable {
@Persisted(primaryKey: true) var id: String
@Persisted var title: String
}

struct RealmDataSource: LocalDataSource {
init() {
Realm.Configuration.defaultConfiguration.deleteRealmIfMigrationNeeded = true
}

func search(text: String) async throws (BooksViewModelError) -> BookResponse {
do {
return try await Task { @MainActor in
let realm = try await Realm()
let books = realm.objects(BookRealm.self).map {
Book(
kind: "",
id: $0.id,
etag: "",
volumeInfo: .init(
title: $0.title,
authors: nil,
publisher: nil,
publishedDate: nil,
description: nil,
industryIdentifiers: nil,
pageCount: nil,
printType: nil,
categories: nil,
averageRating: nil,
ratingsCount: nil,
imageLinks: nil,
language: "cs"
),
saleInfo: nil,
accessInfo: nil,
searchInfo: nil
)
}
let booksForTitle = books.filter { book in
book.volumeInfo.title.contains(text)
}
return BookResponse(kind: "", totalItems: booksForTitle.count, items: Array(booksForTitle))
}.value
} catch {
throw BooksViewModelError.noData
}
}

func save(books: [Book]) async throws (BooksViewModelError) {
do {
try await Task { @MainActor in
let realm = try await Realm()
for book in books {
try realm.write {
let obj = BookRealm()
obj.id = book.id
obj.title = book.volumeInfo.title
realm.add(obj)
}
}
}.value
} catch {
throw BooksViewModelError.noData
}
}
}
74 changes: 74 additions & 0 deletions Bookie/ViewModel/SwiftDataSource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//
// SwiftDataSource.swift
// Bookie
//
// Created by Roman Podymov on 27/04/2025.
// Copyright © 2025 Bookie. All rights reserved.
//

import SwiftData

@available(iOS 17, *)
@Model
final class BookSwiftData {
var id: String
var title: String

init(id: String, title: String) {
self.id = id
self.title = title
}
}

@available(iOS 17, *)
@ModelActor
actor SwiftDataSource: LocalDataSource {
private var context: ModelContext { modelExecutor.modelContext }

func search(text: String) async throws (BooksViewModelError) -> BookResponse {
do {
let books = try context.fetch(FetchDescriptor<BookSwiftData>()).map {
Book(
kind: "",
id: $0.id,
etag: "",
volumeInfo: .init(
title: $0.title,
authors: nil,
publisher: nil,
publishedDate: nil,
description: nil,
industryIdentifiers: nil,
pageCount: nil,
printType: nil,
categories: nil,
averageRating: nil,
ratingsCount: nil,
imageLinks: nil,
language: "cs"
),
saleInfo: nil,
accessInfo: nil,
searchInfo: nil
)
}
let booksForTitle = books.filter { book in
book.volumeInfo.title.contains(text)
}
return .init(kind: "", totalItems: booksForTitle.count, items: booksForTitle)
} catch {
throw BooksViewModelError.noData
}
}

func save(books: [Book]) async throws (BooksViewModelError) {
do {
books.forEach {
context.insert(BookSwiftData(id: $0.id, title: $0.volumeInfo.title))
}
try context.save()
} catch {
throw BooksViewModelError.noData
}
}
}
91 changes: 89 additions & 2 deletions BookieTests/BookieTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,103 @@
//

@testable import BookieApp
import Swinject
import XCTest

extension Book: @retroactive Equatable {
public static func == (lhs: BookieApp.Book, rhs: BookieApp.Book) -> Bool {
lhs.id == rhs.id
}
}

private final class TestScreen: AnyBooksScreen {
@MainActor
var testCheck: (@Sendable (DataSetType) -> Void)!

required init(searchText _: String, previousBook _: Book?) {}

func onNewDataReceived(oldSet _: DataSetType, newSet: DataSetType) async {
await testCheck(newSet)
}

func onNewDataError(_: BooksViewModelError) async {}

func onSearchTextChanged(_: String) async {}
}

class BookieTests: XCTestCase {
private var screen: TestScreen!
private static let expected = [Book(
kind: "",
id: "1",
etag: "",
volumeInfo: .init(
title: "",
authors: nil,
publisher: nil,
publishedDate: nil,
description: nil,
industryIdentifiers: nil,
pageCount: nil,
printType: nil,
categories: nil,
averageRating: nil,
ratingsCount: nil,
imageLinks: nil,
language: "cs"
),
saleInfo: nil,
accessInfo: nil,
searchInfo: nil
)]

override class func setUp() {
super.setUp()

let objectScope: ObjectScope = .container
dependenciesContainer.register(RemoteDataSource.self) { _ in
TestRemoteDataSource(expected: Self.expected)
}.inObjectScope(objectScope)
}

func testAsyncMap() async {
let mappedValue = await (10 as Int?).mapAsync(someAsyncFunc)
// Given
let source = 10 as Int?

// When
let mappedValue = await source.mapAsync(Self.someAsyncFunc)

// Then
XCTAssertEqual(mappedValue, 100)
}

@Sendable private func someAsyncFunc(previousValue: Int) async -> Int {
private static func someAsyncFunc(previousValue: Int) async -> Int {
_ = try? await Task.sleep(nanoseconds: 1_000_000_000)
return previousValue * previousValue
}

@MainActor
func testBooksViewModel() async {
screen = TestScreen(searchText: "", previousBook: nil)
screen.testCheck = { @Sendable newSet in
// Then
XCTAssertEqual(newSet.first?.elements, Self.expected)
}

// When
let viewModel = BooksViewModel(screen: screen, searchText: "", previousBook: nil)
await viewModel.reloadData()
}
}

private struct TestRemoteDataSource: RemoteDataSource {
let expected: [Book]

func search(text _: String) async throws (BooksViewModelError) -> BookResponse {
.init(
kind: "",
totalItems: expected.count,
items: expected
)
}
}
Loading