diff --git a/MyLibrary/Sources/ScheduleFeature/Localizable.xcstrings b/MyLibrary/Sources/ScheduleFeature/Localizable.xcstrings index faa0460..5ce91ed 100644 --- a/MyLibrary/Sources/ScheduleFeature/Localizable.xcstrings +++ b/MyLibrary/Sources/ScheduleFeature/Localizable.xcstrings @@ -55,6 +55,16 @@ } } }, + "All" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "全て" + } + } + } + }, "As most people tend to use cloud sync services to store relevant content, we will explore File Provider framework on both iOS and macOS and all the related features: Finder and Files app integration, remote synchronisation with upload and downloads. So let’s explore how it works on both iOS and macOS and how you can sync, upload and download files on these platforms." : { "extractionState" : "manual", "localizations" : { @@ -326,6 +336,26 @@ } } }, + "Favorite" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お気に入り" + } + } + } + }, + "Filter" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "フィルター" + } + } + } + }, "Getting started with controlling LEGO using Swift" : { "extractionState" : "manual", "localizations" : { @@ -733,6 +763,16 @@ } } }, + "No items to show." : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "表示できるものがありません。" + } + } + } + }, "Office hour %@" : { "extractionState" : "manual", "localizations" : { diff --git a/MyLibrary/Sources/ScheduleFeature/Schedule.swift b/MyLibrary/Sources/ScheduleFeature/Schedule.swift index 241c81a..196f72e 100644 --- a/MyLibrary/Sources/ScheduleFeature/Schedule.swift +++ b/MyLibrary/Sources/ScheduleFeature/Schedule.swift @@ -32,10 +32,41 @@ public struct Schedule { var day1: Conference? var day2: Conference? var workshop: Conference? + var selectedFilter: FilterItem = .all var favorites: Favorites = [:] @Presents var destination: Destination.State? + var day1ToShow: Conference? { + filteredConference(of: .day1) + } + var day2ToShow: Conference? { + filteredConference(of: .day2) + } + var workshopToShow: Conference? { + filteredConference(of: .day3) + } + public init() {} + + func filteredConference(of day: Days) -> Conference? { + let conference = + switch selectedDay { + case .day1: + day1 + case .day2: + day2 + case .day3: + workshop + } + guard let conference = conference else { return nil } + switch selectedFilter { + case .all: + return conference + case .favorite: + let schedules = conference.schedules.filtered(using: favorites, in: conference) + return Conference(id: conference.id, title: conference.title, date: conference.date, schedules: schedules) + } + } } public enum Action: BindableAction, ViewAction { @@ -54,6 +85,11 @@ public struct Schedule { } } + public enum FilterItem: LocalizedStringKey, CaseIterable { + case all = "All" + case favorite = "Favorite" + } + @Reducer(state: .equatable) public enum Path { case detail(ScheduleDetail) @@ -103,7 +139,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 +148,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) @@ -164,6 +200,21 @@ private extension Favorites { } } +extension [SharedModels.Schedule] { + func filtered(using favorites: Favorites, in conference: Conference) -> Self { + self + .map { + SharedModels.Schedule(time: $0.time, sessions: $0.sessions.filter { + guard let favorites = favorites[conference.title] else { + return false + } + return favorites.contains($0) + }) + } + .filter { $0.sessions.count > 0 } + } +} + @ViewAction(for: Schedule.self) public struct ScheduleView: View { @@ -198,25 +249,42 @@ public struct ScheduleView: View { .padding(.horizontal) switch store.selectedDay { case .day1: - if let day1 = store.day1 { + if let day1 = store.day1ToShow { conferenceList(conference: day1) } else { Text("") } case .day2: - if let day2 = store.day2 { + if let day2 = store.day2ToShow { conferenceList(conference: day2) } else { Text("") } case .day3: - if let workshop = store.workshop { + if let workshop = store.workshopToShow { conferenceList(conference: workshop) } else { Text("") } } } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Picker(String(localized: "Filter", bundle: .module), selection: $store.selectedFilter, content: { + ForEach(Schedule.FilterItem.allCases, id:\.self) { item in + Text(item.rawValue, bundle: .module) + .tag(item) + } + }) + } label: { + HStack { + Text("Filter", bundle: .module) + Image(systemName: "line.horizontal.3.decrease") + } + } + } + } .onAppear(perform: { send(.onAppear) }) @@ -229,37 +297,53 @@ public struct ScheduleView: View { VStack(alignment: .leading, spacing: 8) { Text(conference.date, style: .date) .font(.title2) - ForEach(conference.schedules, id: \.self) { schedule in - VStack(alignment: .leading, spacing: 4) { - Text(schedule.time, style: .time) - .font(.subheadline.bold()) - ForEach(schedule.sessions, id: \.self) { session in - if session.description != nil { - Button { - send(.disclosureTapped(session)) - } label: { - listRow(session: session) - .padding() - } - .background( - Color(uiColor: .secondarySystemBackground) - .clipShape(RoundedRectangle(cornerRadius: 8)) - ) - } else { - listRow(session: session) - .padding() + + if conference.schedules.count > 0 { + ForEach(conference.schedules, id: \.self) { schedule in + VStack(alignment: .leading, spacing: 4) { + Text(schedule.time, style: .time) + .font(.subheadline.bold()) + ForEach(schedule.sessions, id: \.self) { session in + if session.description != nil { + Button { + send(.disclosureTapped(session)) + } label: { + listRow(session: session) + .padding() + } .background( Color(uiColor: .secondarySystemBackground) .clipShape(RoundedRectangle(cornerRadius: 8)) ) + } else { + listRow(session: session) + .padding() + .background( + Color(uiColor: .secondarySystemBackground) + .clipShape(RoundedRectangle(cornerRadius: 8)) + ) + } } } } + } else { + noItemsToShowMessage() } } .padding() } + @ViewBuilder + func noItemsToShowMessage() -> some View { + Text("No items to show.", bundle: .module) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background( + Color(uiColor: .secondarySystemBackground) + .clipShape(RoundedRectangle(cornerRadius: 8)) + ) + } + @ViewBuilder func listRow(session: Session) -> some View { HStack(spacing: 8) { diff --git a/MyLibrary/Sources/SharedModels/Conference.swift b/MyLibrary/Sources/SharedModels/Conference.swift index 17c470a..460eada 100644 --- a/MyLibrary/Sources/SharedModels/Conference.swift +++ b/MyLibrary/Sources/SharedModels/Conference.swift @@ -1,11 +1,13 @@ import Foundation public struct Conference: Codable, Equatable, Hashable, Sendable { + public var id: Int public var title: String public var date: Date public var schedules: [Schedule] public init(id: Int, title: String, date: Date, schedules: [Schedule]) { + self.id = id self.title = title self.date = date self.schedules = schedules diff --git a/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift b/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift index f49be0a..9ab6581 100644 --- a/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift +++ b/MyLibrary/Tests/ScheduleFeatureTests/ScheduleTests.swift @@ -115,4 +115,24 @@ final class ScheduleTests: XCTestCase { initialState.favorites = [initialState.day1!.title: [firstSession]] return initialState }() + + @MainActor + func testSchedulesFilteredFavoritesOnly() async { + let initialState: ScheduleFeature.Schedule.State = ScheduleTests.selectingDay1ScheduleWithOneFavorite + let store = TestStore(initialState: initialState) { + Schedule() + } + let schedulesMock1Only = [Schedule(time: Date(timeIntervalSince1970: 10_000), sessions: [.mock1])] + let expected = Conference( + id: 1, + title: "conference1", + date: Date(timeIntervalSince1970: 1_000), + schedules: schedulesMock1Only + ) + + await store.send(.binding(.set(\.selectedFilter, Schedule.FilterItem.favorite))) { + $0.selectedFilter = .favorite + XCTAssertEqual($0.day1ToShow, expected) + } + } }