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 */; }; A70171CB2AB4CD1700064C43 /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CA2AB4CD1700064C43 /* UserDefaults.swift */; };
A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CC2AB501B100064C43 /* SettingsView.swift */; }; A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CC2AB501B100064C43 /* SettingsView.swift */; };
A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70322692ABAF49800D7C4ED /* JSONCoderExtension.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 */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -76,7 +79,10 @@
A70171CA2AB4CD1700064C43 /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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 */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -168,7 +174,7 @@
A70171B22AB211F000064C43 /* Network */ = { A70171B22AB211F000064C43 /* Network */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A703226C2ABAF90D00D7C4ED /* APIInterface.swift */, A703226C2ABAF90D00D7C4ED /* APIController.swift */,
A70171B32AB2122900064C43 /* NetworkRequests.swift */, A70171B32AB2122900064C43 /* NetworkRequests.swift */,
A70171AE2AB2116B00064C43 /* NetworkHandler.swift */, A70171AE2AB2116B00064C43 /* NetworkHandler.swift */,
A70171B02AB211DF00064C43 /* CustomError.swift */, A70171B02AB211DF00064C43 /* CustomError.swift */,
@@ -192,6 +198,7 @@
A70171BD2AB4987900064C43 /* CategoryDetailView.swift */, A70171BD2AB4987900064C43 /* CategoryDetailView.swift */,
A70171C12AB498C600064C43 /* RecipeCardView.swift */, A70171C12AB498C600064C43 /* RecipeCardView.swift */,
A70171BF2AB498A900064C43 /* RecipeDetailView.swift */, A70171BF2AB498A900064C43 /* RecipeDetailView.swift */,
A70D7CA02AC73CA700D53DBF /* RecipeEditView.swift */,
A70171C82AB4CBB400064C43 /* OnboardingView.swift */, A70171C82AB4CBB400064C43 /* OnboardingView.swift */,
A70171CC2AB501B100064C43 /* SettingsView.swift */, A70171CC2AB501B100064C43 /* SettingsView.swift */,
); );
@@ -212,7 +219,9 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A70171B82AB399FB00064C43 /* DateFormatterExtension.swift */, A70171B82AB399FB00064C43 /* DateFormatterExtension.swift */,
A70D7CA22AC74B3B00D53DBF /* DateExtension.swift */,
A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */, A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */,
A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */,
); );
path = Extensions; path = Extensions;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -347,6 +356,8 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
A70D7CA12AC73CA800D53DBF /* RecipeEditView.swift in Sources */,
A70D7CA32AC74B3B00D53DBF /* DateExtension.swift in Sources */,
A70171B12AB211DF00064C43 /* CustomError.swift in Sources */, A70171B12AB211DF00064C43 /* CustomError.swift in Sources */,
A70171C42AB4A31200064C43 /* DataStore.swift in Sources */, A70171C42AB4A31200064C43 /* DataStore.swift in Sources */,
A70171AF2AB2116B00064C43 /* NetworkHandler.swift in Sources */, A70171AF2AB2116B00064C43 /* NetworkHandler.swift in Sources */,
@@ -361,10 +372,11 @@
A70171842AA8E71900064C43 /* MainView.swift in Sources */, A70171842AA8E71900064C43 /* MainView.swift in Sources */,
A70171CB2AB4CD1700064C43 /* UserDefaults.swift in Sources */, A70171CB2AB4CD1700064C43 /* UserDefaults.swift in Sources */,
A703226A2ABAF49800D7C4ED /* JSONCoderExtension.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 */, A70171822AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift in Sources */,
A70171AD2AA8EF4700064C43 /* MainViewModel.swift in Sources */, A70171AD2AA8EF4700064C43 /* MainViewModel.swift in Sources */,
A70171C92AB4CBB400064C43 /* OnboardingView.swift in Sources */, A70171C92AB4CBB400064C43 /* OnboardingView.swift in Sources */,
A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -516,6 +528,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_Client.entitlements"; CODE_SIGN_ENTITLEMENTS = "Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_Client.entitlements";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Nextcloud Cookbook iOS Client/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Nextcloud Cookbook iOS Client/Preview Content\"";
@@ -539,7 +552,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client"; PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto; SDKROOT = auto;
@@ -556,6 +569,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_Client.entitlements"; CODE_SIGN_ENTITLEMENTS = "Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_Client.entitlements";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Nextcloud Cookbook iOS Client/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Nextcloud Cookbook iOS Client/Preview Content\"";
@@ -579,7 +593,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client"; PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto; 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 { struct RecipeDetail: Codable {
let name: String var name: String
let keywords: String var keywords: String
let dateCreated: String var dateCreated: String
let dateModified: String var dateModified: String
let imageUrl: String var imageUrl: String
let id: String var id: String
let prepTime: String? var prepTime: String?
let cookTime: String? var cookTime: String?
let totalTime: String? var totalTime: String?
let description: String var description: String
let url: String var url: String
let recipeYield: Int var recipeYield: Int
let recipeCategory: String var recipeCategory: String
let tool: [String] var tool: [String]
let recipeIngredient: [String] var recipeIngredient: [String]
let recipeInstructions: [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 { static func error() -> RecipeDetail {
return RecipeDetail( return RecipeDetail(
@@ -69,6 +107,11 @@ struct RecipeImage {
var full: UIImage? var full: UIImage?
} }
// Login flow
struct LoginV2Request: Codable { struct LoginV2Request: Codable {
let poll: LoginV2Poll let poll: LoginV2Poll
let login: String let login: String
@@ -84,3 +127,16 @@ struct LoginV2Response: Codable {
let loginName: String let loginName: String
let appPassword: 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 // Nextcloud Cookbook iOS Client
// //
// Created by Vincent Meilinger on 20.09.23. // Created by Vincent Meilinger on 20.09.23.
@@ -7,7 +7,7 @@
import Foundation import Foundation
class APIInterface { class APIController {
var userSettings: UserSettings var userSettings: UserSettings
var apiPath: String var apiPath: String
@@ -15,7 +15,7 @@ class APIInterface {
let apiVersion = "1" let apiVersion = "1"
init(userSettings: UserSettings) { init(userSettings: UserSettings) {
print("Initializing NetworkController.") print("Initializing APIController.")
self.userSettings = userSettings self.userSettings = userSettings
self.apiPath = "https://\(userSettings.serverAddress)/index.php/apps/cookbook/api/v\(apiVersion)/" 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)! let loginData = loginString.data(using: String.Encoding.utf8)!
self.authString = loginData.base64EncodedString() self.authString = loginData.base64EncodedString()
} }
}
extension APIController {
func imageDataFromServer(recipeId: Int, thumb: Bool) async -> Data? { func imageDataFromServer(recipeId: Int, thumb: Bool) async -> Data? {
do { do {
let request = RequestWrapper.imageRequest(path: .IMAGE(recipeId: recipeId, thumb: thumb)) let request = RequestWrapper.imageRequest(path: .IMAGE(recipeId: recipeId, thumb: thumb))
@@ -43,9 +47,7 @@ class APIInterface {
} }
return nil return nil
} }
}
extension APIInterface {
func sendDataRequest<D: Decodable>(_ request: RequestWrapper) async -> (D?, Error?) { func sendDataRequest<D: Decodable>(_ request: RequestWrapper) async -> (D?, Error?) {
do { do {
let (data, error) = try await NetworkHandler.sendHTTPRequest( let (data, error) = try await NetworkHandler.sendHTTPRequest(

View File

@@ -49,7 +49,7 @@ struct NetworkHandler {
request.httpBody = body 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 // Wait for and return data and (decoded) response
var data: Data? = nil var data: Data? = nil
@@ -57,6 +57,7 @@ struct NetworkHandler {
do { do {
(data, response) = try await URLSession.shared.data(for: request) (data, response) = try await URLSession.shared.data(for: request)
print("Response: ", response) print("Response: ", response)
print("Data: ", data?.description, data, String(data: data ?? Data(), encoding: .utf8))
return (data, nil) return (data, nil)
} catch { } catch {
return (nil, decodeURLResponse(response: response as? HTTPURLResponse)) return (nil, decodeURLResponse(response: response as? HTTPURLResponse))

View File

@@ -6,5 +6,7 @@
<true/> <true/>
<key>com.apple.security.files.user-selected.read-only</key> <key>com.apple.security.files.user-selected.read-only</key>
<true/> <true/>
<key>com.apple.security.network.client</key>
<true/>
</dict> </dict>
</plist> </plist>

View File

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

View File

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

View File

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

View File

@@ -20,14 +20,14 @@ struct RecipeBookView: View {
if let recipes = viewModel.recipes[categoryName] { if let recipes = viewModel.recipes[categoryName] {
ForEach(recipes, id: \.recipe_id) { recipe in ForEach(recipes, id: \.recipe_id) { recipe in
NavigationLink(destination: RecipeDetailView(viewModel: viewModel, recipe: recipe)) { 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) .buttonStyle(.plain)
} }
} }
} }
} }
.navigationTitle(categoryName) .navigationTitle(categoryName == "*" ? "Other" : categoryName)
.toolbar { .toolbar {
Menu { Menu {
Button { Button {

View File

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

View File

@@ -19,7 +19,7 @@ struct OnboardingView: View {
} }
.tabViewStyle(.page) .tabViewStyle(.page)
.background( .background(
selectedTab == 1 ? Color("ncblue").ignoresSafeArea() : Color(uiColor: .systemBackground).ignoresSafeArea() selectedTab == 1 ? Color.nextcloudBlue.ignoresSafeArea() : Color(uiColor: .systemBackground).ignoresSafeArea()
) )
.animation(.easeInOut, value: selectedTab) .animation(.easeInOut, value: selectedTab)
} }
@@ -33,16 +33,13 @@ struct WelcomeTab: View {
.resizable() .resizable()
.frame(width: 120, height: 120) .frame(width: 120, height: 120)
.clipShape(RoundedRectangle(cornerRadius: 10)) .clipShape(RoundedRectangle(cornerRadius: 10))
Text("Tank you for downloading") Text("Tank you for downloading the")
.font(.headline) .font(.headline)
Text("Nextcloud") Text("Cookbook Client")
.font(.largeTitle)
.bold()
Text("Cookbook")
.font(.largeTitle) .font(.largeTitle)
.bold() .bold()
Spacer() 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() .padding()
Spacer() Spacer()
} }
@@ -54,12 +51,18 @@ struct WelcomeTab: View {
struct LoginTab: View { struct LoginTab: View {
@ObservedObject var userSettings: UserSettings @ObservedObject var userSettings: UserSettings
// Login flow
enum LoginMethod { enum LoginMethod {
case v2, token case v2, token
} }
@State var selectedLoginMethod: LoginMethod = .v2 @State var selectedLoginMethod: LoginMethod = .v2
@State var loginRequest: LoginV2Request? = nil @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 { enum Field {
case server case server
case username case username
@@ -70,15 +73,7 @@ struct LoginTab: View {
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(alignment: .leading) { VStack(alignment: .leading) {
HStack {
Spacer() Spacer()
Image("nc-logo-white")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxHeight: 150)
.padding()
Spacer()
}
Picker("Login Method", selection: $selectedLoginMethod) { Picker("Login Method", selection: $selectedLoginMethod) {
Text("Nextcloud Login").tag(LoginMethod.v2) Text("Nextcloud Login").tag(LoginMethod.v2)
Text("App Token Login").tag(LoginMethod.token) Text("App Token Login").tag(LoginMethod.token)
@@ -109,7 +104,11 @@ struct LoginTab: View {
HStack{ HStack{
Spacer() Spacer()
Button { Button {
Task {
if await loginCheck(nextcloudLogin: false) {
userSettings.onboarding = false userSettings.onboarding = false
}
}
} label: { } label: {
Text("Submit") Text("Submit")
.foregroundColor(.white) .foregroundColor(.white)
@@ -138,20 +137,43 @@ struct LoginTab: View {
await sendLoginV2Request() await sendLoginV2Request()
if let loginRequest = loginRequest { if let loginRequest = loginRequest {
await UIApplication.shared.open(URL(string: loginRequest.login)!) 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) .font(.subheadline)
.padding(.bottom) .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{ HStack{
Spacer() Spacer()
Button { Button {
// fetch login v2 response // fetch login v2 response
Task { 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)!") print("Login successfull for user \(res.loginName)!")
userSettings.username = res.loginName userSettings.username = res.loginName
userSettings.token = res.appPassword userSettings.token = res.appPassword
@@ -187,6 +209,9 @@ struct LoginTab: View {
} }
.fontDesign(.rounded) .fontDesign(.rounded)
.padding() .padding()
.alert(alertMessage, isPresented: $showAlert) {
Button("Ok", role: .cancel) { }
}
} }
} }
@@ -250,6 +275,53 @@ struct LoginTab: View {
print("Could not decode.") print("Could not decode.")
return nil 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 { struct LoginLabel: View {

View File

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

View File

@@ -15,23 +15,22 @@ struct RecipeDetailView: View {
@State var recipeDetail: RecipeDetail? @State var recipeDetail: RecipeDetail?
@State var recipeImage: UIImage? @State var recipeImage: UIImage?
@State var showTitle: Bool = false @State var showTitle: Bool = false
@State var isDownloaded: Bool? = nil
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(alignment: .leading) { VStack(alignment: .leading) {
if let recipeImage = recipeImage { if let recipeImage = recipeImage {
Image(uiImage: recipeImage) Image(uiImage: recipeImage)
.resizable() .resizable()
.aspectRatio(contentMode: .fill) .scaledToFill()
.frame(height: 300) .frame(maxHeight: 300)
.clipped() .clipped()
} else {
Color("ncblue")
.frame(height: 150)
} }
if let recipeDetail = recipeDetail { if let recipeDetail = recipeDetail {
LazyVStack (alignment: .leading) { LazyVStack (alignment: .leading) {
Divider() Divider()
HStack {
Text(recipeDetail.name) Text(recipeDetail.name)
.font(.title) .font(.title)
.bold() .bold()
@@ -42,8 +41,15 @@ struct RecipeDetailView: View {
.onAppear { .onAppear {
showTitle = false showTitle = false
} }
if let isDownloaded = isDownloaded {
Spacer()
Image(systemName: isDownloaded ? "checkmark.circle" : "icloud.and.arrow.down")
.foregroundColor(.secondary)
.padding()
}
}
Divider() Divider()
RecipeYieldSection(recipeDetail: recipeDetail)
RecipeDurationSection(recipeDetail: recipeDetail) RecipeDurationSection(recipeDetail: recipeDetail)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) { LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
if(!recipeDetail.recipeIngredient.isEmpty) { if(!recipeDetail.recipeIngredient.isEmpty) {
@@ -59,13 +65,14 @@ struct RecipeDetailView: View {
}.padding(.horizontal, 5) }.padding(.horizontal, 5)
} }
} }.animation(.easeInOut, value: recipeImage)
} }
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.navigationTitle(showTitle ? recipe.name : "") .navigationTitle(showTitle ? recipe.name : "")
.task { .task {
recipeDetail = await viewModel.loadRecipeDetail(recipeId: recipe.recipe_id) recipeDetail = await viewModel.loadRecipeDetail(recipeId: recipe.recipe_id)
recipeImage = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: false) recipeImage = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: false)
self.isDownloaded = viewModel.recipeDetailExists(recipeId: recipe.recipe_id)
} }
.refreshable { .refreshable {
recipeDetail = await viewModel.loadRecipeDetail(recipeId: recipe.recipe_id, needsUpdate: true) 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 { struct RecipeDurationSection: View {
@State var recipeDetail: RecipeDetail @State var recipeDetail: RecipeDetail
var body: some View { var body: some View {
HStack { HStack(alignment: .center) {
if let prepTime = recipeDetail.prepTime { if let prepTime = recipeDetail.prepTime {
VStack { VStack {
SecondaryLabel(text: "Prep time") SecondaryLabel(text: "Prep time")
Text(formatDate(duration: prepTime)) Text(formatDate(duration: prepTime))
.lineLimit(1) .lineLimit(1)
}.padding() }.padding()
.frame(maxWidth: .infinity)
.background(Color("accent"))
.clipShape(RoundedRectangle(cornerRadius: 10))
} }
if let cookTime = recipeDetail.cookTime { if let cookTime = recipeDetail.cookTime {
@@ -108,9 +102,6 @@ struct RecipeDurationSection: View {
Text(formatDate(duration: cookTime)) Text(formatDate(duration: cookTime))
.lineLimit(1) .lineLimit(1)
}.padding() }.padding()
.frame(maxWidth: .infinity)
.background(Color("accent"))
.clipShape(RoundedRectangle(cornerRadius: 10))
} }
if let totalTime = recipeDetail.totalTime { if let totalTime = recipeDetail.totalTime {
@@ -119,9 +110,6 @@ struct RecipeDurationSection: View {
Text(formatDate(duration: totalTime)) Text(formatDate(duration: totalTime))
.lineLimit(1) .lineLimit(1)
}.padding() }.padding()
.frame(maxWidth: .infinity)
.background(Color("accent"))
.clipShape(RoundedRectangle(cornerRadius: 10))
} }
} }
} }
@@ -134,7 +122,13 @@ struct RecipeIngredientSection: View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Divider() Divider()
HStack { HStack {
if recipeDetail.recipeYield == 0 {
SecondaryLabel(text: "Ingredients") SecondaryLabel(text: "Ingredients")
} else if recipeDetail.recipeYield == 1 {
SecondaryLabel(text: "Ingredients per serving")
} else {
SecondaryLabel(text: "Ingredients for \(recipeDetail.recipeYield) servings")
}
Spacer() Spacer()
} }
ForEach(recipeDetail.recipeIngredient, id: \.self) { ingredient in 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 Foundation
import SwiftUI 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 { struct SettingsView: View {
@ObservedObject var userSettings: UserSettings @ObservedObject var userSettings: UserSettings
@ObservedObject var viewModel: MainViewModel @ObservedObject var viewModel: MainViewModel
@State fileprivate var alertType: SettingsAlert = .NONE
@State var showAlert: Bool = false
var body: some View { var body: some View {
List { Form {
SettingsSection(title: "Language", description: "Language settings coming soon.") Section() {
SettingsSection(title: "Accent Color", description: "The accent color setting will be released in a future update.") Link("Visit the GitHub page", destination: URL(string: "https://github.com/VincentMeilinger/Nextcloud-Cookbook-iOS")!)
SettingsSection(title: "Log out", description: "Log out of your Nextcloud account in this app. Your recipes will be removed from local storage.") } 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") { Button("Log out") {
print("Log out.") print("Log out.")
alertType = .LOG_OUT
showAlert = true
}
.tint(.red)
Button("Delete local data.") {
print("Clear cache.")
alertType = .DELETE_CACHE
showAlert = true
}
.tint(.red)
} header: {
Text("Danger Zone")
}
}
.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.serverAddress = ""
userSettings.username = "" userSettings.username = ""
userSettings.token = "" userSettings.token = ""
viewModel.deleteAllData()
userSettings.onboarding = true userSettings.onboarding = true
} }
.buttonStyle(.borderedProminent)
.accentColor(.red)
.padding()
}
SettingsSection(title: "Clear local data", description: "Your recipes will be removed from local storage.") func deleteCache() {
{ //viewModel.deleteAllData()
Button("Clear Cache") {
print("Clear cache.")
viewModel.deleteAllData()
}
.buttonStyle(.borderedProminent)
.accentColor(.red)
.padding()
}
}.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()
}
}
}