-
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 23 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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()) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,6 @@ | ||
import ComposableArchitecture | ||
import DataClient | ||
import FileClient | ||
import Foundation | ||
import SharedModels | ||
import SwiftUI | ||
|
@@ -31,6 +32,7 @@ public struct Schedule { | |
var day1: Conference? | ||
var day2: Conference? | ||
var workshop: Conference? | ||
var favorites: Favorites = [:] | ||
@Presents var destination: Destination.State? | ||
|
||
public init() {} | ||
|
@@ -42,10 +44,13 @@ public struct Schedule { | |
case destination(PresentationAction<Destination.Action>) | ||
case view(View) | ||
case fetchResponse(Result<SchedulesResponse, Error>) | ||
case loadResponse(Result<Favorites, Error>) | ||
case savedFavorites(Session, Conference) | ||
|
||
public enum View { | ||
case onAppear | ||
case disclosureTapped(Session) | ||
case favoriteIconTapped(Session) | ||
} | ||
} | ||
|
||
|
@@ -58,6 +63,7 @@ public struct Schedule { | |
public enum Destination {} | ||
|
||
@Dependency(DataClient.self) var dataClient | ||
@Dependency(FileClient.self) var fileClient | ||
|
||
public init() {} | ||
|
||
|
@@ -66,14 +72,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() | ||
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() | ||
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 | ||
|
@@ -89,6 +102,24 @@ 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 | ||
|
@@ -100,6 +131,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 | ||
} | ||
|
@@ -109,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 { | ||
|
||
|
@@ -257,6 +312,38 @@ 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! | ||
} | ||
|
||
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 { | ||
Image(systemName: "star") | ||
.foregroundColor(.gray) | ||
} | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 { | ||
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 = [Conference.mock1.title: [Session.mock1]] | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,25 +1,31 @@ | ||
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 } | ||
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 | ||
} | ||
await store.receive(\.loadResponse.success) { | ||
$0.favorites = .mock1 | ||
} | ||
} | ||
|
||
@MainActor | ||
|
@@ -31,8 +37,82 @@ 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) | ||
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 | ||
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 | ||
}() | ||
} |
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.
It might be better to change response to
Result<(SchedulesResponse, Favorites), Error>
.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.
Thank you, fixed simpler in 0e3b9f8.