diff --git a/MyLibrary/Package.swift b/MyLibrary/Package.swift index 404847f..43036d9 100644 --- a/MyLibrary/Package.swift +++ b/MyLibrary/Package.swift @@ -40,6 +40,13 @@ let package = Package( .process("Resources") ] ), + .target( + name: "FileClient", + dependencies: [ + "SharedModels", + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ), .target( name: "DependencyExtra", dependencies: [ @@ -65,6 +72,7 @@ let package = Package( name: "ScheduleFeature", dependencies: [ "DataClient", + "FileClient", "DependencyExtra", .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), ] diff --git a/MyLibrary/Sources/DataClient/Client.swift b/MyLibrary/Sources/DataClient/DataClient.swift similarity index 100% rename from MyLibrary/Sources/DataClient/Client.swift rename to MyLibrary/Sources/DataClient/DataClient.swift diff --git a/MyLibrary/Sources/FileClient/FileClient.swift b/MyLibrary/Sources/FileClient/FileClient.swift new file mode 100644 index 0000000..a983957 --- /dev/null +++ b/MyLibrary/Sources/FileClient/FileClient.swift @@ -0,0 +1,58 @@ +import Dependencies +import DependenciesMacros +import SharedModels +import Foundation + +public typealias Favorites = [String: [Session]] + +@DependencyClient +public struct FileClient { + public var loadFavorites: @Sendable () throws -> Favorites + public var saveFavorites: @Sendable (Favorites) throws -> Void +} + +extension FileClient: DependencyKey { + static public var liveValue: FileClient = .init( + loadFavorites: { + guard let saveData = serialize(from: "Favorites") else { + return [:] + } + let response = try jsonDecoder.decode(Favorites.self, from: saveData) + return response + }, + saveFavorites: { favorites in + guard let data = try? jsonEncoder.encode(favorites) else { + return + } + deserialize(data: data, into: "Favorites") + } + ) + + static func deserialize(data : Data, into filePath: String) { + guard let documentPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + return + } + let fileURL = documentPath.appendingPathComponent(filePath + ".json") + try? data.write(to: fileURL) + } + + static func serialize(from filePath: String) -> Data? { + guard let documentPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + return nil + } + let fileURL = documentPath.appendingPathComponent(filePath + ".json") + return try? Data(contentsOf: fileURL) + } +} + +let jsonEncoder = { + $0.dateEncodingStrategy = .iso8601 + $0.keyEncodingStrategy = .convertToSnakeCase + return $0 +}(JSONEncoder()) + +let jsonDecoder = { + $0.dateDecodingStrategy = .iso8601 + $0.keyDecodingStrategy = .convertFromSnakeCase + return $0 +}(JSONDecoder()) diff --git a/MyLibrary/Sources/ScheduleFeature/Schedule.swift b/MyLibrary/Sources/ScheduleFeature/Schedule.swift index 5138b62..b3854c2 100644 --- a/MyLibrary/Sources/ScheduleFeature/Schedule.swift +++ b/MyLibrary/Sources/ScheduleFeature/Schedule.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import DataClient +import FileClient import Foundation import SharedModels import SwiftUI @@ -31,8 +32,20 @@ public struct Schedule { var day1: Conference? var day2: Conference? var workshop: Conference? + var favorites: Favorites = [:] @Presents var destination: Destination.State? + var selectedConference: Conference? { + switch selectedDay { + case .day1: + day1 + case .day2: + day2 + case .day3: + workshop + } + } + public init() {} } @@ -41,11 +54,13 @@ public struct Schedule { case path(StackAction) case destination(PresentationAction) case view(View) - case fetchResponse(Result) + case fetchResponse(Result<(scheduleResponse: SchedulesResponse, favorites: Favorites), Error>) + case savedFavorites(Session, Conference) public enum View { case onAppear case disclosureTapped(Session) + case favoriteIconTapped(Session) } } @@ -58,6 +73,7 @@ public struct Schedule { public enum Destination {} @Dependency(DataClient.self) var dataClient + @Dependency(FileClient.self) var fileClient public init() {} @@ -66,14 +82,17 @@ public struct Schedule { Reduce { state, action in switch action { case .view(.onAppear): - return .send( - .fetchResponse( - Result { - let day1 = try dataClient.fetchDay1() - let day2 = try dataClient.fetchDay2() - let workshop = try dataClient.fetchWorkshop() - return .init(day1: day1, day2: day2, workshop: workshop) - })) + return .run { send in + await send( + .fetchResponse( + Result { + let day1 = try dataClient.fetchDay1() + let day2 = try dataClient.fetchDay2() + let workshop = try dataClient.fetchWorkshop() + let favorites = try fileClient.loadFavorites() + return (.init(day1: day1, day2: day2, workshop: workshop), favorites) + })) + } case let .view(.disclosureTapped(session)): guard let description = session.description, let speakers = session.speakers else { return .none @@ -89,10 +108,29 @@ public struct Schedule { ) ) return .none + case let .view(.favoriteIconTapped(session)): + let conference = switch state.selectedDay { + case .day1: + state.day1! + case .day2: + state.day2! + case .day3: + state.workshop! + } + var favorites = state.favorites + favorites.updateFavoriteState(of: session, in: conference) + return .run { [favorites = favorites] send in + try? fileClient.saveFavorites(favorites) + await send(.savedFavorites(session, conference)) + } + case let .savedFavorites(session, day): + state.favorites.updateFavoriteState(of: session, in: day) + return .none case let .fetchResponse(.success(response)): - state.day1 = response.day1 - state.day2 = response.day2 - state.workshop = response.workshop + state.day1 = response.scheduleResponse.day1 + state.day2 = response.scheduleResponse.day2 + state.workshop = response.scheduleResponse.workshop + state.favorites = response.favorites return .none case let .fetchResponse(.failure(error as DecodingError)): assertionFailure(error.localizedDescription) @@ -109,6 +147,21 @@ public struct Schedule { } } +private extension Favorites { + mutating func updateFavoriteState(of session: Session, in conference: Conference) { + guard var favorites = self[conference.title] else { + self[conference.title] = [session] + return + } + if favorites.contains(session) { + self[conference.title] = favorites.filter { $0 != session } + } else { + favorites.append(session) + self[conference.title] = favorites + } + } +} + @ViewAction(for: Schedule.self) public struct ScheduleView: View { @@ -257,6 +310,28 @@ public struct ScheduleView: View { } } .frame(maxWidth: .infinity, alignment: .leading) + + favoriteIcon(for: session) + .onTapGesture { + send(.favoriteIconTapped(session)) + } + } + } + + @ViewBuilder + func favoriteIcon(for session: Session) -> some View { + let isFavorited = { + guard let favorites = store.favorites[store.selectedConference!.title] else { + return false + } + return favorites.contains(session) + }() + if isFavorited { + Image(systemName: "star.fill") + .foregroundColor(.yellow) + } else { + Image(systemName: "star") + .foregroundColor(.gray) } } diff --git a/MyLibrary/Tests/ScheduleFeatureTests/Mocks.swift b/MyLibrary/Tests/ScheduleFeatureTests/Mocks.swift index 4cc3b4f..ae9fc98 100644 --- a/MyLibrary/Tests/ScheduleFeatureTests/Mocks.swift +++ b/MyLibrary/Tests/ScheduleFeatureTests/Mocks.swift @@ -1,5 +1,8 @@ import Foundation import SharedModels +import FileClient + +@testable import ScheduleFeature extension Conference { static let mock1 = Self( @@ -95,3 +98,7 @@ extension Speaker { ] ) } + +extension Favorites { + static let mock1 = [Conference.mock1.title: [Session.mock1]] +} diff --git a/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift b/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift index f0f0954..c737679 100644 --- a/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift +++ b/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift @@ -1,24 +1,28 @@ import ComposableArchitecture import DataClient +import FileClient +import SharedModels import XCTest @testable import ScheduleFeature final class ScheduleTests: XCTestCase { @MainActor - func testFetchData() async { + func testOnAppear() async { let store = TestStore(initialState: Schedule.State()) { Schedule() } withDependencies: { $0[DataClient.self].fetchDay1 = { @Sendable in .mock1 } $0[DataClient.self].fetchDay2 = { @Sendable in .mock2 } $0[DataClient.self].fetchWorkshop = { @Sendable in .mock3 } + $0[FileClient.self].loadFavorites = { @Sendable in .mock1 } } await store.send(.view(.onAppear)) await store.receive(\.fetchResponse.success) { $0.day1 = .mock1 $0.day2 = .mock2 $0.workshop = .mock3 + $0.favorites = .mock1 } } @@ -31,8 +35,74 @@ final class ScheduleTests: XCTestCase { $0[DataClient.self].fetchDay1 = { @Sendable in throw FetchError() } $0[DataClient.self].fetchDay2 = { @Sendable in .mock2 } $0[DataClient.self].fetchWorkshop = { @Sendable in .mock3 } + $0[FileClient.self].loadFavorites = { @Sendable in .mock1} } await store.send(.view(.onAppear)) await store.receive(\.fetchResponse.failure) } + + @MainActor + func testLoadFavoritesFailure() async { + struct LoadError: Equatable, Error {} + let store = TestStore(initialState: Schedule.State()) { + Schedule() + } withDependencies: { + $0[DataClient.self].fetchDay1 = { @Sendable in .mock1 } + $0[DataClient.self].fetchDay2 = { @Sendable in .mock2 } + $0[DataClient.self].fetchWorkshop = { @Sendable in .mock3 } + $0[FileClient.self].loadFavorites = { @Sendable in throw LoadError() } + } + await store.send(.view(.onAppear)) + await store.receive(\.fetchResponse.failure) + } + + @MainActor + func testAddingFavorites() async { + let initialState: ScheduleFeature.Schedule.State = ScheduleTests.selectingDay1ScheduleWithNoFavorites + let firstSession = initialState.day1!.schedules.first!.sessions.first! + let firstSessionFavorited = [initialState.day1!.title: [firstSession]] + let store = TestStore(initialState: initialState) { + Schedule() + } withDependencies: { + $0[FileClient.self].saveFavorites = { @Sendable in XCTAssertEqual($0, firstSessionFavorited) } + } + + await store.send(.view(.favoriteIconTapped(firstSession))) + await store.receive(\.savedFavorites) { + $0.favorites = firstSessionFavorited + } + } + + @MainActor + func testRemovingFavorites() async { + let initialState: ScheduleFeature.Schedule.State = ScheduleTests.selectingDay1ScheduleWithOneFavorite + let firstSession = initialState.day1!.schedules.first!.sessions.first! + let noFavorites: Favorites = [initialState.day1!.title: []] + let store = TestStore(initialState: initialState) { + Schedule() + } withDependencies: { + $0[FileClient.self].saveFavorites = { @Sendable in XCTAssertEqual($0, noFavorites) } + } + + await store.send(.view(.favoriteIconTapped(firstSession))) + await store.receive(\.savedFavorites) { + $0.favorites = noFavorites + } + } + + static let selectingDay1ScheduleWithNoFavorites = { + var initialState = Schedule.State() + initialState.selectedDay = .day1 + initialState.day1 = .mock1 + return initialState + }() + + static let selectingDay1ScheduleWithOneFavorite = { + var initialState = Schedule.State() + initialState.selectedDay = .day1 + initialState.day1 = .mock1 + let firstSession = initialState.day1!.schedules.first!.sessions.first! + initialState.favorites = [initialState.day1!.title: [firstSession]] + return initialState + }() }