From 875b195a5b3b4e74e25f46054ee9c904e6ca4f60 Mon Sep 17 00:00:00 2001 From: Yusuke Uchida <65172567+ewa1989@users.noreply.github.com> Date: Sun, 17 Mar 2024 22:48:50 +0900 Subject: [PATCH 01/22] add star icon in the right of session row --- MyLibrary/Sources/ScheduleFeature/Schedule.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/MyLibrary/Sources/ScheduleFeature/Schedule.swift b/MyLibrary/Sources/ScheduleFeature/Schedule.swift index 8786f2e..ca76ab8 100644 --- a/MyLibrary/Sources/ScheduleFeature/Schedule.swift +++ b/MyLibrary/Sources/ScheduleFeature/Schedule.swift @@ -266,6 +266,9 @@ public struct ScheduleView: View { } } .frame(maxWidth: .infinity, alignment: .leading) + + Image(systemName: "star") + .foregroundColor(.gray) } } From 33888d91c74569fcb06b405b1c563a75846fc6ed Mon Sep 17 00:00:00 2001 From: Yusuke Uchida <65172567+ewa1989@users.noreply.github.com> Date: Mon, 18 Mar 2024 00:22:44 +0900 Subject: [PATCH 02/22] change icon according to the session is favorited --- MyLibrary/Sources/ScheduleFeature/Schedule.swift | 10 ++++++++++ MyLibrary/Sources/SharedModels/Conference.swift | 1 + 2 files changed, 11 insertions(+) diff --git a/MyLibrary/Sources/ScheduleFeature/Schedule.swift b/MyLibrary/Sources/ScheduleFeature/Schedule.swift index ca76ab8..d06fb67 100644 --- a/MyLibrary/Sources/ScheduleFeature/Schedule.swift +++ b/MyLibrary/Sources/ScheduleFeature/Schedule.swift @@ -267,6 +267,16 @@ public struct ScheduleView: View { } .frame(maxWidth: .infinity, alignment: .leading) + favoriteIcon(for: session) + } + } + + @ViewBuilder + func favoriteIcon(for session: Session) -> some View { + if let isFavorited = session.isFavorited, isFavorited { + Image(systemName: "star.fill") + .foregroundColor(.yellow) + } else { Image(systemName: "star") .foregroundColor(.gray) } diff --git a/MyLibrary/Sources/SharedModels/Conference.swift b/MyLibrary/Sources/SharedModels/Conference.swift index 17c470a..d9e2431 100644 --- a/MyLibrary/Sources/SharedModels/Conference.swift +++ b/MyLibrary/Sources/SharedModels/Conference.swift @@ -29,6 +29,7 @@ public struct Session: Codable, Equatable, Hashable, Sendable { public var place: String? public var description: String? public var requirements: String? + public var isFavorited: Bool? public init( title: String, speakers: [Speaker]?, place: String?, description: String?, From 08a2b0dd31bb231162799caede497088cc5c5a02 Mon Sep 17 00:00:00 2001 From: Yusuke Uchida <65172567+ewa1989@users.noreply.github.com> Date: Mon, 18 Mar 2024 00:26:01 +0900 Subject: [PATCH 03/22] create new action, view favoriteIconTapped --- MyLibrary/Sources/ScheduleFeature/Schedule.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/MyLibrary/Sources/ScheduleFeature/Schedule.swift b/MyLibrary/Sources/ScheduleFeature/Schedule.swift index d06fb67..efb2e84 100644 --- a/MyLibrary/Sources/ScheduleFeature/Schedule.swift +++ b/MyLibrary/Sources/ScheduleFeature/Schedule.swift @@ -43,6 +43,7 @@ public struct Schedule { case onAppear case disclosureTapped(Session) case mapItemTapped + case favoriteIconTapped(Session) } } @@ -93,6 +94,8 @@ public struct Schedule { #elseif os(visionOS) return .run { _ in await openURL(url) } #endif + case let .view(.favoriteIconTapped(session)): + return .none case .binding, .path, .destination: return .none } @@ -268,6 +271,9 @@ public struct ScheduleView: View { .frame(maxWidth: .infinity, alignment: .leading) favoriteIcon(for: session) + .onTapGesture { + send(.favoriteIconTapped(session)) + } } } From c32c5bfcc29376e01e702302e33dd6cf72a0f1b9 Mon Sep 17 00:00:00 2001 From: Yusuke Uchida <65172567+ewa1989@users.noreply.github.com> Date: Mon, 18 Mar 2024 00:34:37 +0900 Subject: [PATCH 04/22] change to toggle favorite condition of tapped session --- .../Sources/ScheduleFeature/Schedule.swift | 14 ++++++++++++ .../Sources/SharedModels/Conference.swift | 22 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/MyLibrary/Sources/ScheduleFeature/Schedule.swift b/MyLibrary/Sources/ScheduleFeature/Schedule.swift index efb2e84..049110e 100644 --- a/MyLibrary/Sources/ScheduleFeature/Schedule.swift +++ b/MyLibrary/Sources/ScheduleFeature/Schedule.swift @@ -95,6 +95,14 @@ public struct Schedule { return .run { _ in await openURL(url) } #endif case let .view(.favoriteIconTapped(session)): + switch state.selectedDay { + case .day1: + state.day1 = update(state.day1!, togglingFavoriteOf: session) + case .day2: + state.day2 = update(state.day2!, togglingFavoriteOf: session) + case .day3: + state.workshop = update(state.workshop!, togglingFavoriteOf: session) + } return .none case .binding, .path, .destination: return .none @@ -103,6 +111,12 @@ public struct Schedule { .forEach(\.path, action: \.path) .ifLet(\.$destination, action: \.destination) } + + private func update(_ conference: Conference, togglingFavoriteOf session: Session) -> Conference { + var newValue = conference + newValue.toggleFavorite(of: session) + return newValue + } } @ViewAction(for: Schedule.self) diff --git a/MyLibrary/Sources/SharedModels/Conference.swift b/MyLibrary/Sources/SharedModels/Conference.swift index d9e2431..8a23d89 100644 --- a/MyLibrary/Sources/SharedModels/Conference.swift +++ b/MyLibrary/Sources/SharedModels/Conference.swift @@ -10,6 +10,12 @@ public struct Conference: Codable, Equatable, Hashable, Sendable { self.date = date self.schedules = schedules } + + public mutating func toggleFavorite(of session: Session) { + for index in schedules.indices { + schedules[index].toggleFavorite(of: session) + } + } } public struct Schedule: Codable, Equatable, Hashable, Sendable { @@ -20,6 +26,12 @@ public struct Schedule: Codable, Equatable, Hashable, Sendable { self.time = time self.sessions = sessions } + + mutating func toggleFavorite(of session: Session) { + for index in sessions.indices { + sessions[index].toggleFavoriteIfEqual(with: session) + } + } } public struct Session: Codable, Equatable, Hashable, Sendable { @@ -41,4 +53,14 @@ public struct Session: Codable, Equatable, Hashable, Sendable { self.description = description self.requirements = requirements } + + mutating func toggleFavoriteIfEqual(with session: Session) { + if self == session { + if let isFavorited = isFavorited, isFavorited { + self.isFavorited = false + } else { + self.isFavorited = true + } + } + } } From cf49338ea3f8190e148ea4f1002be58756401945 Mon Sep 17 00:00:00 2001 From: Yusuke Uchida <65172567+ewa1989@users.noreply.github.com> Date: Mon, 18 Mar 2024 00:41:52 +0900 Subject: [PATCH 05/22] change to save and load conferences in UserDefaullts --- MyLibrary/Sources/DataClient/Client.swift | 43 +++++++++++++++++-- .../Sources/ScheduleFeature/Schedule.swift | 9 +++- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/MyLibrary/Sources/DataClient/Client.swift b/MyLibrary/Sources/DataClient/Client.swift index 0bb4377..3fb3ad6 100644 --- a/MyLibrary/Sources/DataClient/Client.swift +++ b/MyLibrary/Sources/DataClient/Client.swift @@ -10,23 +10,29 @@ public struct DataClient { public var fetchWorkshop: @Sendable () throws -> Conference public var fetchSponsors: @Sendable () throws -> Sponsors public var fetchOrganizers: @Sendable () throws -> [Organizer] + public var saveDay1: @Sendable (Conference) throws -> Void + public var saveDay2: @Sendable (Conference) throws -> Void + public var saveWorkshop: @Sendable (Conference) throws -> Void } extension DataClient: DependencyKey { + private static let day1 = "day1" + private static let day2 = "day2" + private static let workshop = "workshop" static public var liveValue: DataClient = .init( fetchDay1: { - let data = loadDataFromBundle(fileName: "day1") + let data = loadData(dayOf: day1) let response = try jsonDecoder.decode(Conference.self, from: data) return response }, fetchDay2: { - let data = loadDataFromBundle(fileName: "day2") + let data = loadData(dayOf: day2) let response = try jsonDecoder.decode(Conference.self, from: data) return response }, fetchWorkshop: { - let data = loadDataFromBundle(fileName: "workshop") + let data = loadData(dayOf: workshop) let response = try jsonDecoder.decode(Conference.self, from: data) return response }, @@ -39,15 +45,40 @@ extension DataClient: DependencyKey { let data = loadDataFromBundle(fileName: "organizers") let response = try jsonDecoder.decode([Organizer].self, from: data) return response + }, + saveDay1: { conference in + saveDataToUserDefaults(conference, as: day1) + }, + saveDay2: { conference in + saveDataToUserDefaults(conference, as: day2) + }, + saveWorkshop: { conference in + saveDataToUserDefaults(conference, as: workshop) } ) + static func loadData(dayOf day: String) -> Data { + if let saveData = loadDataFromUserDefaults(key: day) { + return saveData + } + return loadDataFromBundle(fileName: day) + } + static func loadDataFromBundle(fileName: String) -> Data { let filePath = Bundle.module.path(forResource: fileName, ofType: "json")! let fileURL = URL(fileURLWithPath: filePath) let data = try! Data(contentsOf: fileURL) return data } + + static func saveDataToUserDefaults(_ conference : Conference, as key: String) { + let data = try? jsonEncoder.encode(conference) + UserDefaults.standard.set(data, forKey: key) + } + + static func loadDataFromUserDefaults(key: String) -> Data? { + return UserDefaults.standard.data(forKey: key) + } } let jsonDecoder = { @@ -55,3 +86,9 @@ let jsonDecoder = { $0.keyDecodingStrategy = .convertFromSnakeCase return $0 }(JSONDecoder()) + +let jsonEncoder = { + $0.dateEncodingStrategy = .iso8601 + $0.keyEncodingStrategy = .convertToSnakeCase + return $0 +}(JSONEncoder()) diff --git a/MyLibrary/Sources/ScheduleFeature/Schedule.swift b/MyLibrary/Sources/ScheduleFeature/Schedule.swift index 049110e..543829b 100644 --- a/MyLibrary/Sources/ScheduleFeature/Schedule.swift +++ b/MyLibrary/Sources/ScheduleFeature/Schedule.swift @@ -103,7 +103,14 @@ public struct Schedule { case .day3: state.workshop = update(state.workshop!, togglingFavoriteOf: session) } - return .none + let day1 = state.day1! + let day2 = state.day2! + let workshop = state.workshop! + return .run { _ in + try? dataClient.saveDay1(day1) + try? dataClient.saveDay2(day2) + try? dataClient.saveWorkshop(workshop) + } case .binding, .path, .destination: return .none } From 846098f979eb18c7dff4be8e3d7261139302f177 Mon Sep 17 00:00:00 2001 From: Yusuke Uchida <65172567+ewa1989@users.noreply.github.com> Date: Wed, 20 Mar 2024 17:34:11 +0900 Subject: [PATCH 06/22] change save data type as `[String: [Session]]` for performance --- MyLibrary/Sources/DataClient/Client.swift | 42 +++++++---------- .../Sources/ScheduleFeature/Schedule.swift | 46 ++++++++++--------- .../Sources/SharedModels/Conference.swift | 23 ---------- .../Sources/SharedModels/Favorites.swift | 30 ++++++++++++ 4 files changed, 71 insertions(+), 70 deletions(-) create mode 100644 MyLibrary/Sources/SharedModels/Favorites.swift diff --git a/MyLibrary/Sources/DataClient/Client.swift b/MyLibrary/Sources/DataClient/Client.swift index 3fb3ad6..d83e456 100644 --- a/MyLibrary/Sources/DataClient/Client.swift +++ b/MyLibrary/Sources/DataClient/Client.swift @@ -10,29 +10,25 @@ public struct DataClient { public var fetchWorkshop: @Sendable () throws -> Conference public var fetchSponsors: @Sendable () throws -> Sponsors public var fetchOrganizers: @Sendable () throws -> [Organizer] - public var saveDay1: @Sendable (Conference) throws -> Void - public var saveDay2: @Sendable (Conference) throws -> Void - public var saveWorkshop: @Sendable (Conference) throws -> Void + public var loadFavorites: @Sendable () throws -> Favorites + public var saveFavorites: @Sendable (Favorites) throws -> Void } extension DataClient: DependencyKey { - private static let day1 = "day1" - private static let day2 = "day2" - private static let workshop = "workshop" static public var liveValue: DataClient = .init( fetchDay1: { - let data = loadData(dayOf: day1) + let data = loadDataFromBundle(fileName: "day1") let response = try jsonDecoder.decode(Conference.self, from: data) return response }, fetchDay2: { - let data = loadData(dayOf: day2) + let data = loadDataFromBundle(fileName: "day2") let response = try jsonDecoder.decode(Conference.self, from: data) return response }, fetchWorkshop: { - let data = loadData(dayOf: workshop) + let data = loadDataFromBundle(fileName: "workshop") let response = try jsonDecoder.decode(Conference.self, from: data) return response }, @@ -45,25 +41,19 @@ extension DataClient: DependencyKey { let data = loadDataFromBundle(fileName: "organizers") let response = try jsonDecoder.decode([Organizer].self, from: data) return response + }, + loadFavorites: { + guard let saveData = loadDataFromUserDefaults(key: "Favorites") else { + return .init(eachConferenceFavorites: []) + } + let response = try jsonDecoder.decode(Favorites.self, from: saveData) + return response }, - saveDay1: { conference in - saveDataToUserDefaults(conference, as: day1) - }, - saveDay2: { conference in - saveDataToUserDefaults(conference, as: day2) - }, - saveWorkshop: { conference in - saveDataToUserDefaults(conference, as: workshop) + saveFavorites: { favorites in + saveDataToUserDefaults(favorites, as: "Favorites") } ) - static func loadData(dayOf day: String) -> Data { - if let saveData = loadDataFromUserDefaults(key: day) { - return saveData - } - return loadDataFromBundle(fileName: day) - } - static func loadDataFromBundle(fileName: String) -> Data { let filePath = Bundle.module.path(forResource: fileName, ofType: "json")! let fileURL = URL(fileURLWithPath: filePath) @@ -71,8 +61,8 @@ extension DataClient: DependencyKey { return data } - static func saveDataToUserDefaults(_ conference : Conference, as key: String) { - let data = try? jsonEncoder.encode(conference) + static func saveDataToUserDefaults(_ favorites : Favorites, as key: String) { + let data = try? jsonEncoder.encode(favorites) UserDefaults.standard.set(data, forKey: key) } diff --git a/MyLibrary/Sources/ScheduleFeature/Schedule.swift b/MyLibrary/Sources/ScheduleFeature/Schedule.swift index c6da62f..148515a 100644 --- a/MyLibrary/Sources/ScheduleFeature/Schedule.swift +++ b/MyLibrary/Sources/ScheduleFeature/Schedule.swift @@ -32,6 +32,8 @@ public struct Schedule { var day1: Conference? var day2: Conference? var workshop: Conference? + + var favorites: Favorites = .init(eachConferenceFavorites: []) @Presents var destination: Destination.State? public init() { @@ -44,7 +46,7 @@ public struct Schedule { case path(StackAction) case destination(PresentationAction) case view(View) - case fetchResponse(Result) + case fetchResponse(Result<(schedules: SchedulesResponse, favorites: Favorites), Error>) public enum View { case onAppear @@ -80,7 +82,8 @@ public struct Schedule { let day1 = try dataClient.fetchDay1() let day2 = try dataClient.fetchDay2() let workshop = try dataClient.fetchWorkshop() - return .init(day1: day1, day2: day2, workshop: workshop) + let favorites = try dataClient.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 { @@ -108,24 +111,21 @@ public struct Schedule { case let .view(.favoriteIconTapped(session)): switch state.selectedDay { case .day1: - state.day1 = update(state.day1!, togglingFavoriteOf: session) + state.favorites.updateFavoriteState(of: session, in: state.day1!) case .day2: - state.day2 = update(state.day2!, togglingFavoriteOf: session) + state.favorites.updateFavoriteState(of: session, in: state.day2!) case .day3: - state.workshop = update(state.workshop!, togglingFavoriteOf: session) + state.favorites.updateFavoriteState(of: session, in: state.workshop!) } - let day1 = state.day1! - let day2 = state.day2! - let workshop = state.workshop! + let favorites = state.favorites return .run { _ in - try? dataClient.saveDay1(day1) - try? dataClient.saveDay2(day2) - try? dataClient.saveWorkshop(workshop) + try? dataClient.saveFavorites(favorites) } case let .fetchResponse(.success(response)): - state.day1 = response.day1 - state.day2 = response.day2 - state.workshop = response.workshop + state.day1 = response.schedules.day1 + state.day2 = response.schedules.day2 + state.workshop = response.schedules.workshop + state.favorites = response.favorites return .none case let .fetchResponse(.failure(error as DecodingError)): assertionFailure(error.localizedDescription) @@ -140,12 +140,6 @@ public struct Schedule { .forEach(\.path, action: \.path) .ifLet(\.$destination, action: \.destination) } - - private func update(_ conference: Conference, togglingFavoriteOf session: Session) -> Conference { - var newValue = conference - newValue.toggleFavorite(of: session) - return newValue - } } @ViewAction(for: Schedule.self) @@ -322,7 +316,17 @@ public struct ScheduleView: View { @ViewBuilder func favoriteIcon(for session: Session) -> some View { - if let isFavorited = session.isFavorited, isFavorited { + let conference = + switch store.selectedDay { + case .day1: + store.day1! + case .day2: + store.day2! + case .day3: + store.workshop! + } + + if store.favorites.isFavorited(session, in: conference) { Image(systemName: "star.fill") .foregroundColor(.yellow) } else { diff --git a/MyLibrary/Sources/SharedModels/Conference.swift b/MyLibrary/Sources/SharedModels/Conference.swift index 8a23d89..17c470a 100644 --- a/MyLibrary/Sources/SharedModels/Conference.swift +++ b/MyLibrary/Sources/SharedModels/Conference.swift @@ -10,12 +10,6 @@ public struct Conference: Codable, Equatable, Hashable, Sendable { self.date = date self.schedules = schedules } - - public mutating func toggleFavorite(of session: Session) { - for index in schedules.indices { - schedules[index].toggleFavorite(of: session) - } - } } public struct Schedule: Codable, Equatable, Hashable, Sendable { @@ -26,12 +20,6 @@ public struct Schedule: Codable, Equatable, Hashable, Sendable { self.time = time self.sessions = sessions } - - mutating func toggleFavorite(of session: Session) { - for index in sessions.indices { - sessions[index].toggleFavoriteIfEqual(with: session) - } - } } public struct Session: Codable, Equatable, Hashable, Sendable { @@ -41,7 +29,6 @@ public struct Session: Codable, Equatable, Hashable, Sendable { public var place: String? public var description: String? public var requirements: String? - public var isFavorited: Bool? public init( title: String, speakers: [Speaker]?, place: String?, description: String?, @@ -53,14 +40,4 @@ public struct Session: Codable, Equatable, Hashable, Sendable { self.description = description self.requirements = requirements } - - mutating func toggleFavoriteIfEqual(with session: Session) { - if self == session { - if let isFavorited = isFavorited, isFavorited { - self.isFavorited = false - } else { - self.isFavorited = true - } - } - } } diff --git a/MyLibrary/Sources/SharedModels/Favorites.swift b/MyLibrary/Sources/SharedModels/Favorites.swift new file mode 100644 index 0000000..e43656c --- /dev/null +++ b/MyLibrary/Sources/SharedModels/Favorites.swift @@ -0,0 +1,30 @@ +public struct Favorites: Equatable, Codable { + private var eachConferenceFavorites: [String: [Session]] + + public init(eachConferenceFavorites: [(conference: Conference, favoriteSessions: [Session])]) { + self.eachConferenceFavorites = [:] + for conferenceFavorites in eachConferenceFavorites { + self.eachConferenceFavorites[conferenceFavorites.conference.title] = conferenceFavorites.favoriteSessions + } + } + + public mutating func updateFavoriteState(of session: Session, in conference: Conference) { + guard var favorites = eachConferenceFavorites[conference.title] else { + eachConferenceFavorites[conference.title] = [session] + return + } + if favorites.contains(session) { + eachConferenceFavorites[conference.title] = favorites.filter { $0 != session } + } else { + favorites.append(session) + eachConferenceFavorites[conference.title] = favorites + } + } + + public func isFavorited(_ session: Session, in conference: Conference) -> Bool { + guard let favorites = eachConferenceFavorites[conference.title] else { + return false + } + return favorites.contains(session) + } +} From 23983b585d6da422a944416d2e44475a55d26ce8 Mon Sep 17 00:00:00 2001 From: Yusuke Uchida <65172567+ewa1989@users.noreply.github.com> Date: Wed, 20 Mar 2024 17:38:17 +0900 Subject: [PATCH 07/22] extract favorites load/save methods to FileClient, separating with fetching methods which might change API Client in future --- .../{Client.swift => DataClient.swift} | 27 ------------- MyLibrary/Sources/DataClient/FileClient.swift | 40 +++++++++++++++++++ .../Sources/ScheduleFeature/Schedule.swift | 5 ++- 3 files changed, 43 insertions(+), 29 deletions(-) rename MyLibrary/Sources/DataClient/{Client.swift => DataClient.swift} (66%) create mode 100644 MyLibrary/Sources/DataClient/FileClient.swift diff --git a/MyLibrary/Sources/DataClient/Client.swift b/MyLibrary/Sources/DataClient/DataClient.swift similarity index 66% rename from MyLibrary/Sources/DataClient/Client.swift rename to MyLibrary/Sources/DataClient/DataClient.swift index d83e456..0bb4377 100644 --- a/MyLibrary/Sources/DataClient/Client.swift +++ b/MyLibrary/Sources/DataClient/DataClient.swift @@ -10,8 +10,6 @@ public struct DataClient { public var fetchWorkshop: @Sendable () throws -> Conference public var fetchSponsors: @Sendable () throws -> Sponsors public var fetchOrganizers: @Sendable () throws -> [Organizer] - public var loadFavorites: @Sendable () throws -> Favorites - public var saveFavorites: @Sendable (Favorites) throws -> Void } extension DataClient: DependencyKey { @@ -41,16 +39,6 @@ extension DataClient: DependencyKey { let data = loadDataFromBundle(fileName: "organizers") let response = try jsonDecoder.decode([Organizer].self, from: data) return response - }, - loadFavorites: { - guard let saveData = loadDataFromUserDefaults(key: "Favorites") else { - return .init(eachConferenceFavorites: []) - } - let response = try jsonDecoder.decode(Favorites.self, from: saveData) - return response - }, - saveFavorites: { favorites in - saveDataToUserDefaults(favorites, as: "Favorites") } ) @@ -60,15 +48,6 @@ extension DataClient: DependencyKey { let data = try! Data(contentsOf: fileURL) return data } - - static func saveDataToUserDefaults(_ favorites : Favorites, as key: String) { - let data = try? jsonEncoder.encode(favorites) - UserDefaults.standard.set(data, forKey: key) - } - - static func loadDataFromUserDefaults(key: String) -> Data? { - return UserDefaults.standard.data(forKey: key) - } } let jsonDecoder = { @@ -76,9 +55,3 @@ let jsonDecoder = { $0.keyDecodingStrategy = .convertFromSnakeCase return $0 }(JSONDecoder()) - -let jsonEncoder = { - $0.dateEncodingStrategy = .iso8601 - $0.keyEncodingStrategy = .convertToSnakeCase - return $0 -}(JSONEncoder()) diff --git a/MyLibrary/Sources/DataClient/FileClient.swift b/MyLibrary/Sources/DataClient/FileClient.swift new file mode 100644 index 0000000..1831240 --- /dev/null +++ b/MyLibrary/Sources/DataClient/FileClient.swift @@ -0,0 +1,40 @@ +import Dependencies +import DependenciesMacros +import SharedModels +import Foundation + +@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 = loadDataFromUserDefaults(key: "Favorites") else { + return .init(eachConferenceFavorites: []) + } + let response = try jsonDecoder.decode(Favorites.self, from: saveData) + return response + }, + saveFavorites: { favorites in + saveDataToUserDefaults(favorites, as: "Favorites") + } + ) + + static func saveDataToUserDefaults(_ favorites : Favorites, as key: String) { + let data = try? jsonEncoder.encode(favorites) + UserDefaults.standard.set(data, forKey: key) + } + + static func loadDataFromUserDefaults(key: String) -> Data? { + return UserDefaults.standard.data(forKey: key) + } +} + +let jsonEncoder = { + $0.dateEncodingStrategy = .iso8601 + $0.keyEncodingStrategy = .convertToSnakeCase + return $0 +}(JSONEncoder()) diff --git a/MyLibrary/Sources/ScheduleFeature/Schedule.swift b/MyLibrary/Sources/ScheduleFeature/Schedule.swift index 148515a..a8cbb20 100644 --- a/MyLibrary/Sources/ScheduleFeature/Schedule.swift +++ b/MyLibrary/Sources/ScheduleFeature/Schedule.swift @@ -67,6 +67,7 @@ public struct Schedule { } @Dependency(DataClient.self) var dataClient + @Dependency(FileClient.self) var fileClient @Dependency(\.openURL) var openURL public init() {} @@ -82,7 +83,7 @@ public struct Schedule { let day1 = try dataClient.fetchDay1() let day2 = try dataClient.fetchDay2() let workshop = try dataClient.fetchWorkshop() - let favorites = try dataClient.loadFavorites() + let favorites = try fileClient.loadFavorites() return (.init(day1: day1, day2: day2, workshop: workshop), favorites) })) case let .view(.disclosureTapped(session)): @@ -119,7 +120,7 @@ public struct Schedule { } let favorites = state.favorites return .run { _ in - try? dataClient.saveFavorites(favorites) + try? fileClient.saveFavorites(favorites) } case let .fetchResponse(.success(response)): state.day1 = response.schedules.day1 From 0e8b40340006e6aba6054a936211d851674df4ba Mon Sep 17 00:00:00 2001 From: Yusuke Uchida <65172567+ewa1989@users.noreply.github.com> Date: Wed, 20 Mar 2024 18:24:50 +0900 Subject: [PATCH 08/22] change file save/load area to Document that is more appropriate because favorite data aren't customizing app's behavior --- MyLibrary/Sources/DataClient/FileClient.swift | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/MyLibrary/Sources/DataClient/FileClient.swift b/MyLibrary/Sources/DataClient/FileClient.swift index 1831240..3735a00 100644 --- a/MyLibrary/Sources/DataClient/FileClient.swift +++ b/MyLibrary/Sources/DataClient/FileClient.swift @@ -12,24 +12,34 @@ public struct FileClient { extension FileClient: DependencyKey { static public var liveValue: FileClient = .init( loadFavorites: { - guard let saveData = loadDataFromUserDefaults(key: "Favorites") else { + guard let saveData = loadDataFromFile(named: "Favorites") else { return .init(eachConferenceFavorites: []) } let response = try jsonDecoder.decode(Favorites.self, from: saveData) return response }, saveFavorites: { favorites in - saveDataToUserDefaults(favorites, as: "Favorites") + guard let data = try? jsonEncoder.encode(favorites) else { + return + } + saveDataToFile(data, named: "Favorites") } ) - static func saveDataToUserDefaults(_ favorites : Favorites, as key: String) { - let data = try? jsonEncoder.encode(favorites) - UserDefaults.standard.set(data, forKey: key) + static func saveDataToFile(_ data : Data, named fileName: String) { + guard let documentPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + return + } + let fileURL = documentPath.appendingPathComponent(fileName + ".json") + try? data.write(to: fileURL) } - static func loadDataFromUserDefaults(key: String) -> Data? { - return UserDefaults.standard.data(forKey: key) + static func loadDataFromFile(named fileName: String) -> Data? { + guard let documentPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + return nil + } + let fileURL = documentPath.appendingPathComponent(fileName + ".json") + return try? Data(contentsOf: fileURL) } } From daa7fa715e3a76cc1b63983c9e76af761ad7f863 Mon Sep 17 00:00:00 2001 From: Yusuke Uchida <65172567+ewa1989@users.noreply.github.com> Date: Wed, 20 Mar 2024 18:35:32 +0900 Subject: [PATCH 09/22] fix failed test due to adding load Favorites when onAppear --- MyLibrary/Tests/ScheduleFeatureTests/Mocks.swift | 6 ++++++ MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift | 3 +++ 2 files changed, 9 insertions(+) diff --git a/MyLibrary/Tests/ScheduleFeatureTests/Mocks.swift b/MyLibrary/Tests/ScheduleFeatureTests/Mocks.swift index 4cc3b4f..cb1d789 100644 --- a/MyLibrary/Tests/ScheduleFeatureTests/Mocks.swift +++ b/MyLibrary/Tests/ScheduleFeatureTests/Mocks.swift @@ -95,3 +95,9 @@ extension Speaker { ] ) } + +extension Favorites { + static let mock1 = Self(eachConferenceFavorites: [ + (.mock1, [.mock1]) + ]) +} diff --git a/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift b/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift index f0f0954..9278c2c 100644 --- a/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift +++ b/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift @@ -13,12 +13,14 @@ final class ScheduleTests: XCTestCase { $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,6 +33,7 @@ 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) From 1a5419aea0b1885bf929ecd0e8b2fa9012a454c7 Mon Sep 17 00:00:00 2001 From: Yusuke Uchida <65172567+ewa1989@users.noreply.github.com> Date: Wed, 20 Mar 2024 19:02:41 +0900 Subject: [PATCH 10/22] separate saving favorites and updating state for testability --- .../Sources/ScheduleFeature/Schedule.swift | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/MyLibrary/Sources/ScheduleFeature/Schedule.swift b/MyLibrary/Sources/ScheduleFeature/Schedule.swift index c273d90..031a7a7 100644 --- a/MyLibrary/Sources/ScheduleFeature/Schedule.swift +++ b/MyLibrary/Sources/ScheduleFeature/Schedule.swift @@ -45,6 +45,7 @@ public struct Schedule { case destination(PresentationAction) case view(View) case fetchResponse(Result<(schedules: SchedulesResponse, favorites: Favorites), Error>) + case savedFavorites(Session, Conference) public enum View { case onAppear @@ -97,18 +98,23 @@ public struct Schedule { ) return .none case let .view(.favoriteIconTapped(session)): - switch state.selectedDay { + let day = switch state.selectedDay { case .day1: - state.favorites.updateFavoriteState(of: session, in: state.day1!) + state.day1! case .day2: - state.favorites.updateFavoriteState(of: session, in: state.day2!) + state.day2! case .day3: - state.favorites.updateFavoriteState(of: session, in: state.workshop!) + state.workshop! } - let favorites = state.favorites - return .run { _ in + var favorites = state.favorites + favorites.updateFavoriteState(of: session, in: day) + return .run { [favorites = favorites] send in try? fileClient.saveFavorites(favorites) + await send(.savedFavorites(session, day)) } + case let .savedFavorites(session, day): + state.favorites.updateFavoriteState(of: session, in: day) + return .none case let .fetchResponse(.success(response)): state.day1 = response.schedules.day1 state.day2 = response.schedules.day2 From 35b4d146156cb0a2c1a568afb87dc2672a9773a4 Mon Sep 17 00:00:00 2001 From: Yusuke Uchida <65172567+ewa1989@users.noreply.github.com> Date: Wed, 20 Mar 2024 19:34:30 +0900 Subject: [PATCH 11/22] add tests for adding/removing favorites --- .../Tests/ScheduleFeatureTests/Mocks.swift | 23 +++++++++++- .../ScheduleFeatureTests/ScheduleTests.swift | 35 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/MyLibrary/Tests/ScheduleFeatureTests/Mocks.swift b/MyLibrary/Tests/ScheduleFeatureTests/Mocks.swift index cb1d789..9dc1e5f 100644 --- a/MyLibrary/Tests/ScheduleFeatureTests/Mocks.swift +++ b/MyLibrary/Tests/ScheduleFeatureTests/Mocks.swift @@ -1,6 +1,8 @@ import Foundation import SharedModels +@testable import ScheduleFeature + extension Conference { static let mock1 = Self( id: 1, @@ -97,7 +99,26 @@ extension Speaker { } extension Favorites { - static let mock1 = Self(eachConferenceFavorites: [ + static let mock1 = favoritedSession1InConference1 + static let favoritedSession1InConference1 = Self(eachConferenceFavorites: [ (.mock1, [.mock1]) ]) } + +extension ScheduleFeature.Schedule.State { + 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 = .init(eachConferenceFavorites: [(initialState.day1!, [firstSession])]) + return initialState + }() +} diff --git a/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift b/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift index 9278c2c..2b6245e 100644 --- a/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift +++ b/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import DataClient +import SharedModels import XCTest @testable import ScheduleFeature @@ -38,4 +39,38 @@ final class ScheduleTests: XCTestCase { await store.send(.view(.onAppear)) await store.receive(\.fetchResponse.failure) } + + @MainActor + func testAddingFavorites() async { + let initialState: ScheduleFeature.Schedule.State = .selectingDay1ScheduleWithNoFavorites + let firstSession = initialState.day1!.schedules.first!.sessions.first! + let firstSessionFavorited: Favorites = .init(eachConferenceFavorites: [(initialState.day1!, [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 = .selectingDay1ScheduleWithOneFavorite + let firstSession = initialState.day1!.schedules.first!.sessions.first! + let noFavorites: Favorites = .init(eachConferenceFavorites: [(initialState.day1!, [])]) + 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 + } + } } From f2622b8cb2fa9440856c79ec13de2c51339ead2d Mon Sep 17 00:00:00 2001 From: Yusuke Uchida <65172567+ewa1989@users.noreply.github.com> Date: Thu, 21 Mar 2024 03:09:07 +0900 Subject: [PATCH 12/22] extract json encoder and decorder as JSONHandler --- MyLibrary/Sources/DataClient/DataClient.swift | 6 ------ MyLibrary/Sources/DataClient/FileClient.swift | 6 ------ MyLibrary/Sources/DataClient/JSONHandler.swift | 14 ++++++++++++++ 3 files changed, 14 insertions(+), 12 deletions(-) create mode 100644 MyLibrary/Sources/DataClient/JSONHandler.swift diff --git a/MyLibrary/Sources/DataClient/DataClient.swift b/MyLibrary/Sources/DataClient/DataClient.swift index 0bb4377..52533b5 100644 --- a/MyLibrary/Sources/DataClient/DataClient.swift +++ b/MyLibrary/Sources/DataClient/DataClient.swift @@ -49,9 +49,3 @@ extension DataClient: DependencyKey { return data } } - -let jsonDecoder = { - $0.dateDecodingStrategy = .iso8601 - $0.keyDecodingStrategy = .convertFromSnakeCase - return $0 -}(JSONDecoder()) diff --git a/MyLibrary/Sources/DataClient/FileClient.swift b/MyLibrary/Sources/DataClient/FileClient.swift index 3735a00..8290f57 100644 --- a/MyLibrary/Sources/DataClient/FileClient.swift +++ b/MyLibrary/Sources/DataClient/FileClient.swift @@ -42,9 +42,3 @@ extension FileClient: DependencyKey { return try? Data(contentsOf: fileURL) } } - -let jsonEncoder = { - $0.dateEncodingStrategy = .iso8601 - $0.keyEncodingStrategy = .convertToSnakeCase - return $0 -}(JSONEncoder()) diff --git a/MyLibrary/Sources/DataClient/JSONHandler.swift b/MyLibrary/Sources/DataClient/JSONHandler.swift new file mode 100644 index 0000000..12bf800 --- /dev/null +++ b/MyLibrary/Sources/DataClient/JSONHandler.swift @@ -0,0 +1,14 @@ +import Foundation + +let jsonEncoder = { + $0.dateEncodingStrategy = .iso8601 + $0.keyEncodingStrategy = .convertToSnakeCase + return $0 +}(JSONEncoder()) + + +let jsonDecoder = { + $0.dateDecodingStrategy = .iso8601 + $0.keyDecodingStrategy = .convertFromSnakeCase + return $0 +}(JSONDecoder()) From b648df2c155b30062b0c8d3cc42dd2f3106da784 Mon Sep 17 00:00:00 2001 From: Yusuke Uchida <65172567+ewa1989@users.noreply.github.com> Date: Thu, 21 Mar 2024 14:29:57 +0900 Subject: [PATCH 13/22] Revert "extract json encoder and decorder as JSONHandler" This reverts commit f2622b8cb2fa9440856c79ec13de2c51339ead2d. --- MyLibrary/Sources/DataClient/DataClient.swift | 6 ++++++ MyLibrary/Sources/DataClient/FileClient.swift | 6 ++++++ MyLibrary/Sources/DataClient/JSONHandler.swift | 14 -------------- 3 files changed, 12 insertions(+), 14 deletions(-) delete mode 100644 MyLibrary/Sources/DataClient/JSONHandler.swift diff --git a/MyLibrary/Sources/DataClient/DataClient.swift b/MyLibrary/Sources/DataClient/DataClient.swift index 52533b5..0bb4377 100644 --- a/MyLibrary/Sources/DataClient/DataClient.swift +++ b/MyLibrary/Sources/DataClient/DataClient.swift @@ -49,3 +49,9 @@ extension DataClient: DependencyKey { return data } } + +let jsonDecoder = { + $0.dateDecodingStrategy = .iso8601 + $0.keyDecodingStrategy = .convertFromSnakeCase + return $0 +}(JSONDecoder()) diff --git a/MyLibrary/Sources/DataClient/FileClient.swift b/MyLibrary/Sources/DataClient/FileClient.swift index 8290f57..3735a00 100644 --- a/MyLibrary/Sources/DataClient/FileClient.swift +++ b/MyLibrary/Sources/DataClient/FileClient.swift @@ -42,3 +42,9 @@ extension FileClient: DependencyKey { return try? Data(contentsOf: fileURL) } } + +let jsonEncoder = { + $0.dateEncodingStrategy = .iso8601 + $0.keyEncodingStrategy = .convertToSnakeCase + return $0 +}(JSONEncoder()) diff --git a/MyLibrary/Sources/DataClient/JSONHandler.swift b/MyLibrary/Sources/DataClient/JSONHandler.swift deleted file mode 100644 index 12bf800..0000000 --- a/MyLibrary/Sources/DataClient/JSONHandler.swift +++ /dev/null @@ -1,14 +0,0 @@ -import Foundation - -let jsonEncoder = { - $0.dateEncodingStrategy = .iso8601 - $0.keyEncodingStrategy = .convertToSnakeCase - return $0 -}(JSONEncoder()) - - -let jsonDecoder = { - $0.dateDecodingStrategy = .iso8601 - $0.keyDecodingStrategy = .convertFromSnakeCase - return $0 -}(JSONDecoder()) From a92958648444a42b40ea10261e1be05a42f688c4 Mon Sep 17 00:00:00 2001 From: Yusuke Uchida <65172567+ewa1989@users.noreply.github.com> Date: Thu, 21 Mar 2024 14:32:58 +0900 Subject: [PATCH 14/22] separate FileClient --- MyLibrary/Package.swift | 8 ++++++++ .../Sources/{DataClient => FileClient}/FileClient.swift | 6 ++++++ MyLibrary/Sources/ScheduleFeature/Schedule.swift | 1 + MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift | 1 + 4 files changed, 16 insertions(+) rename MyLibrary/Sources/{DataClient => FileClient}/FileClient.swift (91%) diff --git a/MyLibrary/Package.swift b/MyLibrary/Package.swift index a97a05e..7a953ba 100644 --- a/MyLibrary/Package.swift +++ b/MyLibrary/Package.swift @@ -39,6 +39,13 @@ let package = Package( .process("Resources") ] ), + .target( + name: "FileClient", + dependencies: [ + "SharedModels", + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ), .target( name: "GuidanceFeature", dependencies: [ @@ -64,6 +71,7 @@ let package = Package( name: "ScheduleFeature", dependencies: [ "DataClient", + "FileClient", "Safari", .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), ] diff --git a/MyLibrary/Sources/DataClient/FileClient.swift b/MyLibrary/Sources/FileClient/FileClient.swift similarity index 91% rename from MyLibrary/Sources/DataClient/FileClient.swift rename to MyLibrary/Sources/FileClient/FileClient.swift index 3735a00..233aee0 100644 --- a/MyLibrary/Sources/DataClient/FileClient.swift +++ b/MyLibrary/Sources/FileClient/FileClient.swift @@ -48,3 +48,9 @@ let jsonEncoder = { $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 031a7a7..9c4a51b 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 Safari import SharedModels diff --git a/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift b/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift index 2b6245e..d4c1a89 100644 --- a/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift +++ b/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import DataClient +import FileClient import SharedModels import XCTest From 4e279e4c9ccb3c9114c3af9533abb767731574fe Mon Sep 17 00:00:00 2001 From: Yusuke Uchida <65172567+ewa1989@users.noreply.github.com> Date: Thu, 21 Mar 2024 17:42:59 +0900 Subject: [PATCH 15/22] move mocks that are intended for specific behavior near related tests --- .../Tests/ScheduleFeatureTests/Mocks.swift | 21 +--------------- .../ScheduleFeatureTests/ScheduleTests.swift | 24 +++++++++++++++++-- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/MyLibrary/Tests/ScheduleFeatureTests/Mocks.swift b/MyLibrary/Tests/ScheduleFeatureTests/Mocks.swift index 9dc1e5f..9d8b2bd 100644 --- a/MyLibrary/Tests/ScheduleFeatureTests/Mocks.swift +++ b/MyLibrary/Tests/ScheduleFeatureTests/Mocks.swift @@ -99,26 +99,7 @@ extension Speaker { } extension Favorites { - static let mock1 = favoritedSession1InConference1 - static let favoritedSession1InConference1 = Self(eachConferenceFavorites: [ + static let mock1 = Self(eachConferenceFavorites: [ (.mock1, [.mock1]) ]) } - -extension ScheduleFeature.Schedule.State { - 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 = .init(eachConferenceFavorites: [(initialState.day1!, [firstSession])]) - return initialState - }() -} diff --git a/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift b/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift index d4c1a89..fd2cadb 100644 --- a/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift +++ b/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift @@ -43,7 +43,7 @@ final class ScheduleTests: XCTestCase { @MainActor func testAddingFavorites() async { - let initialState: ScheduleFeature.Schedule.State = .selectingDay1ScheduleWithNoFavorites + let initialState: ScheduleFeature.Schedule.State = ScheduleTests.selectingDay1ScheduleWithNoFavorites let firstSession = initialState.day1!.schedules.first!.sessions.first! let firstSessionFavorited: Favorites = .init(eachConferenceFavorites: [(initialState.day1!, [firstSession])]) let store = TestStore(initialState: initialState) { @@ -60,7 +60,7 @@ final class ScheduleTests: XCTestCase { @MainActor func testRemovingFavorites() async { - let initialState: ScheduleFeature.Schedule.State = .selectingDay1ScheduleWithOneFavorite + let initialState: ScheduleFeature.Schedule.State = ScheduleTests.selectingDay1ScheduleWithOneFavorite let firstSession = initialState.day1!.schedules.first!.sessions.first! let noFavorites: Favorites = .init(eachConferenceFavorites: [(initialState.day1!, [])]) let store = TestStore(initialState: initialState) { @@ -74,4 +74,24 @@ final class ScheduleTests: XCTestCase { $0.favorites = noFavorites } } + + static let favoritedSession1InConference1 = Favorites(eachConferenceFavorites: [ + (.mock1, [.mock1]) + ]) + + 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 = .init(eachConferenceFavorites: [(initialState.day1!, [firstSession])]) + return initialState + }() } From d3a49ab0a3ae46577a93ee232c329e6f74c17595 Mon Sep 17 00:00:00 2001 From: Yusuke Uchida <65172567+ewa1989@users.noreply.github.com> Date: Sun, 31 Mar 2024 22:11:23 +0900 Subject: [PATCH 16/22] remove unused mock --- MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift b/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift index fd2cadb..4df6de5 100644 --- a/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift +++ b/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift @@ -74,11 +74,7 @@ final class ScheduleTests: XCTestCase { $0.favorites = noFavorites } } - - static let favoritedSession1InConference1 = Favorites(eachConferenceFavorites: [ - (.mock1, [.mock1]) - ]) - + static let selectingDay1ScheduleWithNoFavorites = { var initialState = Schedule.State() initialState.selectedDay = .day1 From 04cefc07faeec895fb6e6ec5cb9806d040e55806 Mon Sep 17 00:00:00 2001 From: Yusuke Uchida <65172567+ewa1989@users.noreply.github.com> Date: Sun, 31 Mar 2024 22:20:48 +0900 Subject: [PATCH 17/22] fix to separate load favorites from fetch data, and to assert separately in tests. --- .../Sources/ScheduleFeature/Schedule.swift | 43 +++++++++++++------ .../ScheduleFeatureTests/ScheduleTests.swift | 31 +++++++++++-- 2 files changed, 57 insertions(+), 17 deletions(-) diff --git a/MyLibrary/Sources/ScheduleFeature/Schedule.swift b/MyLibrary/Sources/ScheduleFeature/Schedule.swift index d891362..182f1b2 100644 --- a/MyLibrary/Sources/ScheduleFeature/Schedule.swift +++ b/MyLibrary/Sources/ScheduleFeature/Schedule.swift @@ -44,7 +44,8 @@ public struct Schedule { case path(StackAction) case destination(PresentationAction) case view(View) - case fetchResponse(Result<(schedules: SchedulesResponse, favorites: Favorites), Error>) + case fetchResponse(Result) + case loadResponse(Result) case savedFavorites(Session, Conference) public enum View { @@ -72,15 +73,21 @@ 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() - let favorites = try fileClient.loadFavorites() - return (.init(day1: day1, day2: day2, workshop: workshop), favorites) - })) + return .run { send in + await 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) + })) + await send( + .loadResponse( + Result { + try fileClient.loadFavorites() + })) + } case let .view(.disclosureTapped(session)): guard let description = session.description, let speakers = session.speakers else { return .none @@ -115,10 +122,9 @@ public struct Schedule { state.favorites.updateFavoriteState(of: session, in: day) return .none case let .fetchResponse(.success(response)): - state.day1 = response.schedules.day1 - state.day2 = response.schedules.day2 - state.workshop = response.schedules.workshop - state.favorites = response.favorites + state.day1 = response.day1 + state.day2 = response.day2 + state.workshop = response.workshop return .none case let .fetchResponse(.failure(error as DecodingError)): assertionFailure(error.localizedDescription) @@ -126,6 +132,15 @@ public struct Schedule { case let .fetchResponse(.failure(error)): print(error) // TODO: replace to Logger API return .none + case let .loadResponse(.success(response)): + state.favorites = response + return .none + case let .loadResponse(.failure(error as DecodingError)): + assertionFailure(error.localizedDescription) + return .none + case let .loadResponse(.failure(error)): + print(error) // TODO: replace to Logger API + return .none case .binding, .path, .destination: return .none } diff --git a/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift b/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift index 4df6de5..744013b 100644 --- a/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift +++ b/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift @@ -8,7 +8,7 @@ import XCTest final class ScheduleTests: XCTestCase { @MainActor - func testFetchData() async { + func testOnAppear() async { let store = TestStore(initialState: Schedule.State()) { Schedule() } withDependencies: { @@ -22,6 +22,8 @@ final class ScheduleTests: XCTestCase { $0.day1 = .mock1 $0.day2 = .mock2 $0.workshop = .mock3 + } + await store.receive(\.loadResponse.success) { $0.favorites = .mock1 } } @@ -35,10 +37,33 @@ 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 } + $0[FileClient.self].loadFavorites = { @Sendable in .mock1} } await store.send(.view(.onAppear)) await store.receive(\.fetchResponse.failure) + await store.receive(\.loadResponse) { + $0.favorites = .mock1 + } + } + + @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.success) { + $0.day1 = .mock1 + $0.day2 = .mock2 + $0.workshop = .mock3 + } + await store.receive(\.loadResponse.failure) } @MainActor @@ -74,7 +99,7 @@ final class ScheduleTests: XCTestCase { $0.favorites = noFavorites } } - + static let selectingDay1ScheduleWithNoFavorites = { var initialState = Schedule.State() initialState.selectedDay = .day1 From d3b990178c97e4c5779249990ce492e4ff0d16c3 Mon Sep 17 00:00:00 2001 From: Yusuke Uchida <65172567+ewa1989@users.noreply.github.com> Date: Sun, 31 Mar 2024 22:56:53 +0900 Subject: [PATCH 18/22] to keep models having no logics, move logics in Favorites to Reducer and View, and remove Favorites and use typealias instead because Favorites has one property only. --- MyLibrary/Sources/FileClient/FileClient.swift | 4 ++- .../Sources/ScheduleFeature/Schedule.swift | 26 ++++++++++++++-- .../Sources/SharedModels/Favorites.swift | 30 ------------------- .../Tests/ScheduleFeatureTests/Mocks.swift | 5 ++-- .../ScheduleFeatureTests/ScheduleTests.swift | 6 ++-- 5 files changed, 31 insertions(+), 40 deletions(-) delete mode 100644 MyLibrary/Sources/SharedModels/Favorites.swift diff --git a/MyLibrary/Sources/FileClient/FileClient.swift b/MyLibrary/Sources/FileClient/FileClient.swift index 233aee0..2696aaa 100644 --- a/MyLibrary/Sources/FileClient/FileClient.swift +++ b/MyLibrary/Sources/FileClient/FileClient.swift @@ -3,6 +3,8 @@ import DependenciesMacros import SharedModels import Foundation +public typealias Favorites = [String: [Session]] + @DependencyClient public struct FileClient { public var loadFavorites: @Sendable () throws -> Favorites @@ -13,7 +15,7 @@ extension FileClient: DependencyKey { static public var liveValue: FileClient = .init( loadFavorites: { guard let saveData = loadDataFromFile(named: "Favorites") else { - return .init(eachConferenceFavorites: []) + return [:] } let response = try jsonDecoder.decode(Favorites.self, from: saveData) return response diff --git a/MyLibrary/Sources/ScheduleFeature/Schedule.swift b/MyLibrary/Sources/ScheduleFeature/Schedule.swift index 182f1b2..241c81a 100644 --- a/MyLibrary/Sources/ScheduleFeature/Schedule.swift +++ b/MyLibrary/Sources/ScheduleFeature/Schedule.swift @@ -32,8 +32,7 @@ public struct Schedule { var day1: Conference? var day2: Conference? var workshop: Conference? - - var favorites: Favorites = .init(eachConferenceFavorites: []) + var favorites: Favorites = [:] @Presents var destination: Destination.State? public init() {} @@ -150,6 +149,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 { @@ -318,7 +332,13 @@ public struct ScheduleView: View { store.workshop! } - if store.favorites.isFavorited(session, in: conference) { + let isFavorited = { + guard let favorites = store.favorites[conference.title] else { + return false + } + return favorites.contains(session) + }() + if isFavorited { Image(systemName: "star.fill") .foregroundColor(.yellow) } else { diff --git a/MyLibrary/Sources/SharedModels/Favorites.swift b/MyLibrary/Sources/SharedModels/Favorites.swift deleted file mode 100644 index e43656c..0000000 --- a/MyLibrary/Sources/SharedModels/Favorites.swift +++ /dev/null @@ -1,30 +0,0 @@ -public struct Favorites: Equatable, Codable { - private var eachConferenceFavorites: [String: [Session]] - - public init(eachConferenceFavorites: [(conference: Conference, favoriteSessions: [Session])]) { - self.eachConferenceFavorites = [:] - for conferenceFavorites in eachConferenceFavorites { - self.eachConferenceFavorites[conferenceFavorites.conference.title] = conferenceFavorites.favoriteSessions - } - } - - public mutating func updateFavoriteState(of session: Session, in conference: Conference) { - guard var favorites = eachConferenceFavorites[conference.title] else { - eachConferenceFavorites[conference.title] = [session] - return - } - if favorites.contains(session) { - eachConferenceFavorites[conference.title] = favorites.filter { $0 != session } - } else { - favorites.append(session) - eachConferenceFavorites[conference.title] = favorites - } - } - - public func isFavorited(_ session: Session, in conference: Conference) -> Bool { - guard let favorites = eachConferenceFavorites[conference.title] else { - return false - } - return favorites.contains(session) - } -} diff --git a/MyLibrary/Tests/ScheduleFeatureTests/Mocks.swift b/MyLibrary/Tests/ScheduleFeatureTests/Mocks.swift index 9d8b2bd..ae9fc98 100644 --- a/MyLibrary/Tests/ScheduleFeatureTests/Mocks.swift +++ b/MyLibrary/Tests/ScheduleFeatureTests/Mocks.swift @@ -1,5 +1,6 @@ import Foundation import SharedModels +import FileClient @testable import ScheduleFeature @@ -99,7 +100,5 @@ extension Speaker { } extension Favorites { - static let mock1 = Self(eachConferenceFavorites: [ - (.mock1, [.mock1]) - ]) + static let mock1 = [Conference.mock1.title: [Session.mock1]] } diff --git a/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift b/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift index 744013b..f49be0a 100644 --- a/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift +++ b/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift @@ -70,7 +70,7 @@ final class ScheduleTests: XCTestCase { func testAddingFavorites() async { let initialState: ScheduleFeature.Schedule.State = ScheduleTests.selectingDay1ScheduleWithNoFavorites let firstSession = initialState.day1!.schedules.first!.sessions.first! - let firstSessionFavorited: Favorites = .init(eachConferenceFavorites: [(initialState.day1!, [firstSession])]) + let firstSessionFavorited = [initialState.day1!.title: [firstSession]] let store = TestStore(initialState: initialState) { Schedule() } withDependencies: { @@ -87,7 +87,7 @@ final class ScheduleTests: XCTestCase { func testRemovingFavorites() async { let initialState: ScheduleFeature.Schedule.State = ScheduleTests.selectingDay1ScheduleWithOneFavorite let firstSession = initialState.day1!.schedules.first!.sessions.first! - let noFavorites: Favorites = .init(eachConferenceFavorites: [(initialState.day1!, [])]) + let noFavorites: Favorites = [initialState.day1!.title: []] let store = TestStore(initialState: initialState) { Schedule() } withDependencies: { @@ -112,7 +112,7 @@ final class ScheduleTests: XCTestCase { initialState.selectedDay = .day1 initialState.day1 = .mock1 let firstSession = initialState.day1!.schedules.first!.sessions.first! - initialState.favorites = .init(eachConferenceFavorites: [(initialState.day1!, [firstSession])]) + initialState.favorites = [initialState.day1!.title: [firstSession]] return initialState }() } From cfb2fa38dbbd57182fd62bc84974a77f3032b2b2 Mon Sep 17 00:00:00 2001 From: Yusuke Uchida <65172567+ewa1989@users.noreply.github.com> Date: Sun, 31 Mar 2024 23:05:53 +0900 Subject: [PATCH 19/22] rename saveDataToFile and loadDataFromFile to explain exactly how they do. --- MyLibrary/Sources/FileClient/FileClient.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/MyLibrary/Sources/FileClient/FileClient.swift b/MyLibrary/Sources/FileClient/FileClient.swift index 2696aaa..a983957 100644 --- a/MyLibrary/Sources/FileClient/FileClient.swift +++ b/MyLibrary/Sources/FileClient/FileClient.swift @@ -14,7 +14,7 @@ public struct FileClient { extension FileClient: DependencyKey { static public var liveValue: FileClient = .init( loadFavorites: { - guard let saveData = loadDataFromFile(named: "Favorites") else { + guard let saveData = serialize(from: "Favorites") else { return [:] } let response = try jsonDecoder.decode(Favorites.self, from: saveData) @@ -24,23 +24,23 @@ extension FileClient: DependencyKey { guard let data = try? jsonEncoder.encode(favorites) else { return } - saveDataToFile(data, named: "Favorites") + deserialize(data: data, into: "Favorites") } ) - static func saveDataToFile(_ data : Data, named fileName: String) { + 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(fileName + ".json") + let fileURL = documentPath.appendingPathComponent(filePath + ".json") try? data.write(to: fileURL) } - static func loadDataFromFile(named fileName: String) -> Data? { + 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(fileName + ".json") + let fileURL = documentPath.appendingPathComponent(filePath + ".json") return try? Data(contentsOf: fileURL) } } From 77b7a099d3c1fe27b59be63de83053c4641a488e Mon Sep 17 00:00:00 2001 From: Yusuke Uchida <65172567+ewa1989@users.noreply.github.com> Date: Mon, 1 Apr 2024 18:25:01 +0900 Subject: [PATCH 20/22] change variable name of Conference, day to conference, as other place does --- MyLibrary/Sources/ScheduleFeature/Schedule.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/MyLibrary/Sources/ScheduleFeature/Schedule.swift b/MyLibrary/Sources/ScheduleFeature/Schedule.swift index 241c81a..5924e51 100644 --- a/MyLibrary/Sources/ScheduleFeature/Schedule.swift +++ b/MyLibrary/Sources/ScheduleFeature/Schedule.swift @@ -103,7 +103,7 @@ public struct Schedule { ) return .none case let .view(.favoriteIconTapped(session)): - let day = switch state.selectedDay { + let conference = switch state.selectedDay { case .day1: state.day1! case .day2: @@ -112,10 +112,10 @@ public struct Schedule { state.workshop! } var favorites = state.favorites - favorites.updateFavoriteState(of: session, in: day) + favorites.updateFavoriteState(of: session, in: conference) return .run { [favorites = favorites] send in try? fileClient.saveFavorites(favorites) - await send(.savedFavorites(session, day)) + await send(.savedFavorites(session, conference)) } case let .savedFavorites(session, day): state.favorites.updateFavoriteState(of: session, in: day) From 0e3b9f8740af380a7ee88210b3514019b07c31b4 Mon Sep 17 00:00:00 2001 From: Yusuke Uchida <65172567+ewa1989@users.noreply.github.com> Date: Thu, 11 Apr 2024 22:16:25 +0900 Subject: [PATCH 21/22] fix to combine load response into fetch response for simplicity. --- .../Sources/ScheduleFeature/Schedule.swift | 27 +++++-------------- .../ScheduleFeatureTests/ScheduleTests.swift | 12 +-------- 2 files changed, 8 insertions(+), 31 deletions(-) diff --git a/MyLibrary/Sources/ScheduleFeature/Schedule.swift b/MyLibrary/Sources/ScheduleFeature/Schedule.swift index 5924e51..1a70694 100644 --- a/MyLibrary/Sources/ScheduleFeature/Schedule.swift +++ b/MyLibrary/Sources/ScheduleFeature/Schedule.swift @@ -43,8 +43,7 @@ public struct Schedule { case path(StackAction) case destination(PresentationAction) case view(View) - case fetchResponse(Result) - case loadResponse(Result) + case fetchResponse(Result<(scheduleResponse: SchedulesResponse, favorites: Favorites), Error>) case savedFavorites(Session, Conference) public enum View { @@ -79,12 +78,8 @@ public struct Schedule { let day1 = try dataClient.fetchDay1() let day2 = try dataClient.fetchDay2() let workshop = try dataClient.fetchWorkshop() - return .init(day1: day1, day2: day2, workshop: workshop) - })) - await send( - .loadResponse( - Result { - try fileClient.loadFavorites() + let favorites = try fileClient.loadFavorites() + return (.init(day1: day1, day2: day2, workshop: workshop), favorites) })) } case let .view(.disclosureTapped(session)): @@ -121,9 +116,10 @@ public struct Schedule { 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) @@ -131,15 +127,6 @@ public struct Schedule { case let .fetchResponse(.failure(error)): print(error) // TODO: replace to Logger API return .none - case let .loadResponse(.success(response)): - state.favorites = response - return .none - case let .loadResponse(.failure(error as DecodingError)): - assertionFailure(error.localizedDescription) - return .none - case let .loadResponse(.failure(error)): - print(error) // TODO: replace to Logger API - return .none case .binding, .path, .destination: return .none } diff --git a/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift b/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift index f49be0a..c737679 100644 --- a/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift +++ b/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift @@ -22,8 +22,6 @@ final class ScheduleTests: XCTestCase { $0.day1 = .mock1 $0.day2 = .mock2 $0.workshop = .mock3 - } - await store.receive(\.loadResponse.success) { $0.favorites = .mock1 } } @@ -41,9 +39,6 @@ final class ScheduleTests: XCTestCase { } await store.send(.view(.onAppear)) await store.receive(\.fetchResponse.failure) - await store.receive(\.loadResponse) { - $0.favorites = .mock1 - } } @MainActor @@ -58,12 +53,7 @@ final class ScheduleTests: XCTestCase { $0[FileClient.self].loadFavorites = { @Sendable in throw LoadError() } } await store.send(.view(.onAppear)) - await store.receive(\.fetchResponse.success) { - $0.day1 = .mock1 - $0.day2 = .mock2 - $0.workshop = .mock3 - } - await store.receive(\.loadResponse.failure) + await store.receive(\.fetchResponse.failure) } @MainActor From 6cca9bd78f3401147c22133a503b31dbbacb8d98 Mon Sep 17 00:00:00 2001 From: Yusuke Uchida <65172567+ewa1989@users.noreply.github.com> Date: Thu, 11 Apr 2024 23:55:13 +0900 Subject: [PATCH 22/22] move a part of logic to Reducer for collecting logics relative with state --- .../Sources/ScheduleFeature/Schedule.swift | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/MyLibrary/Sources/ScheduleFeature/Schedule.swift b/MyLibrary/Sources/ScheduleFeature/Schedule.swift index 1a70694..b3854c2 100644 --- a/MyLibrary/Sources/ScheduleFeature/Schedule.swift +++ b/MyLibrary/Sources/ScheduleFeature/Schedule.swift @@ -35,6 +35,17 @@ public struct Schedule { var favorites: Favorites = [:] @Presents var destination: Destination.State? + var selectedConference: Conference? { + switch selectedDay { + case .day1: + day1 + case .day2: + day2 + case .day3: + workshop + } + } + public init() {} } @@ -309,18 +320,8 @@ public struct ScheduleView: View { @ViewBuilder func favoriteIcon(for session: Session) -> some View { - let conference = - switch store.selectedDay { - case .day1: - store.day1! - case .day2: - store.day2! - case .day3: - store.workshop! - } - let isFavorited = { - guard let favorites = store.favorites[conference.title] else { + guard let favorites = store.favorites[store.selectedConference!.title] else { return false } return favorites.contains(session)