mirror of
https://github.com/leonboe1/QuickDrop.git
synced 2026-11-11 19:11:10 +00:00
Add sending files, images, and clipboard to iOS app
This commit is contained in:
1 parent
045725072b
commit
c781af554f
14 files changed
+802
-231
No files matched your search
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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>
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user