Basic Edit View and components

This commit is contained in:
Vicnet
2023-09-30 10:07:27 +02:00
parent d4db6fbd82
commit ee1c0d9aed
21 changed files with 485 additions and 206 deletions

View File

@@ -29,7 +29,10 @@
A70171CB2AB4CD1700064C43 /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CA2AB4CD1700064C43 /* UserDefaults.swift */; };
A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CC2AB501B100064C43 /* SettingsView.swift */; };
A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */; };
A703226D2ABAF90D00D7C4ED /* APIInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = A703226C2ABAF90D00D7C4ED /* APIInterface.swift */; };
A703226D2ABAF90D00D7C4ED /* APIController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A703226C2ABAF90D00D7C4ED /* APIController.swift */; };
A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */; };
A70D7CA12AC73CA800D53DBF /* RecipeEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70D7CA02AC73CA700D53DBF /* RecipeEditView.swift */; };
A70D7CA32AC74B3B00D53DBF /* DateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70D7CA22AC74B3B00D53DBF /* DateExtension.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -76,7 +79,10 @@
A70171CA2AB4CD1700064C43 /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = "<group>"; };
A70171CC2AB501B100064C43 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCoderExtension.swift; sourceTree = "<group>"; };
A703226C2ABAF90D00D7C4ED /* APIInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIInterface.swift; sourceTree = "<group>"; };
A703226C2ABAF90D00D7C4ED /* APIController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIController.swift; sourceTree = "<group>"; };
A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = "<group>"; };
A70D7CA02AC73CA700D53DBF /* RecipeEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeEditView.swift; sourceTree = "<group>"; };
A70D7CA22AC74B3B00D53DBF /* DateExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtension.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -168,7 +174,7 @@
A70171B22AB211F000064C43 /* Network */ = {
isa = PBXGroup;
children = (
A703226C2ABAF90D00D7C4ED /* APIInterface.swift */,
A703226C2ABAF90D00D7C4ED /* APIController.swift */,
A70171B32AB2122900064C43 /* NetworkRequests.swift */,
A70171AE2AB2116B00064C43 /* NetworkHandler.swift */,
A70171B02AB211DF00064C43 /* CustomError.swift */,
@@ -192,6 +198,7 @@
A70171BD2AB4987900064C43 /* CategoryDetailView.swift */,
A70171C12AB498C600064C43 /* RecipeCardView.swift */,
A70171BF2AB498A900064C43 /* RecipeDetailView.swift */,
A70D7CA02AC73CA700D53DBF /* RecipeEditView.swift */,
A70171C82AB4CBB400064C43 /* OnboardingView.swift */,
A70171CC2AB501B100064C43 /* SettingsView.swift */,
);
@@ -212,7 +219,9 @@
isa = PBXGroup;
children = (
A70171B82AB399FB00064C43 /* DateFormatterExtension.swift */,
A70D7CA22AC74B3B00D53DBF /* DateExtension.swift */,
A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */,
A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */,
);
path = Extensions;
sourceTree = "<group>";
@@ -347,6 +356,8 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A70D7CA12AC73CA800D53DBF /* RecipeEditView.swift in Sources */,
A70D7CA32AC74B3B00D53DBF /* DateExtension.swift in Sources */,
A70171B12AB211DF00064C43 /* CustomError.swift in Sources */,
A70171C42AB4A31200064C43 /* DataStore.swift in Sources */,
A70171AF2AB2116B00064C43 /* NetworkHandler.swift in Sources */,
@@ -361,10 +372,11 @@
A70171842AA8E71900064C43 /* MainView.swift in Sources */,
A70171CB2AB4CD1700064C43 /* UserDefaults.swift in Sources */,
A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */,
A703226D2ABAF90D00D7C4ED /* APIInterface.swift in Sources */,
A703226D2ABAF90D00D7C4ED /* APIController.swift in Sources */,
A70171822AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift in Sources */,
A70171AD2AA8EF4700064C43 /* MainViewModel.swift in Sources */,
A70171C92AB4CBB400064C43 /* OnboardingView.swift in Sources */,
A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -516,6 +528,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_Client.entitlements";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Nextcloud Cookbook iOS Client/Preview Content\"";
@@ -539,7 +552,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
@@ -556,6 +569,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_Client.entitlements";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Nextcloud Cookbook iOS Client/Preview Content\"";
@@ -579,7 +593,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;

View File

@@ -1,11 +0,0 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -1,21 +0,0 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "logo-white.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -24,22 +24,60 @@ struct Recipe: Codable {
}
struct RecipeDetail: Codable {
let name: String
let keywords: String
let dateCreated: String
let dateModified: String
let imageUrl: String
let id: String
let prepTime: String?
let cookTime: String?
let totalTime: String?
let description: String
let url: String
let recipeYield: Int
let recipeCategory: String
let tool: [String]
let recipeIngredient: [String]
let recipeInstructions: [String]
var name: String
var keywords: String
var dateCreated: String
var dateModified: String
var imageUrl: String
var id: String
var prepTime: String?
var cookTime: String?
var totalTime: String?
var description: String
var url: String
var recipeYield: Int
var recipeCategory: String
var tool: [String]
var recipeIngredient: [String]
var recipeInstructions: [String]
init(name: String, keywords: String, dateCreated: String, dateModified: String, imageUrl: String, id: String, prepTime: String? = nil, cookTime: String? = nil, totalTime: String? = nil, description: String, url: String, recipeYield: Int, recipeCategory: String, tool: [String], recipeIngredient: [String], recipeInstructions: [String]) {
self.name = name
self.keywords = keywords
self.dateCreated = dateCreated
self.dateModified = dateModified
self.imageUrl = imageUrl
self.id = id
self.prepTime = prepTime
self.cookTime = cookTime
self.totalTime = totalTime
self.description = description
self.url = url
self.recipeYield = recipeYield
self.recipeCategory = recipeCategory
self.tool = tool
self.recipeIngredient = recipeIngredient
self.recipeInstructions = recipeInstructions
}
init() {
name = ""
keywords = ""
dateCreated = ""
dateModified = ""
imageUrl = ""
id = ""
prepTime = ""
cookTime = ""
totalTime = ""
description = ""
url = ""
recipeYield = 0
recipeCategory = ""
tool = []
recipeIngredient = []
recipeInstructions = []
}
static func error() -> RecipeDetail {
return RecipeDetail(
@@ -69,6 +107,11 @@ struct RecipeImage {
var full: UIImage?
}
// Login flow
struct LoginV2Request: Codable {
let poll: LoginV2Poll
let login: String
@@ -84,3 +127,16 @@ struct LoginV2Response: Codable {
let loginName: String
let appPassword: String
}
struct LoginValidation: Codable {
let ocs: Ocs
}
struct Ocs: Codable {
let meta: MetaData
}
struct MetaData: Codable {
let status: String
let statuscode: Int
}

View File

@@ -0,0 +1,18 @@
//
// ColorExtension.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 20.09.23.
//
import Foundation
import SwiftUI
extension Color {
public static var nextcloudBlue: Color {
return Color("ncblue")
}
public static var backgroundHighlight: Color {
return Color("backgroundHighlight")
}
}

View File

@@ -0,0 +1,21 @@
//
// DateExtension.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 29.09.23.
//
import Foundation
extension Date {
static var zero: Date {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "HH:mm"
if let date = dateFormatter.date(from:"00:00") {
return date
} else {
return Date()
}
}
}

View File

@@ -1,5 +1,5 @@
//
// APIInterface.swift
// APIController.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 20.09.23.
@@ -7,7 +7,7 @@
import Foundation
class APIInterface {
class APIController {
var userSettings: UserSettings
var apiPath: String
@@ -15,7 +15,7 @@ class APIInterface {
let apiVersion = "1"
init(userSettings: UserSettings) {
print("Initializing NetworkController.")
print("Initializing APIController.")
self.userSettings = userSettings
self.apiPath = "https://\(userSettings.serverAddress)/index.php/apps/cookbook/api/v\(apiVersion)/"
@@ -24,7 +24,11 @@ class APIInterface {
let loginData = loginString.data(using: String.Encoding.utf8)!
self.authString = loginData.base64EncodedString()
}
}
extension APIController {
func imageDataFromServer(recipeId: Int, thumb: Bool) async -> Data? {
do {
let request = RequestWrapper.imageRequest(path: .IMAGE(recipeId: recipeId, thumb: thumb))
@@ -43,9 +47,7 @@ class APIInterface {
}
return nil
}
}
extension APIInterface {
func sendDataRequest<D: Decodable>(_ request: RequestWrapper) async -> (D?, Error?) {
do {
let (data, error) = try await NetworkHandler.sendHTTPRequest(

View File

@@ -49,7 +49,7 @@ struct NetworkHandler {
request.httpBody = body
}
print("Request:\nMethod: \(request.httpMethod)\nHeaders: \(request.allHTTPHeaderFields)\nBody: \(request.httpBody)")
print("Request:\nMethod: \(request.httpMethod)\nPath: \(request.url?.absoluteString)\nHeaders: \(request.allHTTPHeaderFields)\nBody: \(request.httpBody)")
// Wait for and return data and (decoded) response
var data: Data? = nil
@@ -57,6 +57,7 @@ struct NetworkHandler {
do {
(data, response) = try await URLSession.shared.data(for: request)
print("Response: ", response)
print("Data: ", data?.description, data, String(data: data ?? Data(), encoding: .utf8))
return (data, nil)
} catch {
return (nil, decodeURLResponse(response: response as? HTTPURLResponse))

View File

@@ -2,9 +2,11 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

View File

@@ -17,7 +17,10 @@ struct Nextcloud_Cookbook_iOS_ClientApp: App {
if userSettings.onboarding {
OnboardingView(userSettings: userSettings)
} else {
MainView(userSettings: userSettings, viewModel: mainViewModel)
MainView(viewModel: mainViewModel, userSettings: userSettings)
.onAppear {
mainViewModel.apiInterface = APIController(userSettings: userSettings)
}
}
}.transition(.slide)
}

View File

@@ -7,6 +7,7 @@
import Foundation
import UIKit
import SwiftUI
@MainActor class MainViewModel: ObservableObject {
@Published var categories: [Category] = []
@@ -15,7 +16,7 @@ import UIKit
private var imageCache: [Int: RecipeImage] = [:]
let dataStore: DataStore
var apiInterface: APIInterface? = nil
var apiInterface: APIController? = nil
/// The path of an image in storage
private var localImagePath: (Int, Bool) -> (String) = { recipeId, thumb in

View File

@@ -23,7 +23,7 @@ struct CategoryCardView: View {
.ultraThickMaterial
)
.overlay(
Text(category.name)
Text(category.name == "*" ? "Other" : category.name)
.font(.headline)
)
.frame(maxHeight: 25)

View File

@@ -20,14 +20,14 @@ struct RecipeBookView: View {
if let recipes = viewModel.recipes[categoryName] {
ForEach(recipes, id: \.recipe_id) { recipe in
NavigationLink(destination: RecipeDetailView(viewModel: viewModel, recipe: recipe)) {
RecipeCardView(viewModel: viewModel, recipe: recipe, isDownloaded: viewModel.recipeDetailExists(recipeId: recipe.recipe_id))
RecipeCardView(viewModel: viewModel, recipe: recipe)
}
.buttonStyle(.plain)
}
}
}
}
.navigationTitle(categoryName)
.navigationTitle(categoryName == "*" ? "Other" : categoryName)
.toolbar {
Menu {
Button {

View File

@@ -10,33 +10,30 @@ import SwiftUI
struct MainView: View {
@ObservedObject var viewModel: MainViewModel
@ObservedObject var userSettings: UserSettings
var columns: [GridItem] = [GridItem(.adaptive(minimum: 150), spacing: 0)]
init(userSettings: UserSettings, viewModel: MainViewModel) {
self.userSettings = userSettings
self.viewModel = viewModel
self.viewModel.apiInterface = APIInterface(userSettings: userSettings)
}
@State var showEditView: Bool = false
var columns: [GridItem] = [GridItem(.adaptive(minimum: 150), spacing: 0)]
var body: some View {
NavigationView {
ScrollView(.vertical, showsIndicators: false) {
LazyVGrid(columns: columns, spacing: 0) {
ForEach(viewModel.categories, id: \.name) { category in
NavigationLink(
destination: RecipeBookView(
categoryName: category.name,
viewModel: viewModel)
) {
CategoryCardView(category: category)
if category.recipe_count != 0 {
NavigationLink(
destination: RecipeBookView(
categoryName: category.name,
viewModel: viewModel
)
) {
CategoryCardView(category: category)
}
.buttonStyle(.plain)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
}
.navigationTitle("CookBook")
.navigationTitle("Cookbooks")
.toolbar {
Menu {
Button {
@@ -51,14 +48,27 @@ struct MainView: View {
}
}
Button {
showEditView = true
} label: {
HStack {
Text("Create new recipe")
Image(systemName: "plus.circle")
}
}
} label: {
Image(systemName: "ellipsis.circle")
}
NavigationLink( destination: SettingsView(userSettings: userSettings, viewModel: viewModel)) {
Image(systemName: "gearshape")
}
}
.background(
NavigationLink(destination: RecipeEditView(), isActive: $showEditView) { EmptyView() }
)
}
.tint(.nextcloudBlue)
.task {
await viewModel.loadCategoryList()
}

View File

@@ -19,7 +19,7 @@ struct OnboardingView: View {
}
.tabViewStyle(.page)
.background(
selectedTab == 1 ? Color("ncblue").ignoresSafeArea() : Color(uiColor: .systemBackground).ignoresSafeArea()
selectedTab == 1 ? Color.nextcloudBlue.ignoresSafeArea() : Color(uiColor: .systemBackground).ignoresSafeArea()
)
.animation(.easeInOut, value: selectedTab)
}
@@ -33,16 +33,13 @@ struct WelcomeTab: View {
.resizable()
.frame(width: 120, height: 120)
.clipShape(RoundedRectangle(cornerRadius: 10))
Text("Tank you for downloading")
Text("Tank you for downloading the")
.font(.headline)
Text("Nextcloud")
.font(.largeTitle)
.bold()
Text("Cookbook")
Text("Cookbook Client")
.font(.largeTitle)
.bold()
Spacer()
Text("This application is an open source effort and still in development. If you encounter any problems, please report them on our GitHub page.\n\nCurrently, only app token login is supported. You can create an app token in the nextcloud security settings.")
Text("This application is an open source effort and still in development. If you encounter any problems, please report them on our GitHub page.")
.padding()
Spacer()
}
@@ -54,12 +51,18 @@ struct WelcomeTab: View {
struct LoginTab: View {
@ObservedObject var userSettings: UserSettings
// Login flow
enum LoginMethod {
case v2, token
}
@State var selectedLoginMethod: LoginMethod = .v2
@State var loginRequest: LoginV2Request? = nil
// Login error alert
@State var showAlert: Bool = false
@State var alertMessage: String = "Error: Could not connect to server."
// TextField handling
enum Field {
case server
case username
@@ -70,15 +73,7 @@ struct LoginTab: View {
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading) {
HStack {
Spacer()
Image("nc-logo-white")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxHeight: 150)
.padding()
Spacer()
}
Spacer()
Picker("Login Method", selection: $selectedLoginMethod) {
Text("Nextcloud Login").tag(LoginMethod.v2)
Text("App Token Login").tag(LoginMethod.token)
@@ -109,7 +104,11 @@ struct LoginTab: View {
HStack{
Spacer()
Button {
userSettings.onboarding = false
Task {
if await loginCheck(nextcloudLogin: false) {
userSettings.onboarding = false
}
}
} label: {
Text("Submit")
.foregroundColor(.white)
@@ -138,20 +137,43 @@ struct LoginTab: View {
await sendLoginV2Request()
if let loginRequest = loginRequest {
await UIApplication.shared.open(URL(string: loginRequest.login)!)
} else {
alertMessage = "Unable to reach server. Please check your server address and internet connection."
showAlert = true
}
}
}
Text("Submitting will open a web browser. Please follow the login instructions provided there.\nAfter a successfull login, return to this application and press 'Validate'.")
Text("Entering the server address will open a web browser. Please follow the login instructions provided there. If the browser does not open, click the link 'Open in browser'\nAfter a successfull login, return to this application and press 'Validate'.")
.font(.subheadline)
.padding(.bottom)
.tint(.white)
.foregroundStyle(.white)
Button {
Task {
await sendLoginV2Request()
if let loginRequest = loginRequest {
await UIApplication.shared.open(URL(string: loginRequest.login)!)
} else {
alertMessage = "Unable to reach server. Please check your server address and internet connection."
showAlert = true
}
}
} label: {
Text("Open in browser")
.foregroundColor(.white)
.font(.headline)
}
HStack{
Spacer()
Button {
// fetch login v2 response
Task {
guard let res = await fetchLoginV2Response() else { return }
guard let res = await fetchLoginV2Response() else {
alertMessage = "Login failed. Please login via the browser and try again."
showAlert = true
return
}
print("Login successfull for user \(res.loginName)!")
userSettings.username = res.loginName
userSettings.token = res.appPassword
@@ -187,6 +209,9 @@ struct LoginTab: View {
}
.fontDesign(.rounded)
.padding()
.alert(alertMessage, isPresented: $showAlert) {
Button("Ok", role: .cancel) { }
}
}
}
@@ -250,6 +275,53 @@ struct LoginTab: View {
print("Could not decode.")
return nil
}
func loginCheck(nextcloudLogin: Bool) async -> Bool {
if userSettings.serverAddress == "" {
alertMessage = "Please enter a server address!"
showAlert = true
return false
} else if !nextcloudLogin && (userSettings.username == "" || userSettings.token == "") {
alertMessage = "Please enter a user name and app token!"
showAlert = true
return false
}
let headerFields = [
HeaderField.ocsRequest(value: true),
]
let request = RequestWrapper.customRequest(
method: .GET,
path: .CATEGORIES,
headerFields: headerFields,
authenticate: true
)
var (data, error): (Data?, Error?) = (nil, nil)
do {
let loginString = "\(userSettings.username):\(userSettings.token)"
let loginData = loginString.data(using: String.Encoding.utf8)!
let authString = loginData.base64EncodedString()
(data, error) = try await NetworkHandler.sendHTTPRequest(
request,
hostPath: "https://\(userSettings.serverAddress)/index.php/apps/cookbook/api/v1/",
authString: authString
)
} catch {
print("Error: ", error)
}
guard let data = data else {
alertMessage = "Login failed. Please check your inputs."
showAlert = true
return false
}
if let testRequest: [Category] = JSONDecoder.safeDecode(data) {
print("validationResponse: \(testRequest)")
return true
}
alertMessage = "Login failed. Please check your inputs and internet connection."
showAlert = true
return false
}
}
struct LoginLabel: View {

View File

@@ -12,7 +12,7 @@ struct RecipeCardView: View {
@State var viewModel: MainViewModel
@State var recipe: Recipe
@State var recipeThumb: UIImage?
@State var isDownloaded: Bool
@State var isDownloaded: Bool? = nil
var body: some View {
HStack {
@@ -25,18 +25,21 @@ struct RecipeCardView: View {
.font(.headline)
Spacer()
VStack {
Image(systemName: isDownloaded ? "checkmark.icloud" : "icloud.and.arrow.down")
.foregroundColor(.secondary)
.padding()
Spacer()
if let isDownloaded = isDownloaded {
VStack {
Image(systemName: isDownloaded ? "checkmark.circle" : "icloud.and.arrow.down")
.foregroundColor(.secondary)
.padding()
Spacer()
}
}
}
.background(.ultraThickMaterial)
.background(Color.backgroundHighlight)
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding(.horizontal)
.task {
recipeThumb = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: true)
self.isDownloaded = viewModel.recipeDetailExists(recipeId: recipe.recipe_id)
}
.refreshable {
recipeThumb = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: true, needsUpdate: true)

View File

@@ -15,35 +15,41 @@ struct RecipeDetailView: View {
@State var recipeDetail: RecipeDetail?
@State var recipeImage: UIImage?
@State var showTitle: Bool = false
@State var isDownloaded: Bool? = nil
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading) {
if let recipeImage = recipeImage {
Image(uiImage: recipeImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 300)
.scaledToFill()
.frame(maxHeight: 300)
.clipped()
} else {
Color("ncblue")
.frame(height: 150)
}
if let recipeDetail = recipeDetail {
LazyVStack (alignment: .leading) {
Divider()
Text(recipeDetail.name)
.font(.title)
.bold()
.padding()
.onDisappear {
showTitle = true
}
.onAppear {
showTitle = false
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()
}
}
Divider()
RecipeYieldSection(recipeDetail: recipeDetail)
RecipeDurationSection(recipeDetail: recipeDetail)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
if(!recipeDetail.recipeIngredient.isEmpty) {
@@ -59,13 +65,14 @@ struct RecipeDetailView: View {
}.padding(.horizontal, 5)
}
}
}.animation(.easeInOut, value: recipeImage)
}
.navigationBarTitleDisplayMode(.inline)
.navigationTitle(showTitle ? recipe.name : "")
.task {
recipeDetail = await viewModel.loadRecipeDetail(recipeId: recipe.recipe_id)
recipeImage = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: false)
self.isDownloaded = viewModel.recipeDetailExists(recipeId: recipe.recipe_id)
}
.refreshable {
recipeDetail = await viewModel.loadRecipeDetail(recipeId: recipe.recipe_id, needsUpdate: true)
@@ -75,31 +82,18 @@ struct RecipeDetailView: View {
}
}
struct RecipeYieldSection: View {
@State var recipeDetail: RecipeDetail
var body: some View {
HStack {
Text("Servings: \(recipeDetail.recipeYield)")
Spacer()
}.padding()
}
}
struct RecipeDurationSection: View {
@State var recipeDetail: RecipeDetail
var body: some View {
HStack {
HStack(alignment: .center) {
if let prepTime = recipeDetail.prepTime {
VStack {
SecondaryLabel(text: "Prep time")
Text(formatDate(duration: prepTime))
.lineLimit(1)
}.padding()
.frame(maxWidth: .infinity)
.background(Color("accent"))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
if let cookTime = recipeDetail.cookTime {
@@ -108,9 +102,6 @@ struct RecipeDurationSection: View {
Text(formatDate(duration: cookTime))
.lineLimit(1)
}.padding()
.frame(maxWidth: .infinity)
.background(Color("accent"))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
if let totalTime = recipeDetail.totalTime {
@@ -119,9 +110,6 @@ struct RecipeDurationSection: View {
Text(formatDate(duration: totalTime))
.lineLimit(1)
}.padding()
.frame(maxWidth: .infinity)
.background(Color("accent"))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
}
@@ -134,7 +122,13 @@ struct RecipeIngredientSection: View {
VStack(alignment: .leading) {
Divider()
HStack {
SecondaryLabel(text: "Ingredients")
if recipeDetail.recipeYield == 0 {
SecondaryLabel(text: "Ingredients")
} else if recipeDetail.recipeYield == 1 {
SecondaryLabel(text: "Ingredients per serving")
} else {
SecondaryLabel(text: "Ingredients for \(recipeDetail.recipeYield) servings")
}
Spacer()
}
ForEach(recipeDetail.recipeIngredient, id: \.self) { ingredient in

View File

@@ -0,0 +1,88 @@
//
// RecipeEditView.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 29.09.23.
//
import Foundation
import SwiftUI
struct RecipeEditView: View {
@State var recipe: RecipeDetail
@State var times = [Date.zero, Date.zero, Date.zero]
init(recipe: RecipeDetail? = nil) {
self.recipe = recipe ?? RecipeDetail()
}
var body: some View {
Form {
TextField("Title", text: $recipe.name)
Section() {
DatePicker("Prep time:", selection: $times[0], displayedComponents: .hourAndMinute)
DatePicker("Cook time:", selection: $times[1], displayedComponents: .hourAndMinute)
DatePicker("Total time:", selection: $times[2], displayedComponents: .hourAndMinute)
}
Section() {
List {
ForEach(recipe.recipeInstructions.indices, id: \.self) { ix in
HStack(alignment: .top) {
Text("\(ix+1).")
TextEditor(text: $recipe.recipeInstructions[ix])
.multilineTextAlignment(.leading)
}
}
.onMove { indexSet, offset in
recipe.recipeInstructions.move(fromOffsets: indexSet, toOffset: offset)
}
.onDelete { indexSet in
recipe.recipeInstructions.remove(atOffsets: indexSet)
}
}
HStack {
Spacer()
Text("Add instruction")
Button() {
recipe.recipeInstructions.append("")
} label: {
Image(systemName: "plus.circle.fill")
}
}
} header: {
HStack {
Text("Ingredients")
Spacer()
EditButton()
}
}
}
}
}
struct TimePicker: View {
@Binding var hours: Int
@Binding var minutes: Int
var body: some View {
HStack {
Picker("", selection: $hours){
ForEach(0..<99, id: \.self) { i in
Text("\(i) hours").tag(i)
}
}.pickerStyle(.wheel)
Picker("", selection: $minutes){
ForEach(0..<60, id: \.self) { i in
Text("\(i) min").tag(i)
}
}.pickerStyle(.wheel)
}
.padding(.horizontal)
}
}

View File

@@ -8,72 +8,98 @@
import Foundation
import SwiftUI
fileprivate enum SettingsAlert {
case LOG_OUT,
DELETE_CACHE,
NONE
func getTitle() -> String {
switch self {
case .LOG_OUT: return "Log out"
case .DELETE_CACHE: return "Delete local data"
default: return "Please confirm your action."
}
}
func getMessage() -> String {
switch self {
case .LOG_OUT: return "Are you sure that you want to log out of your account?"
case .DELETE_CACHE: return "Are you sure that you want to delete the downloaded recipes? This action will not affect any recipes stored on your server."
default: return ""
}
}
}
struct SettingsView: View {
@ObservedObject var userSettings: UserSettings
@ObservedObject var viewModel: MainViewModel
@State fileprivate var alertType: SettingsAlert = .NONE
@State var showAlert: Bool = false
var body: some View {
List {
SettingsSection(title: "Language", description: "Language settings coming soon.")
SettingsSection(title: "Accent Color", description: "The accent color setting will be released in a future update.")
SettingsSection(title: "Log out", description: "Log out of your Nextcloud account in this app. Your recipes will be removed from local storage.")
{
Form {
Section() {
Link("Visit the GitHub page", destination: URL(string: "https://github.com/VincentMeilinger/Nextcloud-Cookbook-iOS")!)
} header: {
Text("About")
} footer: {
Text("If you are interested in contributing to this project or simply wish to review its source code, we encourage you to visit the GitHub repository for this application.")
}
Section() {
Link("Get support", destination: URL(string: "https://vincentmeilinger.github.io/Nextcloud-Cookbook-Client-Support/")!)
} header: {
Text("Support")
} footer: {
Text("If you have any inquiries, feedback, or require assistance, please refer to the support page for contact information.")
}
Section() {
Button("Log out") {
print("Log out.")
userSettings.serverAddress = ""
userSettings.username = ""
userSettings.token = ""
userSettings.onboarding = true
alertType = .LOG_OUT
showAlert = true
}
.buttonStyle(.borderedProminent)
.accentColor(.red)
.padding()
}
SettingsSection(title: "Clear local data", description: "Your recipes will be removed from local storage.")
{
Button("Clear Cache") {
.tint(.red)
Button("Delete local data.") {
print("Clear cache.")
viewModel.deleteAllData()
alertType = .DELETE_CACHE
showAlert = true
}
.buttonStyle(.borderedProminent)
.accentColor(.red)
.padding()
.tint(.red)
} header: {
Text("Danger Zone")
}
}.navigationTitle("Settings")
}
}
struct SettingsSection<Content: View>: View {
let title: String
let description: String
@ViewBuilder let content: () -> Content
init(title: String, description: String, content: @escaping () -> Content) {
self.title = title
self.description = description
self.content = content
}
init(title: String, description: String) where Content == EmptyView {
self.title = title
self.description = description
self.content = { EmptyView() }
}
var body: some View {
HStack {
VStack(alignment: .leading) {
Text(title)
.font(.headline)
Text(description)
.font(.caption)
}.padding()
Spacer()
content()
}
.navigationTitle("Settings")
.alert(alertType.getTitle(), isPresented: $showAlert) {
Button("Cancel", role: .cancel) { }
if alertType == .LOG_OUT {
Button("Log out", role: .destructive) { logOut() }
} else if alertType == .DELETE_CACHE {
Button("Delete", role: .destructive) { deleteCache() }
}
} message: {
Text(alertType.getMessage())
}
}
func logOut() {
userSettings.serverAddress = ""
userSettings.username = ""
userSettings.token = ""
viewModel.deleteAllData()
userSettings.onboarding = true
}
func deleteCache() {
//viewModel.deleteAllData()
}
}