diff --git a/Localization/en.lproj/Localizable.strings b/Localization/en.lproj/Localizable.strings index 039e176..1be22a3 100644 --- a/Localization/en.lproj/Localizable.strings +++ b/Localization/en.lproj/Localizable.strings @@ -3,6 +3,9 @@ "global.title.seePost" = "See post"; "global.title.refresh" = "Refresh"; "global.title.momentsAgo" = "moments ago"; +"global.title.success" = "Success"; +"global.title.photoSaved" = "Photo has been saved."; +"global.title.ok" = "OK"; // MARK: Global errors. "global.error.unexpected" = "Unexpected error."; @@ -252,6 +255,9 @@ "status.title.unbookmark" = "Unbookmark"; "status.title.comment" = "Comment"; "status.title.report" = "Report"; +"status.title.saveImage" = "Save image"; +"status.title.showMediaDescription" = "Show media description"; +"status.title.mediaDescription" = "Media description"; "status.error.loadingStatusFailed" = "Loading status failed."; "status.error.notFound" = "Status not existing anymore."; "status.error.loadingCommentsFailed" = "Comments cannot be downloaded."; diff --git a/Localization/eu.lproj/Localizable.strings b/Localization/eu.lproj/Localizable.strings index 4fdcfc3..e7a42d6 100644 --- a/Localization/eu.lproj/Localizable.strings +++ b/Localization/eu.lproj/Localizable.strings @@ -3,6 +3,9 @@ "global.title.seePost" = "Ikusi bidalketa"; "global.title.refresh" = "Freskatu"; "global.title.momentsAgo" = "oraintxe bertan"; +"global.title.success" = "Success"; +"global.title.photoSaved" = "Photo has been saved."; +"global.title.ok" = "OK"; // MARK: Global errors. "global.error.unexpected" = "Espero ez zen errorea."; @@ -252,6 +255,9 @@ "status.title.unbookmark" = "Kendu laster-marka"; "status.title.comment" = "Egin iruzkina"; "status.title.report" = "Salatu"; +"status.title.saveImage" = "Save image"; +"status.title.showMediaDescription" = "Show media description"; +"status.title.mediaDescription" = "Media description"; "status.error.loadingStatusFailed" = "Egoera kargatzeak huts egin du."; "status.error.notFound" = "Egoera ez da dagoeneko existitzen."; "status.error.loadingCommentsFailed" = "Ezin dira iruzkinak eskuratu."; diff --git a/Localization/pl.lproj/Localizable.strings b/Localization/pl.lproj/Localizable.strings index c2cae72..5e50849 100644 --- a/Localization/pl.lproj/Localizable.strings +++ b/Localization/pl.lproj/Localizable.strings @@ -3,6 +3,9 @@ "global.title.seePost" = "Pokaż zdjęcie"; "global.title.refresh" = "Odśwież"; "global.title.momentsAgo" = "chwilę temu"; +"global.title.success" = "Sukces"; +"global.title.photoSaved" = "Zdjęcie zostało zapisane."; +"global.title.ok" = "OK"; // MARK: Global errors. "global.error.unexpected" = "Wystąpił nieoczekiwany błąd."; @@ -252,6 +255,9 @@ "status.title.unbookmark" = "Usuń z zakładek"; "status.title.comment" = "Skomentuj"; "status.title.report" = "Zgłoś"; +"status.title.saveImage" = "Zapisz zdjęcie"; +"status.title.showMediaDescription" = "Pokaż opis zdjęcia"; +"status.title.mediaDescription" = "Opis zdjęcia"; "status.error.loadingStatusFailed" = "Błąd podczas wczytywanie statusu."; "status.error.notFound" = "Status już nie istnieje."; "status.error.loadingCommentsFailed" =" Błąd podczas wczytywanie komentarzy."; diff --git a/Vernissage.xcodeproj/project.pbxproj b/Vernissage.xcodeproj/project.pbxproj index 5d6aac8..2a14645 100644 --- a/Vernissage.xcodeproj/project.pbxproj +++ b/Vernissage.xcodeproj/project.pbxproj @@ -82,6 +82,7 @@ F86B7214296BFDCE00EE59EC /* UserProfileHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F86B7213296BFDCE00EE59EC /* UserProfileHeaderView.swift */; }; F86B7216296BFFDA00EE59EC /* UserProfileStatusesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F86B7215296BFFDA00EE59EC /* UserProfileStatusesView.swift */; }; F86B7221296C49A300EE59EC /* EmptyButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F86B7220296C49A300EE59EC /* EmptyButtonStyle.swift */; }; + F86BC9E929EBBB67009415EC /* ImageSaver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F86BC9E829EBBB66009415EC /* ImageSaver.swift */; }; F8742FC429990AFB00E9642B /* ClientError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8742FC329990AFB00E9642B /* ClientError.swift */; }; F8764187298ABB520057D362 /* ViewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8764186298ABB520057D362 /* ViewState.swift */; }; F876418D298AE5020057D362 /* PaginableStatusesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F876418C298AE5020057D362 /* PaginableStatusesView.swift */; }; @@ -272,6 +273,7 @@ F86B7213296BFDCE00EE59EC /* UserProfileHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileHeaderView.swift; sourceTree = ""; }; F86B7215296BFFDA00EE59EC /* UserProfileStatusesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileStatusesView.swift; sourceTree = ""; }; F86B7220296C49A300EE59EC /* EmptyButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyButtonStyle.swift; sourceTree = ""; }; + F86BC9E829EBBB66009415EC /* ImageSaver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageSaver.swift; sourceTree = ""; }; F8742FC329990AFB00E9642B /* ClientError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientError.swift; sourceTree = ""; }; F8764186298ABB520057D362 /* ViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewState.swift; sourceTree = ""; }; F876418C298AE5020057D362 /* PaginableStatusesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginableStatusesView.swift; sourceTree = ""; }; @@ -718,6 +720,7 @@ F85D4974296407F100751DF7 /* HomeTimelineService.swift */, F88E4D49297EA0490057491A /* RouterPath.swift */, F878842129A4A4E3003CFAD2 /* AppMetadataService.swift */, + F86BC9E829EBBB66009415EC /* ImageSaver.swift */, ); path = Services; sourceTree = ""; @@ -1117,6 +1120,7 @@ F89F57B029D1C11200001EE3 /* RelationshipModel.swift in Sources */, F88AB05829B36B8200345EDE /* AccountsPhotoView.swift in Sources */, F85D4971296402DC00751DF7 /* AuthorizationService.swift in Sources */, + F86BC9E929EBBB67009415EC /* ImageSaver.swift in Sources */, F88AB05329B3613900345EDE /* PhotoUrl.swift in Sources */, F88E4D56297EAD6E0057491A /* AppRouteur.swift in Sources */, F88FAD27295F400E009B20C9 /* NotificationsView.swift in Sources */, @@ -1412,6 +1416,7 @@ INFOPLIST_FILE = Vernissage/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Vernissage; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography"; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Saving photos from Pixelfed"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; @@ -1452,6 +1457,7 @@ INFOPLIST_FILE = Vernissage/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Vernissage; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography"; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Saving photos from Pixelfed"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; diff --git a/Vernissage/Services/ImageSaver.swift b/Vernissage/Services/ImageSaver.swift new file mode 100644 index 0000000..99d1e2c --- /dev/null +++ b/Vernissage/Services/ImageSaver.swift @@ -0,0 +1,24 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the Apache License 2.0. +// + +import Foundation +import UIKit + +class ImageSaver: NSObject { + private let completed: () -> Void + + init(completed: @escaping () -> Void) { + self.completed = completed + } + + func writeToPhotoAlbum(image: UIImage) { + UIImageWriteToSavedPhotosAlbum(image, self, #selector(saveCompleted), nil) + } + + @objc func saveCompleted(_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer) { + self.completed() + } +} diff --git a/Vernissage/ViewModifiers/ImageContextMenu.swift b/Vernissage/ViewModifiers/ImageContextMenu.swift index 8ed5871..054d8c7 100644 --- a/Vernissage/ViewModifiers/ImageContextMenu.swift +++ b/Vernissage/ViewModifiers/ImageContextMenu.swift @@ -10,24 +10,41 @@ import ClientKit import ServicesKit public extension View { - func imageContextMenu(statusModel: StatusModel) -> some View { - modifier(ImageContextMenu(id: statusModel.id, url: statusModel.url)) + func imageContextMenu(statusModel: StatusModel, attachmentModel: AttachmentModel, uiImage: UIImage?) -> some View { + modifier(ImageContextMenu(id: statusModel.id, url: statusModel.url, altText: attachmentModel.description, uiImage: uiImage)) } - func imageContextMenu(statusData: StatusData) -> some View { - modifier(ImageContextMenu(id: statusData.id, url: statusData.url)) + func imageContextMenu(statusData: StatusData, attachmentData: AttachmentData, uiImage: UIImage?) -> some View { + modifier(ImageContextMenu(id: statusData.id, url: statusData.url, altText: attachmentData.text, uiImage: uiImage)) } } private struct ImageContextMenu: ViewModifier { + private struct AlertInfo: Identifiable { + enum AlertType { + case showAlternativeText + case photoHasBeenSaved + } + + let id: AlertType + let title: Text + let message: Text + } + @EnvironmentObject var client: Client + @State private var alertInfo: AlertInfo? + private let id: String private let url: URL? + private let altText: String? + private let uiImage: UIImage? - init(id: String, url: URL?) { + init(id: String, url: URL?, altText: String?, uiImage: UIImage?) { self.id = id self.url = url + self.altText = altText + self.uiImage = uiImage } func body(content: Content) -> some View { @@ -69,8 +86,43 @@ private struct ImageContextMenu: ViewModifier { Label("status.title.shareStatus", systemImage: "square.and.arrow.up") } } + + Divider() + + if let altText, altText.count > 0 { + Button { + self.alertInfo = AlertInfo( + id: .showAlternativeText, + title: Text("status.title.mediaDescription", comment: "Media description"), + message: Text(altText) + ) + } label: { + Label("status.title.showMediaDescription", systemImage: "eye.trianglebadge.exclamationmark") + } + } + + if let uiImage { + Button { + let imageSaver = ImageSaver { + self.alertInfo = AlertInfo( + id: .photoHasBeenSaved, + title: Text("global.title.success", comment: "Success"), + message: Text("global.title.photoSaved", comment: "Photo has been saved") + ) + } + + imageSaver.writeToPhotoAlbum(image: uiImage) + } label: { + Label("status.title.saveImage", systemImage: "square.and.arrow.down") + } + } } } + .alert(item: $alertInfo, content: { info in + Alert(title: info.title, + message: info.message, + dismissButton: .default(Text("global.title.ok", comment: "OK"))) + }) } private func reboost() async { diff --git a/Vernissage/ViewModifiers/NavigationMenu.swift b/Vernissage/ViewModifiers/NavigationMenu.swift index f922842..5f5511f 100644 --- a/Vernissage/ViewModifiers/NavigationMenu.swift +++ b/Vernissage/ViewModifiers/NavigationMenu.swift @@ -73,7 +73,7 @@ private struct NavigationMenu: ViewModifier where MenuItems: View { } .padding(.horizontal, 8) .background(.ultraThinMaterial) - .clipShape(RoundedRectangle(cornerRadius: 22)) + .clipShape(Capsule()) } @ViewBuilder @@ -81,12 +81,11 @@ private struct NavigationMenu: ViewModifier where MenuItems: View { Menu { self.menuItems() } label: { - Image(systemName: "line.3.horizontal") - .resizable() - .foregroundColor(.mainTextColor.opacity(0.8)) - .shadow(radius: 5) - .padding(12) - .frame(width: 44, height: 44) + Image(systemName: "ellipsis") + .font(.system(size: 26)) + .foregroundColor(.mainTextColor.opacity(0.75)) + .padding(.vertical, 10) + .padding(.horizontal, 8) } } @@ -96,11 +95,10 @@ private struct NavigationMenu: ViewModifier where MenuItems: View { self.routerPath.presentedSheet = .newStatusEditor } label: { Image(systemName: "plus") - .resizable() - .foregroundColor(.mainTextColor.opacity(0.8)) - .shadow(radius: 5) - .padding(12) - .frame(width: 44, height: 44) + .font(.system(size: 26)) + .foregroundColor(.mainTextColor.opacity(0.75)) + .padding(.vertical, 10) + .padding(.horizontal, 8) } } } diff --git a/Vernissage/Widgets/ImageRowItem.swift b/Vernissage/Widgets/ImageRowItem.swift index 236034d..dcaa669 100644 --- a/Vernissage/Widgets/ImageRowItem.swift +++ b/Vernissage/Widgets/ImageRowItem.swift @@ -43,7 +43,7 @@ struct ImageRowItem: View { ZStack { ContentWarning(spoilerText: self.status.spoilerText) { self.imageContainerView(uiImage: uiImage) - .imageContextMenu(statusData: self.status) + .imageContextMenu(statusData: self.status, attachmentData: self.attachmentData, uiImage: uiImage) } blurred: { ZStack { BlurredImage(blurhash: attachmentData.blurhash) @@ -62,7 +62,7 @@ struct ImageRowItem: View { } } else { self.imageContainerView(uiImage: uiImage) - .imageContextMenu(statusData: self.status) + .imageContextMenu(statusData: self.status, attachmentData: self.attachmentData, uiImage: uiImage) .opacity(self.opacity) .onAppear { withAnimation { diff --git a/Vernissage/Widgets/ImageRowItemAsync.swift b/Vernissage/Widgets/ImageRowItemAsync.swift index 7c4d833..e63ac7e 100644 --- a/Vernissage/Widgets/ImageRowItemAsync.swift +++ b/Vernissage/Widgets/ImageRowItemAsync.swift @@ -43,7 +43,9 @@ struct ImageRowItemAsync: View { ZStack { ContentWarning(spoilerText: self.statusViewModel.spoilerText) { self.imageContainerView(image: image) - .imageContextMenu(statusModel: self.statusViewModel) + .imageContextMenu(statusModel: self.statusViewModel, + attachmentModel: self.attachment, + uiImage: state.imageResponse?.image) } blurred: { ZStack { BlurredImage(blurhash: attachment.blurhash) @@ -67,7 +69,9 @@ struct ImageRowItemAsync: View { } } else { self.imageContainerView(image: image) - .imageContextMenu(statusModel: self.statusViewModel) + .imageContextMenu(statusModel: self.statusViewModel, + attachmentModel: self.attachment, + uiImage: state.imageResponse?.image) .opacity(self.opacity) .onAppear { if let uiImage = state.imageResponse?.image {