Code cleanup
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
//
|
||||
// CategoryDetailView.swift
|
||||
// Nextcloud Cookbook iOS Client
|
||||
//
|
||||
// Created by Vincent Meilinger on 15.09.23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
|
||||
|
||||
struct CategoryDetailView: View {
|
||||
@State var categoryName: String
|
||||
@State var searchText: String = ""
|
||||
@ObservedObject var viewModel: AppState
|
||||
@Binding var showEditView: Bool
|
||||
|
||||
var body: some View {
|
||||
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)
|
||||
}
|
||||
.navigationTitle(categoryName == "*" ? String(localized: "Other") : categoryName)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
print("Add new recipe")
|
||||
showEditView = true
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText, prompt: "Search recipes/keywords")
|
||||
.task {
|
||||
await viewModel.getCategory(
|
||||
named: categoryName,
|
||||
fetchMode: UserSettings.shared.storeRecipes ? .preferLocal : .onlyServer
|
||||
)
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.getCategory(
|
||||
named: categoryName,
|
||||
fetchMode: UserSettings.shared.storeRecipes ? .preferServer : .onlyServer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func recipesFiltered() -> [Recipe] {
|
||||
guard let recipes = viewModel.recipes[categoryName] else { return [] }
|
||||
guard searchText != "" else { return recipes }
|
||||
return recipes.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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//
|
||||
// RecipeCardView.swift
|
||||
// Nextcloud Cookbook iOS Client
|
||||
//
|
||||
// Created by Vincent Meilinger on 15.09.23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct RecipeCardView: View {
|
||||
@State var viewModel: AppState
|
||||
@State var recipe: Recipe
|
||||
@State var recipeThumb: UIImage?
|
||||
@State var isDownloaded: Bool? = nil
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
if let recipeThumb = recipeThumb {
|
||||
Image(uiImage: recipeThumb)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 80, height: 80)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 17))
|
||||
} else {
|
||||
Image(systemName: "square.text.square")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.foregroundStyle(Color.white)
|
||||
.padding(10)
|
||||
.background(Color("ncblue"))
|
||||
.frame(width: 80, height: 80)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 17))
|
||||
}
|
||||
Text(recipe.name)
|
||||
.font(.headline)
|
||||
.padding(.leading, 4)
|
||||
|
||||
Spacer()
|
||||
if let isDownloaded = isDownloaded {
|
||||
VStack {
|
||||
Image(systemName: isDownloaded ? "checkmark.circle" : "icloud.and.arrow.down")
|
||||
.foregroundColor(.secondary)
|
||||
.padding()
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color.backgroundHighlight)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 17))
|
||||
.padding(.horizontal)
|
||||
.task {
|
||||
recipeThumb = await viewModel.getImage(
|
||||
id: recipe.recipe_id,
|
||||
size: .THUMB,
|
||||
fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer
|
||||
)
|
||||
if recipe.storedLocally == nil {
|
||||
recipe.storedLocally = viewModel.recipeDetailExists(recipeId: recipe.recipe_id)
|
||||
}
|
||||
isDownloaded = recipe.storedLocally
|
||||
}
|
||||
.refreshable {
|
||||
recipeThumb = await viewModel.getImage(
|
||||
id: recipe.recipe_id,
|
||||
size: .THUMB,
|
||||
fetchMode: UserSettings.shared.storeThumb ? .preferServer : .onlyServer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
//
|
||||
// RecipeDetailView.swift
|
||||
// Nextcloud Cookbook iOS Client
|
||||
//
|
||||
// Created by Vincent Meilinger on 15.09.23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
|
||||
struct RecipeDetailView: View {
|
||||
@ObservedObject var viewModel: AppState
|
||||
@State var recipe: Recipe
|
||||
@State var recipeDetail: RecipeDetail?
|
||||
@State var recipeImage: UIImage?
|
||||
@State var showTitle: Bool = false
|
||||
@State var isDownloaded: Bool? = nil
|
||||
@State private var presentEditView: Bool = false
|
||||
@State private var presentNutritionPopover: Bool = false
|
||||
@State private var presentKeywordPopover: Bool = false
|
||||
@State private var presentShareSheet: Bool = false
|
||||
@State private var sharedURL: URL? = nil
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading) {
|
||||
if let recipeImage = recipeImage {
|
||||
Image(uiImage: recipeImage)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(maxHeight: 300)
|
||||
.clipped()
|
||||
}
|
||||
|
||||
if let recipeDetail = recipeDetail {
|
||||
LazyVStack (alignment: .leading) {
|
||||
HStack {
|
||||
Text(recipeDetail.name)
|
||||
.font(.title)
|
||||
.bold()
|
||||
.padding()
|
||||
.onDisappear {
|
||||
showTitle = true
|
||||
}
|
||||
.onAppear {
|
||||
showTitle = false
|
||||
}
|
||||
|
||||
if let isDownloaded = isDownloaded {
|
||||
Spacer()
|
||||
Image(systemName: isDownloaded ? "checkmark.circle" : "icloud.and.arrow.down")
|
||||
.foregroundColor(.secondary)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
if recipeDetail.description != "" {
|
||||
Text(recipeDetail.description)
|
||||
.padding([.bottom, .horizontal])
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
RecipeDurationSection(viewModel: viewModel, recipeDetail: recipeDetail)
|
||||
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
|
||||
if(!recipeDetail.recipeIngredient.isEmpty) {
|
||||
RecipeIngredientSection(recipeDetail: recipeDetail)
|
||||
}
|
||||
if(!recipeDetail.recipeInstructions.isEmpty) {
|
||||
RecipeInstructionSection(recipeDetail: recipeDetail)
|
||||
}
|
||||
if(!recipeDetail.tool.isEmpty) {
|
||||
RecipeToolSection(recipeDetail: recipeDetail)
|
||||
}
|
||||
RecipeNutritionSection(recipeDetail: recipeDetail)
|
||||
RecipeKeywordSection(recipeDetail: recipeDetail)
|
||||
MoreInformationSection(recipeDetail: recipeDetail)
|
||||
}
|
||||
|
||||
}.padding(.horizontal, 5)
|
||||
|
||||
}
|
||||
}.animation(.easeInOut, value: recipeImage)
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationTitle(showTitle ? recipe.name : "")
|
||||
.toolbar {
|
||||
if recipeDetail != nil {
|
||||
Menu {
|
||||
Button {
|
||||
presentEditView = true
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Edit")
|
||||
Image(systemName: "pencil")
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
print("Sharing recipe ...")
|
||||
self.presentShareSheet = true
|
||||
} label: {
|
||||
Text("Share recipe")
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $presentEditView) {
|
||||
if let recipeDetail = recipeDetail {
|
||||
RecipeEditView(
|
||||
viewModel:
|
||||
RecipeEditViewModel(
|
||||
mainViewModel: viewModel,
|
||||
recipeDetail: recipeDetail,
|
||||
uploadNew: false
|
||||
),
|
||||
isPresented: $presentEditView
|
||||
)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $presentShareSheet) {
|
||||
if let recipeDetail = recipeDetail {
|
||||
ShareView(recipeDetail: recipeDetail,
|
||||
recipeImage: recipeImage,
|
||||
presentShareSheet: $presentShareSheet)
|
||||
}
|
||||
}
|
||||
|
||||
.task {
|
||||
recipeDetail = await viewModel.getRecipe(
|
||||
id: recipe.recipe_id,
|
||||
fetchMode: UserSettings.shared.storeRecipes ? .preferLocal : .onlyServer
|
||||
)
|
||||
recipeImage = await viewModel.getImage(
|
||||
id: recipe.recipe_id,
|
||||
size: .FULL,
|
||||
fetchMode: UserSettings.shared.storeImages ? .preferLocal : .onlyServer
|
||||
)
|
||||
if recipe.storedLocally == nil {
|
||||
recipe.storedLocally = viewModel.recipeDetailExists(recipeId: recipe.recipe_id)
|
||||
}
|
||||
self.isDownloaded = recipe.storedLocally
|
||||
}
|
||||
.refreshable {
|
||||
recipeDetail = await viewModel.getRecipe(
|
||||
id: recipe.recipe_id,
|
||||
fetchMode: UserSettings.shared.storeRecipes ? .preferServer : .onlyServer
|
||||
)
|
||||
recipeImage = await viewModel.getImage(
|
||||
id: recipe.recipe_id,
|
||||
size: .FULL,
|
||||
fetchMode: UserSettings.shared.storeImages ? .preferServer : .onlyServer
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
if UserSettings.shared.keepScreenAwake {
|
||||
UIApplication.shared.isIdleTimerDisabled = true
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
UIApplication.shared.isIdleTimerDisabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct ShareView: View {
|
||||
@State var recipeDetail: RecipeDetail
|
||||
@State var recipeImage: UIImage?
|
||||
@Binding var presentShareSheet: Bool
|
||||
|
||||
@State var exporter = RecipeExporter()
|
||||
@State var sharedURL: URL? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
if let url = sharedURL {
|
||||
ShareLink(item: url, subject: Text("PDF Document")) {
|
||||
Image(systemName: "doc")
|
||||
Text("Share as PDF")
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
.bold()
|
||||
.padding()
|
||||
}
|
||||
|
||||
ShareLink(item: exporter.createText(recipe: recipeDetail), subject: Text("Recipe")) {
|
||||
Image(systemName: "ellipsis.message")
|
||||
Text("Share as text")
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
.bold()
|
||||
.padding()
|
||||
|
||||
/*ShareLink(item: exporter.createJson(recipe: recipeDetail), subject: Text("Recipe")) {
|
||||
Image(systemName: "doc.badge.gearshape")
|
||||
Text("Share as JSON")
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
.bold()
|
||||
.padding()
|
||||
*/
|
||||
}
|
||||
.task {
|
||||
self.sharedURL = exporter.createPDF(recipe: recipeDetail, image: recipeImage)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fileprivate struct RecipeDurationSection: View {
|
||||
@ObservedObject var viewModel: AppState
|
||||
@State var recipeDetail: RecipeDetail
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 250), alignment: .leading)]) {
|
||||
if let prepTime = recipeDetail.prepTime, let time = DurationComponents.ptToText(prepTime) {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
SecondaryLabel(text: LocalizedStringKey("Preparation"))
|
||||
Spacer()
|
||||
}
|
||||
Text(time)
|
||||
.lineLimit(1)
|
||||
}.padding()
|
||||
}
|
||||
|
||||
if let cookTime = recipeDetail.cookTime, let time = DurationComponents.ptToText(cookTime) {
|
||||
TimerView(timer: viewModel.getTimer(forRecipe: recipeDetail.id, duration: DurationComponents.fromPTString(cookTime)))
|
||||
.padding()
|
||||
}
|
||||
|
||||
/*
|
||||
if let cookTime = recipeDetail.cookTime, let time = DurationComponents.ptToText(cookTime) {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
SecondaryLabel(text: LocalizedStringKey("Cooking"))
|
||||
Spacer()
|
||||
}
|
||||
Text(time)
|
||||
.lineLimit(1)
|
||||
}.padding()
|
||||
}*/
|
||||
|
||||
if let totalTime = recipeDetail.totalTime, let time = DurationComponents.ptToText(totalTime) {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
SecondaryLabel(text: LocalizedStringKey("Total time"))
|
||||
Spacer()
|
||||
}
|
||||
Text(time)
|
||||
.lineLimit(1)
|
||||
}.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
fileprivate struct RecipeNutritionSection: View {
|
||||
@State var recipeDetail: RecipeDetail
|
||||
|
||||
var body: some View {
|
||||
HStack() {
|
||||
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandNutritionSection) {
|
||||
Group {
|
||||
if let nutritionList = recipeDetail.getNutritionList() {
|
||||
RecipeListSection(list: nutritionList)
|
||||
} else {
|
||||
Text(LocalizedStringKey("No nutritional information."))
|
||||
}
|
||||
}
|
||||
} title: {
|
||||
HStack {
|
||||
if let servingSize = recipeDetail.nutrition["servingSize"] {
|
||||
SecondaryLabel(text: "Nutrition (\(servingSize))")
|
||||
} else {
|
||||
SecondaryLabel(text: LocalizedStringKey("Nutrition"))
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
fileprivate struct RecipeKeywordSection: View {
|
||||
@State var recipeDetail: RecipeDetail
|
||||
|
||||
var body: some View {
|
||||
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandKeywordSection) {
|
||||
Group {
|
||||
if let keywords = getKeywords() {
|
||||
RecipeListSection(list: keywords)
|
||||
} else {
|
||||
Text(LocalizedStringKey("No keywords."))
|
||||
}
|
||||
}
|
||||
} title: {
|
||||
HStack {
|
||||
SecondaryLabel(text: LocalizedStringKey("Keywords"))
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
func getKeywords() -> [String]? {
|
||||
let keywords = recipeDetail.keywords.components(separatedBy: ",")
|
||||
return keywords.isEmpty ? nil : keywords
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fileprivate struct MoreInformationSection: View {
|
||||
let recipeDetail: RecipeDetail
|
||||
|
||||
var body: some View {
|
||||
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandInfoSection) {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Created: \(Date.convertISOStringToLocalString(isoDateString: recipeDetail.dateCreated) ?? "")")
|
||||
Text("Last modified: \(Date.convertISOStringToLocalString(isoDateString: recipeDetail.dateModified) ?? "")")
|
||||
if recipeDetail.url != "", let url = URL(string: recipeDetail.url) {
|
||||
HStack() {
|
||||
Text("URL:")
|
||||
Link(destination: url) {
|
||||
Text(recipeDetail.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.secondary)
|
||||
} title: {
|
||||
HStack {
|
||||
SecondaryLabel(text: "More information")
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fileprivate struct RecipeIngredientSection: View {
|
||||
@EnvironmentObject var groceryList: GroceryList
|
||||
@State var recipeDetail: RecipeDetail
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
if recipeDetail.recipeYield == 0 {
|
||||
SecondaryLabel(text: LocalizedStringKey("Ingredients"))
|
||||
} else if recipeDetail.recipeYield == 1 {
|
||||
SecondaryLabel(text: LocalizedStringKey("Ingredients per serving"))
|
||||
} else {
|
||||
SecondaryLabel(text: LocalizedStringKey("Ingredients for \(recipeDetail.recipeYield) servings"))
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
withAnimation {
|
||||
if groceryList.containsRecipe(recipeDetail.id) {
|
||||
groceryList.deleteGroceryRecipe(recipeDetail.id)
|
||||
} else {
|
||||
groceryList.addItems(recipeDetail.recipeIngredient, toRecipe: recipeDetail.id, recipeName: recipeDetail.name)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "storefront")
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(recipeDetail.recipeIngredient, id: \.self) { ingredient in
|
||||
IngredientListItem(ingredient: ingredient, recipeId: recipeDetail.id) {
|
||||
groceryList.addItem(ingredient, toRecipe: recipeDetail.id, recipeName: recipeDetail.name)
|
||||
}
|
||||
.padding(4)
|
||||
|
||||
}
|
||||
}.padding()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fileprivate struct RecipeToolSection: View {
|
||||
@State var recipeDetail: RecipeDetail
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
SecondaryLabel(text: "Tools")
|
||||
Spacer()
|
||||
}
|
||||
RecipeListSection(list: recipeDetail.tool)
|
||||
}.padding()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fileprivate struct IngredientListItem: View {
|
||||
@EnvironmentObject var groceryList: GroceryList
|
||||
@State var ingredient: String
|
||||
@State var recipeId: String
|
||||
let addToGroceryListAction: () -> Void
|
||||
@State var isSelected: Bool = false
|
||||
|
||||
// Drag animation
|
||||
@State private var dragOffset: CGFloat = 0
|
||||
@State private var animationStartOffset: CGFloat = 0
|
||||
let maxDragDistance = 50.0
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top) {
|
||||
if groceryList.containsItem(at: recipeId, item: ingredient) {
|
||||
Image(systemName: "storefront")
|
||||
.foregroundStyle(Color.green)
|
||||
} else if isSelected {
|
||||
Image(systemName: "checkmark.circle")
|
||||
} else {
|
||||
Image(systemName: "circle")
|
||||
}
|
||||
|
||||
Text("\(ingredient)")
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(5)
|
||||
Spacer()
|
||||
}
|
||||
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
|
||||
.onTapGesture {
|
||||
isSelected.toggle()
|
||||
}
|
||||
.offset(x: dragOffset, y: 0)
|
||||
.animation(.easeInOut, value: isSelected)
|
||||
|
||||
.gesture(
|
||||
DragGesture()
|
||||
.onChanged { gesture in
|
||||
// Update drag offset as the user drags
|
||||
if animationStartOffset == 0 {
|
||||
animationStartOffset = gesture.translation.width
|
||||
}
|
||||
let dragAmount = gesture.translation.width
|
||||
let offset = min(dragAmount, maxDragDistance + pow(dragAmount - maxDragDistance, 0.7)) - animationStartOffset
|
||||
self.dragOffset = max(0, offset)
|
||||
}
|
||||
.onEnded { gesture in
|
||||
withAnimation {
|
||||
if dragOffset > maxDragDistance * 0.3 { // Swipe threshold
|
||||
if groceryList.containsItem(at: recipeId, item: ingredient) {
|
||||
groceryList.deleteItem(ingredient, fromRecipe: recipeId)
|
||||
} else {
|
||||
addToGroceryListAction()
|
||||
}
|
||||
|
||||
}
|
||||
// Animate back to original position
|
||||
|
||||
self.dragOffset = 0
|
||||
self.animationStartOffset = 0
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
fileprivate struct RecipeListSection: View {
|
||||
@State var list: [String]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
ForEach(list, id: \.self) { item in
|
||||
HStack(alignment: .top) {
|
||||
Text("\u{2022}")
|
||||
Text("\(item)")
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fileprivate struct RecipeInstructionSection: View {
|
||||
@State var recipeDetail: RecipeDetail
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
SecondaryLabel(text: LocalizedStringKey("Instructions"))
|
||||
Spacer()
|
||||
}
|
||||
ForEach(0..<recipeDetail.recipeInstructions.count) { ix in
|
||||
RecipeInstructionListItem(instruction: recipeDetail.recipeInstructions[ix], index: ix+1)
|
||||
}
|
||||
}.padding()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
fileprivate struct RecipeInstructionListItem: View {
|
||||
@State var instruction: String
|
||||
@State var index: Int
|
||||
@State var isSelected: Bool = false
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top) {
|
||||
Text("\(index)")
|
||||
.monospaced()
|
||||
Text(instruction)
|
||||
}.padding(4)
|
||||
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
|
||||
.onTapGesture {
|
||||
isSelected.toggle()
|
||||
}
|
||||
.animation(.easeInOut, value: isSelected)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
fileprivate struct SecondaryLabel: View {
|
||||
let text: LocalizedStringKey
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.foregroundColor(.secondary)
|
||||
.font(.headline)
|
||||
.padding(.vertical, 5)
|
||||
}
|
||||
}
|
||||
206
Nextcloud Cookbook iOS Client/Views/Recipes/TimerView.swift
Normal file
206
Nextcloud Cookbook iOS Client/Views/Recipes/TimerView.swift
Normal file
@@ -0,0 +1,206 @@
|
||||
//
|
||||
// TimerView.swift
|
||||
// Nextcloud Cookbook iOS Client
|
||||
//
|
||||
// Created by Vincent Meilinger on 11.01.24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import AVFoundation
|
||||
import UserNotifications
|
||||
|
||||
|
||||
struct TimerView: View {
|
||||
@ObservedObject var timer: RecipeTimer
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Gauge(value: timer.timeTotal - timer.timeElapsed, in: 0...timer.timeTotal) {
|
||||
Text("Cooking time")
|
||||
} currentValueLabel: {
|
||||
Button {
|
||||
if timer.isRunning {
|
||||
timer.pause()
|
||||
} else {
|
||||
timer.start()
|
||||
}
|
||||
} label: {
|
||||
if timer.isRunning {
|
||||
Image(systemName: "pause.fill")
|
||||
} else {
|
||||
Image(systemName: "play.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
.gaugeStyle(.accessoryCircularCapacity)
|
||||
.animation(.easeInOut, value: timer.timeElapsed)
|
||||
.tint(timer.isRunning ? .green : .nextcloudBlue)
|
||||
.foregroundStyle(timer.isRunning ? Color.green : Color.nextcloudBlue)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text("Cooking")
|
||||
Text(timer.duration.toTimerText())
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
Button {
|
||||
timer.cancel()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(timer.isRunning ? Color.nextcloudBlue : Color.secondary)
|
||||
}
|
||||
}
|
||||
.bold()
|
||||
.padding()
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.foregroundStyle(.ultraThickMaterial)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
class RecipeTimer: ObservableObject {
|
||||
var timeTotal: Double
|
||||
@Published var duration: DurationComponents
|
||||
private var startDate: Date?
|
||||
private var pauseDate: Date?
|
||||
@Published var timeElapsed: Double = 0
|
||||
@Published var isRunning: Bool = false
|
||||
@Published var timerExpired: Bool = false
|
||||
private var timer: Timer.TimerPublisher?
|
||||
private var timerCancellable: Cancellable?
|
||||
var audioPlayer: AVAudioPlayer?
|
||||
|
||||
init(duration: DurationComponents) {
|
||||
self.duration = duration
|
||||
self.timeTotal = duration.toSeconds()
|
||||
}
|
||||
|
||||
func start() {
|
||||
self.isRunning = true
|
||||
if startDate == nil {
|
||||
startDate = Date()
|
||||
} else if let pauseDate = pauseDate {
|
||||
// Adjust start date based on the pause duration
|
||||
let pauseDuration = Date().timeIntervalSince(pauseDate)
|
||||
startDate = startDate?.addingTimeInterval(pauseDuration)
|
||||
}
|
||||
requestNotificationPermissions()
|
||||
scheduleTimerNotification(timeInterval: timeTotal)
|
||||
// Prepare audio session
|
||||
setupAudioSession()
|
||||
prepareAudioPlayer(with: "alarm_sound_0")
|
||||
|
||||
self.timer = Timer.publish(every: 1, on: .main, in: .common)
|
||||
self.timerCancellable = self.timer?.autoconnect().sink { [weak self] _ in
|
||||
DispatchQueue.main.async {
|
||||
if let self = self, let startTime = self.startDate {
|
||||
let elapsed = Date().timeIntervalSince(startTime)
|
||||
if elapsed < self.timeTotal {
|
||||
self.timeElapsed = elapsed
|
||||
self.duration.fromSeconds(Int(self.timeTotal - self.timeElapsed))
|
||||
} else {
|
||||
self.timerExpired = true
|
||||
self.timeElapsed = self.timeTotal
|
||||
self.duration.fromSeconds(Int(self.timeTotal - self.timeElapsed))
|
||||
self.pause()
|
||||
|
||||
self.startAlarm()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pause() {
|
||||
self.isRunning = false
|
||||
pauseDate = Date()
|
||||
self.timerCancellable?.cancel()
|
||||
self.timerCancellable = nil
|
||||
self.timer = nil
|
||||
}
|
||||
|
||||
func resume() {
|
||||
self.isRunning = true
|
||||
start()
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
self.isRunning = false
|
||||
self.timerCancellable?.cancel()
|
||||
self.timerCancellable = nil
|
||||
self.timer = nil
|
||||
self.timeElapsed = 0
|
||||
self.startDate = nil
|
||||
self.pauseDate = nil
|
||||
self.duration.fromSeconds(Int(timeTotal))
|
||||
}
|
||||
}
|
||||
|
||||
extension RecipeTimer {
|
||||
func setupAudioSession() {
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
} catch {
|
||||
print("Failed to set audio session category. Error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func prepareAudioPlayer(with soundName: String) {
|
||||
if let soundURL = Bundle.main.url(forResource: "alarm_sound_0", withExtension: "mp3") {
|
||||
do {
|
||||
audioPlayer = try AVAudioPlayer(contentsOf: soundURL)
|
||||
audioPlayer?.prepareToPlay()
|
||||
audioPlayer?.numberOfLoops = -1 // Loop indefinitely
|
||||
} catch {
|
||||
print("Error loading sound file: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func postNotification() {
|
||||
NotificationCenter.default.post(name: Notification.Name("AlarmNotification"), object: nil)
|
||||
}
|
||||
|
||||
func startAlarm() {
|
||||
audioPlayer?.play()
|
||||
postNotification()
|
||||
}
|
||||
|
||||
func stopAlarm() {
|
||||
audioPlayer?.stop()
|
||||
try? AVAudioSession.sharedInstance().setActive(false)
|
||||
}
|
||||
|
||||
func requestNotificationPermissions() {
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||
if granted {
|
||||
print("Notification permission granted.")
|
||||
} else if let error = error {
|
||||
print("Notification permission denied because: \(error.localizedDescription).")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func scheduleTimerNotification(timeInterval: TimeInterval) {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "Timer Finished"
|
||||
content.body = "Your timer is up!"
|
||||
content.sound = UNNotificationSound.default
|
||||
|
||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: timeInterval, repeats: false)
|
||||
|
||||
let request = UNNotificationRequest(identifier: "timerNotification", content: content, trigger: trigger)
|
||||
|
||||
UNUserNotificationCenter.current().add(request) { error in
|
||||
if let error = error {
|
||||
print("Error scheduling notification: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user