Add sending files, images, and clipboard to iOS app

This commit is contained in:
Leon Böttger committed 2025-08-15 11:29:40 +02:00
1 parent 045725072b
commit c781af554f
14 files changed
+802 -231

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
//
// Configuration.swift
// QuickDrop
//
// Created by Leon Böttger on 15.08.25.
//
import LUI
let configuration = LUIConfiguration(
appName: "QuickDrop",
hasRoundedFont: false,
copyrightInformation: licenseText,
introductionViewInformation: "QuickDropWelcomeDescription",
usesPagedIntroduction: false,
supportsSplitView: false,
supportsPortraitSplitView: false,
supportsDarkModeShadow: false
)
+87 -93
View File
@@ -13,7 +13,11 @@ struct ContentView: View {
@StateObject var model = ShareViewModel()
var body: some View {
DeviceListView(model: model)
AppRootView(isPlus: .constant(true), phoneView: {
DeviceListView(model: model)
}, settingsView: {
EmptyView()
})
}
}
@@ -23,112 +27,100 @@ struct DeviceListView: View {
var body: some View {
NavigationView {
NavigationSubView(header: "QuickDrop ") {
NavigationSubView(header: "QuickDrop ") {
// "You're visible as"
VStack(alignment: .leading, spacing: 8) {
FooterView(text: "YouAreVisibleAs")
// "You're visible as"
VStack(alignment: .leading, spacing: 8) {
FooterView(text: "YouAreVisibleAs")
Text(NearbyConnectionManager.shared.getEndpointInfo().name ?? "QuickDrop")
.fontWeight(.bold)
.padding(.horizontal, 16)
.padding(.vertical, 6)
.background(Color.defaultForegroundColor)
.cornerRadius(24)
.padding(.horizontal)
}
.padding(.top, 8)
.frame(maxWidth: .infinity, alignment: .leading)
if model.foundDevices.isEmpty {
CustomSection(header: "NoDevicesFound") {
Text(NearbyConnectionManager.shared.getEndpointInfo().name ?? "QuickDrop")
.fontWeight(.bold)
.padding(.horizontal, 16)
.padding(.vertical, 6)
.background(Color.defaultForegroundColor)
.cornerRadius(24)
.padding(.horizontal)
}
.padding(.top, 8)
.frame(maxWidth: .infinity, alignment: .leading)
if model.foundDevices.isEmpty {
CustomSection(header: "NoDevicesFound") {
HStack(spacing: 12) {
Image(systemName: "square.and.arrow.down")
.font(.system(size: 30))
.foregroundColor(.gray)
HStack(spacing: 12) {
Image(systemName: "square.and.arrow.down")
.font(.system(size: 30))
.foregroundColor(.gray)
Text("DownloadQuickDropOnPlayStore")
}
.padding(.vertical)
Text("DownloadQuickDropOnPlayStore")
}
} else {
CustomSection(header: "AvailableDevices") {
ForEach(model.foundDevices) { device in
LUIButton {
model.selectDevice(device: device)
} label: {
HStack(spacing: 12) {
let name = device.name ?? "Android"
ZStack {
Circle()
.fill(
LinearGradient(
colors: [Color(red: 0.623, green: 0.659, blue: 0.855),
Color(red: 0.474, green: 0.525, blue: 0.796)],
startPoint: .top,
endPoint: .bottom
)
.padding(.vertical)
}
} else {
CustomSection(header: "AvailableDevices") {
ForEach(model.foundDevices) { device in
SendPickerButton {
HStack(spacing: 12) {
let name = device.name ?? "Android"
ZStack {
Circle()
.fill(
LinearGradient(
colors: [Color(red: 0.623, green: 0.659, blue: 0.855),
Color(red: 0.474, green: 0.525, blue: 0.796)],
startPoint: .top,
endPoint: .bottom
)
.frame(width: 40, height: 40)
Image(systemName: device.icon)
.foregroundColor(.white)
.fontWeight(.bold)
}
)
.frame(width: 40, height: 40)
VStack(alignment: .leading, spacing: 5) {
Text(name)
.fontWeight(.medium)
.foregroundColor(Color.primary)
Text(model.selectedDevice == device ? model.progressState ?? "..." : "Available".localized())
.font(.system(size: 12))
.foregroundColor(Color.primary.opacity(0.6))
}
Image(systemName: device.icon)
.foregroundColor(.white)
.fontWeight(.bold)
}
VStack(alignment: .leading, spacing: 5) {
Text(name)
.fontWeight(.medium)
.foregroundColor(Color.primary)
Spacer()
Image(systemName: "chevron.right")
Text(model.selectedDevice == device ? model.progressState ?? "..." : "Available".localized())
.font(.system(size: 12))
.foregroundColor(Color.primary.opacity(0.6))
}
.padding(.vertical, 13)
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(Color.primary.opacity(0.6))
}
.padding(.vertical, 13)
} onResult: { urls, text in
model.urls = urls ?? []
model.textToSend = text
model.selectDevice(device: device)
}
}
}
HStack(spacing: 8) {
Text("SearchingForDevices")
.font(.system(size: 14))
.foregroundColor(Color.primary.opacity(0.6))
ProgressView()
.frame(width: 12, height: 12)
}
.padding(.vertical, 16)
Spacer()
// Privacy policy
Button(action: {
if let url = URL(string: "https://leonboettger.com/quickdrop-privacy") {
UIApplication.shared.open(url)
}
}) {
Text("PrivacyPolicy")
.font(.system(size: 14))
.foregroundColor(Color.accentColor.opacity(0.8))
}
}
HStack(spacing: 8) {
Text("SearchingForDevices")
.font(.system(size: 14))
.foregroundColor(Color.primary.opacity(0.6))
ProgressView()
.frame(width: 12, height: 12)
}
.padding(.vertical, 16)
}
.navigationBarItems(trailing: LUISettingsButton())
}
}
@@ -146,5 +138,7 @@ extension DeviceListView {
}
#Preview {
DeviceListView.preview
NavigationView {
DeviceListView.preview
}
}
+169
View File
@@ -0,0 +1,169 @@
//
// FilePicker.swift
// QuickDrop
//
// Created by Leon Böttger on 15.08.25.
//
import SwiftUI
import PhotosUI
import LUI
import UniformTypeIdentifiers
struct SendPickerButton<Label: View>: View {
var label: () -> Label
var onResult: (_ urls: [URL]?, _ text: String?) -> Void
@State private var showPhotoPicker = false
@State private var selectedPhotoItems: [PhotosPickerItem] = []
@State private var showFilePicker = false
var body: some View {
Menu {
Button("SendImages", systemImage: "photo") {
showPhotoPicker = true
}
Button("SendFiles", systemImage: "folder") {
showFilePicker = true
}
Button("SendClipboard", systemImage: "doc.on.clipboard") {
if let clipboardText = UIPasteboard.general.string, !clipboardText.isEmpty {
onResult(nil, clipboardText)
} else {
showAlert(title: "ClipboardEmpty", message: "ClipboardEmptyMessage")
}
}
} label: {
label()
}
.photosPicker(
isPresented: $showPhotoPicker,
selection: $selectedPhotoItems,
maxSelectionCount: nil,
matching: .any(of: [.images, .videos])
)
.onChange(of: selectedPhotoItems) { newItems in
guard !newItems.isEmpty else { return }
updatedPhotoSelection(content: newItems)
}
// File picker
.fileImporter(isPresented: $showFilePicker,
allowedContentTypes: [.item, .folder], // any file
allowsMultipleSelection: true) { result in
switch result {
case .success(let urls):
onResult(urls, nil)
case .failure:
onResult(nil, nil)
}
}
}
func updatedPhotoSelection(content: [PhotosPickerItem]) {
Task {
do {
let urls = try await fetchContentUrls(content: content)
onResult(urls, nil)
selectedPhotoItems = []
} catch {
log("Could not fetch photo URLs: \(error)")
}
}
}
func fetchContentUrls(content: [PhotosPickerItem]) async throws -> [URL] {
try await withThrowingTaskGroup(of: URL?.self, returning: [URL].self) { group in
let fileManager = FileManager.default
for item in content {
group.addTask {
if let dataUrl = try await item.loadTransferable(type: Data.self) {
if let contentType = item.supportedContentTypes.first {
// Step 2: make the URL file name and a get a file extention.
let url = fileManager.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).\(contentType.preferredFilenameExtension ?? "")")
do {
// Step 3: write to temp App file directory and return in completionHandler
try dataUrl.write(to: url)
return url
} catch {
log("Faied to write data to file: \(error)")
throw error
}
}
// let destination = fileManager.temporaryDirectory.appendingPathComponent(dataUrl.url.lastPathComponent)
//
// // overwrite if file exists
// if fileManager.fileExists(atPath: destination.path) {
// try fileManager.removeItem(at: destination)
// }
//
// log("Copying photo at \(dataUrl.url) to \(destination)")
//
// do {
// try fileManager.copyItem(at: dataUrl.url, to: destination)
// }
// catch {
// log("Failed to copy photo: \(error)")
// throw error
// }
// return destination
}
return nil
}
}
var finalUrls = [URL]()
for try await result in group {
if let url = result {
finalUrls.append(url)
}
}
return finalUrls
}
}
}
/// File representation
fileprivate struct DataUrl: Transferable {
let url: URL
static var transferRepresentation: some TransferRepresentation {
FileRepresentation(contentType: .data) { data in
log("Received data: \(data.url)")
return SentTransferredFile(data.url)
} importing: { received in
log("Importing file: \(received.file)")
return Self(url: received.file)
}
}
}
#Preview {
SendPickerButton {
Text("Send Files")
.font(.headline)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(8)
} onResult: { urls, text in
if let urls = urls {
print("Selected URLs: \(urls)")
}
if let text = text {
print("Clipboard text: \(text)")
}
}
}
+2 -4
View File
@@ -16,8 +16,8 @@ class ShareViewModel: ObservableObject, ShareExtensionDelegate {
@Published var progressState: String? = nil
@Published var progressValue: Double? = nil
private var urls: [URL] = []
private var textToSend = "Test from QuickDrop iOS"
var urls: [URL] = []
var textToSend: String? = nil
//private var errorAlertHandler = ErrorAlertHandler.shared
private var connectionEstablished = false
@@ -70,8 +70,6 @@ class ShareViewModel: ObservableObject, ShareExtensionDelegate {
}
func selectDevice(device: RemoteDeviceInfo) {
NearbyConnectionManager.shared.stopDeviceDiscovery()
progressValue = 0
progressState = "Connecting".localized()
selectedDevice = device
+4
View File
@@ -4,5 +4,9 @@
<dict>
<key>com.apple.developer.networking.multicast</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.leonboettger.neardrop</string>
</array>
</dict>
</plist>
+10
View File
@@ -6,9 +6,19 @@
//
import SwiftUI
import LUI
@main
struct QuickDropiOSApp: App {
#if !os(macOS)
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
#endif
init() {
LUIInit(configuration: configuration)
}
var body: some Scene {
WindowGroup {
ContentView()