mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Update: Popover for Localsend
This commit is contained in:
parent
62f96385d8
commit
794e05c7d6
7 changed files with 662 additions and 72 deletions
|
|
@ -58,13 +58,6 @@
|
|||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
7EA69B212F5B100000EB2D16 /* Exceptions for "DynamicIsland" folder in "DynamicIsland" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = F54406E62CD4987A00C7AFD9 /* DynamicIsland */;
|
||||
};
|
||||
7EA69B1E2F5AE61200EB2D16 /* Exceptions for "Contents" folder in "DynamicIsland" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
|
|
@ -72,6 +65,13 @@
|
|||
);
|
||||
target = F54406E62CD4987A00C7AFD9 /* DynamicIsland */;
|
||||
};
|
||||
7EA69B212F5B100000EB2D16 /* Exceptions for "DynamicIsland" folder in "DynamicIsland" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = F54406E62CD4987A00C7AFD9 /* DynamicIsland */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */
|
||||
|
|
@ -454,7 +454,7 @@
|
|||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 11;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 5W5BDQQ32W;
|
||||
DEVELOPMENT_TEAM = 9Y64TRM77N;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
|
|
@ -526,7 +526,7 @@
|
|||
CURRENT_PROJECT_VERSION = 11;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=macosx*]" = 5W5BDQQ32W;
|
||||
"DEVELOPMENT_TEAM[sdk=macosx*]" = 9Y64TRM77N;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
|
|
|
|||
|
|
@ -10032,6 +10032,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Customize the appearance of the LocalSend device selection popup that appears when you drop files." : {
|
||||
"comment" : "A description of the LocalSend device picker.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Dash outline equals the exact notch bounds." : {
|
||||
"comment" : "A description under the live preview section of the animation editor.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
|
|
@ -10493,6 +10497,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Device Picker Style" : {
|
||||
"comment" : "A dropdown menu that lets you choose how the LocalSend device picker is displayed.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Devices" : {
|
||||
"comment" : "The title of the \"Devices\" section in the settings menu.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
|
|
@ -16737,6 +16745,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Liquid Glass Variant" : {
|
||||
"comment" : "A label for selecting the variant of the Liquid Glass effect.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Listens for Focus session changes via distributed notifications" : {
|
||||
"localizations" : {
|
||||
"ko" : {
|
||||
|
|
@ -16927,6 +16939,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"LocalSend Device Picker" : {
|
||||
"comment" : "A section header for the LocalSend device picker settings.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Lock Screen" : {
|
||||
"localizations" : {
|
||||
"fr" : {
|
||||
|
|
@ -17522,6 +17538,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Make sure LocalSend is running on other devices" : {
|
||||
"comment" : "A description of the error.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Manage indicator preferences from Settings → Privacy. No manual microphone toggle is required." : {
|
||||
"comment" : "A description of how to manage the microphone privacy indicator preferences.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
|
|
@ -24854,6 +24874,14 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Scan Again" : {
|
||||
"comment" : "A button to refresh the list of nearby devices.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Scanning for devices..." : {
|
||||
"comment" : "A description of the scanning process for devices.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Screen Assistant" : {
|
||||
"localizations" : {
|
||||
"ko" : {
|
||||
|
|
@ -25275,6 +25303,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Select Device" : {
|
||||
"comment" : "A label displayed above the LocalSend device picker view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Select one or more profiles to customize your experience" : {
|
||||
"comment" : "A description below the header that instructs users to select one or more profiles to customize their experience.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
|
|
@ -30867,6 +30899,10 @@
|
|||
},
|
||||
"shouldTranslate" : false
|
||||
},
|
||||
"Variant %lld" : {
|
||||
"comment" : "A label for a specific variant of the Liquid Glass.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Version" : {
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
|
|
|
|||
|
|
@ -793,7 +793,8 @@ struct SettingsView: View {
|
|||
SettingsSearchEntry(tab: .shelf, title: "Expanded drag detection area", keywords: ["shelf", "drag"], highlightID: SettingsTab.shelf.highlightID(for: "Expanded drag detection area")),
|
||||
SettingsSearchEntry(tab: .shelf, title: "Copy items on drag", keywords: ["shelf", "drag", "copy"], highlightID: SettingsTab.shelf.highlightID(for: "Copy items on drag")),
|
||||
SettingsSearchEntry(tab: .shelf, title: "Remove from shelf after dragging", keywords: ["shelf", "drag", "remove"], highlightID: SettingsTab.shelf.highlightID(for: "Remove from shelf after dragging")),
|
||||
SettingsSearchEntry(tab: .shelf, title: "Quick Share Service", keywords: ["shelf", "share", "airdrop"], highlightID: SettingsTab.shelf.highlightID(for: "Quick Share Service")),
|
||||
SettingsSearchEntry(tab: .shelf, title: "Quick Share Service", keywords: ["shelf", "share", "airdrop", "localsend"], highlightID: SettingsTab.shelf.highlightID(for: "Quick Share Service")),
|
||||
SettingsSearchEntry(tab: .shelf, title: "LocalSend Device Picker Style", keywords: ["localsend", "glass", "picker", "material"], highlightID: SettingsTab.shelf.highlightID(for: "Device Picker Style")),
|
||||
|
||||
// Appearance
|
||||
SettingsSearchEntry(tab: .appearance, title: "Main screen style", keywords: ["dynamic island", "pill", "non-notch", "display style", "notch style"], highlightID: SettingsTab.appearance.highlightID(for: "Main screen style")),
|
||||
|
|
@ -3486,6 +3487,10 @@ struct Shelf: View {
|
|||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if quickShareProvider == "LocalSend" {
|
||||
LocalSendSettingsSection(highlightID: highlightID)
|
||||
}
|
||||
}
|
||||
.accentColor(.effectiveAccent)
|
||||
.navigationTitle("Shelf")
|
||||
|
|
@ -3496,6 +3501,41 @@ struct Shelf: View {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - LocalSend Settings Section
|
||||
|
||||
private struct LocalSendSettingsSection: View {
|
||||
let highlightID: (String) -> String
|
||||
|
||||
@Default(.localSendDevicePickerGlassMode) private var glassMode
|
||||
@Default(.localSendDevicePickerLiquidGlassVariant) private var liquidGlassVariant
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Picker("Device Picker Style", selection: $glassMode) {
|
||||
ForEach(LockScreenGlassCustomizationMode.allCases) { mode in
|
||||
Text(mode.localizedName).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
|
||||
if glassMode == .customLiquid {
|
||||
Picker("Liquid Glass Variant", selection: $liquidGlassVariant) {
|
||||
ForEach(LiquidGlassVariant.allCases) { variant in
|
||||
Text("Variant \(variant.rawValue)").tag(variant)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
} header: {
|
||||
Text("LocalSend Device Picker")
|
||||
} footer: {
|
||||
Text("Customize the appearance of the LocalSend device selection popup that appears when you drop files.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LiveActivitiesSettings: View {
|
||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||
@ObservedObject var recordingManager = ScreenRecordingManager.shared
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ struct AirDropView: View {
|
|||
@Default(.quickShareProvider) var quickShareProvider
|
||||
@StateObject private var quickShareService = QuickShareService.shared
|
||||
@State private var showShareSettings = false
|
||||
@State private var isGearHover = false
|
||||
@State private var isSwitchHover = false
|
||||
|
||||
var body: some View {
|
||||
dropArea
|
||||
|
|
@ -50,27 +50,26 @@ struct AirDropView: View {
|
|||
ZStack {
|
||||
dropLabel
|
||||
|
||||
// Gear in the bottom-right for quick-share settings
|
||||
// Switch button in the top-right for quick-share settings
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
Button {
|
||||
quickShareService.ensureDiscovered()
|
||||
showShareSettings.toggle()
|
||||
} label: {
|
||||
Image(systemName: "gearshape")
|
||||
Image(systemName: "switch.2")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 14, height: 14)
|
||||
.padding(8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(isGearHover ? Color(.windowBackgroundColor).opacity(0.12) : Color.clear)
|
||||
.fill(isSwitchHover ? Color(.windowBackgroundColor).opacity(0.12) : Color.clear)
|
||||
)
|
||||
.foregroundColor(isGearHover ? .accentColor : .gray)
|
||||
.foregroundColor(isSwitchHover ? .accentColor : .gray)
|
||||
.contentShape(Rectangle())
|
||||
.onHover { over in isGearHover = over }
|
||||
.onHover { over in isSwitchHover = over }
|
||||
.help("Quick Share settings")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
|
@ -132,8 +131,10 @@ struct AirDropView: View {
|
|||
}
|
||||
.padding()
|
||||
}
|
||||
.padding(8)
|
||||
.padding(.trailing, 8)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,12 +31,14 @@ struct FileShareView: View {
|
|||
@StateObject private var localSend = LocalSendService.shared
|
||||
@Default(.quickShareProvider) var quickShareProvider: String
|
||||
@State private var showQuickSharePopover = false
|
||||
@State private var isGearHover = false
|
||||
@State private var isSwitchHover = false
|
||||
@State private var autoCloseToken = UUID()
|
||||
|
||||
@State private var hostView: NSView?
|
||||
@State private var interactionNonce: UUID = .init()
|
||||
@State private var isProcessing = false
|
||||
@State private var pendingDropProviders: [NSItemProvider]?
|
||||
@State private var showLocalSendPicker = false
|
||||
|
||||
private var selectedProvider: QuickShareProvider {
|
||||
quickShare.availableProviders.first(where: { $0.id == quickShareProvider }) ?? QuickShareProvider(id: "System Share Menu", imageData: nil, supportsRawText: true)
|
||||
|
|
@ -56,7 +58,12 @@ struct FileShareView: View {
|
|||
.onDrop(of: [.fileURL, .url, .utf8PlainText, .plainText, .data, .image], isTargeted: $vm.dropZoneTargeting) { providers in
|
||||
interactionNonce = .init()
|
||||
vm.dropEvent = true
|
||||
if selectedProvider.id == "LocalSend" {
|
||||
pendingDropProviders = providers
|
||||
showLocalSendPicker = true
|
||||
} else {
|
||||
Task { await handleDrop(providers) }
|
||||
}
|
||||
return true
|
||||
}
|
||||
.onTapGesture {
|
||||
|
|
@ -125,76 +132,30 @@ struct FileShareView: View {
|
|||
.truncationMode(.tail)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
// Share mode selector moved to the notch tab gear icon
|
||||
|
||||
if selectedProvider.id == "LocalSend" {
|
||||
HStack(spacing: 8) {
|
||||
Menu {
|
||||
if localSend.devices.isEmpty {
|
||||
Text("No devices found")
|
||||
} else {
|
||||
ForEach(localSend.devices) { device in
|
||||
Button(device.displayName) {
|
||||
localSend.selectedDeviceID = device.id
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "iphone.and.arrow.forward")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
Text(localSend.devices.first(where: { $0.id == localSend.selectedDeviceID })?.displayName ?? "Select device")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
}
|
||||
|
||||
Button {
|
||||
localSend.refreshDeviceScan()
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
.rotationEffect(.degrees(localSend.isRefreshing ? 360 : 0))
|
||||
.animation(
|
||||
localSend.isRefreshing
|
||||
? .linear(duration: 0.85).repeatForever(autoreverses: false)
|
||||
: .easeOut(duration: 0.2),
|
||||
value: localSend.isRefreshing
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(localSend.isRefreshing)
|
||||
.help("Refresh devices")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
.padding(18)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
// Gear pinned to top-left corner of quick share box
|
||||
// Switch button pinned to top-right corner
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button {
|
||||
// Ensure notch stays open immediately when opening the selector
|
||||
vm.setAutoCloseSuppression(true, token: autoCloseToken)
|
||||
quickShare.ensureDiscovered()
|
||||
showQuickSharePopover.toggle()
|
||||
} label: {
|
||||
Image(systemName: "gear")
|
||||
Image(systemName: "switch.2")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 14, height: 14)
|
||||
.padding(8)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(isGearHover ? Color(.windowBackgroundColor).opacity(0.12) : Color.clear))
|
||||
.foregroundColor(isGearHover ? .accentColor : .gray)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(isSwitchHover ? Color(.windowBackgroundColor).opacity(0.12) : Color.clear))
|
||||
.foregroundColor(isSwitchHover ? .accentColor : .gray)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.onHover { hovering in
|
||||
isGearHover = hovering
|
||||
isSwitchHover = hovering
|
||||
vm.setAutoCloseSuppression(hovering, token: autoCloseToken)
|
||||
}
|
||||
.popover(isPresented: $showQuickSharePopover, arrowEdge: .bottom) {
|
||||
|
|
@ -267,10 +228,8 @@ struct FileShareView: View {
|
|||
}
|
||||
.onHover { hovering in vm.setAutoCloseSuppression(hovering, token: autoCloseToken) }
|
||||
}
|
||||
.padding(.leading, 8)
|
||||
.padding(.trailing, 8)
|
||||
.padding(.top, 8)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
|
@ -319,6 +278,28 @@ struct FileShareView: View {
|
|||
}
|
||||
}
|
||||
.contentShape(RoundedRectangle(cornerRadius: 12))
|
||||
.onChange(of: showLocalSendPicker) { _, show in
|
||||
if show {
|
||||
LocalSendDevicePickerWindowManager.shared.show(
|
||||
onDeviceSelected: { device in
|
||||
localSend.selectedDeviceID = device.id
|
||||
showLocalSendPicker = false
|
||||
if let providers = pendingDropProviders {
|
||||
Task {
|
||||
await handleDrop(providers)
|
||||
pendingDropProviders = nil
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismiss: {
|
||||
showLocalSendPicker = false
|
||||
pendingDropProviders = nil
|
||||
}
|
||||
)
|
||||
} else {
|
||||
LocalSendDevicePickerWindowManager.shared.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
|
|
|||
530
DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift
Normal file
530
DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift
Normal file
|
|
@ -0,0 +1,530 @@
|
|||
/*
|
||||
* 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 AppKit
|
||||
import SwiftUI
|
||||
import Defaults
|
||||
|
||||
@MainActor
|
||||
final class LocalSendDevicePickerWindowManager {
|
||||
static let shared = LocalSendDevicePickerWindowManager()
|
||||
|
||||
private var window: NSWindow?
|
||||
private var onDeviceSelected: ((LocalSendDeviceInfo) -> Void)?
|
||||
private var onDismiss: (() -> Void)?
|
||||
|
||||
private init() {}
|
||||
|
||||
func show(
|
||||
onDeviceSelected: @escaping (LocalSendDeviceInfo) -> Void,
|
||||
onDismiss: @escaping () -> Void
|
||||
) {
|
||||
self.onDeviceSelected = onDeviceSelected
|
||||
self.onDismiss = onDismiss
|
||||
|
||||
let pickerView = LocalSendDevicePickerView(
|
||||
onDeviceSelected: { [weak self] device in
|
||||
self?.onDeviceSelected?(device)
|
||||
self?.hide()
|
||||
},
|
||||
onDismiss: { [weak self] in
|
||||
self?.onDismiss?()
|
||||
self?.hide()
|
||||
}
|
||||
)
|
||||
|
||||
if let existingWindow = window {
|
||||
existingWindow.contentView = NSHostingView(rootView: pickerView)
|
||||
existingWindow.makeKeyAndOrderFront(nil)
|
||||
return
|
||||
}
|
||||
|
||||
guard let screen = NSScreen.main else { return }
|
||||
|
||||
let windowSize = CGSize(width: 480, height: 400)
|
||||
let windowOrigin = CGPoint(
|
||||
x: screen.frame.midX - windowSize.width / 2,
|
||||
y: screen.frame.midY - windowSize.height / 2
|
||||
)
|
||||
|
||||
let newWindow = NSWindow(
|
||||
contentRect: NSRect(origin: windowOrigin, size: windowSize),
|
||||
styleMask: [.borderless],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
|
||||
newWindow.isOpaque = false
|
||||
newWindow.backgroundColor = .clear
|
||||
newWindow.hasShadow = true
|
||||
newWindow.level = .floating
|
||||
newWindow.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||
newWindow.contentView = NSHostingView(rootView: pickerView)
|
||||
newWindow.isMovableByWindowBackground = true
|
||||
|
||||
window = newWindow
|
||||
newWindow.makeKeyAndOrderFront(nil)
|
||||
|
||||
// Add click-outside-to-dismiss
|
||||
NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in
|
||||
guard let self, let window = self.window else { return event }
|
||||
let locationInWindow = event.locationInWindow
|
||||
let windowFrame = window.frame
|
||||
let clickLocation = NSPoint(
|
||||
x: windowFrame.origin.x + locationInWindow.x,
|
||||
y: windowFrame.origin.y + locationInWindow.y
|
||||
)
|
||||
|
||||
if !windowFrame.contains(clickLocation) && event.window != window {
|
||||
self.onDismiss?()
|
||||
self.hide()
|
||||
}
|
||||
return event
|
||||
}
|
||||
}
|
||||
|
||||
func hide() {
|
||||
window?.orderOut(nil)
|
||||
window = nil
|
||||
onDeviceSelected = nil
|
||||
onDismiss = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - LocalSend Device Picker View
|
||||
|
||||
struct LocalSendDevicePickerView: View {
|
||||
@StateObject private var localSend = LocalSendService.shared
|
||||
@Default(.localSendDevicePickerGlassMode) private var glassMode
|
||||
@Default(.localSendDevicePickerLiquidGlassVariant) private var liquidGlassVariant
|
||||
|
||||
let onDeviceSelected: (LocalSendDeviceInfo) -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
@State private var hoveredDeviceID: String?
|
||||
|
||||
private let cornerRadius: CGFloat = 24
|
||||
|
||||
private var usesCustomLiquidGlass: Bool {
|
||||
glassMode == .customLiquid
|
||||
}
|
||||
|
||||
private var usesStandardLiquidGlass: Bool {
|
||||
guard glassMode == .standard else { return false }
|
||||
if #available(macOS 26.0, *) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Background
|
||||
panelBackground
|
||||
|
||||
// Content
|
||||
VStack(spacing: 16) {
|
||||
// Header
|
||||
HStack {
|
||||
Image("LocalSend")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
Text("Select Device")
|
||||
.font(.system(size: 18, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(.white)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Refresh button
|
||||
Button {
|
||||
localSend.refreshDeviceScan()
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
.rotationEffect(.degrees(localSend.isRefreshing ? 360 : 0))
|
||||
.animation(
|
||||
localSend.isRefreshing
|
||||
? .linear(duration: 0.85).repeatForever(autoreverses: false)
|
||||
: .easeOut(duration: 0.2),
|
||||
value: localSend.isRefreshing
|
||||
)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Circle().fill(Color.white.opacity(0.1)))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(localSend.isRefreshing)
|
||||
.help("Refresh devices")
|
||||
|
||||
// Close button
|
||||
Button {
|
||||
onDismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
.frame(width: 28, height: 28)
|
||||
.background(Circle().fill(Color.white.opacity(0.1)))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 20)
|
||||
|
||||
// Devices grid
|
||||
if localSend.devices.isEmpty {
|
||||
emptyStateView
|
||||
} else {
|
||||
devicesGridView
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 480, height: 400)
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.stroke(Color.white.opacity(0.2), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: Color.black.opacity(0.4), radius: 30, x: 0, y: 15)
|
||||
.onAppear {
|
||||
localSend.startDiscovery()
|
||||
localSend.refreshDeviceScan()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var panelBackground: some View {
|
||||
if usesCustomLiquidGlass {
|
||||
LiquidGlassBackground(variant: liquidGlassVariant, cornerRadius: cornerRadius) {
|
||||
Color.black.opacity(0.3)
|
||||
}
|
||||
} else if usesStandardLiquidGlass {
|
||||
if #available(macOS 26.0, *) {
|
||||
LocalSendGlassBackdrop(cornerRadius: cornerRadius)
|
||||
}
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(.ultraThinMaterial)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(Color.black.opacity(0.4))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyStateView: some View {
|
||||
VStack(spacing: 12) {
|
||||
Spacer()
|
||||
|
||||
if localSend.isRefreshing {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
.scaleEffect(1.2)
|
||||
|
||||
Text("Scanning for devices...")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
} else {
|
||||
Image(systemName: "antenna.radiowaves.left.and.right.slash")
|
||||
.font(.system(size: 48, weight: .light))
|
||||
.foregroundColor(.white.opacity(0.4))
|
||||
|
||||
Text("No devices found")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
|
||||
Text("Make sure LocalSend is running on other devices")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.white.opacity(0.5))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Button {
|
||||
localSend.refreshDeviceScan()
|
||||
} label: {
|
||||
Text("Scan Again")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.accentColor.opacity(0.8))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var devicesGridView: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(
|
||||
columns: [
|
||||
GridItem(.flexible(), spacing: 16),
|
||||
GridItem(.flexible(), spacing: 16),
|
||||
GridItem(.flexible(), spacing: 16)
|
||||
],
|
||||
spacing: 16
|
||||
) {
|
||||
ForEach(localSend.devices) { device in
|
||||
DeviceGridItem(
|
||||
device: device,
|
||||
isHovered: hoveredDeviceID == device.id,
|
||||
onSelect: {
|
||||
onDeviceSelected(device)
|
||||
}
|
||||
)
|
||||
.onHover { hovering in
|
||||
hoveredDeviceID = hovering ? device.id : nil
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Device Grid Item
|
||||
|
||||
private struct DeviceGridItem: View {
|
||||
let device: LocalSendDeviceInfo
|
||||
let isHovered: Bool
|
||||
let onSelect: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onSelect) {
|
||||
VStack(spacing: 10) {
|
||||
// Device icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(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))
|
||||
}
|
||||
|
||||
// Device name
|
||||
VStack(spacing: 2) {
|
||||
Text(device.alias)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
|
||||
if let model = device.model, !model.isEmpty {
|
||||
Text(model)
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.white.opacity(0.5))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color.white.opacity(isHovered ? 0.12 : 0.06))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(isHovered ? Color.accentColor.opacity(0.6) : Color.white.opacity(0.1), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.scaleEffect(isHovered ? 1.02 : 1.0)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: isHovered)
|
||||
}
|
||||
|
||||
private var deviceIcon: String {
|
||||
let aliasLower = device.alias.lowercased()
|
||||
let modelLower = (device.model ?? "").lowercased()
|
||||
let combined = aliasLower + " " + modelLower
|
||||
|
||||
// iPhone / iOS devices
|
||||
if combined.contains("iphone") {
|
||||
return "iphone"
|
||||
}
|
||||
if combined.contains("ipad") {
|
||||
return "ipad"
|
||||
}
|
||||
if combined.contains("ipod") {
|
||||
return "ipodtouch"
|
||||
}
|
||||
|
||||
// Android phones by brand
|
||||
if combined.contains("pixel") || combined.contains("nexus") {
|
||||
return "smartphone"
|
||||
}
|
||||
if combined.contains("samsung") || combined.contains("galaxy") {
|
||||
return "smartphone"
|
||||
}
|
||||
if combined.contains("oneplus") || combined.contains("one plus") {
|
||||
return "smartphone"
|
||||
}
|
||||
if combined.contains("xiaomi") || combined.contains("redmi") || combined.contains("poco") || combined.contains("mi ") {
|
||||
return "smartphone"
|
||||
}
|
||||
if combined.contains("huawei") || combined.contains("honor") {
|
||||
return "smartphone"
|
||||
}
|
||||
if combined.contains("oppo") || combined.contains("realme") || combined.contains("vivo") {
|
||||
return "smartphone"
|
||||
}
|
||||
if combined.contains("motorola") || combined.contains("moto ") {
|
||||
return "smartphone"
|
||||
}
|
||||
if combined.contains("nokia") {
|
||||
return "smartphone"
|
||||
}
|
||||
if combined.contains("sony") || combined.contains("xperia") {
|
||||
return "smartphone"
|
||||
}
|
||||
if combined.contains("lg") {
|
||||
return "smartphone"
|
||||
}
|
||||
if combined.contains("asus") && (combined.contains("phone") || combined.contains("rog")) {
|
||||
return "smartphone"
|
||||
}
|
||||
if combined.contains("nothing") && combined.contains("phone") {
|
||||
return "smartphone"
|
||||
}
|
||||
|
||||
// Android tablets
|
||||
if combined.contains("tab") || combined.contains("tablet") {
|
||||
return "tablet.portrait"
|
||||
}
|
||||
|
||||
// Mac devices
|
||||
if combined.contains("macbook") {
|
||||
return "laptopcomputer"
|
||||
}
|
||||
if combined.contains("imac") {
|
||||
return "desktopcomputer"
|
||||
}
|
||||
if combined.contains("mac mini") || combined.contains("mini") && combined.contains("mac") {
|
||||
return "macmini"
|
||||
}
|
||||
if combined.contains("mac pro") {
|
||||
return "macpro.gen3"
|
||||
}
|
||||
if combined.contains("mac studio") {
|
||||
return "macstudio"
|
||||
}
|
||||
if combined.contains("mac") || combined.contains("macos") {
|
||||
return "desktopcomputer"
|
||||
}
|
||||
|
||||
// Windows/Linux laptops and desktops
|
||||
if combined.contains("laptop") || combined.contains("notebook") {
|
||||
return "laptopcomputer"
|
||||
}
|
||||
if combined.contains("thinkpad") || combined.contains("lenovo") {
|
||||
return "laptopcomputer"
|
||||
}
|
||||
if combined.contains("dell") || combined.contains("xps") || combined.contains("inspiron") || combined.contains("latitude") {
|
||||
return "laptopcomputer"
|
||||
}
|
||||
if combined.contains("hp") || combined.contains("pavilion") || combined.contains("envy") || combined.contains("spectre") || combined.contains("elitebook") {
|
||||
return "laptopcomputer"
|
||||
}
|
||||
if combined.contains("asus") || combined.contains("zenbook") || combined.contains("vivobook") {
|
||||
return "laptopcomputer"
|
||||
}
|
||||
if combined.contains("acer") || combined.contains("aspire") || combined.contains("swift") {
|
||||
return "laptopcomputer"
|
||||
}
|
||||
if combined.contains("msi") {
|
||||
return "laptopcomputer"
|
||||
}
|
||||
if combined.contains("surface") {
|
||||
return "laptopcomputer"
|
||||
}
|
||||
if combined.contains("razer") {
|
||||
return "laptopcomputer"
|
||||
}
|
||||
if combined.contains("chromebook") {
|
||||
return "laptopcomputer"
|
||||
}
|
||||
|
||||
// Desktop keywords
|
||||
if combined.contains("desktop") || combined.contains("pc") || combined.contains("workstation") {
|
||||
return "desktopcomputer"
|
||||
}
|
||||
if combined.contains("windows") || combined.contains("linux") || combined.contains("ubuntu") || combined.contains("fedora") {
|
||||
return "desktopcomputer"
|
||||
}
|
||||
|
||||
// Android generic
|
||||
if combined.contains("android") {
|
||||
return "smartphone"
|
||||
}
|
||||
|
||||
// Phone/Mobile generic
|
||||
if combined.contains("phone") || combined.contains("mobile") {
|
||||
return "smartphone"
|
||||
}
|
||||
|
||||
// TV devices
|
||||
if combined.contains("tv") || combined.contains("shield") || combined.contains("fire") || combined.contains("roku") || combined.contains("chromecast") {
|
||||
return "appletv"
|
||||
}
|
||||
|
||||
// Default: try to guess based on common patterns
|
||||
// If it looks like a computer name with a person's name, assume laptop
|
||||
if combined.contains("'s ") || combined.contains("-pc") || combined.contains("-laptop") {
|
||||
return "laptopcomputer"
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return "desktopcomputer"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Glass Backdrop for LocalSend Picker
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
private struct LocalSendGlassBackdrop: View {
|
||||
let cornerRadius: CGFloat
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
let dynamicFontSize = max(min(proxy.size.width, proxy.size.height) / 8, 42)
|
||||
|
||||
Text("LocalSend Device Picker")
|
||||
.font(.system(size: dynamicFontSize, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(Color.clear)
|
||||
.frame(width: proxy.size.width, height: proxy.size.height)
|
||||
.glassEffect(
|
||||
.clear.interactive(),
|
||||
in: .rect(cornerRadius: cornerRadius)
|
||||
)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
|
@ -889,6 +889,8 @@ extension Defaults.Keys {
|
|||
static let openShelfByDefault = Key<Bool>("openShelfByDefault", default: true)
|
||||
static let quickShareProvider = Key<String>("quickShareProvider", default: "AirDrop")
|
||||
static let localSendSelectedDeviceID = Key<String>("localSendSelectedDeviceID", default: "")
|
||||
static let localSendDevicePickerGlassMode = Key<LockScreenGlassCustomizationMode>("localSendDevicePickerGlassMode", default: .standard)
|
||||
static let localSendDevicePickerLiquidGlassVariant = Key<LiquidGlassVariant>("localSendDevicePickerLiquidGlassVariant", default: .v11)
|
||||
static let copyOnDrag = Key<Bool>("copyOnDrag", default: false)
|
||||
static let autoRemoveShelfItems = Key<Bool>("autoRemoveShelfItems", default: false)
|
||||
static let expandedDragDetection = Key<Bool>("expandedDragDetection", default: true)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue