diff --git a/FoodSpec.xcodeproj/project.pbxproj b/FoodSpec.xcodeproj/project.pbxproj index e87c8f7..c32426d 100644 --- a/FoodSpec.xcodeproj/project.pbxproj +++ b/FoodSpec.xcodeproj/project.pbxproj @@ -315,11 +315,12 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = FoodSpec/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = FoodDB; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -349,11 +350,12 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = FoodSpec/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = FoodDB; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/FoodSpec.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/FoodSpec.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 133ef29..7e18dc5 100644 --- a/FoodSpec.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/FoodSpec.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -69,7 +69,7 @@ "location" : "https://github.com/pointfreeco/swift-composable-architecture", "state" : { "branch" : "observation-beta", - "revision" : "a1f2ebbe973e35d4d476da4e0a297e0002af25a6" + "revision" : "8e1573479723f9c064bcefef7c20ec53b0433eba" } }, { diff --git a/FoodSpec.xctestplan b/FoodSpec.xctestplan index cba62ae..3e47390 100644 --- a/FoodSpec.xctestplan +++ b/FoodSpec.xctestplan @@ -24,6 +24,10 @@ } }, { + "skippedTests" : [ + "BillboardReducerTests", + "SpotlightReducerTests" + ], "target" : { "containerPath" : "container:food-spec", "identifier" : "FoodListTests", @@ -106,6 +110,20 @@ "identifier" : "MealListTests", "name" : "MealListTests" } + }, + { + "target" : { + "containerPath" : "container:food-spec", + "identifier" : "SearchTests", + "name" : "SearchTests" + } + }, + { + "target" : { + "containerPath" : "container:food-spec", + "identifier" : "FoodObservationTests", + "name" : "FoodObservationTests" + } } ], "version" : 1 diff --git a/FoodSpec/Assets.xcassets/AppIcon.appiconset/Contents.json b/FoodSpec/Assets.xcassets/AppIcon.appiconset/Contents.json index 7482682..9c3e7f6 100644 --- a/FoodSpec/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/FoodSpec/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "Untitled design-2.png", + "filename" : "DALLĀ·E 2023-12-23 21.10.22 - An icon for a nutrition information app, featuring a stylized apple in the center surrounded by various healthy foods like a carrot, a slice of whole .png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git "a/FoodSpec/Assets.xcassets/AppIcon.appiconset/DALL\302\267E 2023-12-23 21.10.22 - An icon for a nutrition information app, featuring a stylized apple in the center surrounded by various healthy foods like a carrot, a slice of whole .png" "b/FoodSpec/Assets.xcassets/AppIcon.appiconset/DALL\302\267E 2023-12-23 21.10.22 - An icon for a nutrition information app, featuring a stylized apple in the center surrounded by various healthy foods like a carrot, a slice of whole .png" new file mode 100644 index 0000000..773cb3b Binary files /dev/null and "b/FoodSpec/Assets.xcassets/AppIcon.appiconset/DALL\302\267E 2023-12-23 21.10.22 - An icon for a nutrition information app, featuring a stylized apple in the center surrounded by various healthy foods like a carrot, a slice of whole .png" differ diff --git a/FoodSpec/Assets.xcassets/AppIcon.appiconset/Untitled design-2.png b/FoodSpec/Assets.xcassets/AppIcon.appiconset/Untitled design-2.png deleted file mode 100644 index 5daf8db..0000000 Binary files a/FoodSpec/Assets.xcassets/AppIcon.appiconset/Untitled design-2.png and /dev/null differ diff --git a/food-spec/Package.swift b/food-spec/Package.swift index a095a62..e851e36 100644 --- a/food-spec/Package.swift +++ b/food-spec/Package.swift @@ -27,6 +27,7 @@ let package = Package( .library(name: "AddIngredients"), .library(name: "IngredientPicker"), .library(name: "QuantityPicker"), + .library(name: "Search"), .library(name: "Shared"), ], dependencies: [ @@ -38,16 +39,18 @@ let package = Package( ], targets: [ .feature(name: "TabBar", dependencies: ["FoodList", "FoodSelection", "MealList"]), - .feature(name: "FoodList", dependencies: ["FoodDetails", "API", "Database", "UserPreferences", "Ads", "Spotlight"]), + .feature(name: "FoodList", dependencies: ["FoodDetails", "Search", "FoodObservation", "Database", "UserPreferences", "Ads", "Spotlight"]), .feature(name: "FoodDetails", dependencies: ["QuantityPicker"]), - .feature(name: "FoodSelection", dependencies: ["Database", "FoodComparison"]), + .feature(name: "FoodSelection", dependencies: ["Database", "FoodComparison", "Search", "FoodObservation"]), .feature(name: "FoodComparison", dependencies: ["QuantityPicker"]), .feature(name: "MealList", dependencies: ["Database", "MealForm", "MealDetails"]), .feature(name: "MealDetails", dependencies: ["FoodDetails", "FoodComparison", "MealForm"]), .feature(name: "MealForm", dependencies: ["Database", "AddIngredients"]), - .feature(name: "AddIngredients", dependencies: ["Database", "IngredientPicker"]), + .feature(name: "AddIngredients", dependencies: ["Database", "IngredientPicker", "Search", "FoodObservation"]), .feature(name: "IngredientPicker", dependencies: ["QuantityPicker"]), .feature(name: "QuantityPicker"), + .feature(name: "Search", dependencies: ["API", "Database"]), + .feature(name: "FoodObservation", dependencies: ["Database"]), .client(name: "UserPreferences", dependencies: ["UserDefaults", asyncSemaphoreDependency]), .client(name: "UserDefaults"), @@ -78,6 +81,8 @@ let package = Package( .featureTests(for: "AddIngredients"), .featureTests(for: "IngredientPicker"), .featureTests(for: "QuantityPicker"), + .featureTests(for: "Search"), + .featureTests(for: "FoodObservation"), .testTarget(for: "API"), .testTarget(for: "Shared"), diff --git a/food-spec/Sources/API/FoodClient.swift b/food-spec/Sources/API/FoodClient.swift index 51dc992..323035c 100644 --- a/food-spec/Sources/API/FoodClient.swift +++ b/food-spec/Sources/API/FoodClient.swift @@ -23,10 +23,15 @@ extension FoodClient: DependencyKey { var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue(apiKeys.ninja, forHTTPHeaderField: "X-Api-Key") - let (data, _) = try await session.data(for: request) - let items = try JSONDecoder().decode([FoodApiModel].self, from: data) - let foodsWithValidServingSize = items.filter(hasValidServingSize) - return foodsWithValidServingSize + do { + let (data, _) = try await session.data(for: request) + let items = try JSONDecoder().decode([FoodApiModel].self, from: data) + let foodsWithValidServingSize = items.filter(hasValidServingSize) + return foodsWithValidServingSize + } catch { + try Task.checkCancellation() + throw error + } } ) diff --git a/food-spec/Sources/AddIngredients/AddIngredients.swift b/food-spec/Sources/AddIngredients/AddIngredients.swift index 90f3ae1..62a34c4 100644 --- a/food-spec/Sources/AddIngredients/AddIngredients.swift +++ b/food-spec/Sources/AddIngredients/AddIngredients.swift @@ -2,16 +2,21 @@ import Foundation import Shared import IngredientPicker import Database +import Search +import FoodObservation import ComposableArchitecture @Reducer public struct AddIngredients { + public typealias IngredientPickers = IdentifiedArray public typealias FoodID = Int64? @ObservableState public struct State: Hashable { var initialIngredients: [Ingredient] - var ingredientPickers: IdentifiedArray = .init(id: \.food.id) + var ingredientPickers: IngredientPickers = .init(id: \.food.id) + var foodSearch: FoodSearch.State = .init() + var foodObservation: FoodObservation.State = .init() public var selectedIngredients: [Ingredient] { ingredientPickers @@ -19,6 +24,13 @@ public struct AddIngredients { .map(\.ingredient) } + var searchResults: IngredientPickers { + let results = Set(foodSearch.searchResults.map(\.id)) + return ingredientPickers.filter { picker in + results.contains(picker.food.id) + } + } + public init(ingredients: [Ingredient] = []) { self.initialIngredients = ingredients for ingredient in ingredients { @@ -32,41 +44,41 @@ public struct AddIngredients { @CasePathable public enum Action { - case onFirstAppear - case updateFoods([Food]) case ingredientPickers(IdentifiedAction) + case foodSearch(FoodSearch.Action) + case foodObservation(FoodObservation.Action) case doneButtonTapped } public init() { } - @Dependency(\.databaseClient) private var databaseClient @Dependency(\.dismiss) private var dismiss public var body: some ReducerOf { + Scope(state: \.foodObservation, action: \.foodObservation) { + FoodObservation() + } + Scope(state: \.foodSearch, action: \.foodSearch) { + FoodSearch() + } Reduce { state, action in switch action { - case .onFirstAppear: - return .run { [databaseClient] send in - let foods = try await databaseClient.getRecentFoods(sortedBy: Column("name"), order: .forward) - await send(.updateFoods(foods)) - } - - // todo: when reducer is initialized with nonempty ingredients, put them at the top of the list, if any is deselected, move it in the list (sort it) - // don't move pickers up and down based on selection as it can be bad UX to the user - - case .updateFoods(let foods): - for food in foods { - if let alreadySelectedIngredient = state.initialIngredients.first(where: { $0.food.id == food.id }) { - let ingredientPicker = IngredientPicker.State( - food: food, - quantity: alreadySelectedIngredient.quantity - ) - state.ingredientPickers.updateOrAppend(ingredientPicker) + case .foodObservation(.updateFoods(let newFoods)): + var pickers: IngredientPickers = .init(id: \.food.id) + for food in newFoods { + if let picker = state.ingredientPickers[id: food.id] { + pickers.append(picker) } else { - state.ingredientPickers.updateOrAppend(.init(food: food)) + pickers.append(.init(food: food)) } } + state.ingredientPickers = pickers + return .none + + case .foodObservation: + return .none + + case .foodSearch: return .none case .ingredientPickers: diff --git a/food-spec/Sources/AddIngredients/AddIngredientsScreen.swift b/food-spec/Sources/AddIngredients/AddIngredientsScreen.swift index 50f4104..670730a 100644 --- a/food-spec/Sources/AddIngredients/AddIngredientsScreen.swift +++ b/food-spec/Sources/AddIngredients/AddIngredientsScreen.swift @@ -14,13 +14,13 @@ public struct AddIngredientsScreen: View { public var body: some View { ScrollView { - VStack(spacing: 16) { - ForEachStore(self.store.scope( - state: \.ingredientPickers, - action: \.ingredientPickers) - ) { store in - IngredientPickerView(store: store) - .padding(.horizontal) + LazyVStack(spacing: 16) { + if self.store.foodSearch.shouldShowSearchResults { + searchResultsSection + } else if !self.store.ingredientPickers.isEmpty { + ingredientsSection + } else { + ContentUnavailableView("Search for ingredients", systemImage: "magnifyingglass") } } } @@ -34,9 +34,53 @@ public struct AddIngredientsScreen: View { DefaultKeyboardToolbar() } .environment(\.focusState, $focusedField) + .foodSearch( + store: self.store.scope( + state: \.foodSearch, + action: \.foodSearch + ) + ) + .foodObservation( + store: self.store.scope( + state: \.foodObservation, + action: \.foodObservation + ) + ) .navigationTitle(navigationTitle) - .onFirstAppear { - self.store.send(.onFirstAppear) + } + + @ViewBuilder + private var searchResultsSection: some View { + ForEachStore(self.store.scope( + state: \.searchResults, + action: \.ingredientPickers + )) { store in + IngredientPickerView(store: store) + .padding(.horizontal) + } + + if self.store.foodSearch.shouldShowNoResults { + ContentUnavailableView.search(text: self.store.foodSearch.query) + .id(UUID()) + } + + if self.store.foodSearch.isSearching { + HStack { + Spacer() + ProgressView("Searching...") + .id(UUID()) + Spacer() + } + } + } + + private var ingredientsSection: some View { + ForEachStore(self.store.scope( + state: \.ingredientPickers, + action: \.ingredientPickers + )) { store in + IngredientPickerView(store: store) + .padding(.horizontal) } } diff --git a/food-spec/Sources/Database/Database+Creation.swift b/food-spec/Sources/Database/Database+Creation.swift index afb7896..6223d18 100644 --- a/food-spec/Sources/Database/Database+Creation.swift +++ b/food-spec/Sources/Database/Database+Creation.swift @@ -80,7 +80,7 @@ fileprivate func setupDatabase(_ writer: any DatabaseWriter) throws { migrator.registerMigration("createMeal") { db in try db.create(table: "mealDB") { t in t.autoIncrementedPrimaryKey("id") - t.column("name", .text).notNull().unique(onConflict: .replace) + t.column("name", .text).notNull() t.column("servings", .double).notNull() t.column("instructions") } diff --git a/food-spec/Sources/Database/DatabaseClient.swift b/food-spec/Sources/Database/DatabaseClient.swift index e4f833a..48fd680 100644 --- a/food-spec/Sources/Database/DatabaseClient.swift +++ b/food-spec/Sources/Database/DatabaseClient.swift @@ -7,13 +7,19 @@ import DependenciesMacros @DependencyClient public struct DatabaseClient { // MARK: Foods - public var observeFoods: (_ sortedBy: Column, _ order: SortOrder) -> AsyncStream<[Food]> = { _, _ in .finished } - public var getRecentFoods: (_ sortedBy: Column, _ order: SortOrder) async throws -> [Food] + public var observeFoods: (_ sortedBy: Food.SortStrategy, _ order: SortOrder) -> AsyncStream<[Food]> = { _, _ in .finished } + public var getRecentFoods: (_ sortedBy: Food.SortStrategy, _ order: SortOrder) async throws -> [Food] + public var numberOfFoods: (_ matching: String) async throws -> Int + public var getFoods: (_ matching: String, _ sortedBy: Food.SortStrategy, _ order: SortOrder) async throws -> [Food] public var getFood: (_ name: String) async throws -> Food? @DependencyEndpoint(method: "insert") public var insertFood: (_ food: Food) async throws -> Food + @DependencyEndpoint(method: "insert") + public var insertFoods: (_ foods: [Food]) async throws -> [Food] @DependencyEndpoint(method: "delete") public var deleteFood: (_ food: Food) async throws -> Void + @DependencyEndpoint(method: "delete") + public var deleteFoods: (_ foods: [Food]) async throws -> Void // MARK: Meals public var observeMeals: () -> AsyncStream<[Meal]> = { .finished } @@ -44,15 +50,31 @@ extension DatabaseClient: DependencyKey { return try Meal.fetchAll(db, request) } return .init( - observeFoods: { column, order in + observeFoods: { strategy, order in let observation = ValueObservation.tracking { - try fetchFoods(db: $0, sortedBy: column, order: order) + try fetchFoods(db: $0, sortedBy: strategy.column, order: order) } return AsyncStream(observation.values(in: db)) }, - getRecentFoods: { column, order in + getRecentFoods: { strategy, order in return try await db.read { - try fetchFoods(db: $0, sortedBy: column, order: order) + try fetchFoods(db: $0, sortedBy: strategy.column, order: order) + } + }, + numberOfFoods: { matching in + try await db.read { + try FoodDB + .filter(Column("name").like("%\(matching)%")) + .fetchCount($0) + } + }, + getFoods: { matching, strategy, order in + try await db.read { + let column = strategy.column + let request = FoodDB + .filter(Column("name").like("%\(matching)%")) + .order(order == .forward ? column : column.desc) + return try Food.fetchAll($0, request) } }, getFood: { name in @@ -68,11 +90,32 @@ extension DatabaseClient: DependencyKey { return Food(foodDb: foodDb) } }, + insertFoods: { foods in + try await db.write { + do { + var insertedFoods: [Food] = [] + for food in foods { + var foodDb = FoodDB(food: food) + try foodDb.upsert($0) + insertedFoods.append(Food(foodDb: foodDb)) + } + return insertedFoods + } catch { + try $0.rollback() + throw error + } + } + }, deleteFood: { food in try await db.write { _ = try FoodDB.deleteOne($0, key: food.id) } }, + deleteFoods: { foods in + try await db.write { + _ = try FoodDB.deleteAll($0, keys: foods.map(\.id)) + } + }, observeMeals: { let observation = ValueObservation.tracking { try fetchMeals(db: $0) diff --git a/food-spec/Sources/Database/Extensions/Food+Database.swift b/food-spec/Sources/Database/Extensions/Food+Database.swift index 0b3106e..0186bfd 100644 --- a/food-spec/Sources/Database/Extensions/Food+Database.swift +++ b/food-spec/Sources/Database/Extensions/Food+Database.swift @@ -56,3 +56,15 @@ extension FoodDB { ) } } + +public extension Food.SortStrategy { + var column: Column { + switch self { + case .name: FoodDB.Columns.name + case .energy: FoodDB.Columns.energy + case .carbohydrate: FoodDB.Columns.carbohydrate + case .protein: FoodDB.Columns.protein + case .fat: FoodDB.Columns.fatTotal + } + } +} diff --git a/food-spec/Sources/Database/Models/FoodDB.swift b/food-spec/Sources/Database/Models/FoodDB.swift index a7e6c11..dac2fe6 100644 --- a/food-spec/Sources/Database/Models/FoodDB.swift +++ b/food-spec/Sources/Database/Models/FoodDB.swift @@ -32,3 +32,19 @@ extension FoodDB: FetchableRecord, MutablePersistableRecord { id = inserted.rowID } } + +extension FoodDB { + enum Columns { + static let name = Column(CodingKeys.name) + static let energy = Column(CodingKeys.energy) + static let fatTotal = Column(CodingKeys.fatTotal) + static let fatSaturated = Column(CodingKeys.fatSaturated) + static let protein = Column(CodingKeys.protein) + static let sodium = Column(CodingKeys.sodium) + static let potassium = Column(CodingKeys.potassium) + static let cholesterol = Column(CodingKeys.cholesterol) + static let carbohydrate = Column(CodingKeys.carbohydrate) + static let fiber = Column(CodingKeys.fiber) + static let sugar = Column(CodingKeys.sugar) + } +} diff --git a/food-spec/Sources/FoodList/BillboardReducer.swift b/food-spec/Sources/FoodList/BillboardReducer.swift index ddcc145..25909e6 100644 --- a/food-spec/Sources/FoodList/BillboardReducer.swift +++ b/food-spec/Sources/FoodList/BillboardReducer.swift @@ -1,6 +1,7 @@ import Foundation import ComposableArchitecture import Ads +import Billboard @Reducer struct BillboardReducer { @@ -9,29 +10,31 @@ struct BillboardReducer { @Dependency(\.billboardClient) private var billboardClient - func reduce(into state: inout FoodList.State, action: FoodList.Action) -> Effect { - switch action { - case .onFirstAppear: - return .run { [billboardClient] send in - do { - let stream = try await billboardClient.getRandomBanners() - for try await ad in stream { - await send(.billboard(.showBanner(ad)), animation: .default) + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .onFirstAppear: + return .run { [billboardClient] send in + do { + let stream = try await billboardClient.getRandomBanners() + for try await ad in stream { + await send(.billboard(.showBanner(ad)), animation: .default) + } + } catch { + dump(error) } - } catch { - dump(error) } - } - case .billboard(let billboard): - return reduce(into: &state, action: billboard) + case .billboard(let billboard): + return reduce(state: &state, action: billboard) - default: - return .none + default: + return .none + } } } - private func reduce(into state: inout FoodList.State, action: FoodList.Action.Billboard) -> Effect { + private func reduce(state: inout FoodList.State, action: FoodList.Action.Billboard) -> EffectOf { switch action { case .showBanner(let banner): state.billboard.banner = banner @@ -39,16 +42,3 @@ struct BillboardReducer { } } } - -extension FoodList.State { - public struct Billboard: Equatable { - var banner: BillboardAd? - } -} - -extension FoodList.Action { - @CasePathable - public enum Billboard { - case showBanner(BillboardAd?) - } -} diff --git a/food-spec/Sources/FoodList/FoodList.swift b/food-spec/Sources/FoodList/FoodList.swift index f18a7c3..02ef069 100644 --- a/food-spec/Sources/FoodList/FoodList.swift +++ b/food-spec/Sources/FoodList/FoodList.swift @@ -2,94 +2,81 @@ import Foundation import Database import ComposableArchitecture import Shared -import API import Ads import FoodDetails import UserPreferences +import Search +import FoodObservation @Reducer public struct FoodList { @ObservableState public struct State: Equatable { - var recentFoods: [Food] = [] - var recentFoodsSortingStrategy: SortingStrategy - var recentFoodsSortingOrder: SortOrder - var searchQuery = "" - var isSearchFocused = false - var isSearching = false - var searchResults: [Food] = [] - var shouldShowNoResults: Bool = false - var inlineFood: FoodDetails.State? + var foodSearch: FoodSearch.State + var foodObservation: FoodObservation.State + var sortStrategy: Food.SortStrategy + var sortOrder: SortOrder var billboard: Billboard = .init() @Presents var destination: Destination.State? - var shouldShowRecentSearches: Bool { - searchQuery.isEmpty && !recentFoods.isEmpty + var recentSearches: [Food] { + foodObservation.foods } - var shouldShowPrompt: Bool { - searchQuery.isEmpty && recentFoods.isEmpty && !shouldShowNoResults - } - - var shouldShowSpinner: Bool { - isSearching - } - - var shouldShowSearchResults: Bool { - isSearchFocused && !searchResults.isEmpty && inlineFood == nil + var searchResults: [Food] { + foodSearch.searchResults } var isSortMenuDisabled: Bool { - recentFoods.count < 2 + recentSearches.count < 2 } - public enum SortingStrategy: String, Codable, Identifiable, Hashable, CaseIterable, Sendable { - case name - case energy - case carbohydrate - case protein - case fat - - public var id: Self { self } - - var column: Column { - switch self { - case .name: Column("name") - case .energy: Column("energy") - case .carbohydrate: Column("carbohydrate") - case .protein: Column("protein") - case .fat: Column("fatTotal") - } - } + public struct Billboard: Equatable { + var banner: BillboardAd? } public init() { @Dependency(\.userPreferencesClient) var userPreferencesClient let prefs = userPreferencesClient.getPreferences() - self.recentFoodsSortingStrategy = prefs.foodSortingStrategy ?? .name - self.recentFoodsSortingOrder = prefs.recentSearchesSortingOrder ?? .forward + let sortStrategy = prefs.recentSearchesSortStrategy ?? .name + let sortOrder = prefs.recentSearchesSortOrder ?? .forward + self.sortStrategy = sortStrategy + self.sortOrder = sortOrder + self.foodSearch = .init( + sortStrategy: sortStrategy, + sortOrder: sortOrder + ) + self.foodObservation = .init( + sortStrategy: sortStrategy, + sortOrder: sortOrder + ) } } @CasePathable public enum Action { case onFirstAppear - case startObservingRecentFoods - case onRecentFoodsChange([Food]) - case onUserPreferencesChange(UserPreferences) - case updateSearchQuery(String) - case updateSearchFocus(Bool) case didSelectRecentFood(Food) case didSelectSearchResult(Food) case didDeleteRecentFoods(IndexSet) - case startSearching - case didReceiveSearchFoods([FoodApiModel]) - case inlineFood(FoodDetails.Action) - case updateRecentFoodsSortingStrategy(State.SortingStrategy) + case foodSearch(FoodSearch.Action) + case foodObservation(FoodObservation.Action) + case updateRecentFoodsSortingStrategy(Food.SortStrategy) case billboard(Billboard) case spotlight(Spotlight) case showGenericAlert case destination(PresentationAction) + + @CasePathable + public enum Billboard { + case showBanner(BillboardAd?) + } + + @CasePathable + public enum Spotlight { + case handleSelectedFood(NSUserActivity) + case handleSearchInApp(NSUserActivity) + } } enum CancelID { @@ -100,102 +87,18 @@ public struct FoodList { public init() { } @Dependency(\.databaseClient) private var databaseClient - @Dependency(\.foodClient) private var foodClient - @Dependency(\.mainQueue) private var mainQueue @Dependency(\.userPreferencesClient) private var userPreferencesClient - public var body: some ReducerOf { + Scope(state: \.foodSearch, action: \.foodSearch) { + FoodSearch() + } + Scope(state: \.foodObservation, action: \.foodObservation) { + FoodObservation() + } Reduce { state, action in switch action { case .onFirstAppear: - return .run { send in - await send(.startObservingRecentFoods) - }.merge(with: .run { [userPreferencesClient] send in - let stream = await userPreferencesClient.observeChanges() - for await preferences in stream { - await send(.onUserPreferencesChange(preferences)) - } - }) - - case .startObservingRecentFoods: - return .run { [databaseClient, strategy = state.recentFoodsSortingStrategy, order = state.recentFoodsSortingOrder] send in - let stream = databaseClient.observeFoods(sortedBy: strategy.column, order: order) - for await foods in stream { - await send(.onRecentFoodsChange(foods), animation: .default) - } - } - .cancellable(id: CancelID.recentFoodsObservation, cancelInFlight: true) - - case .onRecentFoodsChange(let foods): - state.recentFoods = foods - if foods.isEmpty && state.searchQuery.isEmpty { - state.isSearchFocused = true - } - return .none - - case .onUserPreferencesChange(let preferences): - var shouldRestartDatabaseObservation = false - if let newStrategy = preferences.foodSortingStrategy, newStrategy != state.recentFoodsSortingStrategy { - state.recentFoodsSortingStrategy = newStrategy - shouldRestartDatabaseObservation = true - } - if let newOrder = preferences.recentSearchesSortingOrder, newOrder != state.recentFoodsSortingOrder { - state.recentFoodsSortingOrder = newOrder - shouldRestartDatabaseObservation = true - } - if shouldRestartDatabaseObservation { - return .send(.startObservingRecentFoods) - } else { - return .none - } - - case .updateSearchQuery(let query): - guard state.searchQuery != query else { return .none } - state.searchQuery = query - state.shouldShowNoResults = false - state.searchResults = [] - state.inlineFood = nil - if query.isEmpty { - state.isSearching = false - return .cancel(id: CancelID.search) - } else { - return .run { [searchQuery = state.searchQuery] send in - await send(.startSearching) - let foods = try await foodClient.getFoods(query: searchQuery) - await send(.didReceiveSearchFoods(foods)) - } catch: { error, send in - await send(.didReceiveSearchFoods([])) - await send(.showGenericAlert) - } - .debounce(id: CancelID.search, for: .milliseconds(300), scheduler: mainQueue) - } - - case .startSearching: - state.isSearching = true - return .none - - case .didReceiveSearchFoods(let foods): - state.isSearching = false - if foods.isEmpty { - state.shouldShowNoResults = true - } else if foods.count == 1 { - let food = Food(foodApiModel: foods[0]) - state.inlineFood = .init(food: food) - return .run { send in - _ = try await databaseClient.insert(food: food) - } - } else { - state.searchResults = foods.map { .init(foodApiModel: $0) } - } - return .none - - case .updateSearchFocus(let focus): - guard state.isSearchFocused != focus else { return .none } - state.isSearchFocused = focus - if !focus { - state.inlineFood = nil - } return .none case .didSelectRecentFood(let food): @@ -204,42 +107,48 @@ public struct FoodList { case .didSelectSearchResult(let food): state.destination = .foodDetails(.init(food: food)) - return .run { send in - _ = try await databaseClient.insert(food: food) - } + return .none case .didDeleteRecentFoods(let indices): - return .run { [recentFoods = state.recentFoods, databaseClient] send in - let foodsToDelete = indices.map { recentFoods[$0] } - for food in foodsToDelete { - try await databaseClient.delete(food: food) - } + return .run { [foods = state.recentSearches] send in + let foodsToDelete = indices.map { foods[$0] } + try await databaseClient.delete(foods: foodsToDelete) } catch: { error, send in await send(.showGenericAlert) } - case .inlineFood: - return .none - case .updateRecentFoodsSortingStrategy(let newStrategy): - if newStrategy == state.recentFoodsSortingStrategy { - state.recentFoodsSortingOrder.toggle() + if newStrategy == state.sortStrategy { + state.sortOrder.toggle() } else { - state.recentFoodsSortingStrategy = newStrategy - state.recentFoodsSortingOrder = .forward + state.sortStrategy = newStrategy + state.sortOrder = .forward } - return .run { [strategy = state.recentFoodsSortingStrategy, order = state.recentFoodsSortingOrder] send in - try await userPreferencesClient.setPreferences { - $0.foodSortingStrategy = strategy - $0.recentSearchesSortingOrder = order + return .merge( + .send(.foodSearch(.updateSortStrategy(newStrategy, state.sortOrder)), animation: .default), + .send(.foodObservation(.updateSortStrategy(newStrategy, state.sortOrder)), animation: .default), + .run { [order = state.sortOrder] send in + try await userPreferencesClient.setPreferences { + $0.recentSearchesSortStrategy = newStrategy + $0.recentSearchesSortOrder = order + } } - await send(.startObservingRecentFoods) + ) + + case .foodObservation(.updateFoods(let newFoods)): + if newFoods.isEmpty && state.foodSearch.query.isEmpty { + state.foodSearch.isFocused = true } + return .none + + case .foodSearch: + return .none + + case .foodObservation: + return .none case .showGenericAlert: - state.destination = .alert(.init { - TextState("Something went wrong. Please try again later.") - }) + showGenericAlert(state: &state) return .none case .billboard: @@ -254,14 +163,17 @@ public struct FoodList { return .none } } - .ifLet(\.inlineFood, action: \.inlineFood) { - FoodDetails() - } .ifLet(\.$destination, action: \.destination) { Destination() } - SpotlightReducer() - BillboardReducer() +// SpotlightReducer() +// BillboardReducer() + } + + private func showGenericAlert(state: inout State) { + state.destination = .alert(.init { + TextState("Something went wrong. Please try again later.") + }) } @Reducer @@ -288,14 +200,3 @@ public struct FoodList { } } } - -fileprivate extension UserPreferences { - var foodSortingStrategy: FoodList.State.SortingStrategy? { - get { - recentSearchesSortingStrategy.flatMap { .init(rawValue: $0) } - } - set { - recentSearchesSortingStrategy = newValue?.rawValue - } - } -} diff --git a/food-spec/Sources/FoodList/FoodListScreen.swift b/food-spec/Sources/FoodList/FoodListScreen.swift index 0bcf90f..41af703 100644 --- a/food-spec/Sources/FoodList/FoodListScreen.swift +++ b/food-spec/Sources/FoodList/FoodListScreen.swift @@ -6,8 +6,6 @@ import Shared import FoodDetails public struct FoodListScreen: View { - typealias State = FoodList.State - @Bindable var store: StoreOf public init(store: StoreOf) { @@ -15,70 +13,84 @@ public struct FoodListScreen: View { } public var body: some View { - list - .toolbar { - toolbar - } - .searchable( - text: self.$store.searchQuery.sending(\.updateSearchQuery), - isPresented: self.$store.isSearchFocused.sending(\.updateSearchFocus), - placement: .navigationBarDrawer - ) - .safeAreaInset(edge: .bottom) { - if let ad = store.billboard.banner { - BillboardBannerView(advert: ad, hideDismissButtonAndTimer: true) - .padding([.horizontal, .bottom]) - } - } - .navigationDestination( - item: $store.scope(state: \.destination?.foodDetails, action: \.destination.foodDetails) - ) { store in - FoodDetailsScreen(store: store) + List { + if self.store.foodSearch.shouldShowSearchResults { + searchResultsSection + } else if !self.store.recentSearches.isEmpty { + recentSearchesSection + } else { + ContentUnavailableView("Search for food", systemImage: "magnifyingglass") } - .navigationTitle("Search") - .alert($store.scope(state: \.destination?.alert, action: \.destination.alert)) - .onFirstAppear { - self.store.send(.onFirstAppear) - } - .onContinueUserActivity(CSSearchableItemActionType) { activity in - store.send(.spotlight(.handleSelectedFood(activity))) - } - .onContinueUserActivity(CSQueryContinuationActionType) { activity in - store.send(.spotlight(.handleSearchInApp(activity))) + } + .safeAreaInset(edge: .bottom) { + if let ad = self.store.billboard.banner { + BillboardBannerView(advert: ad, hideDismissButtonAndTimer: true) + .padding([.horizontal, .bottom]) } + } + .toolbar { + toolbar + } + .navigationDestination( + item: self.$store.scope( + state: \.destination?.foodDetails, + action: \.destination.foodDetails + ) + ) { store in + FoodDetailsScreen(store: store) + } + .foodSearch( + store: self.store.scope( + state: \.foodSearch, + action: \.foodSearch + ) + ) + .foodObservation( + store: self.store.scope( + state: \.foodObservation, + action: \.foodObservation + ) + ) + .alert(self.$store.scope(state: \.destination?.alert, action: \.destination.alert)) + .onFirstAppear { + self.store.send(.onFirstAppear) + } + .navigationTitle("Search") + .onContinueUserActivity(CSSearchableItemActionType) { activity in + self.store.send(.spotlight(.handleSelectedFood(activity))) + } + .onContinueUserActivity(CSQueryContinuationActionType) { activity in + self.store.send(.spotlight(.handleSearchInApp(activity))) + } } - @MainActor @ViewBuilder - private var list: some View { - if let store = store.scope(state: \.inlineFood, action: \.inlineFood) { - FoodDetailsScreen(store: store) - } else { - List { - if self.store.shouldShowRecentSearches { - recentSearches - } - if self.store.shouldShowPrompt { - ContentUnavailableView("Search for food", systemImage: "magnifyingglass") - } - if self.store.shouldShowSearchResults { - searchResultsList - } - if self.store.shouldShowNoResults { - ContentUnavailableView.search(text: self.store.searchQuery) + private var searchResultsSection: some View { + Section("Results") { + ForEach(self.store.foodSearch.searchResults, id: \.id) { item in + ListButton { + self.store.send(.didSelectSearchResult(item)) + } label: { + FoodListRow(food: item) } } - .overlay { - if self.store.isSearching { - ProgressView() - .progressViewStyle(.circular) + if self.store.foodSearch.shouldShowNoResults { + ContentUnavailableView.search(text: self.store.foodSearch.query) + .id(UUID()) + } + if self.store.foodSearch.isSearching { + HStack { + Spacer() + ProgressView("Searching...") + .id(UUID()) + Spacer() } } } } - private var recentSearches: some View { + private var recentSearchesSection: some View { Section { - ForEach(self.store.recentFoods, id: \.id) { item in + ForEach(self.store.recentSearches, id: \.id) { item in ListButton { self.store.send(.didSelectRecentFood(item)) } label: { @@ -91,25 +103,11 @@ public struct FoodListScreen: View { } header: { Text("Recent Searches") } footer: { - Text("Values per \(Quantity.grams( 100).formatted(width: .wide))") + Text("Values per \(Quantity.grams(100).formatted(width: .wide))") .font(.footnote) } } - private var searchResultsList: some View { - Section { - ForEach(self.store.searchResults, id: \.self) { item in - ListButton { - self.store.send(.didSelectSearchResult(item)) - } label: { - FoodListRow(food: item) - } - } - } header: { - Text("Results") - } - } - private var toolbar: some ToolbarContent { ToolbarItemGroup(placement: .topBarTrailing) { sortRecentFoodsMenu @@ -120,13 +118,13 @@ public struct FoodListScreen: View { Menu { Picker( "Sort by", - selection: self.$store.recentFoodsSortingStrategy.sending(\.updateRecentFoodsSortingStrategy) + selection: self.$store.sortStrategy.sending(\.updateRecentFoodsSortingStrategy) ) { - ForEach(State.SortingStrategy.allCases) { strategy in + ForEach(Food.SortStrategy.allCases) { strategy in let text = strategy.rawValue.capitalized ZStack { - if strategy == self.store.recentFoodsSortingStrategy { - let systemImageName = self.store.recentFoodsSortingOrder == .forward ? "chevron.up" : "chevron.down" + if strategy == self.store.sortStrategy { + let systemImageName = self.store.sortOrder == .forward ? "chevron.up" : "chevron.down" Label(text, systemImage: systemImageName) .imageScale(.small) } else { @@ -141,7 +139,7 @@ public struct FoodListScreen: View { .imageScale(.medium) } .menuActionDismissBehavior(.disabled) - .disabled(store.isSortMenuDisabled) + .disabled(self.store.isSortMenuDisabled) } } diff --git a/food-spec/Sources/FoodList/SpotlightReducer.swift b/food-spec/Sources/FoodList/SpotlightReducer.swift index d78da3c..cc39bdb 100644 --- a/food-spec/Sources/FoodList/SpotlightReducer.swift +++ b/food-spec/Sources/FoodList/SpotlightReducer.swift @@ -1,8 +1,11 @@ import Foundation import ComposableArchitecture import Spotlight +import CoreSpotlight import Database +// TODO: Move to AppReducer + @Reducer struct SpotlightReducer { typealias State = FoodList.State @@ -11,52 +14,38 @@ struct SpotlightReducer { @Dependency(\.spotlightClient) var spotlightClient @Dependency(\.databaseClient) var databaseClient - func reduce(into state: inout FoodList.State, action: FoodList.Action) -> Effect { - switch action { - case .onRecentFoodsChange(let recentFoods): - return .run { send in - do { - try await spotlightClient.indexFoods(foods: recentFoods) - } catch { + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .foodObservation(.updateFoods(let newFoods)): + return .run { _ in + try await spotlightClient.indexFoods(foods: newFoods) + } catch: { _, error in dump(error) } - } - case .spotlight(let spotlight): - return reduce(into: &state, action: spotlight) - - default: - return .none - } - } - - private func reduce(into state: inout FoodList.State, action: FoodList.Action.Spotlight) -> Effect { - switch action { - case .handleSelectedFood(let activity): - guard let foodName = activity.userInfo?[CSSearchableItemActivityIdentifier] as? String else { return .none } - return .run { send in - guard let food = try await databaseClient.getFood(name: foodName) else { return } - await send(.didSelectRecentFood(food)) - } - case .handleSearchInApp(let activity): - guard let searchString = activity.userInfo?[CSSearchQueryString] as? String else { return .none } - return .run { [destination = state.destination, isSearchFocused = state.isSearchFocused] send in - if destination != nil { - await send(.destination(.dismiss)) + case .spotlight(.handleSelectedFood(let activity)): + guard let foodName = activity.userInfo?[CSSearchableItemActivityIdentifier] as? String else { return .none } + return .run { send in + guard let food = try await databaseClient.getFood(name: foodName) else { return } + await send(.didSelectRecentFood(food)) } - if !isSearchFocused { - await send(.updateSearchFocus(true)) + + case .spotlight(.handleSelectedFood(let activity)): + guard let searchString = activity.userInfo?[CSSearchQueryString] as? String else { return .none } + return .run { [destination = state.destination, isSearchFocused = state.foodSearch.isFocused] send in + if destination != nil { + await send(.destination(.dismiss)) + } +// if !isSearchFocused { +// await send(.foodSearch(.updateFocus(true))) +// } + await send(.foodSearch(.updateQuery(searchString))) } - await send(.updateSearchQuery(searchString)) - } - } - } -} -extension FoodList.Action { - @CasePathable - public enum Spotlight { - case handleSelectedFood(NSUserActivity) - case handleSearchInApp(NSUserActivity) + default: + return .none + } + } } } diff --git a/food-spec/Sources/FoodObservation/FoodObservation.swift b/food-spec/Sources/FoodObservation/FoodObservation.swift new file mode 100644 index 0000000..ceff3f7 --- /dev/null +++ b/food-spec/Sources/FoodObservation/FoodObservation.swift @@ -0,0 +1,75 @@ +import Foundation +import Database +import Shared +import ComposableArchitecture + +@Reducer +public struct FoodObservation { + @ObservableState + public struct State: Hashable { + fileprivate let observationId: UUID + public var foods: [Food] = [] + public var sortStrategy: Food.SortStrategy + public var sortOrder: SortOrder + + public init( + sortStrategy: Food.SortStrategy = .name, + sortOrder: SortOrder = .forward + ) { + @Dependency(\.uuid) var uuid + self.observationId = uuid() + self.sortStrategy = sortStrategy + self.sortOrder = sortOrder + } + } + + @CasePathable + public enum Action { + case startObservation + case updateFoods([Food]) + case updateSortStrategy(Food.SortStrategy, SortOrder) + } + + public init() { } + + @Dependency(\.databaseClient) private var databaseClient + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .startObservation: + return observationEffect(state: state) + + case .updateFoods(let foods): + state.foods = foods + return .none + + case .updateSortStrategy(let strategy, let order): + var shouldRestartObservation = false + if strategy != state.sortStrategy { + state.sortStrategy = strategy + shouldRestartObservation = true + } + if order != state.sortOrder { + state.sortOrder = order + shouldRestartObservation = true + } + if shouldRestartObservation { + return observationEffect(state: state) + } else { + return .none + } + } + } + } + + private func observationEffect(state: State) -> EffectOf { + .run { send in + let observation = databaseClient.observeFoods(sortedBy: state.sortStrategy, order: state.sortOrder) + for await foods in observation { + await send(.updateFoods(foods)) + } + } + .cancellable(id: state.observationId, cancelInFlight: true) + } +} diff --git a/food-spec/Sources/FoodObservation/FoodObservationModifier.swift b/food-spec/Sources/FoodObservation/FoodObservationModifier.swift new file mode 100644 index 0000000..7b94139 --- /dev/null +++ b/food-spec/Sources/FoodObservation/FoodObservationModifier.swift @@ -0,0 +1,36 @@ +import SwiftUI +import ComposableArchitecture + +struct FoodObservationModifier: ViewModifier { + let store: StoreOf + + init(store: StoreOf) { + self.store = store + } + + func body(content: Content) -> some View { + content + .onFirstAppear { + self.store.send(.startObservation, animation: .default) + } + } +} + +public extension View { + func foodObservation(store: StoreOf) -> some View { + self + .modifier(FoodObservationModifier(store: store)) + } +} + +#Preview { + Text("Food Observation") + .foodObservation( + store: .init( + initialState: FoodObservation.State(), + reducer: { + FoodObservation() + } + ) + ) +} diff --git a/food-spec/Sources/FoodSelection/FoodSelection.swift b/food-spec/Sources/FoodSelection/FoodSelection.swift index 26408bf..5c4c8fd 100644 --- a/food-spec/Sources/FoodSelection/FoodSelection.swift +++ b/food-spec/Sources/FoodSelection/FoodSelection.swift @@ -2,22 +2,25 @@ import Foundation import Shared import Database import FoodComparison +import Search +import FoodObservation import ComposableArchitecture @Reducer public struct FoodSelection { @ObservableState public struct State: Hashable { - var foods: [Food] = [] var selectedFoodIds: Set = [] - var filterQuery: String = "" + var foodSearch: FoodSearch.State = .init() + var foodObservation: FoodObservation.State = .init() @Presents var foodComparison: FoodComparison.State? - var filteredFoods: [Food] { - guard !filterQuery.isEmpty else { return foods } - return foods.filter { - $0.name.range(of: filterQuery, options: .caseInsensitive) != nil - } + var foods: [Food] { + foodObservation.foods + } + + var searchResults: [Food] { + foodSearch.searchResults } var isCompareButtonDisabled: Bool { @@ -40,10 +43,9 @@ public struct FoodSelection { @CasePathable public enum Action { - case onFirstAppear - case updateFoods([Food]) case updateSelection(Set) - case updateFilter(String) + case foodSearch(FoodSearch.Action) + case foodObservation(FoodObservation.Action) case foodComparison(PresentationAction) case cancelButtonTapped case compareButtonTapped(Comparison) @@ -54,35 +56,25 @@ public struct FoodSelection { @Dependency(\.databaseClient) private var databaseClient public var body: some ReducerOf { + Scope(state: \.foodObservation, action: \.foodObservation) { + FoodObservation() + } + Scope(state: \.foodSearch, action: \.foodSearch) { + FoodSearch() + } Reduce { state, action in switch action { - case .onFirstAppear: - return .run { [databaseClient] send in - let observation = databaseClient.observeFoods(sortedBy: Column("name"), order: .forward) - for await foods in observation { - await send(.updateFoods(foods)) - } - } - - case .updateFoods(let foods): - state.foods = foods - return .none - case .updateSelection(let selection): state.selectedFoodIds = selection return .none - case .updateFilter(let query): - state.filterQuery = query - return .none - case .cancelButtonTapped: state.selectedFoodIds = [] return .none case .compareButtonTapped(let comparison): state.foodComparison = .init( - foods: state.filteredFoods.filter { + foods: state.foods.filter { state.selectedFoodIds.contains($0.id) }, comparison: comparison, @@ -91,6 +83,12 @@ public struct FoodSelection { ) return .none + case .foodSearch: + return .none + + case .foodObservation: + return .none + case .foodComparison: return .none } diff --git a/food-spec/Sources/FoodSelection/FoodSelectionScreen.swift b/food-spec/Sources/FoodSelection/FoodSelectionScreen.swift index 1cf1cc8..e8a2636 100644 --- a/food-spec/Sources/FoodSelection/FoodSelectionScreen.swift +++ b/food-spec/Sources/FoodSelection/FoodSelectionScreen.swift @@ -2,6 +2,7 @@ import SwiftUI import ComposableArchitecture import Shared import FoodComparison +import Search public struct FoodSelectionScreen: View { @Bindable var store: StoreOf @@ -11,50 +12,77 @@ public struct FoodSelectionScreen: View { } public var body: some View { - List(selection: $store.selectedFoodIds.sending(\.updateSelection)) { - if !self.store.filteredFoods.isEmpty { + List(selection: self.$store.selectedFoodIds.sending(\.updateSelection).animation()) { + if self.store.foodSearch.shouldShowSearchResults { + searchResultsSection + } else if !self.store.foods.isEmpty { recentSearchesSection + } else { + ContentUnavailableView("Search for food", systemImage: "magnifyingglass") } } - .listStyle(.sidebar) - .searchable( - text: $store.filterQuery.sending(\.updateFilter), - prompt: "Filter" - ) .environment(\.editMode, .constant(.active)) .navigationTitle(navigationTitle) .navigationBarTitleDisplayMode(.large) .toolbar { toolbar } + .foodSearch( + store: self.store.scope( + state: \.foodSearch, + action: \.foodSearch + ) + ) + .foodObservation( + store: self.store.scope( + state: \.foodObservation, + action: \.foodObservation + ) + ) .navigationDestination( - item: $store.scope(state: \.foodComparison, action: \.foodComparison), + item: self.$store.scope(state: \.foodComparison, action: \.foodComparison), destination: { store in FoodComparisonScreen(store: store) } ) - .onFirstAppear { - store.send(.onFirstAppear) + } + + private var searchResultsSection: some View { + Section("Results") { + ForEach(self.store.searchResults, id: \.id) { item in + LabeledListRow(title: item.name.capitalized) + .selectionDisabled(self.store.state.isSelectionDisabled(for: item)) + } + if self.store.foodSearch.shouldShowNoResults { + ContentUnavailableView.search(text: self.store.foodSearch.query) + .id(UUID()) + } + if self.store.foodSearch.isSearching { + HStack { + Spacer() + ProgressView() + .id(UUID()) + Spacer() + } + } } } private var recentSearchesSection: some View { - Section { - ForEach(store.filteredFoods, id: \.id) { item in + Section("Recent searches") { + ForEach(self.store.foods, id: \.id) { item in LabeledListRow(title: item.name.capitalized) - .selectionDisabled(store.state.isSelectionDisabled(for: item)) + .selectionDisabled(self.store.state.isSelectionDisabled(for: item)) } - } header: { - Text("Recent searches") } } @ToolbarContentBuilder private var toolbar: some ToolbarContent { - if store.shouldShowCancelButton { + if self.store.shouldShowCancelButton { ToolbarItem(placement: .topBarLeading) { Button("Cancel") { - store.send(.cancelButtonTapped) + self.store.send(.cancelButtonTapped) } } } @@ -62,19 +90,19 @@ public struct FoodSelectionScreen: View { Menu("Compare") { ForEach(Comparison.allCases) { comparison in Button(comparison.rawValue.capitalized) { - store.send(.compareButtonTapped(comparison)) + self.store.send(.compareButtonTapped(comparison)) } } } - .disabled(store.isCompareButtonDisabled) + .disabled(self.store.isCompareButtonDisabled) } } private var navigationTitle: String { - if store.selectedFoodIds.count < 2 { + if self.store.selectedFoodIds.count < 2 { "Select \(2 - store.selectedFoodIds.count) or more" } else { - "\(store.selectedFoodIds.count) foods selected" + "\(self.store.selectedFoodIds.count) foods selected" } } } diff --git a/food-spec/Sources/IngredientPicker/IngredientPickerView.swift b/food-spec/Sources/IngredientPicker/IngredientPickerView.swift index 4b53163..d399278 100644 --- a/food-spec/Sources/IngredientPicker/IngredientPickerView.swift +++ b/food-spec/Sources/IngredientPicker/IngredientPickerView.swift @@ -13,7 +13,7 @@ public struct IngredientPickerView: View { public var body: some View { DisclosureGroup( - isExpanded: self.$store.isSelected.sending(\.updateSelection) + isExpanded: self.$store.isSelected.sending(\.updateSelection).animation() ) { QuantityPickerView( store: self.store.scope( diff --git a/food-spec/Sources/MealDetails/MealDetails.swift b/food-spec/Sources/MealDetails/MealDetails.swift index b3ba273..147b661 100644 --- a/food-spec/Sources/MealDetails/MealDetails.swift +++ b/food-spec/Sources/MealDetails/MealDetails.swift @@ -78,21 +78,16 @@ public struct MealDetails { state.destination = .mealForm(.init(meal: state.meal)) return .none - case .destination(.presented(.mealForm(.delegate(.mealSaved(let meal))))): - state.meal = meal + case .destination(.presented(.mealForm(.delegate(.mealSaved(let newMeal))))): + state.meal = newMeal + state.nutritionalValuesPerTotal = calculator.nutritionalValues(meal: newMeal) + state.nutritionalValuesPerServing = calculator.nutritionalValuesPerServing(meal: newMeal) return .none case .destination: return .none } } - .onChange(of: \.meal) { _, newMeal in - Reduce { state, _ in - state.nutritionalValuesPerTotal = calculator.nutritionalValues(meal: newMeal) - state.nutritionalValuesPerServing = calculator.nutritionalValuesPerServing(meal: newMeal) - return .none - } - } .ifLet(\.$destination, action: \.destination) { Destination() } diff --git a/food-spec/Sources/Search/FoodSearch.swift b/food-spec/Sources/Search/FoodSearch.swift new file mode 100644 index 0000000..0a22754 --- /dev/null +++ b/food-spec/Sources/Search/FoodSearch.swift @@ -0,0 +1,158 @@ +import Foundation +import Shared +import API +import Database +import ComposableArchitecture + +@Reducer +public struct FoodSearch { + @ObservableState + public struct State: Hashable { + public var query: String = "" + public var isFocused: Bool = false + public var isSearching: Bool = false + public var searchResults: [Food] = [] + public var sortStrategy: Food.SortStrategy + public var sortOrder: SortOrder + @Presents public var alert: AlertState? + + public var hasNoResults: Bool { + searchResults.isEmpty + } + + public var shouldShowNoResults: Bool { + shouldShowSearchResults && + !isSearching && + hasNoResults + } + + public var shouldShowSearchResults: Bool { + isFocused && + !query.isEmpty + } + + public init( + sortStrategy: Food.SortStrategy = .name, + sortOrder: SortOrder = .forward + ) { + self.sortStrategy = sortStrategy + self.sortOrder = sortOrder + } + } + + @CasePathable + public enum Action { + case updateQuery(String) + case updateFocus(Bool) + case updateSortStrategy(Food.SortStrategy, SortOrder) + case searchStarted + case searchEnded + case searchSubmitted + case result([Food]) + case error(Error) + case alert(PresentationAction) + + @CasePathable + public enum Alert: Hashable { } + } + + enum CancelID: Hashable { + case search + case apiSearch + } + + public init() { } + + @Dependency(\.foodClient) private var foodClient + @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.continuousClock) private var clock + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .updateFocus(let focused): + state.isFocused = focused + if !focused { + state.searchResults = [] + return .cancel(id: CancelID.apiSearch) + } else { + return .none + } + + case .updateQuery(let query): + guard state.query != query else { return .none } + state.query = query + if query.isEmpty { + state.searchResults = [] + return .cancel(id: CancelID.apiSearch) + } else { + return startSearching(state: state) + } + + case .searchSubmitted: + return startSearching(state: state) + + case .searchStarted: + guard !state.isSearching else { return .none } + state.isSearching = true + return .none + + case .result(let foods): + state.searchResults = foods + return .none + + case .error: + if state.hasNoResults { + state.alert = AlertState { + TextState("Something went wrong. Please try again later.") + } + } + return .none + + case .alert: + return .none + + case .searchEnded: + guard state.isSearching else { return .none } + state.isSearching = false + return .none + + case .updateSortStrategy(let strategy, let order): + state.sortStrategy = strategy + state.sortOrder = order + return .none + } + } + .ifLet(\.$alert, action: \.alert) + } + + private func startSearching(state: State) -> EffectOf { + let query = state.query.trimmingCharacters(in: .whitespacesAndNewlines) + return .concatenate( + .send(.searchStarted), + .run { send in + try await send(.result(self.getFoods(state: state))) + try await clock.sleep(for: .milliseconds(300)) + let apiFoods = try await self.foodClient.getFoods(query: query) + if !apiFoods.isEmpty { + _ = try await self.databaseClient.insert(foods: apiFoods.map(Food.init)) + try await send(.result(self.getFoods(state: state))) + } + } catch: { error, send in + await send(.error(error)) + } + .cancellable(id: CancelID.apiSearch, cancelInFlight: true), + .send(.searchEnded) + ) + .cancellable(id: CancelID.search, cancelInFlight: true) + } + + private func getFoods(state: State) async throws -> [Food] { + let query = state.query.trimmingCharacters(in: .whitespacesAndNewlines) + return try await databaseClient.getFoods( + matching: query, + sortedBy: state.sortStrategy, + order: state.sortOrder + ) + } +} diff --git a/food-spec/Sources/Search/FoodSearchModifier.swift b/food-spec/Sources/Search/FoodSearchModifier.swift new file mode 100644 index 0000000..96ac0cb --- /dev/null +++ b/food-spec/Sources/Search/FoodSearchModifier.swift @@ -0,0 +1,28 @@ +import SwiftUI +import ComposableArchitecture + +struct FoodSearchModifier: ViewModifier { + @Bindable var store: StoreOf + var prompt: String + + func body(content: Content) -> some View { + content + .searchable( + text: self.$store.query.sending(\.updateQuery).animation(), + isPresented: self.$store.isFocused.sending(\.updateFocus).animation(), + prompt: prompt + ) + .submitLabel(.search) + .onSubmit(of: .search) { + self.store.send(.searchSubmitted, animation: .default) + } + .alert(self.$store.scope(state: \.alert, action: \.alert)) + } +} + +public extension View { + func foodSearch(store: StoreOf, prompt: String = "Search") -> some View { + self + .modifier(FoodSearchModifier(store: store, prompt: prompt)) + } +} diff --git a/food-spec/Sources/Shared/Extensions/SortOrder+Extensions.swift b/food-spec/Sources/Shared/Extensions/SortOrder+Extensions.swift index e50c431..c54a751 100644 --- a/food-spec/Sources/Shared/Extensions/SortOrder+Extensions.swift +++ b/food-spec/Sources/Shared/Extensions/SortOrder+Extensions.swift @@ -2,7 +2,11 @@ import Foundation public extension SortOrder { mutating func toggle() { - self = switch self { + self = toggled() + } + + func toggled() -> Self { + switch self { case .forward: .reverse case .reverse: .forward } diff --git a/food-spec/Sources/Shared/Model/Food/Food+Utilities.swift b/food-spec/Sources/Shared/Model/Food/Food+Utilities.swift index 58637e1..5a0e87d 100644 --- a/food-spec/Sources/Shared/Model/Food/Food+Utilities.swift +++ b/food-spec/Sources/Shared/Model/Food/Food+Utilities.swift @@ -38,6 +38,18 @@ public extension Food { } } +public extension Food { + enum SortStrategy: String, Codable, Identifiable, Hashable, CaseIterable, Sendable { + case name + case energy + case carbohydrate + case protein + case fat + + public var id: Self { self } + } +} + public extension Food { static var preview: Self { preview(id: nil) diff --git a/food-spec/Sources/TabBar/TabBar.swift b/food-spec/Sources/TabBar/TabBar.swift index 09a073b..0ff5e32 100644 --- a/food-spec/Sources/TabBar/TabBar.swift +++ b/food-spec/Sources/TabBar/TabBar.swift @@ -36,12 +36,12 @@ public struct TabBar { Scope(state: \.foodSelection, action: \.foodSelection) { FoodSelection() } - Scope(state: \.foodList, action: \.foodList) { - FoodList() - } Scope(state: \.mealList, action: \.mealList) { MealList() } + Scope(state: \.foodList, action: \.foodList) { + FoodList() + } Reduce { state, action in switch action { case .updateTab(let tab): diff --git a/food-spec/Sources/TabBar/TabBarScreen.swift b/food-spec/Sources/TabBar/TabBarScreen.swift index c914418..a0f2c44 100644 --- a/food-spec/Sources/TabBar/TabBarScreen.swift +++ b/food-spec/Sources/TabBar/TabBarScreen.swift @@ -18,8 +18,8 @@ public struct TabBarScreen: View { selection: $store.tab.sending(\.updateTab), content: { foodList - foodSelection mealList + foodSelection } ) } @@ -45,7 +45,7 @@ public struct TabBarScreen: View { ) } .tabItem { - Label("Food Comparison", systemImage: "shuffle") + Label("Compare", systemImage: "shuffle") } .tag(Tab.foodSelection) } diff --git a/food-spec/Sources/UserPreferences/UserPreferences.swift b/food-spec/Sources/UserPreferences/UserPreferences.swift index 04ac568..0ebf5c0 100644 --- a/food-spec/Sources/UserPreferences/UserPreferences.swift +++ b/food-spec/Sources/UserPreferences/UserPreferences.swift @@ -2,6 +2,6 @@ import Foundation import Shared public struct UserPreferences: Codable, Hashable { - public var recentSearchesSortingStrategy: String? - public var recentSearchesSortingOrder: SortOrder? + public var recentSearchesSortStrategy: Food.SortStrategy? + public var recentSearchesSortOrder: SortOrder? } diff --git a/food-spec/Tests/AddIngredientsTests/AddIngredientTests.swift b/food-spec/Tests/AddIngredientsTests/AddIngredientsTests.swift similarity index 82% rename from food-spec/Tests/AddIngredientsTests/AddIngredientTests.swift rename to food-spec/Tests/AddIngredientsTests/AddIngredientsTests.swift index 278cad6..326b984 100644 --- a/food-spec/Tests/AddIngredientsTests/AddIngredientTests.swift +++ b/food-spec/Tests/AddIngredientsTests/AddIngredientsTests.swift @@ -5,12 +5,15 @@ import ComposableArchitecture @testable import AddIngredients @MainActor -final class AddIngredientTests: XCTestCase { +final class AddIngredientsTests: XCTestCase { func testStateInitializers() async throws { var store = TestStore( initialState: AddIngredients.State(ingredients: []), reducer: { AddIngredients() + }, + withDependencies: { + $0.uuid = .constant(.init(0)) } ) store.assert { state in @@ -24,6 +27,9 @@ final class AddIngredientTests: XCTestCase { initialState: AddIngredients.State(ingredients: ingredients), reducer: { AddIngredients() + }, + withDependencies: { + $0.uuid = .constant(.init(0)) } ) store.assert { state in @@ -38,91 +44,6 @@ final class AddIngredientTests: XCTestCase { XCTAssertNoDifference(store.state.selectedIngredients, ingredients) } - func testOnFirstAppear() async throws { - var store = TestStore( - initialState: AddIngredients.State(ingredients: []), - reducer: { - AddIngredients() - }, - withDependencies: { - $0.databaseClient.getRecentFoods = { sortedBy, order in - XCTAssertEqual(sortedBy.name, "name") - XCTAssertEqual(order, .forward) - return [.chiliPepper, .coriander, .garlic] - } - } - ) - await store.send(.onFirstAppear) - await store.receive(\.updateFoods) { - $0.ingredientPickers = .init( - uniqueElements: [ - .init(food: .chiliPepper), - .init(food: .coriander), - .init(food: .garlic), - ], - id: \.food.id) - } - XCTAssertNoDifference(store.state.selectedIngredients, []) - - store = TestStore( - initialState: AddIngredients.State( - ingredients: [ - .init( - food: .oliveOil, - quantity: .init(value: 0.5, unit: .cups) - ), - .init( - food: .oregano, - quantity: .init(value: 1, unit: .teaspoons) - ), - ] - ), - reducer: { - AddIngredients() - }, - withDependencies: { - $0.databaseClient.getRecentFoods = { sortedBy, order in - XCTAssertEqual(sortedBy.name, "name") - XCTAssertEqual(order, .forward) - - return [.chiliPepper, .coriander, .garlic, .oliveOil, .oregano] - } - } - ) - await store.send(.onFirstAppear) - await store.receive(\.updateFoods) { - $0.ingredientPickers = .init( - uniqueElements: [ - .init( - food: .oliveOil, - quantity: .init(value: 0.5, unit: .cups) - ), - .init( - food: .oregano, - quantity: .init(value: 1, unit: .teaspoons) - ), - .init(food: .chiliPepper), - .init(food: .coriander), - .init(food: .garlic), - ], - id: \.food.id - ) - } - XCTAssertNoDifference( - store.state.selectedIngredients, - [ - .init( - food: .oliveOil, - quantity: .init(value: 0.5, unit: .cups) - ), - .init( - food: .oregano, - quantity: .init(value: 1, unit: .teaspoons) - ), - ] - ) - } - func testDoneButton() async throws { let store = TestStore( initialState: AddIngredients.State(ingredients: []), @@ -130,6 +51,7 @@ final class AddIngredientTests: XCTestCase { AddIngredients() }, withDependencies: { + $0.uuid = .constant(.init(0)) $0.dismiss = .init { XCTAssert(true) } @@ -139,6 +61,7 @@ final class AddIngredientTests: XCTestCase { } func testIntegrationWithIngredientPickers() async throws { + let (stream, continuation) = AsyncStream.makeStream(of: [Food].self) let store = TestStore( initialState: AddIngredients.State( ingredients: [ @@ -156,18 +79,19 @@ final class AddIngredientTests: XCTestCase { AddIngredients() }, withDependencies: { - $0.databaseClient.getRecentFoods = { sortedBy, order in - XCTAssertEqual(sortedBy.name, "name") - XCTAssertEqual(order, .forward) - - return [.chiliPepper, .coriander, .garlic, .oliveOil, .oregano] - } + $0.uuid = .constant(.init(0)) + $0.databaseClient.observeFoods = { _, _ in stream } } ) - await store.send(.onFirstAppear) - await store.receive(\.updateFoods) { + await store.send(.foodObservation(.startObservation)) + continuation.yield([.chiliPepper, .coriander, .garlic, .oliveOil, .oregano]) + await store.receive(\.foodObservation.updateFoods) { + $0.foodObservation.foods = [.chiliPepper, .coriander, .garlic, .oliveOil, .oregano] $0.ingredientPickers = .init( uniqueElements: [ + .init(food: .chiliPepper), + .init(food: .coriander), + .init(food: .garlic), .init( food: .oliveOil, quantity: .init(value: 0.5, unit: .cups) @@ -176,9 +100,6 @@ final class AddIngredientTests: XCTestCase { food: .oregano, quantity: .init(value: 1, unit: .teaspoons) ), - .init(food: .chiliPepper), - .init(food: .coriander), - .init(food: .garlic), ], id: \.food.id ) @@ -201,25 +122,27 @@ final class AddIngredientTests: XCTestCase { await store.send(.ingredientPickers(.element(id: 1, action: .quantityPicker(.updateUnit(.tablespoons))))) { $0.ingredientPickers[id: 1]?.quantityPicker.quantity = .init(value: 1, unit: .tablespoons) } - await store.send(.ingredientPickers(.element(id: 1, action: .quantityPicker(.incrementButtonTapped)))){ + await store.send(.ingredientPickers(.element(id: 1, action: .quantityPicker(.incrementButtonTapped)))) { $0.ingredientPickers[id: 1]?.quantityPicker.quantity.value = 1.5 } - await store.send(.ingredientPickers(.element(id: 1, action: .quantityPicker(.updateValue(3))))){ + await store.send(.ingredientPickers(.element(id: 1, action: .quantityPicker(.updateValue(3))))) { $0.ingredientPickers[id: 1]?.quantityPicker.quantity.value = 3 } XCTAssertNoDifference( store.state.selectedIngredients, [ - .init( - food: .oregano, - quantity: .init(value: 1, unit: .teaspoons) - ), .init( food: .chiliPepper, quantity: .init(value: 3, unit: .tablespoons) ), + .init( + food: .oregano, + quantity: .init(value: 1, unit: .teaspoons) + ), ] ) + continuation.finish() + await store.finish() } func testFullFlow() async throws { @@ -240,8 +163,10 @@ final class AddIngredientTests: XCTestCase { AddIngredients() }, withDependencies: { + $0.continuousClock = ImmediateClock() + $0.uuid = .constant(.init(0)) $0.databaseClient.getRecentFoods = { sortedBy, order in - XCTAssertEqual(sortedBy.name, "name") + XCTAssertEqual(sortedBy, .name) XCTAssertEqual(order, .forward) return [.chiliPepper, .coriander, .garlic, .oliveOil, .oregano] @@ -252,8 +177,24 @@ final class AddIngredientTests: XCTestCase { } ) store.exhaustivity = .off - await store.send(.onFirstAppear) - await store.receive(\.updateFoods) + await store.send(.foodObservation(.updateFoods([.chiliPepper, .coriander, .garlic, .oliveOil, .oregano]))) { + $0.ingredientPickers = .init( + uncheckedUniqueElements: [ + .init(food: .chiliPepper), + .init(food: .coriander), + .init(food: .garlic), + .init( + food: .oliveOil, + quantity: .init(value: 0.5, unit: .cups) + ), + .init( + food: .oregano, + quantity: .init(value: 1, unit: .teaspoons) + ), + ], + id: \.food.id + ) + } await store.send(.ingredientPickers(.element(id: 4, action: .updateSelection(false)))) await store.send(.ingredientPickers(.element(id: 1, action: .updateSelection(true)))) await store.send(.ingredientPickers(.element(id: 1, action: .quantityPicker(.updateUnit(.tablespoons))))) @@ -263,10 +204,51 @@ final class AddIngredientTests: XCTestCase { XCTAssertNoDifference( store.state.selectedIngredients, [ + .init( + food: .chiliPepper, + quantity: .init(value: 3, unit: .tablespoons) + ), .init( food: .oregano, quantity: .init(value: 1, unit: .teaspoons) ), + ] + ) + + store.exhaustivity = .off + store.dependencies.databaseClient.getFoods = { _, _, _ in [.garlic] } + store.dependencies.databaseClient.insertFoods = { $0 } + store.dependencies.foodClient.getFoods = { _ in [] } + await store.send(.foodSearch(.updateFocus(true))) + await store.send(.foodSearch(.updateQuery("garlic"))) + await store.receive(\.foodSearch.searchEnded) + XCTAssertNoDifference( + store.state.searchResults, + .init( + uncheckedUniqueElements: [.init(food: .garlic)], + id: \.food.id + ) + ) + await store.send(.foodSearch(.updateFocus(false))) + + await store.send(.foodObservation(.updateFoods([.chiliPepper, .coriander, .garlic, .parsley, .redWineVinegar]))) { + $0.ingredientPickers = .init( + uncheckedUniqueElements: [ + .init( + food: .chiliPepper, + quantity: .init(value: 3, unit: .tablespoons) + ), + .init(food: .coriander), + .init(food: .garlic), + .init(food: .parsley), + .init(food: .redWineVinegar), + ], + id: \.food.id + ) + } + XCTAssertNoDifference( + store.state.selectedIngredients, + [ .init( food: .chiliPepper, quantity: .init(value: 3, unit: .tablespoons) diff --git a/food-spec/Tests/FoodListTests/BillboardReducerTests.swift b/food-spec/Tests/FoodListTests/BillboardReducerTests.swift index 438e9e2..a305784 100644 --- a/food-spec/Tests/FoodListTests/BillboardReducerTests.swift +++ b/food-spec/Tests/FoodListTests/BillboardReducerTests.swift @@ -10,16 +10,19 @@ final class BillboardReducerTests: XCTestCase { initialState: FoodList.State(), reducer: { BillboardReducer() + }, + withDependencies: { + $0.uuid = .constant(.init(0)) + $0.billboardClient.getRandomBanners = { + .init { + $0.yield(.preview) + $0.yield(nil) + $0.yield(.preview) + $0.finish() + } + } } ) - store.dependencies.billboardClient.getRandomBanners = { - .init { - $0.yield(.preview) - $0.yield(nil) - $0.yield(.preview) - $0.finish() - } - } await store.send(.onFirstAppear) await store.receive(\.billboard.showBanner) { $0.billboard.banner = .preview @@ -37,14 +40,16 @@ final class BillboardReducerTests: XCTestCase { initialState: FoodList.State(), reducer: { BillboardReducer() + }, withDependencies: { + $0.uuid = .constant(.init(0)) + $0.billboardClient.getRandomBanners = { + .init { + struct Failure: Error { } + $0.finish(throwing: Failure()) + } + } } ) - store.dependencies.billboardClient.getRandomBanners = { - .init { - struct Failure: Error { } - $0.finish(throwing: Failure()) - } - } await store.send(.onFirstAppear) } @@ -53,6 +58,9 @@ final class BillboardReducerTests: XCTestCase { initialState: FoodList.State(), reducer: { BillboardReducer() + }, + withDependencies: { + $0.uuid = .constant(.init(0)) } ) await store.send(.billboard(.showBanner(.preview))) { diff --git a/food-spec/Tests/FoodListTests/FoodListTests.swift b/food-spec/Tests/FoodListTests/FoodListTests.swift index 7dedf86..bca7b49 100644 --- a/food-spec/Tests/FoodListTests/FoodListTests.swift +++ b/food-spec/Tests/FoodListTests/FoodListTests.swift @@ -14,545 +14,442 @@ final class FoodListTests: XCTestCase { initialState: FoodList.State(), reducer: { FoodList() + }, + withDependencies: { + $0.userPreferencesClient.getPreferences = { .init() } + $0.uuid = .constant(.init(0)) } ) store.assert { state in - state.recentFoodsSortingStrategy = .name - state.recentFoodsSortingOrder = .forward - state.searchQuery = "" - state.isSearchFocused = false - state.recentFoods = [] - state.searchResults = [] - state.shouldShowNoResults = false - state.destination = nil + state.sortStrategy = .name + state.sortOrder = .forward + state.foodSearch = .init( + sortStrategy: .name, + sortOrder: .forward + ) + state.foodObservation = .init( + sortStrategy: .name, + sortOrder: .forward + ) state.billboard = .init(banner: nil) + state.destination = nil } + XCTAssertEqual(store.state.isSortMenuDisabled, true) } - func test_onTask() async throws { + func testStateDefaultInitializer_hasUserPreferences() async throws { let store = TestStore( initialState: FoodList.State(), reducer: { FoodList() }, withDependencies: { - $0.userPreferencesClient = .init( - getPreferences: { - .init( - recentSearchesSortingStrategy: FoodList.State.SortingStrategy.energy.rawValue, - recentSearchesSortingOrder: .reverse - ) - }, - setPreferences: { _ in - XCTFail() - }, - observeChanges: { - .finished - } - ) + $0.userPreferencesClient.getPreferences = { + .init( + recentSearchesSortStrategy: .energy, + recentSearchesSortOrder: .reverse + ) + } + $0.uuid = .constant(.init(0)) } ) - let (stream, continuation) = AsyncStream.makeStream(of: [Food].self) - store.dependencies.spotlightClient.indexFoods = { - XCTAssertNoDifference($0, []) - } - store.dependencies.billboardClient.getRandomBanners = { - .finished() - } - store.dependencies.databaseClient.observeFoods = { strategy, order in - XCTAssertEqual(strategy.name, "energy") - XCTAssertEqual(order, .reverse) - return stream - } - await store.send(.onFirstAppear) - await store.receive(\.startObservingRecentFoods) - continuation.yield([]) - await store.receive(\.onRecentFoodsChange) { - $0.isSearchFocused = true + store.assert { state in + state.sortStrategy = .energy + state.sortOrder = .reverse + state.foodSearch = .init( + sortStrategy: .energy, + sortOrder: .reverse + ) + state.foodObservation = .init( + sortStrategy: .energy, + sortOrder: .reverse + ) + state.billboard = .init(banner: nil) + state.destination = nil } - XCTAssertNoDifference(store.state.shouldShowRecentSearches, false) - XCTAssertNoDifference(store.state.shouldShowPrompt, true) - XCTAssertNoDifference(store.state.shouldShowSpinner, false) - XCTAssertNoDifference(store.state.shouldShowSearchResults, false) - continuation.finish() - await store.finish() } - func test_onTask_hasRecentFoods() async throws { - let food = Food.preview + func testOnFirstAppear() async throws { let store = TestStore( initialState: FoodList.State(), reducer: { FoodList() }, withDependencies: { - $0.userPreferencesClient = .init( - getPreferences: { - .init( - recentSearchesSortingStrategy: FoodList.State.SortingStrategy.energy.rawValue, - recentSearchesSortingOrder: .reverse - ) - }, - setPreferences: { _ in - - }, - observeChanges: { - .finished - } - ) + $0.userPreferencesClient.getPreferences = { + .init( + recentSearchesSortStrategy: .energy, + recentSearchesSortOrder: .reverse + ) + } + $0.uuid = .constant(.init(0)) } ) - store.dependencies.spotlightClient.indexFoods = { - XCTAssertNoDifference($0, [food]) - } - store.dependencies.billboardClient.getRandomBanners = { - .finished() - } - let (stream, continuation) = AsyncStream.makeStream(of: [Food].self) - store.dependencies.databaseClient.observeFoods = { strategy, order in - XCTAssertEqual(strategy.name, "energy") - XCTAssertEqual(order, .reverse) - return stream - } await store.send(.onFirstAppear) - await store.receive(\.startObservingRecentFoods) - continuation.yield([food]) - await store.receive(\.onRecentFoodsChange) { - $0.recentFoods = [food] - } - - XCTAssertNoDifference(store.state.isSearchFocused, false) - XCTAssertNoDifference(store.state.shouldShowRecentSearches, true) - XCTAssertNoDifference(store.state.shouldShowPrompt, false) - XCTAssertNoDifference(store.state.shouldShowSpinner, false) - XCTAssertNoDifference(store.state.shouldShowSearchResults, false) - - continuation.finish() - await store.finish() } - func testFullFlow_newInstallation() async throws { - let eggplantApi = FoodApiModel.eggplant - let eggplant = Food(foodApiModel: eggplantApi) - let ribeyeApi = FoodApiModel.ribeye - let ribeye = Food(foodApiModel: ribeyeApi) + func testUpdateRecentFoodsSortingStrategy() async throws { let store = TestStore( initialState: FoodList.State(), reducer: { FoodList() }, withDependencies: { - $0.mainQueue = .immediate - $0.userPreferencesClient = .init( - getPreferences: { - .init( - recentSearchesSortingStrategy: FoodList.State.SortingStrategy.energy.rawValue, - recentSearchesSortingOrder: .reverse - ) - }, - setPreferences: { _ in + $0.userPreferencesClient.getPreferences = { + .init() + } + $0.userPreferencesClient.setPreferences = { modify in + var prefs = UserPreferences() + modify(&prefs) + XCTAssertNoDifference(prefs, .init(recentSearchesSortStrategy: .energy, recentSearchesSortOrder: .forward)) - }, - observeChanges: { - .finished - } - ) + } + $0.databaseClient.observeFoods = { + XCTAssertEqual($0, .energy) + XCTAssertEqual($1, .forward) + return .finished + } + $0.uuid = .constant(.init(0)) } ) - store.dependencies.spotlightClient.indexFoods = { - XCTAssertNoDifference($0, []) - } - store.dependencies.billboardClient.getRandomBanners = { - .init { - $0.yield(.preview) - $0.finish() - } - } - var (stream, continuation) = AsyncStream.makeStream(of: [Food].self) - store.dependencies.databaseClient.observeFoods = { strategy, order in - XCTAssertEqual(strategy.name, "energy") - XCTAssertEqual(order, .reverse) - return stream + await store.send(.updateRecentFoodsSortingStrategy(.energy)) { + $0.sortStrategy = .energy } - - await store.send(.onFirstAppear) - await store.receive(\.startObservingRecentFoods) - await store.receive(\.billboard.showBanner) { - $0.billboard.banner = .preview - } - continuation.yield([]) - await store.receive(\.onRecentFoodsChange) { - $0.isSearchFocused = true - } - XCTAssertNoDifference(store.state.isSortMenuDisabled, true) - XCTAssertNoDifference(store.state.shouldShowRecentSearches, false) - XCTAssertNoDifference(store.state.shouldShowPrompt, true) - XCTAssertNoDifference(store.state.shouldShowSpinner, false) - XCTAssertNoDifference(store.state.shouldShowSearchResults, false) - - store.dependencies.spotlightClient.indexFoods = { - XCTAssertNoDifference($0, [eggplant]) - } - store.dependencies.foodClient.getFoods = { _ in [eggplantApi] } - store.dependencies.databaseClient.insertFood = { - XCTAssertNoDifference($0, .preview) - return $0 - } - await store.send(.updateSearchQuery("C")) { - $0.searchQuery = "C" - $0.shouldShowNoResults = false - $0.searchResults = [] - $0.inlineFood = nil - } - await store.receive(\.startSearching) { - $0.isSearching = true - } - XCTAssertEqual(store.state.shouldShowSpinner, true) - await store.receive(\.didReceiveSearchFoods) { - $0.inlineFood = .init(food: .preview) - $0.isSearching = false - } - XCTAssertEqual(store.state.shouldShowSpinner, false) - continuation.yield([eggplant]) - await store.receive(\.onRecentFoodsChange) { - $0.recentFoods = [eggplant] - } - XCTAssertNoDifference(store.state.isSortMenuDisabled, true) - - await store.send(.updateSearchQuery("")) { - $0.searchQuery = "" - $0.shouldShowNoResults = false - $0.searchResults = [] - $0.inlineFood = nil - $0.isSearching = false - } - store.exhaustivity = .off(showSkippedAssertions: true) - store.dependencies.foodClient.getFoods = { _ in [] } - await store.send(.updateSearchQuery("R")) - await store.send(.updateSearchQuery("Ri")) - await store.send(.updateSearchQuery("Rib")) - await store.send(.updateSearchQuery("Ribe")) - await store.send(.updateSearchQuery("Ribey")) - store.dependencies.spotlightClient.indexFoods = { - XCTAssertNoDifference($0, [ribeye, eggplant]) - } - store.dependencies.foodClient.getFoods = { _ in [ribeyeApi] } - store.dependencies.databaseClient.insertFood = { - XCTAssertEqual($0, ribeye) - return $0 - } - await store.send(.updateSearchQuery("Ribeye")) { - $0.searchQuery = "Ribeye" - $0.shouldShowNoResults = false - } - store.exhaustivity = .on - await store.receive(\.startSearching) { - $0.isSearching = true - } - await store.receive(\.didReceiveSearchFoods) { - $0.isSearching = false - $0.inlineFood = .init(food: ribeye) - } - await store.send(.onRecentFoodsChange([ribeye, eggplant])) { - $0.recentFoods = [ribeye, eggplant] - } - XCTAssertNoDifference(store.state.isSortMenuDisabled, false) - - store.dependencies.spotlightClient.indexFoods = { - XCTAssertNoDifference($0, [eggplant, ribeye]) + await store.receive(\.foodSearch.updateSortStrategy) { + $0.foodSearch.sortStrategy = .energy + $0.foodSearch.sortOrder = .forward } - (stream, continuation) = AsyncStream.makeStream(of: [Food].self) - store.dependencies.databaseClient.observeFoods = { strategy, order in - XCTAssertEqual(strategy.name, "carbohydrate") - XCTAssertEqual(order, .forward) - return stream + await store.receive(\.foodObservation.updateSortStrategy) { + $0.foodObservation.sortStrategy = .energy + $0.foodObservation.sortOrder = .forward } store.dependencies.userPreferencesClient.setPreferences = { modify in var prefs = UserPreferences() modify(&prefs) - XCTAssertNoDifference(prefs, .init(recentSearchesSortingStrategy: "carbohydrate", recentSearchesSortingOrder: .forward)) - } - await store.send(.updateRecentFoodsSortingStrategy(.carbohydrate)) { - $0.recentFoodsSortingStrategy = .carbohydrate - $0.recentFoodsSortingOrder = .forward - } - await store.receive(\.startObservingRecentFoods) - continuation.yield([eggplant, ribeye]) - await store.receive(\.onRecentFoodsChange) { - $0.recentFoods = [eggplant, ribeye] - } - XCTAssertNoDifference(store.state.isSortMenuDisabled, false) + XCTAssertNoDifference(prefs, .init(recentSearchesSortStrategy: .energy, recentSearchesSortOrder: .reverse)) - store.dependencies.spotlightClient.indexFoods = { - XCTAssertNoDifference($0, [ribeye, eggplant]) } - (stream, continuation) = AsyncStream.makeStream(of: [Food].self) - store.dependencies.databaseClient.observeFoods = { strategy, order in - XCTAssertEqual(strategy.name, "carbohydrate") - XCTAssertEqual(order, .reverse) - return stream + store.dependencies.databaseClient.observeFoods = { + XCTAssertEqual($0, .energy) + XCTAssertEqual($1, .reverse) + return .finished } - store.dependencies.userPreferencesClient.setPreferences = { modify in - var prefs = UserPreferences() - modify(&prefs) - XCTAssertNoDifference(prefs, .init(recentSearchesSortingStrategy: "carbohydrate", recentSearchesSortingOrder: .reverse)) + await store.send(.updateRecentFoodsSortingStrategy(.energy)) { + $0.sortStrategy = .energy + $0.sortOrder = .reverse } - await store.send(.updateRecentFoodsSortingStrategy(.carbohydrate)) { - $0.recentFoodsSortingStrategy = .carbohydrate - $0.recentFoodsSortingOrder = .reverse + await store.receive(\.foodSearch.updateSortStrategy) { + $0.foodSearch.sortStrategy = .energy + $0.foodSearch.sortOrder = .reverse } - await store.receive(\.startObservingRecentFoods) - continuation.yield([ribeye, eggplant]) - await store.receive(\.onRecentFoodsChange) { - $0.recentFoods = [ribeye, eggplant] + await store.receive(\.foodObservation.updateSortStrategy) { + $0.foodObservation.sortStrategy = .energy + $0.foodObservation.sortOrder = .reverse } - - store.dependencies.spotlightClient.indexFoods = { - XCTAssertNoDifference($0, [eggplant]) - } - store.dependencies.databaseClient.deleteFood = { - XCTAssertNoDifference($0, ribeye) - } - await store.send(.didDeleteRecentFoods(.init(integer: 0))) - await store.send(.onRecentFoodsChange([eggplant])) { - $0.recentFoods = [eggplant] - } - XCTAssertNoDifference(store.state.isSortMenuDisabled, true) - - await store.send(.didSelectRecentFood(eggplant)) { - $0.destination = .foodDetails(.init(food: eggplant)) - } - - continuation.finish() - await store.finish() } - func testMultipleSearchResults() async throws { - let eggplantApi = FoodApiModel.eggplant - let eggplant = Food(foodApiModel: eggplantApi) - let ribeyeApi = FoodApiModel.ribeye - let ribeye = Food(foodApiModel: ribeyeApi) + func testIntegrationWithFoodObservation() async throws { let store = TestStore( initialState: FoodList.State(), reducer: { FoodList() }, withDependencies: { - $0.mainQueue = .immediate + $0.userPreferencesClient.getPreferences = { + .init() + } + $0.uuid = .constant(.init(0)) } ) - store.dependencies.databaseClient.insertFood = { - XCTAssertNoDifference($0, eggplant) - return $0 + await store.send(.foodObservation(.updateFoods([]))) { + $0.foodSearch.isFocused = true } - await store.send(.didReceiveSearchFoods([eggplantApi, ribeyeApi])) { - $0.searchResults = [eggplant, ribeye] - } - await store.send(.didSelectSearchResult(eggplant)) { - $0.destination = .foodDetails(.init(food: eggplant)) + await store.send(.foodObservation(.updateFoods([.eggplant, .ribeye]))) { + $0.foodObservation.foods = [.eggplant, .ribeye] } + XCTAssertEqual(store.state.isSortMenuDisabled, false) } - func testSearchError() async throws { + func testIntegrationWithFoodSearch() async throws { + var didSearch = false let store = TestStore( initialState: FoodList.State(), reducer: { FoodList() }, withDependencies: { - $0.mainQueue = .immediate + $0.continuousClock = ImmediateClock() + $0.userPreferencesClient.getPreferences = { + .init() + } + $0.uuid = .constant(.init(0)) + $0.databaseClient.getFoods = { q, s, o in + if didSearch { + [.eggplant, .ribeye] + } else { + [.ribeye] + } + } + $0.databaseClient.insertFoods = { + XCTAssertNoDifference($0, [.init(foodApiModel: .preview)]) + return $0 + } + $0.foodClient.getFoods = { q in + didSearch = true + return [.eggplant] + } } ) - store.dependencies.foodClient.getFoods = { _ in - struct FoodError: Error { } - throw FoodError() + await store.send(.foodSearch(.updateFocus(true))) { + $0.foodSearch.isFocused = true } - await store.send(.updateSearchQuery("eggplant")) { - $0.searchQuery = "eggplant" + await store.send(.foodSearch(.updateQuery("eggplant"))) { + $0.foodSearch.query = "eggplant" } - await store.receive(\.startSearching) { - $0.isSearching = true + await store.receive(\.foodSearch.searchStarted) { + $0.foodSearch.isSearching = true } - await store.receive(\.didReceiveSearchFoods) { - $0.shouldShowNoResults = true - $0.isSearching = false + await store.receive(\.foodSearch.result) { + $0.foodSearch.searchResults = [.ribeye] } - await store.receive(\.showGenericAlert) { - $0.destination = .alert(.init { - TextState("Something went wrong. Please try again later.") - }) + await store.receive(\.foodSearch.result) { + $0.foodSearch.searchResults = [.eggplant, .ribeye] + } + await store.receive(\.foodSearch.searchEnded) { + $0.foodSearch.isSearching = false } } - func testSearchBarFocus() async throws { + func testRecentFoodSelection() async throws { let store = TestStore( initialState: FoodList.State(), reducer: { FoodList() }, withDependencies: { - $0.mainQueue = .immediate + $0.userPreferencesClient.getPreferences = { + .init() + } + $0.uuid = .constant(.init(0)) } ) - await store.send(.updateSearchFocus(true)) { - $0.isSearchFocused = true - } - store.dependencies.databaseClient.insertFood = { - XCTAssertNoDifference($0, .eggplant) - return $0 - } - await store.send(.didReceiveSearchFoods([.eggplant])) { - $0.inlineFood = .init(food: .eggplant) - } - await store.send(.updateSearchFocus(false)) { - $0.isSearchFocused = false - $0.inlineFood = nil + await store.send(.didSelectRecentFood(.eggplant)) { + $0.destination = .foodDetails(.init(food: .eggplant)) } } - func testDeletion_error() async throws { + func testSearchResultSelection() async throws { let store = TestStore( - initialState: { - var state = FoodList.State() - state.recentFoods = [.preview] - return state - }(), + initialState: FoodList.State(), reducer: { FoodList() }, withDependencies: { - $0.databaseClient.deleteFood = { _ in - struct Failure: Error { } - throw Failure() + $0.userPreferencesClient.getPreferences = { + .init() } + $0.uuid = .constant(.init(0)) } ) - await store.send(.didDeleteRecentFoods(.init(integer: 0))) - await store.receive(\.showGenericAlert) { - $0.destination = .alert(.init { - TextState("Something went wrong. Please try again later.") - }) + await store.send(.didSelectSearchResult(.eggplant)) { + $0.destination = .foodDetails(.init(food: .eggplant)) } } - func testIntegrationWithSpotlight_foodSelection() async throws { - let eggplant = Food.eggplant + func testFoodDeletion() async throws { + var didDelete = false let store = TestStore( initialState: FoodList.State(), reducer: { FoodList() + }, + withDependencies: { + $0.userPreferencesClient.getPreferences = { + .init() + } + $0.databaseClient.deleteFoods = { + if didDelete { + struct Failure: Error { } + throw Failure() + } else { + XCTAssertNoDifference($0, [.ribeye]) + didDelete = true + } + } + $0.uuid = .constant(.init(0)) } ) - store.dependencies.databaseClient.getFood = { - XCTAssertNoDifference($0, eggplant.name) - return eggplant + await store.send(.foodObservation(.updateFoods([.eggplant, .ribeye]))) { + $0.foodObservation.foods = [.eggplant, .ribeye] } - let activity = NSUserActivity(activityType: "mock") - activity.userInfo?[CSSearchableItemActivityIdentifier] = eggplant.name - await store.send(.spotlight(.handleSelectedFood(activity))) - await store.receive(\.didSelectRecentFood) { - $0.destination = .foodDetails(.init(food: eggplant)) + await store.send(.didDeleteRecentFoods(.init(integer: 1))) + await store.send(.didDeleteRecentFoods(.init(integer: 1))) + await store.receive(\.showGenericAlert) { + $0.destination = .alert(.init { + TextState("Something went wrong. Please try again later.") + }) } } - func testIntegrationWithSpotlight_search() async throws { - let eggplant = Food.eggplant + func testFullFlowNewInstallation() async throws { + var (stream, continuation) = AsyncStream.makeStream(of: [Food].self) let store = TestStore( - initialState: { - var state = FoodList.State() - state.destination = .foodDetails(.init(food: eggplant)) - return state - }(), + initialState: FoodList.State(), reducer: { FoodList() + }, + withDependencies: { + $0.continuousClock = ImmediateClock() + $0.userPreferencesClient.getPreferences = { + .init() + } + $0.uuid = .constant(.init(0)) + $0.databaseClient.observeFoods = { _, _ in stream } + var didSearch = false + $0.databaseClient.getFoods = { q, s, o in + XCTAssertEqual(q, "eggplant") + XCTAssertEqual(s, .name) + XCTAssertEqual(o, .forward) + return if didSearch { + [.eggplant] + } else { + [] + } + } + $0.databaseClient.insertFoods = { $0 } + $0.foodClient.getFoods = { + XCTAssertEqual($0, "eggplant") + didSearch = true + return [.eggplant] + } } ) - store.dependencies.mainQueue = .immediate - store.dependencies.foodClient.getFoods = { - XCTAssertNoDifference($0, eggplant.name) - return [.eggplant] + await store.send(.foodObservation(.startObservation)) + continuation.yield([]) + await store.receive(\.foodObservation.updateFoods) { + $0.foodSearch.isFocused = true } - store.dependencies.databaseClient.insertFood = { - XCTAssertNoDifference($0, eggplant) - return eggplant + + // search + await store.send(.foodSearch(.updateQuery("eggplant"))) { + $0.foodSearch.query = "eggplant" } - let activity = NSUserActivity(activityType: "mock") - activity.userInfo?[CSSearchQueryString] = eggplant.name - await store.send(.spotlight(.handleSearchInApp(activity))) - await store.receive(\.destination.dismiss) { - $0.destination = nil + await store.receive(\.foodSearch.searchStarted) { + $0.foodSearch.isSearching = true } - await store.receive(\.updateSearchFocus) { - $0.isSearchFocused = true + await store.receive(\.foodSearch.result) + await store.receive(\.foodSearch.result) { + $0.foodSearch.searchResults = [.eggplant] } - await store.receive(\.updateSearchQuery) { - $0.searchQuery = eggplant.name + await store.receive(\.foodSearch.searchEnded) { + $0.foodSearch.isSearching = false } - await store.receive(\.startSearching) { - $0.isSearching = true + continuation.yield([.eggplant]) + await store.receive(\.foodObservation.updateFoods) { + $0.foodObservation.foods = [.eggplant] } - await store.receive(\.didReceiveSearchFoods) { - $0.inlineFood = .init(food: eggplant) - $0.isSearching = false + + // food details + await store.send(.didSelectSearchResult(.eggplant)) { + $0.destination = .foodDetails(.init(food: .eggplant)) + } + await store.send(.destination(.dismiss)) { + $0.destination = nil } - } - func testIntegrationWithBillboard_multipleAds() async throws { - let firstAd = BillboardAd.preview - let secondAd = BillboardAd( - appStoreID: "id", - name: "secondAd", - title: "secondTitle", - description: "secondDescription", - media: .cachesDirectory, - backgroundColor: "red", - textColor: "black", - tintColor: "blue", - fullscreen: true, - transparent: true - ) - let store = TestStore( - initialState: FoodList.State(), - reducer: { - FoodList() + // search + var didSearch = false + store.dependencies.databaseClient.getFoods = { q, s, o in + XCTAssertEqual(q, "ribeye") + XCTAssertEqual(s, .name) + XCTAssertEqual(o, .forward) + return if didSearch { + [.ribeye] + } else { + [] } - ) - store.exhaustivity = .off - store.dependencies.userPreferencesClient = .init( - getPreferences: { - .init( - recentSearchesSortingStrategy: FoodList.State.SortingStrategy.energy.rawValue, - recentSearchesSortingOrder: .reverse - ) - }, - setPreferences: { _ in + } + store.dependencies.foodClient.getFoods = { + XCTAssertEqual($0, "ribeye") + didSearch = true + return [.ribeye] + } + await store.send(.foodSearch(.updateQuery("ribeye"))) { + $0.foodSearch.query = "ribeye" + } + await store.receive(\.foodSearch.searchStarted) { + $0.foodSearch.isSearching = true + } + await store.receive(\.foodSearch.result) { + $0.foodSearch.searchResults = [] + } + await store.receive(\.foodSearch.result) { + $0.foodSearch.searchResults = [.ribeye] + } + await store.receive(\.foodSearch.searchEnded) { + $0.foodSearch.isSearching = false + } + continuation.yield([.eggplant, .ribeye]) + await store.receive(\.foodObservation.updateFoods) { + $0.foodObservation.foods = [.eggplant, .ribeye] + } - }, - observeChanges: { - .finished - } - ) - store.dependencies.billboardClient.getRandomBanners = { - .init { - $0.yield(firstAd) - $0.yield(nil) - $0.yield(secondAd) - $0.finish() - } + // food details + await store.send(.foodSearch(.updateQuery(""))) { + $0.foodSearch.query = "" + $0.foodSearch.searchResults = [] } - await store.send(.onFirstAppear) - await store.receive(\.billboard.showBanner) { - $0.billboard.banner = firstAd + await store.send(.foodSearch(.updateFocus(false))) { + $0.foodSearch.isFocused = false + } + await store.send(.didSelectRecentFood(.ribeye)) { + $0.destination = .foodDetails(.init(food: .ribeye)) + } + XCTAssertEqual(store.state.isSortMenuDisabled, false) + + // sort strategy + (stream, continuation) = AsyncStream.makeStream(of: [Food].self) + store.dependencies.databaseClient.observeFoods = { + XCTAssertEqual($0, .fat) + XCTAssertEqual($1, .forward) + return stream + } + store.dependencies.userPreferencesClient.setPreferences = { modify in + var prefs = UserPreferences() + modify(&prefs) + XCTAssertNoDifference(prefs, .init(recentSearchesSortStrategy: .fat, recentSearchesSortOrder: .forward)) + } + await store.send(.updateRecentFoodsSortingStrategy(.fat)) { + $0.sortStrategy = .fat + $0.sortOrder = .forward + } + await store.receive(\.foodSearch.updateSortStrategy) { + $0.foodSearch.sortStrategy = .fat + $0.foodSearch.sortOrder = .forward } - await store.receive(\.billboard.showBanner) { - $0.billboard.banner = nil + await store.receive(\.foodObservation.updateSortStrategy) { + $0.foodObservation.sortStrategy = .fat + $0.foodObservation.sortOrder = .forward } - await store.receive(\.billboard.showBanner) { - $0.billboard.banner = secondAd + continuation.yield([.ribeye, .eggplant]) + await store.receive(\.foodObservation.updateFoods) { + $0.foodObservation.foods = [.ribeye, .eggplant] } + + // delete + store.dependencies.databaseClient.deleteFoods = { _ in } + await store.send(.didDeleteRecentFoods([0, 1])) + continuation.yield([]) + await store.receive(\.foodObservation.updateFoods) { + $0.foodObservation.foods = [] + $0.foodSearch.isFocused = true + } + + continuation.finish() + await store.finish() } } diff --git a/food-spec/Tests/FoodListTests/SpotlightReducerTests.swift b/food-spec/Tests/FoodListTests/SpotlightReducerTests.swift index efdea35..f64ab72 100644 --- a/food-spec/Tests/FoodListTests/SpotlightReducerTests.swift +++ b/food-spec/Tests/FoodListTests/SpotlightReducerTests.swift @@ -14,12 +14,15 @@ final class SpotlightReducerTests: XCTestCase { initialState: FoodList.State(), reducer: { SpotlightReducer() + }, + withDependencies: { + $0.uuid = .constant(.init(0)) + $0.spotlightClient.indexFoods = { + XCTAssertNoDifference($0, foods) + } } ) - store.dependencies.spotlightClient.indexFoods = { - XCTAssertNoDifference($0, foods) - } - await store.send(.onRecentFoodsChange(foods)) + await store.send(.foodObservation(.updateFoods(foods))) } func testSpotlightSelection() async throws { @@ -28,12 +31,15 @@ final class SpotlightReducerTests: XCTestCase { initialState: FoodList.State(), reducer: { SpotlightReducer() + }, + withDependencies: { + $0.uuid = .constant(.init(0)) + $0.databaseClient.getFood = { + XCTAssertNoDifference($0, eggplant.name) + return eggplant + } } ) - store.dependencies.databaseClient.getFood = { - XCTAssertNoDifference($0, eggplant.name) - return eggplant - } let activity = NSUserActivity(activityType: "mock") activity.userInfo?[CSSearchableItemActivityIdentifier] = eggplant.name await store.send(.spotlight(.handleSelectedFood(activity))) @@ -46,13 +52,16 @@ final class SpotlightReducerTests: XCTestCase { initialState: FoodList.State(), reducer: { SpotlightReducer() + }, + withDependencies: { + $0.uuid = .constant(.init(0)) } ) let activity = NSUserActivity(activityType: "mock") activity.userInfo?[CSSearchQueryString] = eggplant.name await store.send(.spotlight(.handleSearchInApp(activity))) - await store.receive(\.updateSearchFocus) - await store.receive(\.updateSearchQuery) + await store.receive(\.foodSearch.updateFocus) + await store.receive(\.foodSearch.updateQuery) } func testSpotlightSearchInApp_foodDetailsAlreadyPresented() async throws { @@ -65,13 +74,16 @@ final class SpotlightReducerTests: XCTestCase { }(), reducer: { SpotlightReducer() + }, + withDependencies: { + $0.uuid = .constant(.init(0)) } ) let activity = NSUserActivity(activityType: "mock") activity.userInfo?[CSSearchQueryString] = eggplant.name await store.send(.spotlight(.handleSearchInApp(activity))) await store.receive(\.destination.dismiss) - await store.receive(\.updateSearchFocus) - await store.receive(\.updateSearchQuery) + await store.receive(\.foodSearch.updateFocus) + await store.receive(\.foodSearch.updateQuery) } } diff --git a/food-spec/Tests/FoodObservationTests/FoodObservationTests.swift b/food-spec/Tests/FoodObservationTests/FoodObservationTests.swift new file mode 100644 index 0000000..52f322d --- /dev/null +++ b/food-spec/Tests/FoodObservationTests/FoodObservationTests.swift @@ -0,0 +1,166 @@ +import Foundation +import Shared +import XCTest +import ComposableArchitecture +@testable import FoodObservation + +@MainActor +final class FoodObservationTests: XCTestCase { + func testStateInitialization() async throws { + let store = TestStore( + initialState: FoodObservation.State(), + reducer: { + FoodObservation() + }, + withDependencies: { + $0.uuid = .constant(.init(0)) + } + ) + store.assert { + $0.foods = [] + $0.sortStrategy = .name + $0.sortOrder = .forward + } + } + + func testStartObservation() async throws { + let store = TestStore( + initialState: FoodObservation.State(), + reducer: { + FoodObservation() + }, + withDependencies: { + $0.uuid = .constant(.init(0)) + $0.databaseClient.observeFoods = { s, o in + XCTAssertEqual(s, .name) + XCTAssertEqual(o, .forward) + return .finished + } + } + ) + await store.send(.startObservation) + } + + func testUpdateFoods() async throws { + let store = TestStore( + initialState: FoodObservation.State(), + reducer: { + FoodObservation() + }, + withDependencies: { + $0.uuid = .constant(.init(0)) + } + ) + await store.send(.updateFoods([.chiliPepper, .redWineVinegar])) { + $0.foods = [.chiliPepper, .redWineVinegar] + } + await store.send(.updateFoods([])) { + $0.foods = [] + } + } + + func testUpdateSortStrategy() async throws { + let store = TestStore( + initialState: FoodObservation.State(), + reducer: { + FoodObservation() + }, + withDependencies: { + $0.uuid = .constant(.init(0)) + $0.databaseClient.observeFoods = { s, o in + XCTAssertEqual(s, .protein) + XCTAssertEqual(o, .reverse) + return .finished + } + } + ) + await store.send(.updateSortStrategy(.protein, .reverse)) { + $0.sortStrategy = .protein + $0.sortOrder = .reverse + } + store.dependencies.databaseClient.observeFoods = { _, _ in + XCTFail() + return .finished + } + await store.send(.updateSortStrategy(.protein, .reverse)) + } + + func testFullFlow() async throws { + var (stream, continuation) = AsyncStream.makeStream(of: [Food].self) + let store = TestStore( + initialState: FoodObservation.State(), + reducer: { + FoodObservation() + }, + withDependencies: { + $0.uuid = .constant(.init(0)) + $0.databaseClient.observeFoods = { s, o in + XCTAssertEqual(s, .name) + XCTAssertEqual(o, .forward) + return stream + } + } + ) + await store.send(.startObservation) + continuation.yield([.chiliPepper]) + await store.receive(\.updateFoods) { + $0.foods = [.chiliPepper] + } + continuation.yield([.chiliPepper, .redWineVinegar]) + await store.receive(\.updateFoods) { + $0.foods = [.chiliPepper, .redWineVinegar] + } + (stream, continuation) = AsyncStream.makeStream(of: [Food].self) + store.dependencies.databaseClient.observeFoods = { s, o in + XCTAssertEqual(s, .name) + XCTAssertEqual(o, .reverse) + return stream + } + await store.send(.updateSortStrategy(.name, .reverse)) { + $0.sortOrder = .reverse + } + continuation.yield([.redWineVinegar, .chiliPepper]) + await store.receive(\.updateFoods) { + $0.foods = [.redWineVinegar, .chiliPepper] + } + + continuation.finish() + await store.finish() + } +} + +fileprivate extension Food { + static var chiliPepper: Self { + .init( + id: 1, + name: "chili pepper", + energy: .kcal(39.4), + fatTotal: .grams(0.4), + fatSaturated: .zero, + protein: .grams(1.9), + sodium: .grams(0.008), + potassium: .grams(0.043), + cholesterol: .zero, + carbohydrate: .grams(8.8), + fiber: .grams(1.5), + sugar: .grams(5.3) + ) + } + + static var redWineVinegar: Self { + .init( + id: 7, + name: "red wine vinegar", + energy: .kcal(18.9), + fatTotal: .zero, + fatSaturated: .zero, + protein: .grams(0.1), + sodium: .grams(0.008), + potassium: .grams(0.007), + cholesterol: .zero, + carbohydrate: .grams(0.3), + fiber: .zero, + sugar: .zero + ) + } +} diff --git a/food-spec/Tests/FoodSelectionTests/FoodSelectionTests.swift b/food-spec/Tests/FoodSelectionTests/FoodSelectionTests.swift index ecc6962..c205bb7 100644 --- a/food-spec/Tests/FoodSelectionTests/FoodSelectionTests.swift +++ b/food-spec/Tests/FoodSelectionTests/FoodSelectionTests.swift @@ -9,58 +9,66 @@ import Database final class FoodSelectionTests: XCTestCase { typealias State = FoodSelection.State - func testComputedProperty_filteredFoods() async throws { - var state = FoodSelection.State() - state.foods = [.ribeye, .eggplant] - state.filterQuery = "e" - - XCTAssertNoDifference(state.filteredFoods, [.ribeye, .eggplant]) - - state = State() - state.foods = [.ribeye, .eggplant] - state.filterQuery = "eg" - XCTAssertNoDifference(state.filteredFoods, [.eggplant]) - - state = State() - state.foods = [.ribeye, .eggplant] - state.filterQuery = "" - XCTAssertNoDifference(state.filteredFoods, [.ribeye, .eggplant]) - } - func testComputedProperty_isCompareButtonDisabled() async throws { - var state = State( - selectedFoodIds: [1, 2] + let store = TestStore( + initialState: State(selectedFoodIds: [1, 2]), + reducer: { + FoodSelection() + }, + withDependencies: { + $0.uuid = .constant(.init(0)) + } ) - XCTAssertNoDifference(state.isCompareButtonDisabled, false) + XCTAssertNoDifference(store.state.isCompareButtonDisabled, false) - state.selectedFoodIds = [1] - XCTAssertNoDifference(state.isCompareButtonDisabled, true) + await store.send(.updateSelection([1])) { + $0.selectedFoodIds = [1] + } + XCTAssertNoDifference(store.state.isCompareButtonDisabled, true) - state.selectedFoodIds = [] - XCTAssertNoDifference(state.isCompareButtonDisabled, true) + await store.send(.updateSelection([])) { + $0.selectedFoodIds = [] + } + XCTAssertNoDifference(store.state.isCompareButtonDisabled, true) - state.selectedFoodIds = [1, 2, 3, 4, 5] - XCTAssertNoDifference(state.isCompareButtonDisabled, false) + await store.send(.updateSelection([1, 2, 3, 4, 5])) { + $0.selectedFoodIds = [1, 2, 3, 4, 5] + } + XCTAssertNoDifference(store.state.isCompareButtonDisabled, false) } func testIsSelectionDisabled() async throws { - let food = Food.eggplant - var state = State( - selectedFoodIds: [] + let store = TestStore( + initialState: State(selectedFoodIds: []), + reducer: { + FoodSelection() + }, + withDependencies: { + $0.uuid = .constant(.init(0)) + } ) - XCTAssertNoDifference(state.isSelectionDisabled(for: food), false) + let food = Food.eggplant + XCTAssertNoDifference(store.state.isSelectionDisabled(for: food), false) - state.selectedFoodIds = [1, 2, 3, 4] - XCTAssertNoDifference(state.isSelectionDisabled(for: food), false) + await store.send(.updateSelection([1, 2, 3, 4])) { + $0.selectedFoodIds = [1, 2, 3, 4] + } + XCTAssertNoDifference(store.state.isSelectionDisabled(for: food), false) - state.selectedFoodIds = [nil] - XCTAssertNoDifference(state.isSelectionDisabled(for: food), false) + await store.send(.updateSelection([nil])) { + $0.selectedFoodIds = [nil] + } + XCTAssertNoDifference(store.state.isSelectionDisabled(for: food), false) - state.selectedFoodIds = [nil, 1, 2, 3, 4, 5, 6] - XCTAssertNoDifference(state.isSelectionDisabled(for: food), false) + await store.send(.updateSelection([nil, 1, 2, 3, 4, 5, 6])) { + $0.selectedFoodIds = [nil, 1, 2, 3, 4, 5, 6] + } + XCTAssertNoDifference(store.state.isSelectionDisabled(for: food), false) - state.selectedFoodIds = [1, 2, 3, 4, 5, 6, 7] - XCTAssertNoDifference(state.isSelectionDisabled(for: food), true) + await store.send(.updateSelection([1, 2, 3, 4, 5, 6, 7])) { + $0.selectedFoodIds = [1, 2, 3, 4, 5, 6, 7] + } + XCTAssertNoDifference(store.state.isSelectionDisabled(for: food), true) } func testFullFlow() async throws { @@ -77,34 +85,17 @@ final class FoodSelectionTests: XCTestCase { FoodSelection() }, withDependencies: { - $0.databaseClient.observeFoods = { strategy, order in - XCTAssertEqual(strategy.name, "name") - XCTAssertEqual(order, .forward) - return stream - } + $0.uuid = .constant(.init(0)) } ) XCTAssertEqual(store.state.shouldShowCancelButton, false) - await store.send(.onFirstAppear) - continuation.yield([eggplant, oliveOil, ribeye]) - await store.receive(\.updateFoods) { - $0.foods = [eggplant, oliveOil, ribeye] - } await store.send(.updateSelection([1])) { $0.selectedFoodIds = [1] } XCTAssertEqual(store.state.shouldShowCancelButton, true) - await store.send(.updateFilter("e")) { - $0.filterQuery = "e" - } - XCTAssertNoDifference(store.state.filteredFoods, [eggplant, oliveOil, ribeye]) await store.send(.updateSelection([1, 3])) { $0.selectedFoodIds = [1, 3] } - await store.send(.updateFilter("")) { - $0.filterQuery = "" - } - XCTAssertNoDifference(store.state.filteredFoods, store.state.foods) XCTAssertNoDifference(store.state.isCompareButtonDisabled, false) await store.send(.updateSelection([1, 3, 4, 5, 6, 7, 8])) { $0.selectedFoodIds = [1, 3, 4, 5, 6, 7, 8] @@ -113,6 +104,9 @@ final class FoodSelectionTests: XCTestCase { await store.send(.updateSelection([1, 2, 3])) { $0.selectedFoodIds = [1, 2, 3] } + await store.send(.foodObservation(.updateFoods([eggplant, oliveOil, ribeye]))) { + $0.foodObservation.foods = [eggplant, oliveOil, ribeye] + } await store.send(.compareButtonTapped(.energy)) { $0.foodComparison = .init( foods: [eggplant, oliveOil, ribeye,], @@ -125,9 +119,6 @@ final class FoodSelectionTests: XCTestCase { $0.foodComparison = nil } continuation.yield([eggplant, oliveOil, ribeye, .preview(id: 10)]) - await store.receive(\.updateFoods) { - $0.foods = [eggplant, oliveOil, ribeye, .preview(id: 10)] - } await store.send(.cancelButtonTapped) { $0.selectedFoodIds = [] } diff --git a/food-spec/Tests/MealFormTests/MealFormTests.swift b/food-spec/Tests/MealFormTests/MealFormTests.swift index 70e7279..d60eb14 100644 --- a/food-spec/Tests/MealFormTests/MealFormTests.swift +++ b/food-spec/Tests/MealFormTests/MealFormTests.swift @@ -101,6 +101,9 @@ final class MealFormTests: XCTestCase { initialState: MealForm.State(), reducer: { MealForm() + }, + withDependencies: { + $0.uuid = .constant(.init(0)) } ) await store.send(.addIngredientsButtonTapped) { @@ -111,6 +114,9 @@ final class MealFormTests: XCTestCase { initialState: MealForm.State(meal: .chimichurri), reducer: { MealForm() + }, + withDependencies: { + $0.uuid = .constant(.init(0)) } ) await store.send(.addIngredientsButtonTapped) { @@ -123,6 +129,9 @@ final class MealFormTests: XCTestCase { initialState: MealForm.State(meal: .chimichurri), reducer: { MealForm() + }, + withDependencies: { + $0.uuid = .constant(.init(0)) } ) await store.send(.ingredientTapped(store.state.meal.ingredients[0])) { @@ -217,13 +226,16 @@ final class MealFormTests: XCTestCase { initialState: MealForm.State(), reducer: { MealForm() + }, + withDependencies: { + $0.uuid = .constant(.init(0)) } ) await store.send(.addIngredientsButtonTapped) { $0.addIngredients = .init() } store.exhaustivity = .off - await store.send(.addIngredients(.presented(.updateFoods([.chiliPepper, .coriander, .garlic, .oliveOil, .oregano, .parsley, .redWineVinegar])))) + await store.send(.addIngredients(.presented(.foodObservation(.updateFoods([.chiliPepper, .coriander, .garlic, .oliveOil, .oregano, .parsley, .redWineVinegar]))))) await store.send(.addIngredients(.presented(.ingredientPickers(.element(id: 1, action: .updateSelection(true)))))) await store.send(.addIngredients(.presented(.ingredientPickers(.element(id: 2, action: .updateSelection(true)))))) await store.send(.addIngredients(.presented(.ingredientPickers(.element(id: 3, action: .updateSelection(true)))))) diff --git a/food-spec/Tests/SearchTests/FoodSearchTests.swift b/food-spec/Tests/SearchTests/FoodSearchTests.swift new file mode 100644 index 0000000..390b73b --- /dev/null +++ b/food-spec/Tests/SearchTests/FoodSearchTests.swift @@ -0,0 +1,207 @@ +import Foundation +import XCTest +import Shared +import ComposableArchitecture +@testable import Search + +@MainActor +final class FoodSearchTests: XCTestCase { + func testStateInitialization() async throws { + let store = TestStore( + initialState: FoodSearch.State(), + reducer: { + FoodSearch() + }, + withDependencies: { + $0.uuid = .constant(.init(0)) + } + ) + store.assert { + $0.query = "" + $0.isFocused = false + $0.isSearching = false + } + } + + func testFocus() async throws { + let store = TestStore( + initialState: FoodSearch.State(), + reducer: { + FoodSearch() + }, + withDependencies: { + $0.uuid = .constant(.init(0)) + } + ) + await store.send(.updateFocus(true)) { + $0.isFocused = true + } + await store.send(.updateFocus(false)) { + $0.isFocused = false + } + } + + func testQuery() async throws { + let didInsert = ActorIsolated(false) + let store = TestStore( + initialState: FoodSearch.State(), + reducer: { + FoodSearch() + }, + withDependencies: { + $0.uuid = .constant(.init(0)) + $0.continuousClock = ImmediateClock() + $0.foodClient.getFoods = { _ in [.preview] } + $0.databaseClient.numberOfFoods = { _ in 1 } + $0.databaseClient.getFoods = { _, _, _ in + if await didInsert.value { + [.chiliPepper, .redWineVinegar] + } else { + [.chiliPepper] + } + } + $0.databaseClient.insertFoods = { + XCTAssertEqual($0, [.init(foodApiModel: .preview)]) + await didInsert.setValue(true) + return $0 + } + } + ) + await store.send(.updateQuery("asd")) { + $0.query = "asd" + } + await store.receive(\.searchStarted) { + $0.isSearching = true + } + await store.receive(\.result) { + $0.searchResults = [.chiliPepper] + } + await store.receive(\.result) { + $0.searchResults = [.chiliPepper, .redWineVinegar] + } + await store.receive(\.searchEnded) { + $0.isSearching = false + } + await store.send(.updateQuery("asd")) + await store.send(.updateQuery("")) { + $0.query = "" + $0.searchResults = [] + } + } + + func testQueryError() async throws { + let store = TestStore( + initialState: FoodSearch.State(), + reducer: { + FoodSearch() + }, + withDependencies: { + $0.uuid = .constant(.init(0)) + $0.continuousClock = ImmediateClock() + $0.foodClient.getFoods = { _ in + struct Failure: Error { } + throw Failure() + } + $0.databaseClient.getFoods = { _, _, _ in [] } + $0.databaseClient.numberOfFoods = { _ in 0 } + } + ) + await store.send(.updateQuery("asd")) { + $0.query = "asd" + } + await store.receive(\.searchStarted) { + $0.isSearching = true + } + await store.receive(\.result) + await store.receive(\.error) { + $0.alert = .init { + TextState("Something went wrong. Please try again later.") + } + } + await store.receive(\.searchEnded) { + $0.isSearching = false + } + } + + func testSearchSubmitted() async throws { + let store = TestStore( + initialState: { + var state = FoodSearch.State() + state.query = "asd" + return state + }(), + reducer: { + FoodSearch() + }, + withDependencies: { + $0.uuid = .constant(.init(0)) + $0.continuousClock = ImmediateClock() + $0.foodClient.getFoods = { _ in [] } + $0.databaseClient.numberOfFoods = { _ in 1 } + $0.databaseClient.getFoods = { _, _, _ in [.chiliPepper] } + } + ) + await store.send(.searchSubmitted) + await store.receive(\.searchStarted) { + $0.isSearching = true + } + await store.receive(\.result) { + $0.searchResults = [.chiliPepper] + } + await store.receive(\.searchEnded) { + $0.isSearching = false + } + } + + func testUpdateSortStrategy() async throws { + let store = TestStore( + initialState: FoodSearch.State(), + reducer: { + FoodSearch() + }, + withDependencies: { + $0.uuid = .constant(.init(0)) + } + ) + await store.send(.updateSortStrategy(.energy, .reverse)) { + $0.sortStrategy = .energy + $0.sortOrder = .reverse + } + } +} + +fileprivate extension Food { + static var chiliPepper: Self { + .init( + id: 1, + name: "chili pepper", + energy: .kcal(39.4), + fatTotal: .grams(0.4), + fatSaturated: .zero, + protein: .grams(1.9), + sodium: .grams(0.008), + potassium: .grams(0.043), + cholesterol: .zero, + carbohydrate: .grams(8.8), + fiber: .grams(1.5), + sugar: .grams(5.3) + ) + } + + static var redWineVinegar: Self { + .init( + id: 7, + name: "red wine vinegar", + energy: .kcal(18.9), + fatTotal: .zero, + fatSaturated: .zero, + protein: .grams(0.1), + sodium: .grams(0.008), + potassium: .grams(0.007), + cholesterol: .zero, + carbohydrate: .grams(0.3), + fiber: .zero, + sugar: .zero + ) + } +} diff --git a/food-spec/Tests/TabBarTests/TabBarTests.swift b/food-spec/Tests/TabBarTests/TabBarTests.swift index 33ef451..f8c9592 100644 --- a/food-spec/Tests/TabBarTests/TabBarTests.swift +++ b/food-spec/Tests/TabBarTests/TabBarTests.swift @@ -10,6 +10,9 @@ final class TabBarTests: XCTestCase { initialState: TabBar.State(), reducer: { TabBar() + }, + withDependencies: { + $0.uuid = .constant(.init(0)) } ) await store.send(.updateTab(.foodSelection)) {