Skip to content

Commit

Permalink
Add search functionality to FoodSelection & AddIngredients (#18)
Browse files Browse the repository at this point in the history
  • Loading branch information
vykut authored Dec 24, 2023
1 parent 91d9cc7 commit bde8e36
Show file tree
Hide file tree
Showing 40 changed files with 1,688 additions and 1,041 deletions.
10 changes: 6 additions & 4 deletions FoodSpec.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
"location" : "https://github.com/pointfreeco/swift-composable-architecture",
"state" : {
"branch" : "observation-beta",
"revision" : "a1f2ebbe973e35d4d476da4e0a297e0002af25a6"
"revision" : "8e1573479723f9c064bcefef7c20ec53b0433eba"
}
},
{
Expand Down
18 changes: 18 additions & 0 deletions FoodSpec.xctestplan
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
}
},
{
"skippedTests" : [
"BillboardReducerTests",
"SpotlightReducerTests"
],
"target" : {
"containerPath" : "container:food-spec",
"identifier" : "FoodListTests",
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion FoodSpec/Assets.xcassets/AppIcon.appiconset/Contents.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
11 changes: 8 additions & 3 deletions food-spec/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ let package = Package(
.library(name: "AddIngredients"),
.library(name: "IngredientPicker"),
.library(name: "QuantityPicker"),
.library(name: "Search"),
.library(name: "Shared"),
],
dependencies: [
Expand All @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down
13 changes: 9 additions & 4 deletions food-spec/Sources/API/FoodClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
)

Expand Down
56 changes: 34 additions & 22 deletions food-spec/Sources/AddIngredients/AddIngredients.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,35 @@ import Foundation
import Shared
import IngredientPicker
import Database
import Search
import FoodObservation
import ComposableArchitecture

@Reducer
public struct AddIngredients {
public typealias IngredientPickers = IdentifiedArray<FoodID, IngredientPicker.State>
public typealias FoodID = Int64?

@ObservableState
public struct State: Hashable {
var initialIngredients: [Ingredient]
var ingredientPickers: IdentifiedArray<FoodID, IngredientPicker.State> = .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
.filter(\.isSelected)
.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 {
Expand All @@ -32,41 +44,41 @@ public struct AddIngredients {

@CasePathable
public enum Action {
case onFirstAppear
case updateFoods([Food])
case ingredientPickers(IdentifiedAction<FoodID, IngredientPicker.Action>)
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<Self> {
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:
Expand Down
62 changes: 53 additions & 9 deletions food-spec/Sources/AddIngredients/AddIngredientsScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
}
Expand All @@ -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)
}
}

Expand Down
2 changes: 1 addition & 1 deletion food-spec/Sources/Database/Database+Creation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
Loading

0 comments on commit bde8e36

Please sign in to comment.