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

@@ -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()
}
}