New app icon and recipe sharing options (pdf and plain text)
@@ -47,6 +47,8 @@
|
||||
A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D792B25C66600A3469E /* OnboardingView.swift */; };
|
||||
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */; };
|
||||
A7FB0D7E2B25C6A200A3469E /* V2LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7D2B25C6A200A3469E /* V2LoginView.swift */; };
|
||||
A9CA6CEF2B4C086100F78AB5 /* RecipeExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */; };
|
||||
A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */ = {isa = PBXBuildFile; productRef = A9CA6CF52B4C63F200F78AB5 /* TPPDF */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -110,6 +112,7 @@
|
||||
A7FB0D792B25C66600A3469E /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
||||
A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenLoginView.swift; sourceTree = "<group>"; };
|
||||
A7FB0D7D2B25C6A200A3469E /* V2LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V2LoginView.swift; sourceTree = "<group>"; };
|
||||
A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeExporter.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -118,6 +121,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A74D33BE2AF82AAE00D06555 /* SwiftSoup in Frameworks */,
|
||||
A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -168,6 +172,7 @@
|
||||
A70171B72AB2445700064C43 /* ViewModels */,
|
||||
A70171B22AB211F000064C43 /* Network */,
|
||||
A781E75F2AF8228100452F6F /* RecipeImport */,
|
||||
A9CA6CED2B4C084100F78AB5 /* RecipeExport */,
|
||||
A703226B2ABAF60D00D7C4ED /* Extensions */,
|
||||
A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */,
|
||||
A7AEAE632AD5521400135378 /* Localizable.xcstrings */,
|
||||
@@ -307,6 +312,14 @@
|
||||
path = Onboarding;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A9CA6CED2B4C084100F78AB5 /* RecipeExport */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */,
|
||||
);
|
||||
path = RecipeExport;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -325,6 +338,7 @@
|
||||
name = "Nextcloud Cookbook iOS Client";
|
||||
packageProductDependencies = (
|
||||
A74D33BD2AF82AAE00D06555 /* SwiftSoup */,
|
||||
A9CA6CF52B4C63F200F78AB5 /* TPPDF */,
|
||||
);
|
||||
productName = "Nextcloud Cookbook iOS Client";
|
||||
productReference = A701717E2AA8E71900064C43 /* Nextcloud Cookbook iOS Client.app */;
|
||||
@@ -403,6 +417,7 @@
|
||||
mainGroup = A70171752AA8E71900064C43;
|
||||
packageReferences = (
|
||||
A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
|
||||
A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */,
|
||||
);
|
||||
productRefGroup = A701717F2AA8E71900064C43 /* Products */;
|
||||
projectDirPath = "";
|
||||
@@ -470,6 +485,7 @@
|
||||
A7F3F8EA2ACC221C0076C227 /* CategoryPickerView.swift in Sources */,
|
||||
A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */,
|
||||
A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */,
|
||||
A9CA6CEF2B4C086100F78AB5 /* RecipeExporter.swift in Sources */,
|
||||
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */,
|
||||
A70171842AA8E71900064C43 /* MainView.swift in Sources */,
|
||||
A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */,
|
||||
@@ -861,6 +877,14 @@
|
||||
minimumVersion = 2.6.1;
|
||||
};
|
||||
};
|
||||
A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/techprimate/TPPDF.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 2.4.1;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
@@ -869,6 +893,11 @@
|
||||
package = A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */;
|
||||
productName = SwiftSoup;
|
||||
};
|
||||
A9CA6CF52B4C63F200F78AB5 /* TPPDF */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */;
|
||||
productName = TPPDF;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = A70171762AA8E71900064C43 /* Project object */;
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
"revision" : "8b6cf29eead8841a1fa7822481cb3af4ddaadba6",
|
||||
"version" : "2.6.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "tppdf",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/techprimate/TPPDF.git",
|
||||
"state" : {
|
||||
"revision" : "1955ebbc090a3fb2149fb53e703595c3146689af",
|
||||
"version" : "2.4.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
|
||||
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 13 KiB |
@@ -2014,6 +2014,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PDF Document" : {
|
||||
|
||||
},
|
||||
"Please check the entered URL." : {
|
||||
"localizations" : {
|
||||
@@ -2124,6 +2127,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Recipe" : {
|
||||
|
||||
},
|
||||
"Recipes" : {
|
||||
"localizations" : {
|
||||
@@ -2236,6 +2242,7 @@
|
||||
}
|
||||
},
|
||||
"Search recipes" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -2366,6 +2373,15 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Share as PDF" : {
|
||||
|
||||
},
|
||||
"Share as text" : {
|
||||
|
||||
},
|
||||
"Share recipe" : {
|
||||
|
||||
},
|
||||
"Show help" : {
|
||||
"localizations" : {
|
||||
|
||||
156
Nextcloud Cookbook iOS Client/RecipeExport/RecipeExporter.swift
Normal file
@@ -0,0 +1,156 @@
|
||||
//
|
||||
// RecipeToPDF.swift
|
||||
// Nextcloud Cookbook iOS Client
|
||||
//
|
||||
// Created by Vincent Meilinger on 08.01.24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import TPPDF
|
||||
import SwiftUI
|
||||
|
||||
class RecipeExporter {
|
||||
|
||||
func createPDF(recipe: RecipeDetail, image: UIImage?) -> URL? {
|
||||
let document = PDFDocument(format: .a4)
|
||||
|
||||
let titleStyle = PDFTextStyle(name: "title", font: UIFont.boldSystemFont(ofSize: 18), color: .black)
|
||||
let headerStyle = PDFTextStyle(name: "header", font: UIFont.boldSystemFont(ofSize: 16), color: .darkGray)
|
||||
let textStyle = PDFTextStyle(name: "text", font: UIFont.systemFont(ofSize: 14), color: .black)
|
||||
|
||||
let titleSection = PDFSection(columnWidths: [0.5, 0.5])
|
||||
if let image = image, let resizedImage = cropAndResizeImage(image: image, targetHeight: 150) {
|
||||
let pdfImg = PDFImage(
|
||||
image: resizedImage,
|
||||
size: resizedImage.size,
|
||||
options: [.rounded],
|
||||
cornerRadius: 5
|
||||
)
|
||||
titleSection.columns[0].add(image: pdfImg)
|
||||
}
|
||||
|
||||
// Title
|
||||
titleSection.columns[1].add(textObject: PDFSimpleText(text: recipe.name, style: titleStyle))
|
||||
|
||||
// Description
|
||||
if !recipe.description.isEmpty {
|
||||
titleSection.columns[1].add(space: 10)
|
||||
titleSection.columns[1].add(textObject: PDFSimpleText(text: recipe.description, style: textStyle))
|
||||
}
|
||||
|
||||
// Time
|
||||
if let prepTime = recipe.prepTime, let prepTimeString = DurationComponents.ptToText(prepTime) {
|
||||
let prepString = "Preparation time: \(prepTimeString)"
|
||||
titleSection.columns[1].add(space: 10)
|
||||
titleSection.columns[1].add(textObject: PDFSimpleText(text: prepString, style: textStyle))
|
||||
}
|
||||
|
||||
if let cookTime = recipe.cookTime, let cookTimeString = DurationComponents.ptToText(cookTime) {
|
||||
let cookString = "Cooking time: \(cookTimeString)"
|
||||
titleSection.columns[1].add(space: 10)
|
||||
titleSection.columns[1].add(textObject: PDFSimpleText(text: cookString, style: textStyle))
|
||||
}
|
||||
|
||||
document.add(section: titleSection)
|
||||
|
||||
// Ingredients
|
||||
var ingr = ""
|
||||
for ingredient in recipe.recipeIngredient {
|
||||
ingr.append("• \(ingredient)\n")
|
||||
}
|
||||
|
||||
let section = PDFSection(columnWidths: [0.5, 0.5])
|
||||
section.columns[0].add(textObject: PDFSimpleText(text: ingr, style: textStyle))
|
||||
document.add(space: 20)
|
||||
document.add(section: section)
|
||||
|
||||
// Instructions
|
||||
var instr = ""
|
||||
for instruction in recipe.recipeInstructions {
|
||||
instr += instruction + "\n\n"
|
||||
}
|
||||
document.add(space: 10)
|
||||
document.add(textObject: PDFSimpleText(text: instr, style: textStyle))
|
||||
|
||||
// Generate PDF
|
||||
let generator = PDFGenerator(document: document)
|
||||
|
||||
do {
|
||||
return try generator.generateURL(filename: "\(recipe.name).pdf")
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func createText(recipe: RecipeDetail) -> String {
|
||||
var recipeString = ""
|
||||
recipeString.append("☛ " + recipe.name + "\n")
|
||||
recipeString.append(recipe.description + "\n\n")
|
||||
|
||||
for ingredient in recipe.recipeIngredient {
|
||||
recipeString.append("•" + ingredient + "\n")
|
||||
}
|
||||
recipeString.append("\n")
|
||||
var counter = 1
|
||||
for instruction in recipe.recipeInstructions {
|
||||
recipeString.append("\(counter). " + instruction + "\n")
|
||||
counter += 1
|
||||
}
|
||||
return recipeString
|
||||
}
|
||||
|
||||
func createJson(recipe: RecipeDetail) -> Data? {
|
||||
return JSONEncoder.safeEncode(recipe)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private extension RecipeExporter {
|
||||
func resizeImage(image: UIImage, targetHeight: CGFloat) -> UIImage? {
|
||||
let size = image.size
|
||||
|
||||
let heightRatio = targetHeight / size.height
|
||||
let newSize = CGSize(width: size.width * heightRatio, height: targetHeight)
|
||||
|
||||
let renderer = UIGraphicsImageRenderer(size: newSize)
|
||||
let resizedImage = renderer.image { (context) in
|
||||
image.draw(in: CGRect(origin: .zero, size: newSize))
|
||||
}
|
||||
|
||||
return resizedImage
|
||||
}
|
||||
|
||||
func cropAndResizeImage(image: UIImage, targetHeight: CGFloat) -> UIImage? {
|
||||
let originalSize = image.size
|
||||
let targetAspectRatio = 4.0 / 3.0
|
||||
var cropRect: CGRect
|
||||
|
||||
// Calculate the rect to crop to 4:3
|
||||
if originalSize.width / originalSize.height > targetAspectRatio {
|
||||
// Image is wider than 4:3, crop width
|
||||
let croppedWidth = originalSize.height * targetAspectRatio
|
||||
let cropX = (originalSize.width - croppedWidth) / 2.0
|
||||
cropRect = CGRect(x: cropX, y: 0, width: croppedWidth, height: originalSize.height)
|
||||
} else {
|
||||
// Image is narrower than 4:3, crop height
|
||||
let croppedHeight = originalSize.width / targetAspectRatio
|
||||
let cropY = (originalSize.height - croppedHeight) / 2.0
|
||||
cropRect = CGRect(x: 0, y: cropY, width: originalSize.width, height: croppedHeight)
|
||||
}
|
||||
|
||||
// Crop the image
|
||||
guard let croppedCGImage = image.cgImage?.cropping(to: cropRect) else { return nil }
|
||||
let croppedImage = UIImage(cgImage: croppedCGImage)
|
||||
|
||||
// Resize the cropped image
|
||||
let resizeRatio = targetHeight / croppedImage.size.height
|
||||
let resizedSize = CGSize(width: croppedImage.size.width * resizeRatio, height: targetHeight)
|
||||
|
||||
let renderer = UIGraphicsImageRenderer(size: resizedSize)
|
||||
let resizedImage = renderer.image { (context) in
|
||||
croppedImage.draw(in: CGRect(origin: .zero, size: resizedSize))
|
||||
}
|
||||
|
||||
return resizedImage
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,7 @@ struct MainView: View {
|
||||
Text("Refresh all")
|
||||
Image(systemName: "icloud.and.arrow.down")
|
||||
}
|
||||
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
@@ -235,7 +236,7 @@ struct RecipeSearchView: View {
|
||||
.navigationDestination(for: Recipe.self) { recipe in
|
||||
RecipeDetailView(viewModel: viewModel, recipe: recipe)
|
||||
}
|
||||
.searchable(text: $searchText, prompt: "Search recipes")
|
||||
.searchable(text: $searchText, prompt: "Search recipes/keywords")
|
||||
}
|
||||
.navigationTitle("Search recipe")
|
||||
}
|
||||
@@ -247,7 +248,9 @@ struct RecipeSearchView: View {
|
||||
func recipesFiltered() -> [Recipe] {
|
||||
guard searchText != "" else { return allRecipes }
|
||||
return allRecipes.filter { recipe in
|
||||
recipe.name.lowercased().contains(searchText.lowercased())
|
||||
recipe.name.lowercased().contains(searchText.lowercased()) || // check name for occurence of search term
|
||||
(recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased())) // check keywords for search term
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ struct RecipeDetailView: View {
|
||||
@State private var presentEditView: Bool = false
|
||||
@State private var presentNutritionPopover: Bool = false
|
||||
@State private var presentKeywordPopover: Bool = false
|
||||
@State private var presentShareSheet: Bool = false
|
||||
@State private var sharedURL: URL? = nil
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
@@ -76,6 +78,7 @@ struct RecipeDetailView: View {
|
||||
RecipeKeywordSection(recipeDetail: recipeDetail)
|
||||
MoreInformationSection(recipeDetail: recipeDetail)
|
||||
}
|
||||
|
||||
}.padding(.horizontal, 5)
|
||||
|
||||
}
|
||||
@@ -85,12 +88,25 @@ struct RecipeDetailView: View {
|
||||
.navigationTitle(showTitle ? recipe.name : "")
|
||||
.toolbar {
|
||||
if recipeDetail != nil {
|
||||
Button {
|
||||
presentEditView = true
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Edit")
|
||||
Menu {
|
||||
Button {
|
||||
presentEditView = true
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Edit")
|
||||
Image(systemName: "pencil")
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
print("Sharing recipe ...")
|
||||
self.presentShareSheet = true
|
||||
} label: {
|
||||
Text("Share recipe")
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,6 +123,14 @@ struct RecipeDetailView: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $presentShareSheet) {
|
||||
if let recipeDetail = recipeDetail {
|
||||
ShareView(recipeDetail: recipeDetail,
|
||||
recipeImage: recipeImage,
|
||||
presentShareSheet: $presentShareSheet)
|
||||
}
|
||||
}
|
||||
|
||||
.task {
|
||||
recipeDetail = await viewModel.getRecipe(
|
||||
id: recipe.recipe_id,
|
||||
@@ -136,6 +160,50 @@ struct RecipeDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct ShareView: View {
|
||||
@State var recipeDetail: RecipeDetail
|
||||
@State var recipeImage: UIImage?
|
||||
@Binding var presentShareSheet: Bool
|
||||
|
||||
@State var exporter = RecipeExporter()
|
||||
@State var sharedURL: URL? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
if let url = sharedURL {
|
||||
ShareLink(item: url, subject: Text("PDF Document")) {
|
||||
Image(systemName: "doc")
|
||||
Text("Share as PDF")
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
.bold()
|
||||
.padding()
|
||||
}
|
||||
|
||||
ShareLink(item: exporter.createText(recipe: recipeDetail), subject: Text("Recipe")) {
|
||||
Image(systemName: "ellipsis.message")
|
||||
Text("Share as text")
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
.bold()
|
||||
.padding()
|
||||
|
||||
/*ShareLink(item: exporter.createJson(recipe: recipeDetail), subject: Text("Recipe")) {
|
||||
Image(systemName: "doc.badge.gearshape")
|
||||
Text("Share as JSON")
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
.bold()
|
||||
.padding()
|
||||
*/
|
||||
}
|
||||
.task {
|
||||
self.sharedURL = exporter.createPDF(recipe: recipeDetail, image: recipeImage)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fileprivate struct RecipeDurationSection: View {
|
||||
@State var recipeDetail: RecipeDetail
|
||||
|
||||