From 794e05c7d67ba67e7933de2b95e4028eb36d6f5f Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sat, 28 Mar 2026 15:53:39 +0530 Subject: [PATCH] Update: Popover for Localsend --- DynamicIsland.xcodeproj/project.pbxproj | 18 +- DynamicIsland/Localizable.xcstrings | 36 ++ .../components/Settings/SettingsView.swift | 42 +- .../components/Shelf/AirDropView.swift | 17 +- .../Shelf/Views/FileShareView.swift | 95 ++-- .../LocalSendDevicePickerWindowManager.swift | 530 ++++++++++++++++++ DynamicIsland/models/Constants.swift | 2 + 7 files changed, 665 insertions(+), 75 deletions(-) create mode 100644 DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index 4c209ab..021b45f 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -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; diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 64ead76..7fb3de0 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -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" : { diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 624d355..a4b40d7 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -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 diff --git a/DynamicIsland/components/Shelf/AirDropView.swift b/DynamicIsland/components/Shelf/AirDropView.swift index 1199646..efa83b0 100644 --- a/DynamicIsland/components/Shelf/AirDropView.swift +++ b/DynamicIsland/components/Shelf/AirDropView.swift @@ -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() } } } diff --git a/DynamicIsland/components/Shelf/Views/FileShareView.swift b/DynamicIsland/components/Shelf/Views/FileShareView.swift index 5795f16..cfcc90b 100644 --- a/DynamicIsland/components/Shelf/Views/FileShareView.swift +++ b/DynamicIsland/components/Shelf/Views/FileShareView.swift @@ -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 - Task { await handleDrop(providers) } + 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 diff --git a/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift b/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift new file mode 100644 index 0000000..8300f8b --- /dev/null +++ b/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift @@ -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 . + */ + +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) + } +} diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 392c081..c03afc5 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -889,6 +889,8 @@ extension Defaults.Keys { static let openShelfByDefault = Key("openShelfByDefault", default: true) static let quickShareProvider = Key("quickShareProvider", default: "AirDrop") static let localSendSelectedDeviceID = Key("localSendSelectedDeviceID", default: "") + static let localSendDevicePickerGlassMode = Key("localSendDevicePickerGlassMode", default: .standard) + static let localSendDevicePickerLiquidGlassVariant = Key("localSendDevicePickerLiquidGlassVariant", default: .v11) static let copyOnDrag = Key("copyOnDrag", default: false) static let autoRemoveShelfItems = Key("autoRemoveShelfItems", default: false) static let expandedDragDetection = Key("expandedDragDetection", default: true)