Release Candidate Version 1.6

This commit is contained in:
Vicnet
2023-12-15 13:43:56 +01:00
parent 222685e05d
commit bb68b29bdf
16 changed files with 1020 additions and 275 deletions

View File

@@ -0,0 +1,50 @@
//
// CollapsibleView.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 15.12.23.
//
import Foundation
import SwiftUI
struct CollapsibleView<C: View, T: View>: View {
@State var titleColor: Color = .white
@State var isCollapsed: Bool = true
@State var content: () -> C
@State var title: () -> T
@State private var rotationAngle: Double = -90
var body: some View {
VStack(alignment: .leading) {
Button {
withAnimation(.easeInOut(duration: 0.2)) {
isCollapsed.toggle()
if isCollapsed {
rotationAngle += 90
} else {
rotationAngle -= 90
}
}
rotationAngle = isCollapsed ? -90 : 0
} label: {
HStack {
Image(systemName: "chevron.down")
.bold()
.rotationEffect(Angle(degrees: rotationAngle))
title()
}.foregroundStyle(titleColor)
}
if !isCollapsed {
content()
.padding(.top)
}
}
.onAppear {
rotationAngle = isCollapsed ? -90 : 0
}
}
}

View File

@@ -30,10 +30,9 @@ struct MainView: View {
RecipeSearchView(viewModel: viewModel)
} label: {
HStack(alignment: .center) {
Image(systemName: "book.closed.fill")
Text("All")
.font(.system(size: 20, weight: .light, design: .serif))
.italic()
Image(systemName: "magnifyingglass")
Text("Search")
.font(.system(size: 20, weight: .medium, design: .default))
}
.padding(7)
}
@@ -43,10 +42,23 @@ struct MainView: View {
if category.recipe_count != 0 {
NavigationLink(value: category) {
HStack(alignment: .center) {
Image(systemName: "book.closed.fill")
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: .light, design: .serif))
.italic()
.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)
}
}

View File

@@ -13,10 +13,7 @@ struct TokenLoginView: View {
@Binding var alertMessage: String
@FocusState private var focusedField: Field?
@AppStorage("serverAddress") var serverAddress = ""
@AppStorage("username") var userName = ""
@AppStorage("token") var token = ""
@AppStorage("onboarding") var onboarding = false
@State var userSettings = UserSettings.shared
// TextField handling
enum Field {
@@ -28,14 +25,14 @@ struct TokenLoginView: View {
var body: some View {
VStack(alignment: .leading) {
LoginLabel(text: "Server address")
LoginTextField(example: "e.g.: example.com", text: $serverAddress)
LoginTextField(example: "e.g.: example.com", text: $userSettings.serverAddress)
.focused($focusedField, equals: .server)
.textContentType(.URL)
.submitLabel(.next)
.padding(.bottom)
LoginLabel(text: "User name")
LoginTextField(example: "username", text: $userName)
LoginTextField(example: "username", text: $userSettings.username)
.focused($focusedField, equals: .username)
.textContentType(.username)
.submitLabel(.next)
@@ -43,7 +40,7 @@ struct TokenLoginView: View {
LoginLabel(text: "App Token")
LoginTextField(example: "can be generated in security settings of your nextcloud", text: $token)
LoginTextField(example: "can be generated in security settings of your nextcloud", text: $userSettings.token)
.focused($focusedField, equals: .token)
.textContentType(.password)
.submitLabel(.join)
@@ -52,7 +49,7 @@ struct TokenLoginView: View {
Button {
Task {
if await loginCheck(nextcloudLogin: false) {
onboarding = false
userSettings.onboarding = false
}
}
} label: {
@@ -83,11 +80,11 @@ struct TokenLoginView: View {
}
func loginCheck(nextcloudLogin: Bool) async -> Bool {
if serverAddress == "" {
if userSettings.serverAddress == "" {
alertMessage = "Please enter a server address!"
showAlert = true
return false
} else if !nextcloudLogin && (userName == "" || token == "") {
} else if !nextcloudLogin && (userSettings.username == "" || userSettings.token == "") {
alertMessage = "Please enter a user name and app token!"
showAlert = true
return false
@@ -104,14 +101,18 @@ struct TokenLoginView: View {
var (data, error): (Data?, Error?) = (nil, nil)
do {
let loginString = "\(userName):\(token)"
let loginString = "\(userSettings.username):\(userSettings.token)"
let loginData = loginString.data(using: String.Encoding.utf8)!
let authString = loginData.base64EncodedString()
DispatchQueue.main.async {
userSettings.authString = authString
}
(data, error) = try await NetworkHandler.sendHTTPRequest(
request,
hostPath: "https://\(serverAddress)/index.php/apps/cookbook/api/v1/",
hostPath: "https://\(userSettings.serverAddress)/index.php/apps/cookbook/api/v1/",
authString: authString
)
} catch {
print("Error: ", error)
}

View File

@@ -28,42 +28,7 @@ enum V2LoginStage: LoginStage {
}
}
struct CollapsibleView<T: View>: View {
@State var titleColor: Color = .white
@State var content: () -> T
@State var title: () -> Text
@State var isCollapsed: Bool = true
@State var rotationAngle: Double = -90
var body: some View {
VStack(alignment: .leading) {
Button {
withAnimation(.easeInOut(duration: 0.2)) {
isCollapsed.toggle()
if isCollapsed {
rotationAngle += 90
} else {
rotationAngle -= 90
}
}
rotationAngle = isCollapsed ? -90 : 0
} label: {
HStack {
Image(systemName: "chevron.down")
.bold()
.rotationEffect(Angle(degrees: rotationAngle))
title()
}.foregroundStyle(titleColor)
}
if !isCollapsed {
content()
.padding(.top, 1)
}
}
}
}
struct V2LoginView: View {
@Binding var showAlert: Bool
@@ -73,10 +38,7 @@ struct V2LoginView: View {
@State var loginRequest: LoginV2Request? = nil
@FocusState private var focusedField: Field?
@AppStorage("serverAddress") var serverAddress = ""
@AppStorage("username") var userName = ""
@AppStorage("token") var token = ""
@AppStorage("onboarding") var onboarding = true
@State var userSettings = UserSettings.shared
// TextField handling
enum Field {
@@ -90,7 +52,7 @@ struct V2LoginView: View {
VStack(alignment: .leading) {
LoginLabel(text: "Server address")
.padding()
LoginTextField(example: "e.g.: example.com", text: $serverAddress, color: loginStage == .serverAddress ? .white : .secondary)
LoginTextField(example: "e.g.: example.com", text: $userSettings.serverAddress, color: loginStage == .serverAddress ? .white : .secondary)
.focused($focusedField, equals: .server)
.textContentType(.URL)
.submitLabel(.done)
@@ -124,7 +86,7 @@ struct V2LoginView: View {
HStack {
if loginStage == .login || loginStage == .validate {
Button {
if serverAddress == "" {
if userSettings.serverAddress == "" {
alertMessage = "Please enter a valid server address."
showAlert = true
return
@@ -164,9 +126,14 @@ struct V2LoginView: View {
return
}
print("Login successfull for user \(res.loginName)!")
self.userName = res.loginName
self.token = res.appPassword
self.onboarding = false
self.userSettings.username = res.loginName
self.userSettings.token = res.appPassword
let loginString = "\(userSettings.username):\(userSettings.token)"
let loginData = loginString.data(using: String.Encoding.utf8)!
DispatchQueue.main.async {
userSettings.authString = loginData.base64EncodedString()
}
self.userSettings.onboarding = false
}
} label: {
Text("Validate")
@@ -188,7 +155,7 @@ struct V2LoginView: View {
}
func sendLoginV2Request() async {
let hostPath = "https://\(serverAddress)"
let hostPath = "https://\(userSettings.serverAddress)"
let headerFields: [HeaderField] = [
//HeaderField.ocsRequest(value: true),
//HeaderField.accept(value: .JSON)

View File

@@ -21,21 +21,20 @@ struct RecipeCardView: View {
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 80)
.clipped()
.clipShape(RoundedRectangle(cornerRadius: 17))
} else {
ZStack {
Image(systemName: "square.text.square")
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundStyle(Color.white)
.padding(10)
}
.background(Color("ncblue"))
.frame(width: 80, height: 80)
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 {

View File

@@ -66,23 +66,16 @@ struct RecipeDetailView: View {
if(!recipeDetail.recipeIngredient.isEmpty) {
RecipeIngredientSection(recipeDetail: recipeDetail)
}
if(!recipeDetail.tool.isEmpty) {
RecipeListSection(title: "Tools", list: recipeDetail.tool)
}
if(!recipeDetail.recipeInstructions.isEmpty) {
RecipeInstructionSection(recipeDetail: recipeDetail)
}
RecipeNutritionSection(recipeDetail: recipeDetail, presentNutritionPopover: $presentNutritionPopover)
RecipeKeywordSection(recipeDetail: recipeDetail, presentKeywordPopover: $presentKeywordPopover)
if(!recipeDetail.tool.isEmpty) {
RecipeToolSection(recipeDetail: recipeDetail)
}
RecipeNutritionSection(recipeDetail: recipeDetail)
RecipeKeywordSection(recipeDetail: recipeDetail)
MoreInformationSection(recipeDetail: recipeDetail)
}
VStack(alignment: .leading) {
Text("Created: \(Date.convertISOStringToLocalString(isoDateString: recipeDetail.dateCreated) ?? "")")
Text("Last modified: \(Date.convertISOStringToLocalString(isoDateString: recipeDetail.dateModified) ?? "")")
}
.font(.caption)
.foregroundStyle(Color.secondary)
.padding()
}.padding(.horizontal, 5)
}
@@ -151,7 +144,10 @@ fileprivate struct RecipeDurationSection: View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), alignment: .leading)]) {
if let prepTime = recipeDetail.prepTime, let time = DurationComponents.ptToText(prepTime) {
VStack(alignment: .leading) {
SecondaryLabel(text: LocalizedStringKey("Preparation"))
HStack {
SecondaryLabel(text: LocalizedStringKey("Preparation"))
Spacer()
}
Text(time)
.lineLimit(1)
}.padding()
@@ -159,7 +155,10 @@ fileprivate struct RecipeDurationSection: View {
if let cookTime = recipeDetail.cookTime, let time = DurationComponents.ptToText(cookTime) {
VStack(alignment: .leading) {
SecondaryLabel(text: LocalizedStringKey("Cooking"))
HStack {
SecondaryLabel(text: LocalizedStringKey("Cooking"))
Spacer()
}
Text(time)
.lineLimit(1)
}.padding()
@@ -167,7 +166,10 @@ fileprivate struct RecipeDurationSection: View {
if let totalTime = recipeDetail.totalTime, let time = DurationComponents.ptToText(totalTime) {
VStack(alignment: .leading) {
SecondaryLabel(text: LocalizedStringKey("Total time"))
HStack {
SecondaryLabel(text: LocalizedStringKey("Total time"))
Spacer()
}
Text(time)
.lineLimit(1)
}.padding()
@@ -180,76 +182,53 @@ fileprivate struct RecipeDurationSection: View {
fileprivate struct RecipeNutritionSection: View {
@State var recipeDetail: RecipeDetail
@Binding var presentNutritionPopover: Bool
var body: some View {
Button {
presentNutritionPopover.toggle()
} label: {
HStack {
SecondaryLabel(text: "Nutrition")
Image(systemName: "chevron.right")
.foregroundStyle(Color.secondary)
.bold()
Spacer()
}.padding()
}
.buttonStyle(.plain)
.popover(isPresented: $presentNutritionPopover) {
if let nutritionList = recipeDetail.getNutritionList() {
ScrollView(showsIndicators: false) {
if let servingSize = recipeDetail.nutrition["servingSize"] {
RecipeListSection(title: "Nutrition (\(servingSize))", list: nutritionList)
.presentationCompactAdaptation(.popover)
HStack() {
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandNutritionSection) {
Group {
if let nutritionList = recipeDetail.getNutritionList() {
RecipeListSection(list: nutritionList)
} else {
RecipeListSection(title: "Nutrition", list: nutritionList)
.presentationCompactAdaptation(.popover)
Text(LocalizedStringKey("No nutritional information."))
}
}
} else {
Text(LocalizedStringKey("No nutritional information."))
.foregroundStyle(Color.secondary)
.bold()
.padding()
.presentationCompactAdaptation(.popover)
} 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
@Binding var presentKeywordPopover: Bool
var body: some View {
Button {
presentKeywordPopover.toggle()
} label: {
HStack {
SecondaryLabel(text: "Keywords")
Image(systemName: "chevron.right")
.foregroundStyle(Color.secondary)
.bold()
Spacer()
}.padding()
}
.buttonStyle(.plain)
.popover(isPresented: $presentKeywordPopover) {
if let keywords = getKeywords() {
ScrollView(showsIndicators: false) {
RecipeListSection(title: "Keywords", list: keywords)
.presentationCompactAdaptation(.popover)
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandKeywordSection) {
Group {
if let keywords = getKeywords() {
RecipeListSection(list: keywords)
} else {
Text(LocalizedStringKey("No keywords."))
}
} else {
Text(LocalizedStringKey("No keywords."))
.foregroundStyle(Color.secondary)
.bold()
.padding()
.presentationCompactAdaptation(.popover)
}
} title: {
HStack {
SecondaryLabel(text: LocalizedStringKey("Keywords"))
Spacer()
}
}
.padding()
}
func getKeywords() -> [String]? {
@@ -259,6 +238,35 @@ fileprivate struct RecipeKeywordSection: View {
}
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 {
@State var recipeDetail: RecipeDetail
@@ -283,6 +291,20 @@ fileprivate struct RecipeIngredientSection: View {
}
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 {
@State var ingredient: String
@@ -311,15 +333,10 @@ fileprivate struct IngredientListItem: View {
fileprivate struct RecipeListSection: View {
@State var title: LocalizedStringKey
@State var list: [String]
var body: some View {
VStack(alignment: .leading) {
HStack {
SecondaryLabel(text: title)
Spacer()
}
ForEach(list, id: \.self) { item in
HStack(alignment: .top) {
Text("\u{2022}")
@@ -328,12 +345,11 @@ fileprivate struct RecipeListSection: View {
}
.padding(4)
}
}.padding()
}
}
}
fileprivate struct RecipeInstructionSection: View {
@State var recipeDetail: RecipeDetail
var body: some View {

View File

@@ -32,6 +32,22 @@ struct SettingsView: View {
Text("The selected cookbook will open on app launch by default.")
}
Section {
Toggle(isOn: $userSettings.expandNutritionSection) {
Text("Expand nutrition section")
}
Toggle(isOn: $userSettings.expandKeywordSection) {
Text("Expand keyword section")
}
Toggle(isOn: $userSettings.expandInfoSection) {
Text("Expand information section")
}
} header: {
Text("Recipes")
} footer: {
Text("Configure which sections in your recipes are expanded by default.")
}
Section {
Toggle(isOn: $userSettings.storeRecipes) {
Text("Offline recipes")
@@ -108,6 +124,12 @@ struct SettingsView: View {
} message: {
Text(alertType.getMessage())
}
.onDisappear {
Task {
userSettings.lastUpdate = .distantPast
await viewModel.updateAllRecipeDetails()
}
}
}