mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 01:05:19 +00:00
Refactor & Extract image viewers (#1074)
* Extract `ImagePreviewView` + add `#Preview` * Extract image pickers + dedupe and bg processing of images * Include a dummy photo in the preview assets vs live url * Fix development assets + url percent encoding * Fix iOS vs macOS build issue * Fix SPM-related issues
This commit is contained in:
@@ -32,6 +32,7 @@ let package = Package(
|
|||||||
exclude: [
|
exclude: [
|
||||||
"Info.plist",
|
"Info.plist",
|
||||||
"Assets.xcassets",
|
"Assets.xcassets",
|
||||||
|
"_PreviewHelpers/PreviewAssets.xcassets",
|
||||||
"bitchat.entitlements",
|
"bitchat.entitlements",
|
||||||
"bitchat-macOS.entitlements",
|
"bitchat-macOS.entitlements",
|
||||||
"LaunchScreen.storyboard",
|
"LaunchScreen.storyboard",
|
||||||
|
|||||||
Generated
+2
@@ -548,6 +548,7 @@
|
|||||||
CODE_SIGNING_REQUIRED = YES;
|
CODE_SIGNING_REQUIRED = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
|
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
|
||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||||
ENABLE_PREVIEWS = NO;
|
ENABLE_PREVIEWS = NO;
|
||||||
INFOPLIST_FILE = bitchat/Info.plist;
|
INFOPLIST_FILE = bitchat/Info.plist;
|
||||||
@@ -608,6 +609,7 @@
|
|||||||
CODE_SIGNING_REQUIRED = YES;
|
CODE_SIGNING_REQUIRED = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
|
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
|
||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
INFOPLIST_FILE = bitchat/Info.plist;
|
INFOPLIST_FILE = bitchat/Info.plist;
|
||||||
|
|||||||
@@ -364,6 +364,36 @@ extension ChatViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
func processThenSendImage(_ image: UIImage?) {
|
||||||
|
guard let image else { return }
|
||||||
|
Task.detached {
|
||||||
|
do {
|
||||||
|
let processedURL = try ImageUtils.processImage(image)
|
||||||
|
await MainActor.run {
|
||||||
|
self.sendImage(from: processedURL)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#elseif os(macOS)
|
||||||
|
func processThenSendImage(from url: URL?) {
|
||||||
|
guard let url else { return }
|
||||||
|
Task.detached {
|
||||||
|
do {
|
||||||
|
let processedURL = try ImageUtils.processImage(at: url)
|
||||||
|
await MainActor.run {
|
||||||
|
self.sendImage(from: processedURL)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) {
|
func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) {
|
||||||
guard canSendMediaInCurrentContext else {
|
guard canSendMediaInCurrentContext else {
|
||||||
|
|||||||
@@ -223,18 +223,7 @@ struct ContentView: View {
|
|||||||
)) {
|
)) {
|
||||||
ImagePickerView(sourceType: imagePickerSourceType) { image in
|
ImagePickerView(sourceType: imagePickerSourceType) { image in
|
||||||
showImagePicker = false
|
showImagePicker = false
|
||||||
if let image = image {
|
viewModel.processThenSendImage(image)
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
let processedURL = try ImageUtils.processImage(image)
|
|
||||||
await MainActor.run {
|
|
||||||
viewModel.sendImage(from: processedURL)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.environmentObject(viewModel)
|
.environmentObject(viewModel)
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
@@ -252,18 +241,7 @@ struct ContentView: View {
|
|||||||
)) {
|
)) {
|
||||||
MacImagePickerView { url in
|
MacImagePickerView { url in
|
||||||
showMacImagePicker = false
|
showMacImagePicker = false
|
||||||
if let url = url {
|
viewModel.processThenSendImage(from: url)
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
let processedURL = try ImageUtils.processImage(at: url)
|
|
||||||
await MainActor.run {
|
|
||||||
viewModel.sendImage(from: processedURL)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.environmentObject(viewModel)
|
.environmentObject(viewModel)
|
||||||
}
|
}
|
||||||
@@ -842,18 +820,7 @@ struct ContentView: View {
|
|||||||
)) {
|
)) {
|
||||||
ImagePickerView(sourceType: imagePickerSourceType) { image in
|
ImagePickerView(sourceType: imagePickerSourceType) { image in
|
||||||
showImagePicker = false
|
showImagePicker = false
|
||||||
if let image = image {
|
viewModel.processThenSendImage(image)
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
let processedURL = try ImageUtils.processImage(image)
|
|
||||||
await MainActor.run {
|
|
||||||
viewModel.sendImage(from: processedURL)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.environmentObject(viewModel)
|
.environmentObject(viewModel)
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
@@ -863,18 +830,7 @@ struct ContentView: View {
|
|||||||
.sheet(isPresented: $showMacImagePicker) {
|
.sheet(isPresented: $showMacImagePicker) {
|
||||||
MacImagePickerView { url in
|
MacImagePickerView { url in
|
||||||
showMacImagePicker = false
|
showMacImagePicker = false
|
||||||
if let url = url {
|
viewModel.processThenSendImage(from: url)
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
let processedURL = try ImageUtils.processImage(at: url)
|
|
||||||
await MainActor.run {
|
|
||||||
viewModel.sendImage(from: processedURL)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.environmentObject(viewModel)
|
.environmentObject(viewModel)
|
||||||
}
|
}
|
||||||
@@ -1850,204 +1806,3 @@ private extension ContentView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
|
||||||
|
|
||||||
struct ImagePreviewView: View {
|
|
||||||
let url: URL
|
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
#if os(iOS)
|
|
||||||
@State private var showExporter = false
|
|
||||||
@State private var platformImage: UIImage?
|
|
||||||
#else
|
|
||||||
@State private var platformImage: NSImage?
|
|
||||||
#endif
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
Color.black.ignoresSafeArea()
|
|
||||||
VStack {
|
|
||||||
Spacer()
|
|
||||||
if let image = platformImage {
|
|
||||||
#if os(iOS)
|
|
||||||
Image(uiImage: image)
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fit)
|
|
||||||
.padding()
|
|
||||||
#else
|
|
||||||
Image(nsImage: image)
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fit)
|
|
||||||
.padding()
|
|
||||||
#endif
|
|
||||||
} else {
|
|
||||||
ProgressView()
|
|
||||||
.progressViewStyle(.circular)
|
|
||||||
.tint(.white)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
HStack {
|
|
||||||
Button(action: { dismiss() }) {
|
|
||||||
Text("close", comment: "Button to dismiss fullscreen media viewer")
|
|
||||||
.font(.bitchatSystem(size: 15, weight: .semibold))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
.background(RoundedRectangle(cornerRadius: 12).stroke(Color.white.opacity(0.5), lineWidth: 1))
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Button(action: saveCopy) {
|
|
||||||
Text("save", comment: "Button to save media to device")
|
|
||||||
.font(.bitchatSystem(size: 15, weight: .semibold))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
.background(RoundedRectangle(cornerRadius: 12).fill(Color.blue.opacity(0.6)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding([.horizontal, .bottom], 24)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear(perform: loadImage)
|
|
||||||
#if os(iOS)
|
|
||||||
.sheet(isPresented: $showExporter) {
|
|
||||||
FileExportWrapper(url: url)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadImage() {
|
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
|
||||||
#if os(iOS)
|
|
||||||
guard let image = UIImage(contentsOfFile: url.path) else { return }
|
|
||||||
#else
|
|
||||||
guard let image = NSImage(contentsOf: url) else { return }
|
|
||||||
#endif
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.platformImage = image
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func saveCopy() {
|
|
||||||
#if os(iOS)
|
|
||||||
showExporter = true
|
|
||||||
#else
|
|
||||||
Task { @MainActor in
|
|
||||||
let panel = NSSavePanel()
|
|
||||||
panel.canCreateDirectories = true
|
|
||||||
panel.nameFieldStringValue = url.lastPathComponent
|
|
||||||
panel.prompt = "save"
|
|
||||||
if panel.runModal() == .OK, let destination = panel.url {
|
|
||||||
do {
|
|
||||||
if FileManager.default.fileExists(atPath: destination.path) {
|
|
||||||
try FileManager.default.removeItem(at: destination)
|
|
||||||
}
|
|
||||||
try FileManager.default.copyItem(at: url, to: destination)
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Failed to save image preview copy: \(error)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
private struct FileExportWrapper: UIViewControllerRepresentable {
|
|
||||||
let url: URL
|
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
|
||||||
let controller = UIDocumentPickerViewController(forExporting: [url])
|
|
||||||
controller.shouldShowFileExtensions = true
|
|
||||||
return controller
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
// MARK: - Image Picker (Camera or Photo Library)
|
|
||||||
struct ImagePickerView: UIViewControllerRepresentable {
|
|
||||||
let sourceType: UIImagePickerController.SourceType
|
|
||||||
let completion: (UIImage?) -> Void
|
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> UIImagePickerController {
|
|
||||||
let picker = UIImagePickerController()
|
|
||||||
picker.sourceType = sourceType
|
|
||||||
picker.delegate = context.coordinator
|
|
||||||
picker.allowsEditing = false
|
|
||||||
|
|
||||||
// Use standard full screen - iOS handles safe areas automatically
|
|
||||||
picker.modalPresentationStyle = .fullScreen
|
|
||||||
|
|
||||||
// Force dark mode to make safe area bars black instead of white
|
|
||||||
picker.overrideUserInterfaceStyle = .dark
|
|
||||||
|
|
||||||
return picker
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
|
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
|
||||||
Coordinator(completion: completion)
|
|
||||||
}
|
|
||||||
|
|
||||||
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
|
||||||
let completion: (UIImage?) -> Void
|
|
||||||
|
|
||||||
init(completion: @escaping (UIImage?) -> Void) {
|
|
||||||
self.completion = completion
|
|
||||||
}
|
|
||||||
|
|
||||||
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
|
||||||
let image = info[.originalImage] as? UIImage
|
|
||||||
completion(image)
|
|
||||||
}
|
|
||||||
|
|
||||||
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
|
||||||
completion(nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if os(macOS)
|
|
||||||
// MARK: - macOS Image Picker
|
|
||||||
struct MacImagePickerView: View {
|
|
||||||
let completion: (URL?) -> Void
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
Text("Choose an image")
|
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
Button("Select Image") {
|
|
||||||
let panel = NSOpenPanel()
|
|
||||||
panel.allowsMultipleSelection = false
|
|
||||||
panel.canChooseDirectories = false
|
|
||||||
panel.canChooseFiles = true
|
|
||||||
panel.allowedContentTypes = [.image, .png, .jpeg, .heic]
|
|
||||||
panel.message = "Choose an image to send"
|
|
||||||
|
|
||||||
if panel.runModal() == .OK {
|
|
||||||
completion(panel.url)
|
|
||||||
} else {
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
|
|
||||||
Button("Cancel") {
|
|
||||||
completion(nil)
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
}
|
|
||||||
.padding(40)
|
|
||||||
.frame(minWidth: 300, minHeight: 150)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//
|
||||||
|
// ImagePickerView.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Camera or Photo Library
|
||||||
|
struct ImagePickerView: UIViewControllerRepresentable {
|
||||||
|
let sourceType: UIImagePickerController.SourceType
|
||||||
|
let completion: (UIImage?) -> Void
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIImagePickerController {
|
||||||
|
let picker = UIImagePickerController()
|
||||||
|
picker.sourceType = sourceType
|
||||||
|
picker.delegate = context.coordinator
|
||||||
|
picker.allowsEditing = false
|
||||||
|
|
||||||
|
// Use standard full screen - iOS handles safe areas automatically
|
||||||
|
picker.modalPresentationStyle = .fullScreen
|
||||||
|
|
||||||
|
// Force dark mode to make safe area bars black instead of white
|
||||||
|
picker.overrideUserInterfaceStyle = .dark
|
||||||
|
|
||||||
|
return picker
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(completion: completion)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||||
|
let completion: (UIImage?) -> Void
|
||||||
|
|
||||||
|
init(completion: @escaping (UIImage?) -> Void) {
|
||||||
|
self.completion = completion
|
||||||
|
}
|
||||||
|
|
||||||
|
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
||||||
|
let image = info[.originalImage] as? UIImage
|
||||||
|
completion(image)
|
||||||
|
}
|
||||||
|
|
||||||
|
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||||
|
completion(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 17, *)
|
||||||
|
#Preview {
|
||||||
|
@Previewable @State var isPresented = true
|
||||||
|
@Previewable @State var selectedImage: UIImage?
|
||||||
|
VStack {
|
||||||
|
if let selectedImage {
|
||||||
|
Image(uiImage: selectedImage)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
} else {
|
||||||
|
Text("No image selected")
|
||||||
|
}
|
||||||
|
Button("Show") { isPresented = true }
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $isPresented) {
|
||||||
|
ImagePickerView(sourceType: .photoLibrary) { image in
|
||||||
|
selectedImage = image
|
||||||
|
isPresented = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
//
|
||||||
|
// ImagePreviewView.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
#if os(macOS)
|
||||||
|
import BitLogger
|
||||||
|
#endif
|
||||||
|
|
||||||
|
struct ImagePreviewView: View {
|
||||||
|
let url: URL
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
#if os(iOS)
|
||||||
|
@State private var showExporter = false
|
||||||
|
@State private var platformImage: UIImage?
|
||||||
|
#else
|
||||||
|
@State private var platformImage: NSImage?
|
||||||
|
#endif
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.black.ignoresSafeArea()
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
if let image = platformImage {
|
||||||
|
#if os(iOS)
|
||||||
|
Image(uiImage: image)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.padding()
|
||||||
|
#else
|
||||||
|
Image(nsImage: image)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.padding()
|
||||||
|
#endif
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
.tint(.white)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
HStack {
|
||||||
|
Button(action: { dismiss() }) {
|
||||||
|
Text("close", comment: "Button to dismiss fullscreen media viewer")
|
||||||
|
.font(.bitchatSystem(size: 15, weight: .semibold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 12).stroke(Color.white.opacity(0.5), lineWidth: 1))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button(action: saveCopy) {
|
||||||
|
Text("save", comment: "Button to save media to device")
|
||||||
|
.font(.bitchatSystem(size: 15, weight: .semibold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 12).fill(Color.blue.opacity(0.6)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding([.horizontal, .bottom], 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear(perform: loadImage)
|
||||||
|
#if os(iOS)
|
||||||
|
.sheet(isPresented: $showExporter) {
|
||||||
|
FileExportWrapper(url: url)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadImage() {
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
#if os(iOS)
|
||||||
|
guard let image = UIImage(contentsOfFile: url.path) else { return }
|
||||||
|
#else
|
||||||
|
guard let image = NSImage(contentsOf: url) else { return }
|
||||||
|
#endif
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.platformImage = image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveCopy() {
|
||||||
|
#if os(iOS)
|
||||||
|
showExporter = true
|
||||||
|
#else
|
||||||
|
Task { @MainActor in
|
||||||
|
let panel = NSSavePanel()
|
||||||
|
panel.canCreateDirectories = true
|
||||||
|
panel.nameFieldStringValue = url.lastPathComponent
|
||||||
|
panel.prompt = "save"
|
||||||
|
if panel.runModal() == .OK, let destination = panel.url {
|
||||||
|
do {
|
||||||
|
if FileManager.default.fileExists(atPath: destination.path) {
|
||||||
|
try FileManager.default.removeItem(at: destination)
|
||||||
|
}
|
||||||
|
try FileManager.default.copyItem(at: url, to: destination)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Failed to save image preview copy: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
private struct FileExportWrapper: UIViewControllerRepresentable {
|
||||||
|
let url: URL
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
||||||
|
let controller = UIDocumentPickerViewController(forExporting: [url])
|
||||||
|
controller.shouldShowFileExtensions = true
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("dummy.jpg")
|
||||||
|
if !FileManager.default.fileExists(atPath: tempURL.path(percentEncoded: false)) {
|
||||||
|
#if os(iOS)
|
||||||
|
let image = UIImage(named: "dummy")
|
||||||
|
let data = image?.jpegData(compressionQuality: 0.8)
|
||||||
|
let _ = try? data?.write(to: tempURL)
|
||||||
|
#elseif os(macOS)
|
||||||
|
let image = NSImage(named: "dummy")
|
||||||
|
var rect = NSRect(origin: .zero, size: image?.size ?? .zero)
|
||||||
|
if let cgImage = image?.cgImage(forProposedRect: &rect, context: nil, hints: nil) {
|
||||||
|
let rep = NSBitmapImageRep(cgImage: cgImage)
|
||||||
|
let jpegData = rep.representation(using: .jpeg, properties: [.compressionFactor: 0.8])
|
||||||
|
let _ = try? jpegData?.write(to: tempURL)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
ImagePreviewView(url: tempURL)
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
//
|
||||||
|
// MacImagePickerView.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MacImagePickerView: View {
|
||||||
|
let completion: (URL?) -> Void
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Text("Choose an image")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Button("Select Image") {
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.allowsMultipleSelection = false
|
||||||
|
panel.canChooseDirectories = false
|
||||||
|
panel.canChooseFiles = true
|
||||||
|
panel.allowedContentTypes = [.image, .png, .jpeg, .heic]
|
||||||
|
panel.message = "Choose an image to send"
|
||||||
|
|
||||||
|
if panel.runModal() == .OK {
|
||||||
|
completion(panel.url)
|
||||||
|
} else {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
|
||||||
|
Button("Cancel") {
|
||||||
|
completion(nil)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
.padding(40)
|
||||||
|
.frame(minWidth: 300, minHeight: 150)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(OSX 14, *)
|
||||||
|
#Preview {
|
||||||
|
@Previewable @State var isPresented = true
|
||||||
|
@Previewable @State var selectedImage: NSImage?
|
||||||
|
|
||||||
|
VStack {
|
||||||
|
if let selectedImage {
|
||||||
|
Image(nsImage: selectedImage)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
} else {
|
||||||
|
Text("No image selected")
|
||||||
|
}
|
||||||
|
Button("Show") { isPresented = true }
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $isPresented) {
|
||||||
|
MacImagePickerView { url in
|
||||||
|
selectedImage = url.map(NSImage.init)
|
||||||
|
isPresented = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "dummy.jpg",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Reference in New Issue
Block a user