diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 24b6ce7..c8059cd 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -52,6 +52,7 @@ struct ContentView: View { @ObservedObject var capsLockManager = CapsLockManager.shared @ObservedObject var extensionLiveActivityManager = ExtensionLiveActivityManager.shared @ObservedObject var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared + @ObservedObject var localSendService = LocalSendService.shared @State private var downloadManager = DownloadManager.shared @Default(.enableStatsFeature) var enableStatsFeature @@ -293,6 +294,19 @@ struct ContentView: View { private var shouldUseHiddenEdgeHoverPolling: Bool { shouldHideUntilHover && !lockScreenManager.isLocked } + + /// Whether the LocalSend live activity should be shown + private var localSendLiveActivityActive: Bool { + localSendService.isSending || + localSendService.transferState == .completed || + isLocalSendFailedOrRejected + } + + private var isLocalSendFailedOrRejected: Bool { + if case .failed = localSendService.transferState { return true } + if case .rejected = localSendService.transferState { return true } + return false + } /// Pill shape for Dynamic Island mode with animated corner radius transitions. private var currentPillShape: DynamicIslandPillShape { @@ -738,6 +752,9 @@ struct ContentView: View { } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .download) && vm.notchState == .closed && downloadManager.isDownloading && Defaults[.enableDownloadListener] && !vm.hideOnClosed { DownloadLiveActivity() .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) + } else if !coordinator.expandingView.show && vm.notchState == .closed && localSendLiveActivityActive && !vm.hideOnClosed { + LocalSendLiveActivity() + .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .doNotDisturb) && vm.notchState == .closed && Defaults[.enableDoNotDisturbDetection] && Defaults[.showDoNotDisturbIndicator] && (doNotDisturbManager.isDoNotDisturbActive || doNotDisturbManager.isFocusToastDismissing) && !vm.hideOnClosed && !lockScreenManager.isLocked { DoNotDisturbLiveActivity() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed { diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 7fb3de0..e8f1a17 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -23715,6 +23715,10 @@ }, "Refresh devices" : { + }, + "Rejected" : { + "comment" : "A label displayed when a device is rejected.", + "isCommentAutoGenerated" : true }, "Release name" : { "localizations" : { diff --git a/DynamicIsland/components/Shelf/Services/LocalSendService.swift b/DynamicIsland/components/Shelf/Services/LocalSendService.swift index 62618c4..e1d5d42 100644 --- a/DynamicIsland/components/Shelf/Services/LocalSendService.swift +++ b/DynamicIsland/components/Shelf/Services/LocalSendService.swift @@ -35,6 +35,14 @@ struct LocalSendDeviceInfo: Identifiable, Hashable, Sendable { } } +enum LocalSendTransferState: Equatable { + case idle + case sending + case completed + case failed(String) + case rejected(deviceID: String) +} + @MainActor final class LocalSendService: NSObject, ObservableObject { static let shared = LocalSendService() @@ -43,6 +51,8 @@ final class LocalSendService: NSObject, ObservableObject { @Published private(set) var isRefreshing = false @Published private(set) var isSending = false @Published private(set) var sendProgress: Double = 0 + @Published private(set) var transferState: LocalSendTransferState = .idle + @Published private(set) var rejectedDeviceIDs: Set = [] @Published var selectedDeviceID: String { didSet { Defaults[.localSendSelectedDeviceID] = selectedDeviceID } } @@ -59,11 +69,20 @@ final class LocalSendService: NSObject, ObservableObject { private var recentProbeIPs: [String] = [] private var knownPeerIPs: [String] = [] private var isStarted = false + private var completionDismissTask: Task? private override init() { selectedDeviceID = Defaults[.localSendSelectedDeviceID] super.init() } + + func clearRejectedStatus(for deviceID: String) { + rejectedDeviceIDs.remove(deviceID) + } + + func clearAllRejectedStatuses() { + rejectedDeviceIDs.removeAll() + } func startDiscovery() { guard !isStarted else { return } @@ -483,6 +502,8 @@ final class LocalSendService: NSObject, ObservableObject { let startedAt = Date() isSending = true sendProgress = 0 + transferState = .sending + completionDismissTask?.cancel() do { guard let target = devices.first(where: { $0.id == selectedDeviceID }) ?? devices.first else { @@ -495,7 +516,7 @@ final class LocalSendService: NSObject, ObservableObject { let prepare = try await prepareUpload(files: files, to: target) guard !prepare.fileTokens.isEmpty else { sendProgress = 1 - await finishSending(startedAt: startedAt) + await finishSending(startedAt: startedAt, success: true) return } @@ -527,14 +548,24 @@ final class LocalSendService: NSObject, ObservableObject { } sendProgress = 1 - await finishSending(startedAt: startedAt) + await finishSending(startedAt: startedAt, success: true) + } catch let error as LocalSendServiceError { + if case .transferRejected = error { + rejectedDeviceIDs.insert(selectedDeviceID) + transferState = .rejected(deviceID: selectedDeviceID) + } else { + transferState = .failed(error.localizedDescription) + } + await finishSending(startedAt: startedAt, success: false) + throw error } catch { - await finishSending(startedAt: startedAt) + transferState = .failed(error.localizedDescription) + await finishSending(startedAt: startedAt, success: false) throw error } } - private func finishSending(startedAt: Date) async { + private func finishSending(startedAt: Date, success: Bool) async { let elapsed = Date().timeIntervalSince(startedAt) let minimumVisibleDuration: TimeInterval = 0.8 if elapsed < minimumVisibleDuration { @@ -543,6 +574,27 @@ final class LocalSendService: NSObject, ObservableObject { } isSending = false sendProgress = 0 + + if success { + transferState = .completed + // Auto-dismiss completed state after 3 seconds + completionDismissTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 3_000_000_000) + if case .completed = self.transferState { + self.transferState = .idle + } + } + } else { + // Auto-dismiss failed/rejected state after 4 seconds + completionDismissTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 4_000_000_000) + if case .failed = self.transferState { + self.transferState = .idle + } else if case .rejected = self.transferState { + self.transferState = .idle + } + } + } } private func sendAnnouncement() { @@ -880,6 +932,10 @@ final class LocalSendService: NSObject, ObservableObject { if http.statusCode == 204 { return ("", [:]) } + // HTTP 403 means the transfer was rejected by the recipient + if http.statusCode == 403 { + throw LocalSendServiceError.transferRejected + } guard (200 ... 299).contains(http.statusCode) else { let body = String(data: data, encoding: .utf8) throw LocalSendServiceError.server(status: http.statusCode, body: body) @@ -1037,6 +1093,7 @@ enum LocalSendServiceError: LocalizedError { case invalidTarget case invalidResponse case server(status: Int, body: String?) + case transferRejected var errorDescription: String? { switch self { @@ -1053,6 +1110,8 @@ enum LocalSendServiceError: LocalizedError { return "LocalSend peer error (\(status)): \(body)" } return "LocalSend peer error (\(status))" + case .transferRejected: + return "Transfer was rejected by the recipient" } } } \ No newline at end of file diff --git a/DynamicIsland/components/Shelf/Views/FileShareView.swift b/DynamicIsland/components/Shelf/Views/FileShareView.swift index de7a761..13e2316 100644 --- a/DynamicIsland/components/Shelf/Views/FileShareView.swift +++ b/DynamicIsland/components/Shelf/Views/FileShareView.swift @@ -288,8 +288,9 @@ struct FileShareView: View { LocalSendDevicePickerWindowManager.shared.show( onDeviceSelected: { device in localSend.selectedDeviceID = device.id - showLocalSendPicker = false if let providers = pendingDropProviders { + // Close the picker first since handleDrop will use quickShare properly + showLocalSendPicker = false Task { await handleDrop(providers) pendingDropProviders = nil diff --git a/DynamicIsland/components/Shelf/Views/LocalSendLiveActivity.swift b/DynamicIsland/components/Shelf/Views/LocalSendLiveActivity.swift new file mode 100644 index 0000000..15a72a2 --- /dev/null +++ b/DynamicIsland/components/Shelf/Views/LocalSendLiveActivity.swift @@ -0,0 +1,173 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Defaults + +struct LocalSendLiveActivity: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @StateObject private var localSend = LocalSendService.shared + + @State private var isHovering: Bool = false + @State private var isExpanded: Bool = false + + private var tint: Color { + switch localSend.transferState { + case .completed: + return .green + case .failed, .rejected: + return .red + default: + return .accentColor + } + } + + private var isActive: Bool { + localSend.isSending || localSend.transferState == .completed || + isFailedOrRejected + } + + private var isFailedOrRejected: Bool { + if case .failed = localSend.transferState { return true } + if case .rejected = localSend.transferState { return true } + return false + } + + var body: some View { + HStack(spacing: 0) { + // Left side: upload icon capsule + Color.clear + .background { + if isExpanded { + HStack { + ZStack { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(tint.opacity(0.14)) + + Image(systemName: leftIcon) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(tint) + } + .frame( + width: vm.effectiveClosedNotchHeight - 12, + height: vm.effectiveClosedNotchHeight - 12 + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + } + .frame( + width: isExpanded ? max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) : 0, + height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + ) + + // Center: closed notch body (slightly wider during transfers) + Rectangle() + .fill(.black) + .frame( + width: vm.closedNotchSize.width + + (isHovering ? 8 : 0) + + (isActive ? 40 : 0) + ) + + // Right side: progress ring or status icon + Color.clear + .background { + if isExpanded { + HStack { + rightContent + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) + } + } + .frame( + width: isExpanded ? max(60, vm.effectiveClosedNotchHeight) : 0, + height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + ) + } + .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0)) + .onAppear { + withAnimation(.smooth(duration: 0.35)) { + isExpanded = true + } + } + .onChange(of: isActive) { _, newValue in + withAnimation(.smooth(duration: 0.35)) { + isExpanded = newValue + } + } + } + + private var leftIcon: String { + // Always show document upload icon on left side + return "document.badge.arrow.up.fill" + } + + @ViewBuilder + private var rightContent: some View { + switch localSend.transferState { + case .completed: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.system(size: 16, weight: .semibold)) + .padding(.trailing, 6) + case .failed: + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.red) + .font(.system(size: 16, weight: .semibold)) + .padding(.trailing, 6) + case .rejected: + Image(systemName: "hand.raised.fill") + .foregroundStyle(.orange) + .font(.system(size: 16, weight: .semibold)) + .padding(.trailing, 6) + case .sending, .idle: + LocalSendProgressRing(progress: localSend.sendProgress) + .padding(.trailing, 6) + } + } +} + +// MARK: - Progress Ring + +private struct LocalSendProgressRing: View { + let progress: Double + + var body: some View { + ZStack { + Circle() + .stroke(Color.white.opacity(0.2), lineWidth: 2.5) + + Circle() + .trim(from: 0, to: min(max(progress, 0), 1)) + .stroke( + Color.accentColor, + style: StrokeStyle(lineWidth: 2.5, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.2), value: progress) + + if progress > 0.99 { + Image(systemName: "checkmark") + .font(.system(size: 8, weight: .bold)) + .foregroundColor(.accentColor) + } + } + .frame(width: 20, height: 20) + } +} diff --git a/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift b/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift index 8fa47d9..b5bc850 100644 --- a/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift +++ b/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift @@ -49,7 +49,9 @@ final class LocalSendDevicePickerWindowManager { ) if let existingWindow = window { - existingWindow.contentView = NSHostingView(rootView: pickerView) + let hostingView = NSHostingView(rootView: pickerView) + hostingView.layer?.backgroundColor = .clear + existingWindow.contentView = hostingView existingWindow.makeKeyAndOrderFront(nil) return } @@ -74,7 +76,15 @@ final class LocalSendDevicePickerWindowManager { newWindow.hasShadow = true newWindow.level = .floating newWindow.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - newWindow.contentView = NSHostingView(rootView: pickerView) + + let hostingView = NSHostingView(rootView: + pickerView + .background(Color.clear) + ) + hostingView.wantsLayer = true + hostingView.layer?.backgroundColor = .clear + hostingView.layer?.isOpaque = false + newWindow.contentView = hostingView newWindow.isMovableByWindowBackground = true window = newWindow @@ -134,8 +144,12 @@ struct LocalSendDevicePickerView: View { var body: some View { ZStack { - // Background + // Explicitly clear background for proper transparency + Color.clear + + // Background - clips to rounded rect panelBackground + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) // Content VStack(spacing: 16) { @@ -203,6 +217,7 @@ struct LocalSendDevicePickerView: View { .stroke(Color.white.opacity(0.2), lineWidth: 1) } .shadow(color: Color.black.opacity(0.4), radius: 30, x: 0, y: 15) + .compositingGroup() .onAppear { localSend.startDiscovery() localSend.refreshDeviceScan() @@ -291,7 +306,9 @@ struct LocalSendDevicePickerView: View { DeviceGridItem( device: device, isHovered: hoveredDeviceID == device.id, + isRejected: localSend.rejectedDeviceIDs.contains(device.id), onSelect: { + localSend.clearRejectedStatus(for: device.id) onDeviceSelected(device) } ) @@ -311,6 +328,7 @@ struct LocalSendDevicePickerView: View { private struct DeviceGridItem: View { let device: LocalSendDeviceInfo let isHovered: Bool + let isRejected: Bool let onSelect: () -> Void var body: some View { @@ -319,23 +337,27 @@ private struct DeviceGridItem: View { // Device icon ZStack { Circle() - .fill(Color.white.opacity(isHovered ? 0.15 : 0.08)) + .fill(isRejected ? Color.red.opacity(0.15) : Color.white.opacity(isHovered ? 0.15 : 0.08)) .frame(width: 56, height: 56) Image(systemName: deviceIcon) .font(.system(size: 24, weight: .medium)) - .foregroundColor(isHovered ? .accentColor : .white.opacity(0.8)) + .foregroundColor(isRejected ? .red : (isHovered ? .accentColor : .white.opacity(0.8))) } // Device name VStack(spacing: 2) { Text(device.alias) .font(.system(size: 12, weight: .medium)) - .foregroundColor(.white) + .foregroundColor(isRejected ? .red : .white) .lineLimit(1) .truncationMode(.tail) - if let model = device.model, !model.isEmpty { + if isRejected { + Text("Rejected") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.red.opacity(0.8)) + } else if let model = device.model, !model.isEmpty { Text(model) .font(.system(size: 10)) .foregroundColor(.white.opacity(0.5)) @@ -348,7 +370,7 @@ private struct DeviceGridItem: View { .padding(.vertical, 16) .background( RoundedRectangle(cornerRadius: 16) - .fill(Color.white.opacity(isHovered ? 0.12 : 0.06)) + .fill(isRejected ? Color.red.opacity(0.08) : Color.white.opacity(isHovered ? 0.12 : 0.06)) ) .overlay( RoundedRectangle(cornerRadius: 16)