mirror of
https://github.com/carey314/mio-plugin-airdrop.git
synced 2026-06-11 03:54:33 +00:00
Quick-access AirDrop from the notch panel. Tap the card body, pick files via NSOpenPanel, hand off to NSSharingService(.sendViaAirDrop). No private APIs, no entitlements, no network — just a thin wrapper around the same AirDrop API Finder uses. Design constraint: drag-and-drop is intentionally not implemented. MioIsland's notch panel auto-collapses on click-outside, which breaks the Cmd-Tab-to-Finder / grab / drag-back workflow before the drop target reaches. Tap-to-choose is rock solid by comparison. Requires MioIsland host v2.2.0+ (panel size clamp floor was lowered to 120pt in that release).
130 lines
4.0 KiB
Swift
130 lines
4.0 KiB
Swift
//
|
|
// DropZoneView.swift
|
|
// MioIsland AirDrop Plugin
|
|
//
|
|
// Click-to-choose zone at the center of the panel. Tapping anywhere
|
|
// inside opens NSOpenPanel → user picks files → NSSharingService
|
|
// fires the AirDrop chooser sheet.
|
|
//
|
|
// Drag-and-drop was intentionally removed: macOS Dynamic Island
|
|
// panels auto-collapse when the user clicks outside them, which
|
|
// prevents the Cmd-Tab → Finder → grab file → drag back workflow.
|
|
// Rather than fight the host's event routing, we commit to the
|
|
// click-to-choose path which is rock-solid.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct DropZoneView: View {
|
|
@ObservedObject var state: AirDropState = .shared
|
|
|
|
private static let lime = Color(
|
|
red: 0xCA / 255.0,
|
|
green: 0xFF / 255.0,
|
|
blue: 0x00 / 255.0
|
|
)
|
|
private static let ink = Color.white
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
.fill(fillColor)
|
|
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
.strokeBorder(strokeColor, lineWidth: 1)
|
|
|
|
centerContent
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
guard case .idle = state.phase else { return }
|
|
state.chooseAndSend()
|
|
}
|
|
.animation(.easeInOut(duration: 0.25), value: state.phase)
|
|
}
|
|
|
|
// MARK: - Reactive styling
|
|
|
|
private var fillColor: Color {
|
|
switch state.phase {
|
|
case .sent: return Self.lime.opacity(0.14)
|
|
case .showingPicker: return Color.white.opacity(0.08)
|
|
case .error: return Color.red.opacity(0.08)
|
|
case .idle: return Color.white.opacity(0.04)
|
|
}
|
|
}
|
|
|
|
private var strokeColor: Color {
|
|
switch state.phase {
|
|
case .sent: return Self.lime.opacity(0.9)
|
|
case .showingPicker: return Self.lime.opacity(0.6)
|
|
case .error: return Color.red.opacity(0.7)
|
|
case .idle: return Color.white.opacity(0.18)
|
|
}
|
|
}
|
|
|
|
// MARK: - Center content (icon + label)
|
|
|
|
@ViewBuilder
|
|
private var centerContent: some View {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: iconName)
|
|
.font(.system(size: 32, weight: .light))
|
|
.foregroundColor(iconColor)
|
|
|
|
Text(primaryText)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundColor(primaryColor)
|
|
.multilineTextAlignment(.center)
|
|
|
|
if let sub = secondaryText {
|
|
Text(sub)
|
|
.font(.system(size: 11))
|
|
.foregroundColor(Self.ink.opacity(0.5))
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
}
|
|
|
|
private var iconName: String {
|
|
switch state.phase {
|
|
case .sent: return "checkmark.circle.fill"
|
|
case .error: return "exclamationmark.triangle.fill"
|
|
case .showingPicker: return "airplayaudio"
|
|
case .idle: return "airplayaudio"
|
|
}
|
|
}
|
|
|
|
private var iconColor: Color {
|
|
switch state.phase {
|
|
case .sent: return Self.lime
|
|
case .error: return Color.red.opacity(0.8)
|
|
case .showingPicker: return Self.ink.opacity(0.85)
|
|
case .idle: return Self.ink.opacity(0.7)
|
|
}
|
|
}
|
|
|
|
private var primaryText: String {
|
|
switch state.phase {
|
|
case .sent(let n): return L10n.sentCount(n)
|
|
case .error(let m): return m
|
|
case .showingPicker: return L10n.opening
|
|
case .idle: return L10n.chooseFiles
|
|
}
|
|
}
|
|
|
|
private var primaryColor: Color {
|
|
switch state.phase {
|
|
case .sent: return Self.lime
|
|
case .error: return Color.red.opacity(0.9)
|
|
default: return Self.ink.opacity(0.85)
|
|
}
|
|
}
|
|
|
|
private var secondaryText: String? {
|
|
guard case .idle = state.phase else { return nil }
|
|
return L10n.clickToChoose
|
|
}
|
|
}
|