Better file caching and update management

This commit is contained in:
Vicnet
2023-12-14 14:11:56 +01:00
parent 899dc20e55
commit a3fc891d0a
23 changed files with 592 additions and 483 deletions

View File

@@ -25,7 +25,7 @@ enum AlertButton: LocalizedStringKey, Identifiable {
enum RecipeCreationError: UserAlert {
enum RecipeAlert: UserAlert {
case NO_TITLE,
DUPLICATE,
@@ -84,7 +84,7 @@ enum RecipeCreationError: UserAlert {
}
enum RecipeImportError: UserAlert {
enum RecipeImportAlert: UserAlert {
case BAD_URL,
CHECK_CONNECTION,
WEBSITE_NOT_SUPPORTED
@@ -113,14 +113,12 @@ enum RecipeImportError: UserAlert {
enum RequestAlert: UserAlert {
case REQUEST_DELAYED,
REQUEST_DROPPED,
REQUEST_SUCCESS
REQUEST_DROPPED
var localizedDescription: LocalizedStringKey {
switch self {
case .REQUEST_DELAYED: return "Could not establish a connection to the server. The action will be retried upon reconnection."
case .REQUEST_DROPPED: return "Unable to complete action."
case .REQUEST_SUCCESS: return "Action completed."
}
}
@@ -128,7 +126,6 @@ enum RequestAlert: UserAlert {
switch self {
case .REQUEST_DELAYED: return "Action delayed"
case .REQUEST_DROPPED: return "Error"
case .REQUEST_SUCCESS: return "Success"
}
}

View File

@@ -22,6 +22,7 @@ struct CategoryDetailView: View {
ForEach(recipesFiltered(), id: \.recipe_id) { recipe in
NavigationLink(value: recipe) {
RecipeCardView(viewModel: viewModel, recipe: recipe)
.shadow(radius: 2)
}
.buttonStyle(.plain)
}
@@ -30,41 +31,29 @@ struct CategoryDetailView: View {
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetailView(viewModel: viewModel, recipe: recipe)
}
.navigationTitle(categoryName == "*" ? "Other" : categoryName)
.navigationTitle(categoryName == "*" ? String(localized: "Other") : categoryName)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button {
print("Add new recipe")
showEditView = true
} label: {
HStack {
Text("Add new recipe")
Image(systemName: "plus.circle.fill")
}
}
Button {
print("Downloading all recipes in category \(categoryName) ...")
downloadRecipes()
} label: {
HStack {
Text("Download recipes")
Image(systemName: "icloud.and.arrow.down")
}
}
Button {
print("Add new recipe")
showEditView = true
} label: {
Image(systemName: "ellipsis.circle")
Image(systemName: "plus.circle.fill")
}
}
}
.searchable(text: $searchText, prompt: "Search recipes")
.task {
await viewModel.getCategory(named: categoryName, fetchMode: .preferLocal)
await viewModel.getCategory(
named: categoryName,
fetchMode: UserSettings.shared.storeRecipes ? .preferLocal : .onlyServer
)
}
.refreshable {
await viewModel.getCategory(named: categoryName, fetchMode: .preferServer)
await viewModel.getCategory(
named: categoryName,
fetchMode: UserSettings.shared.storeRecipes ? .preferServer : .onlyServer
)
}
}
@@ -75,26 +64,4 @@ struct CategoryDetailView: View {
recipe.name.lowercased().contains(searchText.lowercased())
}
}
func downloadRecipes() {
if let recipes = viewModel.recipes[categoryName] {
Task {
for recipe in recipes {
let recipeDetail = await viewModel.getRecipe(id: recipe.recipe_id, fetchMode: .onlyServer)
await viewModel.saveLocal(recipeDetail, path: "recipe\(recipe.recipe_id).data")
let thumbnail = await viewModel.getImage(id: recipe.recipe_id, size: .THUMB, fetchMode: .onlyServer)
guard let thumbnail = thumbnail else { continue }
guard let thumbnailData = thumbnail.pngData() else { continue }
await viewModel.saveLocal(thumbnailData.base64EncodedString(), path: "image\(recipe.recipe_id)_thumb")
let image = await viewModel.getImage(id: recipe.recipe_id, size: .FULL, fetchMode: .onlyServer)
guard let image = image else { continue }
guard let imageData = image.pngData() else { continue }
await viewModel.saveLocal(imageData.base64EncodedString(), path: "image\(recipe.recipe_id)_full")
}
}
}
}
}

View File

@@ -12,7 +12,7 @@ import SwiftUI
struct KeywordPickerView: View {
@State var title: String
@State var searchSuggestions: [String]
@State var searchSuggestions: [RecipeKeyword]
@Binding var selection: [String]
@State var searchText: String = ""
@@ -35,17 +35,18 @@ struct KeywordPickerView: View {
s == keyword ? true : false
})
searchSuggestions.removeAll(where: { s in
s == keyword ? true : false
s.name == keyword ? true : false
})
} else {
selection.append(keyword)
}
}
}
ForEach(suggestionsFiltered(), id: \.self) { suggestion in
ForEach(suggestionsFiltered(), id: \.name) { suggestion in
KeywordItemView(
keyword: suggestion,
isSelected: selection.contains(suggestion)
keyword: suggestion.name,
count: suggestion.recipe_count,
isSelected: selection.contains(suggestion.name)
) { keyword in
if selection.contains(keyword) {
selection.removeAll(where: { s in
@@ -84,14 +85,17 @@ struct KeywordPickerView: View {
}
}
.navigationTitle(title)
.padding(5)
}
func suggestionsFiltered() -> [String] {
func suggestionsFiltered() -> [RecipeKeyword] {
guard searchText != "" else { return searchSuggestions }
return searchSuggestions.filter { suggestion in
suggestion.lowercased().contains(searchText.lowercased())
}
suggestion.name.lowercased().contains(searchText.lowercased())
}.sorted(by: { a, b in
a.recipe_count > b.recipe_count
})
}
}
@@ -99,6 +103,7 @@ struct KeywordPickerView: View {
struct KeywordItemView: View {
var keyword: String
var count: Int? = nil
var isSelected: Bool
var tapped: (String) -> ()
@@ -110,6 +115,9 @@ struct KeywordItemView: View {
Text(keyword)
.lineLimit(2)
Spacer()
if let count = count {
Text("(\(count))")
}
}
.padding()
.background(

View File

@@ -10,13 +10,14 @@ import SwiftUI
struct MainView: View {
@ObservedObject var viewModel: MainViewModel
@StateObject var userSettings: UserSettings = UserSettings()
@StateObject var userSettings: UserSettings = UserSettings.shared
@State private var selectedCategory: Category? = nil
@State private var showEditView: Bool = false
@State private var showSearchView: Bool = false
@State private var showSettingsView: Bool = false
@State private var serverConnection: Bool = false
@State private var showLoadingIndicator: Bool = false
var columns: [GridItem] = [GridItem(.adaptive(minimum: 150), spacing: 0)]
@@ -43,7 +44,7 @@ struct MainView: View {
NavigationLink(value: category) {
HStack(alignment: .center) {
Image(systemName: "book.closed.fill")
Text(category.name == "*" ? "Other" : category.name)
Text(category.name == "*" ? String(localized: "Other") : category.name)
.font(.system(size: 20, weight: .light, design: .serif))
.italic()
}.padding(7)
@@ -53,7 +54,7 @@ struct MainView: View {
}
.navigationTitle("Cookbooks")
.navigationDestination(isPresented: $showSettingsView) {
SettingsView(userSettings: userSettings, viewModel: viewModel)
SettingsView(viewModel: viewModel)
}
.navigationDestination(isPresented: $showSearchView) {
RecipeSearchView(viewModel: viewModel)
@@ -63,7 +64,8 @@ struct MainView: View {
viewModel: viewModel,
showEditView: $showEditView,
showSettingsView: $showSettingsView,
serverConnection: $serverConnection
serverConnection: $serverConnection,
showLoadingIndicator: $showLoadingIndicator
)
}
} detail: {
@@ -80,17 +82,24 @@ struct MainView: View {
}
.tint(.nextcloudBlue)
.sheet(isPresented: $showEditView) {
RecipeEditView(viewModel:
RecipeEditViewModel(
mainViewModel: viewModel,
isPresented: $showEditView,
uploadNew: true
)
RecipeEditView(
viewModel:
RecipeEditViewModel(
mainViewModel: viewModel,
uploadNew: true
),
isPresented: $showEditView
)
}
.task {
showLoadingIndicator = true
self.serverConnection = await viewModel.checkServerConnection()
await viewModel.getCategories()//viewModel.loadCategoryList()
await viewModel.getCategories()
for category in viewModel.categories {
await viewModel.getCategory(named: category.name, fetchMode: .preferLocal)
}
await viewModel.updateAllRecipeDetails()
// Open detail view for default category
if userSettings.defaultCategory != "" {
if let cat = viewModel.categories.first(where: { c in
@@ -102,10 +111,11 @@ struct MainView: View {
self.selectedCategory = cat
}
}
showLoadingIndicator = false
}
.refreshable {
self.serverConnection = await viewModel.checkServerConnection()
await viewModel.getCategories()//loadCategoryList(needsUpdate: true)
await viewModel.getCategories()
}
}
@@ -119,30 +129,33 @@ struct MainView: View {
@Binding var showEditView: Bool
@Binding var showSettingsView: Bool
@Binding var serverConnection: Bool
@Binding var showLoadingIndicator: Bool
@State private var presentPopover: Bool = false
var body: some ToolbarContent {
// Top left menu toolbar item
ToolbarItem(placement: .topBarLeading) {
Menu {
Button {
print("Downloading all recipes ...")
Task {
await viewModel.downloadAllRecipes()
}
} label: {
HStack {
Text("Download all recipes")
Image(systemName: "icloud.and.arrow.down")
}
}
Button {
self.showSettingsView = true
} label: {
Text("Settings")
Image(systemName: "gearshape")
}
Button {
Task {
showLoadingIndicator = true
await viewModel.getCategories()
for category in viewModel.categories {
await viewModel.getCategory(named: category.name, fetchMode: .preferServer)
}
await viewModel.updateAllRecipeDetails()
showLoadingIndicator = false
}
} label: {
Text("Refresh all")
Image(systemName: "icloud.and.arrow.down")
}
} label: {
Image(systemName: "ellipsis.circle")
}
@@ -154,16 +167,24 @@ struct MainView: View {
print("Check server connection")
presentPopover = true
} label: {
if serverConnection {
if showLoadingIndicator {
ProgressView()
} else if serverConnection {
Image(systemName: "checkmark.icloud")
} else {
Image(systemName: "xmark.icloud")
}
}.popover(isPresented: $presentPopover) {
Text(serverConnection ? LocalizedStringKey("Connected to server.") : LocalizedStringKey("Unable to connect to server."))
.bold()
.padding()
.presentationCompactAdaptation(.popover)
VStack(alignment: .leading) {
Text(serverConnection ? LocalizedStringKey("Connected to server.") : LocalizedStringKey("Unable to connect to server."))
.bold()
Text("Last updated: \(DateFormatter.utcToString(date: UserSettings.shared.lastUpdate))")
.font(.caption)
.foregroundStyle(Color.secondary)
}
.padding()
.presentationCompactAdaptation(.popover)
}
}
@@ -195,6 +216,7 @@ struct RecipeSearchView: View {
ForEach(recipesFiltered(), id: \.recipe_id) { recipe in
NavigationLink(value: recipe) {
RecipeCardView(viewModel: viewModel, recipe: recipe)
.shadow(radius: 2)
}
.buttonStyle(.plain)
}
@@ -208,7 +230,7 @@ struct RecipeSearchView: View {
.navigationTitle("Search recipe")
}
.task {
allRecipes = await viewModel.getRecipes()//.getAllRecipes()
allRecipes = await viewModel.getRecipes()
}
}

View File

@@ -51,11 +51,22 @@ struct RecipeCardView: View {
.clipShape(RoundedRectangle(cornerRadius: 17))
.padding(.horizontal)
.task {
recipeThumb = await viewModel.getImage(id: recipe.recipe_id, size: .THUMB, fetchMode: .preferLocal)
self.isDownloaded = viewModel.recipeDetailExists(recipeId: recipe.recipe_id)
recipeThumb = await viewModel.getImage(
id: recipe.recipe_id,
size: .THUMB,
fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer
)
if recipe.storedLocally == nil {
recipe.storedLocally = viewModel.recipeDetailExists(recipeId: recipe.recipe_id)
}
isDownloaded = recipe.storedLocally
}
.refreshable {
recipeThumb = await viewModel.getImage(id: recipe.recipe_id, size: .THUMB, fetchMode: .preferServer)
recipeThumb = await viewModel.getImage(
id: recipe.recipe_id,
size: .THUMB,
fetchMode: UserSettings.shared.storeThumb ? .preferServer : .onlyServer
)
}
}
}

View File

@@ -75,6 +75,14 @@ struct RecipeDetailView: View {
RecipeNutritionSection(recipeDetail: recipeDetail, presentNutritionPopover: $presentNutritionPopover)
RecipeKeywordSection(recipeDetail: recipeDetail, presentKeywordPopover: $presentKeywordPopover)
}
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)
}
@@ -95,24 +103,42 @@ struct RecipeDetailView: View {
}
.sheet(isPresented: $presentEditView) {
if let recipeDetail = recipeDetail {
RecipeEditView(viewModel:
RecipeEditViewModel(
mainViewModel: viewModel,
recipeDetail: recipeDetail,
isPresented: $presentEditView,
uploadNew: false
)
RecipeEditView(
viewModel:
RecipeEditViewModel(
mainViewModel: viewModel,
recipeDetail: recipeDetail,
uploadNew: false
),
isPresented: $presentEditView
)
}
}
.task {
recipeDetail = await viewModel.getRecipe(id: recipe.recipe_id, fetchMode: .preferLocal)//loadRecipeDetail(recipeId: recipe.recipe_id)
recipeImage = await viewModel.getImage(id: recipe.recipe_id, size: .FULL, fetchMode: .preferLocal)//.loadImage(recipeId: recipe.recipe_id, thumb: false)
self.isDownloaded = viewModel.recipeDetailExists(recipeId: recipe.recipe_id)
recipeDetail = await viewModel.getRecipe(
id: recipe.recipe_id,
fetchMode: UserSettings.shared.storeRecipes ? .preferLocal : .onlyServer
)
recipeImage = await viewModel.getImage(
id: recipe.recipe_id,
size: .FULL,
fetchMode: UserSettings.shared.storeImages ? .preferLocal : .onlyServer
)
if recipe.storedLocally == nil {
recipe.storedLocally = viewModel.recipeDetailExists(recipeId: recipe.recipe_id)
}
self.isDownloaded = recipe.storedLocally
}
.refreshable {
recipeDetail = await viewModel.getRecipe(id: recipe.recipe_id, fetchMode: .preferServer)
recipeImage = await viewModel.getImage(id: recipe.recipe_id, size: .FULL, fetchMode: .preferServer)
recipeDetail = await viewModel.getRecipe(
id: recipe.recipe_id,
fetchMode: UserSettings.shared.storeRecipes ? .preferServer : .onlyServer
)
recipeImage = await viewModel.getImage(
id: recipe.recipe_id,
size: .FULL,
fetchMode: UserSettings.shared.storeImages ? .preferServer : .onlyServer
)
}
}
}

View File

@@ -12,14 +12,19 @@ import PhotosUI
struct RecipeEditView: View {
@ObservedObject var viewModel: RecipeEditViewModel
@ObservedObject var viewModel: RecipeEditViewModel
@Binding var isPresented: Bool
@State var presentAlert = false
@State var alertType: UserAlert = RecipeAlert.GENERIC
@State var alertAction: @MainActor () async -> () = { }
var body: some View {
NavigationStack {
VStack {
HStack {
Button() {
viewModel.isPresented.wrappedValue = false
isPresented = false
} label: {
Text("Cancel")
.bold()
@@ -28,9 +33,17 @@ struct RecipeEditView: View {
Menu {
Button {
print("Delete recipe.")
viewModel.alertType = RecipeCreationError.CONFIRM_DELETE
viewModel.alertAction = viewModel.deleteRecipe
viewModel.presentAlert = true
alertType = RecipeAlert.CONFIRM_DELETE
alertAction = {
if let res = await viewModel.deleteRecipe() {
alertType = res
alertAction = { }
presentAlert = true
} else {
self.dismissEditView()
}
}
presentAlert = true
} label: {
Image(systemName: "trash")
.foregroundStyle(.red)
@@ -47,9 +60,19 @@ struct RecipeEditView: View {
Button() {
Task {
if viewModel.uploadNew {
await viewModel.uploadNewRecipe()
if let res = await viewModel.uploadNewRecipe() {
alertType = res
presentAlert = true
} else {
dismissEditView()
}
} else {
await viewModel.uploadEditedRecipe()
if let res = await viewModel.uploadEditedRecipe() {
alertType = res
presentAlert = true
} else {
dismissEditView()
}
}
}
} label: {
@@ -58,7 +81,7 @@ struct RecipeEditView: View {
}
}.padding()
HStack {
Text(viewModel.recipe.name == "" ? LocalizedStringKey("New recipe") : LocalizedStringKey(viewModel.recipe.name))
Text(viewModel.recipe.name == "" ? String(localized: "New recipe") : viewModel.recipe.name)
.font(.title)
.bold()
.padding()
@@ -69,7 +92,16 @@ struct RecipeEditView: View {
Section {
TextField(LocalizedStringKey("URL (e.g. example.com/recipe)"), text: $viewModel.importURL)
Button {
viewModel.importRecipe()
Task {
if let res = await viewModel.importRecipe() {
alertType = RecipeAlert.CUSTOM(
title: res.localizedTitle,
description: res.localizedDescription
)
alertAction = { }
presentAlert = true
}
}
} label: {
Text(LocalizedStringKey("Import"))
}
@@ -148,12 +180,12 @@ struct RecipeEditView: View {
.onAppear {
viewModel.prepareView()
}
.alert(viewModel.alertType.localizedTitle, isPresented: $viewModel.presentAlert) {
ForEach(viewModel.alertType.alertButtons) { buttonType in
.alert(alertType.localizedTitle, isPresented: $presentAlert) {
ForEach(alertType.alertButtons) { buttonType in
if buttonType == .OK {
Button(AlertButton.OK.rawValue, role: .cancel) {
Task {
await viewModel.alertAction()
await alertAction()
}
}
} else if buttonType == .CANCEL {
@@ -161,15 +193,24 @@ struct RecipeEditView: View {
} else if buttonType == .DELETE {
Button(AlertButton.DELETE.rawValue, role: .destructive) {
Task {
await viewModel.alertAction()
await alertAction()
}
}
}
}
} message: {
Text(viewModel.alertType.localizedDescription)
Text(alertType.localizedDescription)
}
}
func dismissEditView() {
Task {
await viewModel.mainViewModel.getCategories()
await viewModel.mainViewModel.getCategory(named: viewModel.recipe.recipeCategory, fetchMode: .preferServer)
await viewModel.mainViewModel.updateRecipeDetails(in: viewModel.recipe.recipeCategory)
}
self.isPresented = false
}
}

View File

@@ -11,8 +11,8 @@ import SwiftUI
struct SettingsView: View {
@ObservedObject var userSettings: UserSettings
@ObservedObject var viewModel: MainViewModel
@ObservedObject var userSettings = UserSettings.shared
@State fileprivate var alertType: SettingsAlert = .NONE
@State var showAlert: Bool = false
@@ -20,9 +20,6 @@ struct SettingsView: View {
var body: some View {
Form {
Section {
/*Toggle(isOn: $userSettings.downloadRecipes) {
Text("Always download new recipes")
}*/
Picker("Select a default cookbook", selection: $userSettings.defaultCategory) {
Text("None").tag("None")
ForEach(viewModel.categories, id: \.name) { category in
@@ -35,6 +32,22 @@ struct SettingsView: View {
Text("The selected cookbook will open on app launch by default.")
}
Section {
Toggle(isOn: $userSettings.storeRecipes) {
Text("Offline recipes")
}
Toggle(isOn: $userSettings.storeImages) {
Text("Store recipe images locally")
}
Toggle(isOn: $userSettings.storeThumb) {
Text("Store recipe thumbnails locally")
}
} header: {
Text("Downloads")
} footer: {
Text("Configure what is stored on your device.")
}
Section {
Picker("Language", selection: $userSettings.language) {
ForEach(SupportedLanguage.allValues, id: \.self) { lang in
@@ -102,6 +115,7 @@ struct SettingsView: View {
userSettings.serverAddress = ""
userSettings.username = ""
userSettings.token = ""
userSettings.authString = ""
viewModel.deleteAllData()
userSettings.onboarding = true
}