-
Notifications
You must be signed in to change notification settings - Fork 19
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
Check favorite sessions in Schedule View #41
base: main
Are you sure you want to change the base?
Changes from 14 commits
875b195
33888d9
08a2b0d
c32c5bf
cf49338
5a32df4
846098f
23983b5
a7a611b
0e8b403
daa7fa7
1a5419a
35b4d14
f2622b8
b648df2
a929586
4e279e4
1db51b5
d3a49ab
04cefc0
d3b9901
cfb2fa3
77b7a09
0e3b9f8
6cca9bd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I ask you create in another module. Define in Package.swift. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
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 = loadDataFromFile(named: "Favorites") else { | ||
return .init(eachConferenceFavorites: []) | ||
} | ||
let response = try jsonDecoder.decode(Favorites.self, from: saveData) | ||
return response | ||
}, | ||
saveFavorites: { favorites in | ||
guard let data = try? jsonEncoder.encode(favorites) else { | ||
return | ||
} | ||
saveDataToFile(data, named: "Favorites") | ||
} | ||
) | ||
|
||
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 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) | ||
} | ||
} |
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not have to create in another file. Just put in under the client code even if duplicated. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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()) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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() {} | ||
|
@@ -42,11 +44,13 @@ public struct Schedule { | |
case path(StackAction<Path.State, Path.Action>) | ||
case destination(PresentationAction<Destination.Action>) | ||
case view(View) | ||
case fetchResponse(Result<SchedulesResponse, Error>) | ||
case fetchResponse(Result<(schedules: SchedulesResponse, favorites: Favorites), Error>) | ||
case savedFavorites(Session, Conference) | ||
|
||
public enum View { | ||
case onAppear | ||
case disclosureTapped(Session) | ||
case favoriteIconTapped(Session) | ||
} | ||
} | ||
|
||
|
@@ -59,6 +63,7 @@ public struct Schedule { | |
public enum Destination {} | ||
|
||
@Dependency(DataClient.self) var dataClient | ||
@Dependency(FileClient.self) var fileClient | ||
@Dependency(\.openURL) var openURL | ||
|
||
public init() {} | ||
|
@@ -74,7 +79,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 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 { | ||
|
@@ -91,10 +97,29 @@ public struct Schedule { | |
) | ||
) | ||
return .none | ||
case let .view(.favoriteIconTapped(session)): | ||
let day = switch state.selectedDay { | ||
case .day1: | ||
state.day1! | ||
case .day2: | ||
state.day2! | ||
case .day3: | ||
state.workshop! | ||
} | ||
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.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) | ||
|
@@ -259,6 +284,32 @@ public struct ScheduleView: View { | |
} | ||
} | ||
.frame(maxWidth: .infinity, alignment: .leading) | ||
|
||
favoriteIcon(for: session) | ||
.onTapGesture { | ||
send(.favoriteIconTapped(session)) | ||
} | ||
} | ||
} | ||
|
||
@ViewBuilder | ||
func favoriteIcon(for session: Session) -> some View { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These logic should put in Reducer so that we can write test easily. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm sorry that I can't image which part of logic to put in Reducer. Now I implemented There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I moved these logic to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please remove this method if you have moved. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry for my lack of explanation. |
||
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 { | ||
Image(systemName: "star") | ||
.foregroundColor(.gray) | ||
} | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
public struct Favorites: Equatable, Codable { | ||
private var eachConferenceFavorites: [String: [Session]] | ||
|
||
public init(eachConferenceFavorites: [(conference: Conference, favoriteSessions: [Session])]) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since initialized in second line, first parameter should be removed. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry for my code readability, but first parameter is necessary. To create I intentionally use conference as input to avoid using non-existent conference title as key. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It might be better to use self.favorites = favorites.reduce(into: [:]) { result, element in
result[element.conference.title] = element.favoriteSessions
} |
||
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) | ||
} | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Move into reducer. Favorite should be keep as pure model. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,8 @@ | ||
import Foundation | ||
import SharedModels | ||
|
||
@testable import ScheduleFeature | ||
|
||
extension Conference { | ||
static let mock1 = Self( | ||
id: 1, | ||
|
@@ -95,3 +97,28 @@ extension Speaker { | |
] | ||
) | ||
} | ||
|
||
extension Favorites { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can add mock under the your test code. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I moved these specific mocks near related tests in 4e279e4. |
||
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 | ||
}() | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,6 @@ | ||
import ComposableArchitecture | ||
import DataClient | ||
import SharedModels | ||
import XCTest | ||
|
||
@testable import ScheduleFeature | ||
|
@@ -13,12 +14,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 } | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should be separated your test. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm sorry that I can't image way to separate. Current implementation, loading favorites using I could only image to separate There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. NO. This effects to current test case. Should be created another test case. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since fetching data using |
||
} | ||
await store.send(.view(.onAppear)) | ||
await store.receive(\.fetchResponse.success) { | ||
$0.day1 = .mock1 | ||
$0.day2 = .mock2 | ||
$0.workshop = .mock3 | ||
$0.favorites = .mock1 | ||
} | ||
} | ||
|
||
|
@@ -31,8 +34,43 @@ 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 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 | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Still we need to use.