Refactoring

This commit is contained in:
Leon Böttger committed 2025-10-19 14:01:02 +02:00
1 parent 1ac988a4fc
commit a69a75cb07
12 files changed
+39 -15

No files matched your search

+325
View File
@@ -0,0 +1,325 @@
//
// ContentView.swift
// QuickDropiOS
//
// Created by Leon Böttger on 28.07.25.
//
import SwiftUI
import LUI
import StoreKit
struct ContentView: View {
@ObservedObject var luiSettings = LUISettings.sharedInstance
@ObservedObject var settings = Settings.shared
var body: some View {
AppRootView(isPlus: $settings.gotPlus, phoneView: {
DeviceListView()
.environment(\.sheetActive, isShareExtension())
}, settingsView: {
CustomSection(header: settings.gotPlus ? "" : "General", footer: "TrustedDevicesFooterShort") {
LUIButton {
SaveFilesManager.shared.openDownloadedFilesFolder()
} label: {
NavigationLinkLabel(imageName: "folder.fill", text: "BrowseDownloadedFiles")
}
LUILink(destination: NavigationSubView(header: "TrustedDevices") {
TrustedDevicesView()
}) {
NavigationLinkLabel(imageName: "checkmark.shield.fill", text: "ManageTrustedDevices", backgroundColor: .green)
}
}
})
}
}
struct DeviceListView: View {
@State private var qrCode: Image? = nil
@StateObject var sendModel = SendModel()
#if !EXTENSION
@StateObject var receiveModel = ReceiveModel()
#endif
@Environment(\.scenePhase) var scenePhase
@Environment(\.sheetActive) var sheetActive
@ObservedObject var nearbyConnectionManager = NearbyConnectionManager.shared
@ObservedObject var settings = Settings.shared
var body: some View {
let showsLoadingIndicator = nearbyConnectionManager.hasLocalNetworkPermission && nearbyConnectionManager.isConnectedToLocalNetwork
BottomBarView(header: "QuickDrop ", navigationBarLayout: isShareExtension() ? .SmallOnlyAlways : .Default, bottomViewHeight: 30) {
VStack {
VStack(alignment: .leading, spacing: 8) {
let attachments = nearbyConnectionManager.attachments
FormHeader(name: attachments == nil ? "YouAreVisibleAs".localized() : "YouAreSending".localized())
HStack {
if let image = attachments?.previewImage {
image
.resizable()
.scaledToFill()
.frame(width: 10, height: 10)
}
LUIText(attachments?.shortDescription ?? NearbyConnectionManager.shared.deviceInfo.name ?? "Unknown".localized(), isBold: true)
.lineLimit(1)
}
.padding(.horizontal, 16)
.padding(.vertical, 6)
.background(sheetActive ? Color.sheetForegroundColor : Color.defaultForegroundColor)
.cornerRadius(24)
}
.padding(.horizontal)
.padding(.top, 8)
.frame(maxWidth: .infinity, alignment: .leading)
if !nearbyConnectionManager.hasLocalNetworkPermission {
CardView(backgroundColor: .red, title: "NoNetworkAccess", titleSymbol: "network.slash") {
CardSubView(symbol: "exclamationmark.triangle.fill", text: "NoLocalNetworkAccessDescription")
}
.padding(.bottom, 10)
.onTapGesture {
openAppSettings()
}
}
if sendModel.foundDevices.isEmpty {
CustomSection(header: "NoDevicesFound") {
let hasWifi = nearbyConnectionManager.isConnectedToLocalNetwork
HStack(spacing: 12) {
Image(systemName: hasWifi ? "arrow.down.circle.fill" : "wifi.slash")
.font(.system(size: 30))
.foregroundColor(.blue)
.background(
Circle()
.fill(Color.white)
.padding(3)
.opacity(hasWifi ? 1 : 0)
)
.frame(width: 32)
LUIText(hasWifi ? "DownloadQuickDropOnPlayStore" : "NoWiFiConnectionDescription")
.frame(maxWidth: .infinity, alignment: .leading)
}
.animation(.default, value: hasWifi)
.padding(.vertical)
}
} else {
CustomSection(header: "AvailableDevices") {
ForEach(sendModel.foundDevices) { device in
let isSelected = sendModel.selectedDevice == device
ZStack {
if let attachments = NearbyConnectionManager.shared.attachments {
LUIButton {
lightVibration()
sendModel.selectDevice(device: device, with: attachments)
} label: {
DeviceButtonLabel(device: device, isSelected: isSelected, progress: sendModel.progressState, progressValue: sendModel.progressValue)
}
}
else {
DeviceFilePickerButton(device: device, isSelected: isSelected, progressState: sendModel.progressState, progressValue: sendModel.progressValue, sendModel: sendModel)
}
}
.animation(.easeInOut, value: sendModel.progressValue)
.animation(.easeInOut, value: isSelected)
}
}
}
if showsLoadingIndicator {
HStack(spacing: 8) {
Text("SearchingForDevices")
.font(.system(size: 14))
.foregroundColor(Color.primary.opacity(0.6))
LoadingIndicator()
.opacity(0.75)
.frame(width: 12, height: 12)
}
.padding(.vertical, 16)
}
}
.frame(maxWidth: 700)
} bottomView: {
LUIButton {
qrCode = NearbyConnectionManager.shared.generateQrCodeKey()
sendModel.showQrCodeView = true
} label: {
UnderlineText(label: "DeviceNotShown")
.padding()
}
.opacity(showsLoadingIndicator ? 1 : 0)
}
.luiSheet(isPresented: $sendModel.showQrCodeView, content: {
NavigationView {
NavigationSubView(header: "ConnectDevice", navigationBarLayout: .SmallOnlyAlways) {
SmallSheetView(type: .sendToDeviceQrCode, dynamicQrCode: qrCode, closeView: {})
}
.navigationBarItems(trailing: XButton(action: { sendModel.showQrCodeView = false }))
}
})
.onChange(of: scenePhase) { newValue in
if newValue == .active {
log("[ScenePhase] App became active")
NearbyConnectionManager.shared.startDeviceDiscovery()
#if !EXTENSION
NearbyConnectionManager.shared.becomeVisible()
if Settings.shared.incomingTransmissionCount > 0 {
runAfter(seconds: 0.3) {
requestReviewOnce()
}
}
#endif
}
if newValue == .background {
log("[ScenePhase] App went to background")
#if !EXTENSION
NearbyConnectionManager.shared.becomeInvisible()
#endif
NearbyConnectionManager.shared.stopDeviceDiscovery()
}
}
.animation(.smooth, value: nearbyConnectionManager.hasLocalNetworkPermission)
.animation(.smooth, value: nearbyConnectionManager.isConnectedToLocalNetwork)
.navigationBarItems(trailing: ZStack {
if isShareExtension() {
XButton(action: { NearbyConnectionManager.shared.attachments?.closeView?() })
}
else {
LUISettingsButton()
}
})
}
}
struct DeviceFilePickerButton: View {
let device: RemoteDeviceInfo
let isSelected: Bool
let progressState: String?
let progressValue: Double?
let sendModel: SendModel
@State private var isPreparing = false
var body: some View {
SendPickerButton {
DeviceButtonLabel(device: device, isSelected: isPreparing || isSelected, progress: isPreparing ? "Preparing".localized() : progressState, progressValue: progressValue)
} onResult: { urls, text in
isPreparing = false
sendModel.selectDevice(device: device, with: AttachmentDetails(urls: urls ?? [], textToSend: text, shortDescription: ""))
} onPrepare: {
isPreparing = true
}
}
}
struct DeviceButtonLabel: View {
let device: RemoteDeviceInfo
let isSelected: Bool
let progress: String?
let progressValue: Double?
var body: some View {
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)
}
VStack(alignment: .leading, spacing: 5) {
Text(name)
.fontWeight(.medium)
.foregroundColor(Color.primary)
Text(isSelected ? progress ?? "..." : "Available".localized())
.font(.system(size: 12))
.foregroundColor(Color.primary.opacity(0.6))
}
Spacer()
if isSelected, let progress = progressValue {
PieProgressView(progress: progress, size: 20)
}
else {
Image(systemName: "chevron.right")
.foregroundColor(Color.primary.opacity(0.6))
}
}
.padding(.vertical, 13)
}
}
func isShareExtension() -> Bool {
#if EXTENSION
return true
#else
return false
#endif
}
//extension DeviceListView {
// static var preview: DeviceListView {
// let model = SendModel()
// model.foundDevices = [
// RemoteDeviceInfo(name: "MacBook Pro", type: .computer, id: "macbook-pro-id"),
// RemoteDeviceInfo(name: "iPhone 14", type: .phone, id: "iphone-14-id"),
// RemoteDeviceInfo(name: "Samsung Galaxy S21", type: .phone, id: "samsung-galaxy-s21-id")
// ]
//
// return DeviceListView(sendModel: model, receiveModel: ReceiveModel())
// }
//}
//
#Preview {
NavigationView {
DeviceListView()
}
}
+171
View File
@@ -0,0 +1,171 @@
//
// 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
var onPrepare: (() -> Void)? = nil
@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 }
onPrepare?()
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("Failed 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 {
log("Selected URLs: \(urls)")
}
if let text = text {
log("Clipboard text: \(text)")
}
}
}
+69
View File
@@ -0,0 +1,69 @@
//
// PieProgressView.swift
// QuickDrop
//
// Created by Leon Böttger on 16.08.25.
//
import SwiftUI
struct PieProgressView: View {
let progress: CGFloat // value between 0.0 and 1.0
var size: CGFloat = 25.0
var color: Color? = nil
var body: some View {
let color = color ?? .gray
ZStack {
Circle()
.stroke(lineWidth: size * 0.08)
.foregroundColor(color)
ZStack {
PieSlice(progress: progress)
.fill(color)
.rotationEffect(.degrees(-90)) // Start from the top
}
.overlay (
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .bold))
.foregroundColor(color == .white ? .blue : .white)
.opacity(progress >= 1.0 ? 1.0 : 0.0) // Show checkmark only when complete
)
}
.frame(width: size, height: size)
}
}
struct PieSlice: Shape {
var progress: CGFloat // from 0.0 to 1.0
func path(in rect: CGRect) -> Path {
var path = Path()
let center = CGPoint(x: rect.midX, y: rect.midY)
let radius = rect.width / 2
let endAngle = Angle(degrees: 360 * Double(progress))
path.move(to: center)
path.addArc(center: center,
radius: radius,
startAngle: .degrees(0),
endAngle: endAngle,
clockwise: false)
path.closeSubpath()
return path
}
}
#Preview {
PieProgressView(progress: 0.76)
.padding()
}
+115
View File
@@ -0,0 +1,115 @@
//
// UIAlert.swift
// QuickDrop
//
// Created by Leon Böttger on 17.08.25.
//
import SwiftUI
import UIKit
// MARK: - UIKit Alert Presenter
final class ProgressAlert {
static let shared = ProgressAlert()
private init() {}
private weak var progressAlert: UIAlertController?
private weak var progressView: UIProgressView?
// MARK: - Initial Accept / Decline
func askForUserPermission(title: String, message: String, acceptLabel: String, acceptAlwaysLabel: String?, rejectLabel: String, acceptAutomatically: Bool, onAccept: @escaping (AcceptAction) -> Void, onCancel: @escaping () -> Void) {
guard let vc = topMostViewController() else { return }
if acceptAutomatically {
onAccept(.Accept)
self.showProgressAlert(on: vc, onCancel: onCancel)
}
else {
let alert = UIAlertController(title: title,
message: message,
preferredStyle: .alert)
alert.addAction(UIAlertAction(title: rejectLabel, style: .cancel, handler: { _ in
onAccept(.Decline)
}))
alert.addAction(UIAlertAction(title: acceptLabel, style: .default, handler: { _ in
onAccept(.Accept)
self.showProgressAlert(on: vc, onCancel: onCancel)
}))
if let acceptAlways = acceptAlwaysLabel {
alert.addAction(UIAlertAction(title: acceptAlways, style: .default, handler: { _ in
onAccept(.AcceptAlways)
self.showProgressAlert(on: vc, onCancel: onCancel)
}))
}
vc.present(alert, animated: true)
}
}
// MARK: - Progress Alert
private func showProgressAlert(on vc: UIViewController, onCancel: @escaping () -> Void) {
let alert = UIAlertController(title: "Receiving".localized(),
message: "\n\n",
preferredStyle: .alert)
let pv = UIProgressView(progressViewStyle: .default)
pv.translatesAutoresizingMaskIntoConstraints = false
alert.view.addSubview(pv)
NSLayoutConstraint.activate([
pv.leadingAnchor.constraint(equalTo: alert.view.leadingAnchor, constant: 20),
pv.trailingAnchor.constraint(equalTo: alert.view.trailingAnchor, constant: -20),
pv.centerYAnchor.constraint(equalTo: alert.view.centerYAnchor, constant: 8)
])
let cancel = UIAlertAction(title: "Cancel".localized(), style: .cancel, handler: { _ in
log("Cancel during progress")
onCancel()
})
alert.addAction(cancel)
self.progressAlert = alert
self.progressView = pv
vc.present(alert, animated: true)
}
// MARK: - Update Progress
// progress: 0…1 → update progress, nil → finished
func updateProgress(_ progress: Double?, completion: @escaping () -> Void = {}) {
DispatchQueue.main.async {
if let p = progress {
self.progressView?.setProgress(Float(p), animated: true)
completion()
} else {
// Completed: dismiss progress and show final alert
self.progressAlert?.dismiss(animated: true) {
completion()
}
self.progressAlert = nil
self.progressView = nil
}
}
}
// MARK: - Helpers
private func topMostViewController(base: UIViewController? = UIApplication.shared.connectedScenes
.compactMap { ($0 as? UIWindowScene)?.keyWindow?.rootViewController }.first) -> UIViewController? {
if let nav = base as? UINavigationController { return topMostViewController(base: nav.visibleViewController) }
if let tab = base as? UITabBarController { return topMostViewController(base: tab.selectedViewController) }
if let presented = base?.presentedViewController { return topMostViewController(base: presented) }
return base
}
enum AcceptAction {
case Accept
case AcceptAlways
case Decline
}
}