Added tabs and a grocery list

This commit is contained in:
VincentMeilinger
2024-01-23 19:02:04 +01:00
parent 59734fb0cc
commit 931364c57c
10 changed files with 485 additions and 61 deletions

View File

@@ -402,6 +402,9 @@
}
}
}
},
"Add groceries to this list by either using the button next to an ingredient list in a recipe, or by swiping right on individual ingredients of a recipe." : {
},
"Add new recipe" : {
"localizations" : {
@@ -1241,6 +1244,9 @@
}
}
}
},
"Grocery List" : {
},
"If 'Same as Device' is selected and your device language is not supported yet, this option will default to english." : {
"localizations" : {
@@ -3161,6 +3167,9 @@
}
}
}
},
"You're all set for cooking 🍓" : {
}
},
"version" : "1.0"

View File

@@ -11,8 +11,6 @@ import UIKit
@MainActor class MainViewModel: ObservableObject {
@ObservedObject var userSettings = UserSettings.shared
@Published var categories: [Category] = []
@Published var recipes: [String: [Recipe]] = [:]
@Published var recipeDetails: [Int: RecipeDetail] = [:]
@@ -30,10 +28,10 @@ import UIKit
self.api = api
self.dataStore = DataStore()
if userSettings.authString == "" {
let loginString = "\(userSettings.username):\(userSettings.token)"
if UserSettings.shared.authString == "" {
let loginString = "\(UserSettings.shared.username):\(UserSettings.shared.token)"
let loginData = loginString.data(using: String.Encoding.utf8)!
userSettings.authString = loginData.base64EncodedString()
UserSettings.shared.authString = loginData.base64EncodedString()
}
}
@@ -51,7 +49,7 @@ import UIKit
*/
func getCategories() async {
let (categories, _) = await api.getCategories(
auth: userSettings.authString
auth: UserSettings.shared.authString
)
if let categories = categories {
print("Successfully loaded categories")
@@ -99,7 +97,7 @@ import UIKit
func getServer(store: Bool = false) async -> Bool {
let (recipes, _) = await api.getCategory(
auth: userSettings.authString,
auth: UserSettings.shared.authString,
named: categoryString
)
if let recipes = recipes {
@@ -132,16 +130,16 @@ import UIKit
for category in self.categories {
await updateRecipeDetails(in: category.name)
}
userSettings.lastUpdate = Date()
UserSettings.shared.lastUpdate = Date()
}
func updateRecipeDetails(in category: String) async {
guard userSettings.storeRecipes else { return }
guard UserSettings.shared.storeRecipes else { return }
guard let recipes = self.recipes[category] else { return }
for recipe in recipes {
if needsUpdate(category: category, lastModified: recipe.dateModified) {
print("\(recipe.name) needs an update. (last modified: \(recipe.dateModified)")
await updateRecipeDetail(id: recipe.recipe_id, withThumb: userSettings.storeThumb, withImage: userSettings.storeImages)
await updateRecipeDetail(id: recipe.recipe_id, withThumb: UserSettings.shared.storeThumb, withImage: UserSettings.shared.storeImages)
} else {
print("\(recipe.name) is up to date.")
}
@@ -161,7 +159,7 @@ import UIKit
*/
func getRecipes() async -> [Recipe] {
let (recipes, error) = await api.getRecipes(
auth: userSettings.authString
auth: UserSettings.shared.authString
)
if let recipes = recipes {
return recipes
@@ -201,7 +199,7 @@ import UIKit
func getServer() async -> RecipeDetail? {
let (recipe, error) = await api.getRecipe(
auth: userSettings.authString,
auth: UserSettings.shared.authString,
id: id
)
if let recipe = recipe {
@@ -294,7 +292,7 @@ import UIKit
func getServer() async -> UIImage? {
let (image, _) = await api.getImage(
auth: userSettings.authString,
auth: UserSettings.shared.authString,
id: id,
size: size
)
@@ -370,7 +368,7 @@ import UIKit
func getServer() async -> [RecipeKeyword]? {
let (tags, _) = await api.getTags(
auth: userSettings.authString
auth: UserSettings.shared.authString
)
return tags
}
@@ -423,7 +421,7 @@ import UIKit
*/
func deleteRecipe(withId id: Int, categoryName: String) async -> RequestAlert? {
let (error) = await api.deleteRecipe(
auth: userSettings.authString,
auth: UserSettings.shared.authString,
id: id
)
@@ -454,7 +452,7 @@ import UIKit
*/
func checkServerConnection() async -> Bool {
let (categories, _) = await api.getCategories(
auth: userSettings.authString
auth: UserSettings.shared.authString
)
if let categories = categories {
self.categories = categories
@@ -483,12 +481,12 @@ import UIKit
var error: NetworkError? = nil
if createNew {
error = await api.createRecipe(
auth: userSettings.authString,
auth: UserSettings.shared.authString,
recipe: recipeDetail
)
} else {
error = await api.updateRecipe(
auth: userSettings.authString,
auth: UserSettings.shared.authString,
recipe: recipeDetail
)
}
@@ -501,7 +499,7 @@ import UIKit
func importRecipe(url: String) async -> (RecipeDetail?, RequestAlert?) {
guard let data = JSONEncoder.safeEncode(RecipeImportRequest(url: url)) else { return (nil, .REQUEST_DROPPED) }
let (recipeDetail, error) = await api.importRecipe(
auth: userSettings.authString,
auth: UserSettings.shared.authString,
data: data
)
if error != nil {
@@ -509,6 +507,7 @@ import UIKit
}
return (recipeDetail, nil)
}
}

View File

@@ -7,8 +7,66 @@
import SwiftUI
struct MainView: View {
@StateObject var viewModel = MainViewModel()
@State var selectedCategory: Category? = nil
@State var showLoadingIndicator: Bool = false
enum Tab {
case recipes, search, shoppingList, settings
}
var body: some View {
TabView {
RecipeTabView(selectedCategory: $selectedCategory, showLoadingIndicator: $showLoadingIndicator)
.environmentObject(viewModel)
.tabItem {
Label("Recipes", systemImage: "book.closed.fill")
}
.tag(Tab.recipes)
SearchTabView()
.environmentObject(viewModel)
.tabItem {
Label("Search", systemImage: "magnifyingglass")
}
.tag(Tab.search)
GroceryListTabView()
.tabItem {
Label("Grocery List", systemImage: "storefront")
}
.tag(Tab.shoppingList)
SettingsView()
.environmentObject(viewModel)
.tabItem {
Label("Settings", systemImage: "gearshape")
}
.tag(Tab.settings)
}
.task {
showLoadingIndicator = true
await viewModel.getCategories()
await viewModel.updateAllRecipeDetails()
// Open detail view for default category
if UserSettings.shared.defaultCategory != "" {
if let cat = viewModel.categories.first(where: { c in
if c.name == UserSettings.shared.defaultCategory {
return true
}
return false
}) {
self.selectedCategory = cat
}
}
showLoadingIndicator = false
await GroceryList.shared.load()
}
}
}
/*struct MainView: View {
@ObservedObject var viewModel: MainViewModel
@StateObject var userSettings: UserSettings = UserSettings.shared
@@ -214,43 +272,5 @@ struct MainView: View {
struct RecipeSearchView: View {
@ObservedObject var viewModel: MainViewModel
@State var searchText: String = ""
@State var allRecipes: [Recipe] = []
var body: some View {
NavigationStack {
VStack {
ScrollView(showsIndicators: false) {
LazyVStack {
ForEach(recipesFiltered(), id: \.recipe_id) { recipe in
NavigationLink(value: recipe) {
RecipeCardView(viewModel: viewModel, recipe: recipe)
.shadow(radius: 2)
}
.buttonStyle(.plain)
}
}
}
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetailView(viewModel: viewModel, recipe: recipe)
}
.searchable(text: $searchText, prompt: "Search recipes/keywords")
}
.navigationTitle("Search recipe")
}
.task {
allRecipes = await viewModel.getRecipes()
}
}
func recipesFiltered() -> [Recipe] {
guard searchText != "" else { return allRecipes }
return allRecipes.filter { recipe in
recipe.name.lowercased().contains(searchText.lowercased()) || // check name for occurence of search term
(recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased())) // check keywords for search term
}
}
}
*/

View File

@@ -364,10 +364,17 @@ fileprivate struct RecipeIngredientSection: View {
SecondaryLabel(text: LocalizedStringKey("Ingredients for \(recipeDetail.recipeYield) servings"))
}
Spacer()
Button {
GroceryList.shared.addItems(recipeDetail.recipeIngredient)
} label: {
Image(systemName: "storefront")
}
}
ForEach(recipeDetail.recipeIngredient, id: \.self) { ingredient in
IngredientListItem(ingredient: ingredient)
.padding(4)
}
}.padding()
}
@@ -392,9 +399,17 @@ fileprivate struct RecipeToolSection: View {
fileprivate struct IngredientListItem: View {
@State var ingredient: String
@State var isSelected: Bool = false
@State private var dragOffset: CGFloat = 0
let maxDragDistance = 30.0
var body: some View {
HStack(alignment: .top) {
if dragOffset > 0 {
Image(systemName: "storefront")
.padding(2)
.background(Color.green)
.opacity((dragOffset - 10)/(maxDragDistance-10))
}
if isSelected {
Image(systemName: "checkmark.circle")
} else {
@@ -404,12 +419,31 @@ fileprivate struct IngredientListItem: View {
Text("\(ingredient)")
.multilineTextAlignment(.leading)
.lineLimit(5)
Spacer()
}
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
.onTapGesture {
isSelected.toggle()
}
.animation(.easeInOut, value: isSelected)
.offset(x: dragOffset, y: 0)
.gesture(
DragGesture()
.onChanged { gesture in
// Update drag offset as the user drags
let dragAmount = gesture.translation.width
self.dragOffset = min(dragAmount, maxDragDistance + pow(dragAmount - maxDragDistance, 0.7))
}
.onEnded { gesture in
if gesture.translation.width > maxDragDistance * 0.8 { // Swipe right threshold
GroceryList.shared.addItem(ingredient)
}
// Animate back to original position
withAnimation {
self.dragOffset = 0
}
}
)
}
}

View File

@@ -11,7 +11,7 @@ import SwiftUI
struct SettingsView: View {
@ObservedObject var viewModel: MainViewModel
@EnvironmentObject var viewModel: MainViewModel
@ObservedObject var userSettings = UserSettings.shared
@State fileprivate var alertType: SettingsAlert = .NONE

View File

@@ -0,0 +1,118 @@
//
// GroceryListTabView.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 23.01.24.
//
import Foundation
import SwiftUI
struct GroceryListTabView: View {
var body: some View {
NavigationStack {
if GroceryList.shared.listItems.isEmpty {
List {
Text("You're all set for cooking 🍓")
.font(.title2)
.foregroundStyle(.secondary)
Text("Add groceries to this list by either using the button next to an ingredient list in a recipe, or by swiping right on individual ingredients of a recipe.")
.foregroundStyle(.secondary)
}
.padding()
.navigationTitle("Grocery List")
} else {
List(GroceryList.shared.listItems) { item in
HStack(alignment: .top) {
if item.isChecked {
Image(systemName: "checkmark.circle")
} else {
Image(systemName: "circle")
}
Text("\(item.name)")
.multilineTextAlignment(.leading)
.lineLimit(5)
}
.foregroundStyle(item.isChecked ? Color.secondary : Color.primary)
.onTapGesture {
item.isChecked.toggle()
}
.animation(.easeInOut, value: item.isChecked)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
GroceryList.shared.removeItem(item.name)
} label: {
Label("Delete", systemImage: "trash")
}
.tint(.red)
}
}
.padding()
.navigationTitle("Grocery List")
}
}
}
}
class GroceryListItem: ObservableObject, Identifiable, Codable {
var name: String
var isChecked: Bool
init(_ name: String, isChecked: Bool = false) {
self.name = name
self.isChecked = isChecked
}
}
class GroceryList: ObservableObject {
static let shared: GroceryList = GroceryList()
let dataStore: DataStore = DataStore()
@Published var listItems: [GroceryListItem] = []
func addItem(_ name: String) {
listItems.append(GroceryListItem(name))
save()
}
func addItems(_ items: [String]) {
for item in items {
addItem(item)
}
save()
}
func removeItem(_ name: String) {
guard let ix = listItems.firstIndex(where: { item in
item.name == name
}) else { return }
listItems.remove(at: ix)
save()
}
func save() {
Task {
await dataStore.save(data: listItems, toPath: "grocery_list.data")
}
}
func load() async {
do {
guard let listItems: [GroceryListItem] = try await dataStore.load(
fromPath: "grocery_list.data"
) else { return }
self.listItems = listItems
} catch {
print("Unable to load grocery list")
}
}
}

View File

@@ -0,0 +1,166 @@
//
// RecipeTabView.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 23.01.24.
//
import Foundation
import SwiftUI
struct RecipeTabView: View {
@Binding var selectedCategory: Category?
@Binding var showLoadingIndicator: Bool
@EnvironmentObject var viewModel: MainViewModel
@StateObject var userSettings: UserSettings = UserSettings.shared
@State private var showEditView: Bool = false
@State private var serverConnection: Bool = false
var body: some View {
NavigationSplitView {
List(selection: $selectedCategory) {
// Categories
ForEach(viewModel.categories) { category in
if category.recipe_count != 0 {
NavigationLink(value: category) {
HStack(alignment: .center) {
if selectedCategory != nil && category.name == selectedCategory!.name {
Image(systemName: "book")
} else {
Image(systemName: "book.closed.fill")
}
Text(category.name == "*" ? String(localized: "Other") : category.name)
.font(.system(size: 20, weight: .medium, design: .default))
Spacer()
Text("\(category.recipe_count)")
.font(.system(size: 15, weight: .bold, design: .default))
.foregroundStyle(Color.background)
.frame(width: 25, height: 25, alignment: .center)
.minimumScaleFactor(0.5)
.background {
Circle()
.foregroundStyle(Color.secondary)
}
}.padding(7)
}
}
}
}
.navigationTitle("Cookbooks")
.toolbar {
RecipeTabViewToolBar(
viewModel: viewModel,
showEditView: $showEditView,
serverConnection: $serverConnection,
showLoadingIndicator: $showLoadingIndicator
)
}
} detail: {
NavigationStack {
if let category = selectedCategory {
CategoryDetailView(
categoryName: category.name,
viewModel: viewModel,
showEditView: $showEditView
)
.id(category.id) // Workaround: This is needed to update the detail view when the selection changes
}
}
}
.tint(.nextcloudBlue)
.sheet(isPresented: $showEditView) {
RecipeEditView(
viewModel:
RecipeEditViewModel(
mainViewModel: viewModel,
uploadNew: true
),
isPresented: $showEditView
)
}
.task {
self.serverConnection = await viewModel.checkServerConnection()
}
.refreshable {
self.serverConnection = await viewModel.checkServerConnection()
await viewModel.getCategories()
}
}
}
fileprivate struct RecipeTabViewToolBar: ToolbarContent {
@ObservedObject var viewModel: MainViewModel
@Binding var showEditView: Bool
@Binding var serverConnection: Bool
@Binding var showLoadingIndicator: Bool
@State private var presentPopover: Bool = false
var body: some ToolbarContent {
// Top left menu toolbar item
ToolbarItem(placement: .topBarLeading) {
Menu {
Button {
Task {
showLoadingIndicator = true
UserSettings.shared.lastUpdate = Date.distantPast
await viewModel.getCategories()
for category in viewModel.categories {
await viewModel.getCategory(named: category.name, fetchMode: .preferServer)
}
await viewModel.updateAllRecipeDetails()
showLoadingIndicator = false
}
} label: {
Text("Refresh all")
Image(systemName: "icloud.and.arrow.down")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
// Server connection indicator
ToolbarItem(placement: .topBarTrailing) {
Button {
print("Check server connection")
presentPopover = true
} label: {
if showLoadingIndicator {
ProgressView()
} else if serverConnection {
Image(systemName: "checkmark.icloud")
} else {
Image(systemName: "xmark.icloud")
}
}.popover(isPresented: $presentPopover) {
VStack(alignment: .leading) {
Text(serverConnection ? LocalizedStringKey("Connected to server.") : LocalizedStringKey("Unable to connect to server."))
.bold()
Text("Last updated: \(DateFormatter.utcToString(date: UserSettings.shared.lastUpdate))")
.font(.caption)
.foregroundStyle(Color.secondary)
}
.padding()
.presentationCompactAdaptation(.popover)
}
}
// Create new recipes
ToolbarItem(placement: .topBarTrailing) {
Button {
print("Add new recipe")
showEditView = true
} label: {
Image(systemName: "plus.circle.fill")
}
}
}
}

View File

@@ -0,0 +1,58 @@
//
// SearchTabView.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 23.01.24.
//
import Foundation
import SwiftUI
struct SearchTabView: View {
@EnvironmentObject var viewModel: MainViewModel
var body: some View {
RecipeSearchView(viewModel: viewModel)
}
}
struct RecipeSearchView: View {
@ObservedObject var viewModel: MainViewModel
@State var searchText: String = ""
@State var allRecipes: [Recipe] = []
var body: some View {
NavigationStack {
VStack {
ScrollView(showsIndicators: false) {
LazyVStack {
ForEach(recipesFiltered(), id: \.recipe_id) { recipe in
NavigationLink(value: recipe) {
RecipeCardView(viewModel: viewModel, recipe: recipe)
.shadow(radius: 2)
}
.buttonStyle(.plain)
}
}
}
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetailView(viewModel: viewModel, recipe: recipe)
}
.searchable(text: $searchText, prompt: "Search recipes/keywords")
}
.navigationTitle("Search recipe")
}
.task {
allRecipes = await viewModel.getRecipes()
}
}
func recipesFiltered() -> [Recipe] {
guard searchText != "" else { return allRecipes }
return allRecipes.filter { recipe in
recipe.name.lowercased().contains(searchText.lowercased()) || // check name for occurence of search term
(recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased())) // check keywords for search term
}
}
}