Update phases

This commit is contained in:
Hariharan Mudaliar 2026-03-29 15:58:38 +05:30
commit 79f41cb69e
No known key found for this signature in database
GPG key ID: 025583EB0BE5640E
6 changed files with 289 additions and 13 deletions

View file

@ -52,6 +52,7 @@ struct ContentView: View {
@ObservedObject var capsLockManager = CapsLockManager.shared @ObservedObject var capsLockManager = CapsLockManager.shared
@ObservedObject var extensionLiveActivityManager = ExtensionLiveActivityManager.shared @ObservedObject var extensionLiveActivityManager = ExtensionLiveActivityManager.shared
@ObservedObject var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared @ObservedObject var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared
@ObservedObject var localSendService = LocalSendService.shared
@State private var downloadManager = DownloadManager.shared @State private var downloadManager = DownloadManager.shared
@Default(.enableStatsFeature) var enableStatsFeature @Default(.enableStatsFeature) var enableStatsFeature
@ -294,6 +295,19 @@ struct ContentView: View {
shouldHideUntilHover && !lockScreenManager.isLocked 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. /// Pill shape for Dynamic Island mode with animated corner radius transitions.
private var currentPillShape: DynamicIslandPillShape { private var currentPillShape: DynamicIslandPillShape {
let radius: CGFloat let radius: CGFloat
@ -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 { } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .download) && vm.notchState == .closed && downloadManager.isDownloading && Defaults[.enableDownloadListener] && !vm.hideOnClosed {
DownloadLiveActivity() DownloadLiveActivity()
.transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) .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 { } 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() DoNotDisturbLiveActivity()
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed { } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed {

View file

@ -23715,6 +23715,10 @@
}, },
"Refresh devices" : { "Refresh devices" : {
},
"Rejected" : {
"comment" : "A label displayed when a device is rejected.",
"isCommentAutoGenerated" : true
}, },
"Release name" : { "Release name" : {
"localizations" : { "localizations" : {

View file

@ -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 @MainActor
final class LocalSendService: NSObject, ObservableObject { final class LocalSendService: NSObject, ObservableObject {
static let shared = LocalSendService() static let shared = LocalSendService()
@ -43,6 +51,8 @@ final class LocalSendService: NSObject, ObservableObject {
@Published private(set) var isRefreshing = false @Published private(set) var isRefreshing = false
@Published private(set) var isSending = false @Published private(set) var isSending = false
@Published private(set) var sendProgress: Double = 0 @Published private(set) var sendProgress: Double = 0
@Published private(set) var transferState: LocalSendTransferState = .idle
@Published private(set) var rejectedDeviceIDs: Set<String> = []
@Published var selectedDeviceID: String { @Published var selectedDeviceID: String {
didSet { Defaults[.localSendSelectedDeviceID] = selectedDeviceID } didSet { Defaults[.localSendSelectedDeviceID] = selectedDeviceID }
} }
@ -59,12 +69,21 @@ final class LocalSendService: NSObject, ObservableObject {
private var recentProbeIPs: [String] = [] private var recentProbeIPs: [String] = []
private var knownPeerIPs: [String] = [] private var knownPeerIPs: [String] = []
private var isStarted = false private var isStarted = false
private var completionDismissTask: Task<Void, Never>?
private override init() { private override init() {
selectedDeviceID = Defaults[.localSendSelectedDeviceID] selectedDeviceID = Defaults[.localSendSelectedDeviceID]
super.init() super.init()
} }
func clearRejectedStatus(for deviceID: String) {
rejectedDeviceIDs.remove(deviceID)
}
func clearAllRejectedStatuses() {
rejectedDeviceIDs.removeAll()
}
func startDiscovery() { func startDiscovery() {
guard !isStarted else { return } guard !isStarted else { return }
isStarted = true isStarted = true
@ -483,6 +502,8 @@ final class LocalSendService: NSObject, ObservableObject {
let startedAt = Date() let startedAt = Date()
isSending = true isSending = true
sendProgress = 0 sendProgress = 0
transferState = .sending
completionDismissTask?.cancel()
do { do {
guard let target = devices.first(where: { $0.id == selectedDeviceID }) ?? devices.first else { 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) let prepare = try await prepareUpload(files: files, to: target)
guard !prepare.fileTokens.isEmpty else { guard !prepare.fileTokens.isEmpty else {
sendProgress = 1 sendProgress = 1
await finishSending(startedAt: startedAt) await finishSending(startedAt: startedAt, success: true)
return return
} }
@ -527,14 +548,24 @@ final class LocalSendService: NSObject, ObservableObject {
} }
sendProgress = 1 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 { } catch {
await finishSending(startedAt: startedAt) transferState = .failed(error.localizedDescription)
await finishSending(startedAt: startedAt, success: false)
throw error throw error
} }
} }
private func finishSending(startedAt: Date) async { private func finishSending(startedAt: Date, success: Bool) async {
let elapsed = Date().timeIntervalSince(startedAt) let elapsed = Date().timeIntervalSince(startedAt)
let minimumVisibleDuration: TimeInterval = 0.8 let minimumVisibleDuration: TimeInterval = 0.8
if elapsed < minimumVisibleDuration { if elapsed < minimumVisibleDuration {
@ -543,6 +574,27 @@ final class LocalSendService: NSObject, ObservableObject {
} }
isSending = false isSending = false
sendProgress = 0 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() { private func sendAnnouncement() {
@ -880,6 +932,10 @@ final class LocalSendService: NSObject, ObservableObject {
if http.statusCode == 204 { if http.statusCode == 204 {
return ("", [:]) 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 { guard (200 ... 299).contains(http.statusCode) else {
let body = String(data: data, encoding: .utf8) let body = String(data: data, encoding: .utf8)
throw LocalSendServiceError.server(status: http.statusCode, body: body) throw LocalSendServiceError.server(status: http.statusCode, body: body)
@ -1037,6 +1093,7 @@ enum LocalSendServiceError: LocalizedError {
case invalidTarget case invalidTarget
case invalidResponse case invalidResponse
case server(status: Int, body: String?) case server(status: Int, body: String?)
case transferRejected
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
@ -1053,6 +1110,8 @@ enum LocalSendServiceError: LocalizedError {
return "LocalSend peer error (\(status)): \(body)" return "LocalSend peer error (\(status)): \(body)"
} }
return "LocalSend peer error (\(status))" return "LocalSend peer error (\(status))"
case .transferRejected:
return "Transfer was rejected by the recipient"
} }
} }
} }

View file

@ -288,8 +288,9 @@ struct FileShareView: View {
LocalSendDevicePickerWindowManager.shared.show( LocalSendDevicePickerWindowManager.shared.show(
onDeviceSelected: { device in onDeviceSelected: { device in
localSend.selectedDeviceID = device.id localSend.selectedDeviceID = device.id
showLocalSendPicker = false
if let providers = pendingDropProviders { if let providers = pendingDropProviders {
// Close the picker first since handleDrop will use quickShare properly
showLocalSendPicker = false
Task { Task {
await handleDrop(providers) await handleDrop(providers)
pendingDropProviders = nil pendingDropProviders = nil

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View file

@ -49,7 +49,9 @@ final class LocalSendDevicePickerWindowManager {
) )
if let existingWindow = window { if let existingWindow = window {
existingWindow.contentView = NSHostingView(rootView: pickerView) let hostingView = NSHostingView(rootView: pickerView)
hostingView.layer?.backgroundColor = .clear
existingWindow.contentView = hostingView
existingWindow.makeKeyAndOrderFront(nil) existingWindow.makeKeyAndOrderFront(nil)
return return
} }
@ -74,7 +76,15 @@ final class LocalSendDevicePickerWindowManager {
newWindow.hasShadow = true newWindow.hasShadow = true
newWindow.level = .floating newWindow.level = .floating
newWindow.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] 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 newWindow.isMovableByWindowBackground = true
window = newWindow window = newWindow
@ -134,8 +144,12 @@ struct LocalSendDevicePickerView: View {
var body: some View { var body: some View {
ZStack { ZStack {
// Background // Explicitly clear background for proper transparency
Color.clear
// Background - clips to rounded rect
panelBackground panelBackground
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
// Content // Content
VStack(spacing: 16) { VStack(spacing: 16) {
@ -203,6 +217,7 @@ struct LocalSendDevicePickerView: View {
.stroke(Color.white.opacity(0.2), lineWidth: 1) .stroke(Color.white.opacity(0.2), lineWidth: 1)
} }
.shadow(color: Color.black.opacity(0.4), radius: 30, x: 0, y: 15) .shadow(color: Color.black.opacity(0.4), radius: 30, x: 0, y: 15)
.compositingGroup()
.onAppear { .onAppear {
localSend.startDiscovery() localSend.startDiscovery()
localSend.refreshDeviceScan() localSend.refreshDeviceScan()
@ -291,7 +306,9 @@ struct LocalSendDevicePickerView: View {
DeviceGridItem( DeviceGridItem(
device: device, device: device,
isHovered: hoveredDeviceID == device.id, isHovered: hoveredDeviceID == device.id,
isRejected: localSend.rejectedDeviceIDs.contains(device.id),
onSelect: { onSelect: {
localSend.clearRejectedStatus(for: device.id)
onDeviceSelected(device) onDeviceSelected(device)
} }
) )
@ -311,6 +328,7 @@ struct LocalSendDevicePickerView: View {
private struct DeviceGridItem: View { private struct DeviceGridItem: View {
let device: LocalSendDeviceInfo let device: LocalSendDeviceInfo
let isHovered: Bool let isHovered: Bool
let isRejected: Bool
let onSelect: () -> Void let onSelect: () -> Void
var body: some View { var body: some View {
@ -319,23 +337,27 @@ private struct DeviceGridItem: View {
// Device icon // Device icon
ZStack { ZStack {
Circle() 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) .frame(width: 56, height: 56)
Image(systemName: deviceIcon) Image(systemName: deviceIcon)
.font(.system(size: 24, weight: .medium)) .font(.system(size: 24, weight: .medium))
.foregroundColor(isHovered ? .accentColor : .white.opacity(0.8)) .foregroundColor(isRejected ? .red : (isHovered ? .accentColor : .white.opacity(0.8)))
} }
// Device name // Device name
VStack(spacing: 2) { VStack(spacing: 2) {
Text(device.alias) Text(device.alias)
.font(.system(size: 12, weight: .medium)) .font(.system(size: 12, weight: .medium))
.foregroundColor(.white) .foregroundColor(isRejected ? .red : .white)
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail) .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) Text(model)
.font(.system(size: 10)) .font(.system(size: 10))
.foregroundColor(.white.opacity(0.5)) .foregroundColor(.white.opacity(0.5))
@ -348,7 +370,7 @@ private struct DeviceGridItem: View {
.padding(.vertical, 16) .padding(.vertical, 16)
.background( .background(
RoundedRectangle(cornerRadius: 16) 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( .overlay(
RoundedRectangle(cornerRadius: 16) RoundedRectangle(cornerRadius: 16)