Added tabs and a grocery list
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
166
Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift
Normal file
166
Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
58
Nextcloud Cookbook iOS Client/Views/Tabs/SearchTabView.swift
Normal file
58
Nextcloud Cookbook iOS Client/Views/Tabs/SearchTabView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user