|
@@ -0,0 +1,553 @@
|
|
|
|
|
+import AppKit
|
|
|
|
|
+import Combine
|
|
|
|
|
+import CoreLocation
|
|
|
|
|
+import SwiftUI
|
|
|
|
|
+import WebKit
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Public SwiftUI view
|
|
|
|
|
+
|
|
|
|
|
+/// Desktop widget embedding Google Maps in a `WKWebView`, with search, traffic, and navigation shortcuts.
|
|
|
|
|
+struct MapsInteractiveWidgetView: View {
|
|
|
|
|
+ let app: LauncherApp
|
|
|
|
|
+ let widgetSize: WidgetSize
|
|
|
|
|
+ var isPreview: Bool = false
|
|
|
|
|
+
|
|
|
|
|
+ @State private var searchText = ""
|
|
|
|
|
+ @State private var mapURL: URL = MapsInteractiveWidgetView.defaultMapURL
|
|
|
|
|
+ @State private var trafficLayerOn = false
|
|
|
|
|
+ @StateObject private var locationHelper = MapsWidgetLocationHelper()
|
|
|
|
|
+ @FocusState private var searchFieldFocused: Bool
|
|
|
|
|
+
|
|
|
|
|
+ private static var defaultMapURL: URL {
|
|
|
|
|
+ MapsInteractiveWidgetView.makeMapURL(center: nil, zoom: nil, layer: nil)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Builds `https://www.google.com/maps/@` with official Maps URL query parameters.
|
|
|
|
|
+ private static func makeMapURL(center: String?, zoom: Int?, layer: String?) -> URL {
|
|
|
|
|
+ var c = URLComponents(string: "https://www.google.com/maps/@")!
|
|
|
|
|
+ var items: [URLQueryItem] = [
|
|
|
|
|
+ URLQueryItem(name: "api", value: "1"),
|
|
|
|
|
+ URLQueryItem(name: "map_action", value: "map"),
|
|
|
|
|
+ ]
|
|
|
|
|
+ if let center { items.append(URLQueryItem(name: "center", value: center)) }
|
|
|
|
|
+ if let zoom { items.append(URLQueryItem(name: "zoom", value: "\(zoom)")) }
|
|
|
|
|
+ if let layer { items.append(URLQueryItem(name: "layer", value: layer)) }
|
|
|
|
|
+ c.queryItems = items
|
|
|
|
|
+ return c.url ?? URL(string: "https://www.google.com/maps")!
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var body: some View {
|
|
|
|
|
+ Group {
|
|
|
|
|
+ if isPreview {
|
|
|
|
|
+ previewChrome
|
|
|
|
|
+ } else {
|
|
|
|
|
+ interactiveChrome
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
|
|
|
|
|
+ .overlay(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 22, style: .continuous)
|
|
|
|
|
+ .stroke(Color.white.opacity(0.12), lineWidth: 1)
|
|
|
|
|
+ )
|
|
|
|
|
+ .shadow(color: .black.opacity(0.25), radius: 14, x: 0, y: 8)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var previewChrome: some View {
|
|
|
|
|
+ ZStack {
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 22, style: .continuous)
|
|
|
|
|
+ .fill(
|
|
|
|
|
+ LinearGradient(
|
|
|
|
|
+ colors: [Color(red: 0.12, green: 0.18, blue: 0.22), Color(red: 0.1, green: 0.35, blue: 0.32)],
|
|
|
|
|
+ startPoint: .topLeading,
|
|
|
|
|
+ endPoint: .bottomTrailing
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ VStack(spacing: 12) {
|
|
|
|
|
+ AppIconView(app: app, size: 48, showAppBackground: false, iconPaddingFactor: 0.08)
|
|
|
|
|
+ Text("Live Maps")
|
|
|
|
|
+ .font(.system(size: 15, weight: .bold))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.92))
|
|
|
|
|
+ Text("Interactive map loads on your desktop.")
|
|
|
|
|
+ .font(.system(size: 11, weight: .medium))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.65))
|
|
|
|
|
+ .multilineTextAlignment(.center)
|
|
|
|
|
+ .padding(.horizontal, 8)
|
|
|
|
|
+ }
|
|
|
|
|
+ .padding(16)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var interactiveChrome: some View {
|
|
|
|
|
+ ZStack {
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 22, style: .continuous)
|
|
|
|
|
+ .fill(Color.black.opacity(0.35))
|
|
|
|
|
+
|
|
|
|
|
+ VStack(spacing: 0) {
|
|
|
|
|
+ VStack(spacing: 8) {
|
|
|
|
|
+ headerBar
|
|
|
|
|
+ searchBar
|
|
|
|
|
+ primaryToolbar
|
|
|
|
|
+ quickPlacesBar
|
|
|
|
|
+ }
|
|
|
|
|
+ .padding(.horizontal, 4)
|
|
|
|
|
+ .padding(.vertical, 6)
|
|
|
|
|
+ .background(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
|
|
|
+ .fill(Color(red: 0.07, green: 0.08, blue: 0.1))
|
|
|
|
|
+ )
|
|
|
|
|
+ .overlay(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
|
|
|
+ .stroke(Color.white.opacity(0.1), lineWidth: 1)
|
|
|
|
|
+ )
|
|
|
|
|
+ .zIndex(20)
|
|
|
|
|
+ .layoutPriority(1)
|
|
|
|
|
+ .allowsHitTesting(true)
|
|
|
|
|
+
|
|
|
|
|
+ MapsWebWidgetView(url: $mapURL)
|
|
|
|
|
+ .frame(height: mapContentHeight)
|
|
|
|
|
+ .frame(maxWidth: .infinity)
|
|
|
|
|
+ .clipped()
|
|
|
|
|
+ .compositingGroup()
|
|
|
|
|
+ .zIndex(0)
|
|
|
|
|
+ .layoutPriority(0)
|
|
|
|
|
+ .allowsHitTesting(true)
|
|
|
|
|
+ }
|
|
|
|
|
+ .padding(10)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var headerBar: some View {
|
|
|
|
|
+ HStack(spacing: 8) {
|
|
|
|
|
+ AppIconView(app: app, size: 28, showAppBackground: false, iconPaddingFactor: 0.08)
|
|
|
|
|
+ Text("Google Maps")
|
|
|
|
|
+ .font(.system(size: 14, weight: .bold))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.95))
|
|
|
|
|
+ .lineLimit(1)
|
|
|
|
|
+ Spacer(minLength: 0)
|
|
|
|
|
+ }
|
|
|
|
|
+ .padding(.bottom, 6)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var searchBar: some View {
|
|
|
|
|
+ HStack(spacing: 8) {
|
|
|
|
|
+ Image(systemName: "magnifyingglass")
|
|
|
|
|
+ .font(.system(size: 13, weight: .semibold))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.75))
|
|
|
|
|
+ TextField("Search places", text: $searchText)
|
|
|
|
|
+ .textFieldStyle(.plain)
|
|
|
|
|
+ .font(.system(size: 13, weight: .medium))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.96))
|
|
|
|
|
+ .focused($searchFieldFocused)
|
|
|
|
|
+ .onSubmit { submitSearch() }
|
|
|
|
|
+ .onChange(of: searchFieldFocused) { focused in
|
|
|
|
|
+ if focused {
|
|
|
|
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ MapSearchGoButton(action: {
|
|
|
|
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
|
|
+ submitSearch()
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ .padding(.horizontal, 12)
|
|
|
|
|
+ .padding(.vertical, 10)
|
|
|
|
|
+ .background(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
|
|
|
+ .fill(Color.white.opacity(0.1))
|
|
|
|
|
+ )
|
|
|
|
|
+ .overlay(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
|
|
|
+ .stroke(Color.white.opacity(0.12), lineWidth: 1)
|
|
|
|
|
+ )
|
|
|
|
|
+ .padding(.bottom, 8)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var primaryToolbar: some View {
|
|
|
|
|
+ VStack(alignment: .leading, spacing: 6) {
|
|
|
|
|
+ HStack(spacing: 8) {
|
|
|
|
|
+ MapToolbarButton(
|
|
|
|
|
+ title: trafficLayerOn ? "Traffic on" : "Traffic",
|
|
|
|
|
+ systemImage: "car.side.fill",
|
|
|
|
|
+ isSelected: trafficLayerOn,
|
|
|
|
|
+ helpText: "Show or hide live traffic on the map"
|
|
|
|
|
+ ) {
|
|
|
|
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
|
|
+ trafficLayerOn.toggle()
|
|
|
|
|
+ applyTrafficLayer()
|
|
|
|
|
+ }
|
|
|
|
|
+ MapToolbarButton(
|
|
|
|
|
+ title: "Directions",
|
|
|
|
|
+ systemImage: "arrow.triangle.turn.up.right.diamond.fill",
|
|
|
|
|
+ helpText: "Open driving directions (set start and destination in Maps)"
|
|
|
|
|
+ ) {
|
|
|
|
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
|
|
+ loadURLString("https://www.google.com/maps/dir/?api=1&travelmode=driving")
|
|
|
|
|
+ }
|
|
|
|
|
+ MapToolbarButton(
|
|
|
|
|
+ title: "My location",
|
|
|
|
|
+ systemImage: "location.fill",
|
|
|
|
|
+ helpText: "Center the map on your current location"
|
|
|
|
|
+ ) {
|
|
|
|
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
|
|
+ locationHelper.requestLocation { coord in
|
|
|
|
|
+ let center = "\(coord.latitude),\(coord.longitude)"
|
|
|
|
|
+ let layer: String? = trafficLayerOn ? "traffic" : nil
|
|
|
|
|
+ let url = MapsInteractiveWidgetView.makeMapURL(center: center, zoom: 15, layer: layer)
|
|
|
|
|
+ Task { @MainActor in
|
|
|
|
|
+ mapURL = url
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ HStack(spacing: 8) {
|
|
|
|
|
+ MapToolbarButton(
|
|
|
|
|
+ title: "Satellite",
|
|
|
|
|
+ systemImage: "globe.americas.fill",
|
|
|
|
|
+ helpText: "Switch to satellite imagery"
|
|
|
|
|
+ ) {
|
|
|
|
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
|
|
+ loadURLString("https://www.google.com/maps/@?api=1&map_action=map&basemap=satellite")
|
|
|
|
|
+ }
|
|
|
|
|
+ MapToolbarButton(
|
|
|
|
|
+ title: "Open",
|
|
|
|
|
+ systemImage: "arrow.up.right.square",
|
|
|
|
|
+ isEmphasized: true,
|
|
|
|
|
+ helpText: "Open this map in the full browser window"
|
|
|
|
|
+ ) {
|
|
|
|
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
|
|
+ InAppBrowserWindowManager.shared.open(url: mapURL, title: app.name)
|
|
|
|
|
+ }
|
|
|
|
|
+ Spacer(minLength: 0)
|
|
|
|
|
+ }
|
|
|
|
|
+ .animation(.easeInOut(duration: 0.18), value: trafficLayerOn)
|
|
|
|
|
+ }
|
|
|
|
|
+ .frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
+ .padding(.bottom, 8)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var quickPlacesBar: some View {
|
|
|
|
|
+ HStack(alignment: .center, spacing: 8) {
|
|
|
|
|
+ MapQuickPlaceChip(title: "Food", icon: "fork.knife", helpText: "Search for restaurants nearby") {
|
|
|
|
|
+ activateAndSearch(.restaurants)
|
|
|
|
|
+ }
|
|
|
|
|
+ MapQuickPlaceChip(title: "Gas", icon: "fuelpump.fill", helpText: "Find gas stations") {
|
|
|
|
|
+ activateAndSearch(.gasStations)
|
|
|
|
|
+ }
|
|
|
|
|
+ MapQuickPlaceChip(title: "Hotels", icon: "bed.double.fill", helpText: "Search for hotels") {
|
|
|
|
|
+ activateAndSearch(.hotels)
|
|
|
|
|
+ }
|
|
|
|
|
+ MapQuickPlaceChip(title: "Coffee", icon: "cup.and.saucer.fill", helpText: "Find coffee shops") {
|
|
|
|
|
+ activateAndSearch(.coffee)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ .frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
+ .padding(.bottom, 2)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var mapContentHeight: CGFloat {
|
|
|
|
|
+ switch widgetSize {
|
|
|
|
|
+ case .small: return 220
|
|
|
|
|
+ case .medium: return 200
|
|
|
|
|
+ case .large: return 340
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func submitSearch() {
|
|
|
|
|
+ let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ guard !q.isEmpty else { return }
|
|
|
|
|
+ openSearchQueryString(q)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Search terms for the four quick chips (Maps “near you” search).
|
|
|
|
|
+ private enum QuickPlaceSearch: String {
|
|
|
|
|
+ case restaurants
|
|
|
|
|
+ case gasStations = "gas stations"
|
|
|
|
|
+ case hotels
|
|
|
|
|
+ case coffee
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func activateAndSearch(_ place: QuickPlaceSearch) {
|
|
|
|
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
|
|
+ openSearchQueryString(place.rawValue)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func openSearchQueryString(_ query: String) {
|
|
|
|
|
+ guard let url = Self.mapsSearchURL(for: query) else { return }
|
|
|
|
|
+ DispatchQueue.main.async {
|
|
|
|
|
+ mapURL = url
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Official search URL: `https://www.google.com/maps/search/?api=1&query=…`
|
|
|
|
|
+ private static func mapsSearchURL(for query: String) -> URL? {
|
|
|
|
|
+ let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ guard !trimmed.isEmpty else { return nil }
|
|
|
|
|
+ var c = URLComponents(string: "https://www.google.com/maps/search/")!
|
|
|
|
|
+ c.queryItems = [
|
|
|
|
|
+ URLQueryItem(name: "api", value: "1"),
|
|
|
|
|
+ URLQueryItem(name: "query", value: trimmed),
|
|
|
|
|
+ ]
|
|
|
|
|
+ return c.url
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func loadURLString(_ string: String) {
|
|
|
|
|
+ if let u = URL(string: string) { mapURL = u }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func applyTrafficLayer() {
|
|
|
|
|
+ let layer: String? = trafficLayerOn ? "traffic" : nil
|
|
|
|
|
+ mapURL = MapsInteractiveWidgetView.makeMapURL(center: nil, zoom: nil, layer: layer)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Interactive controls (hover, press, tooltips)
|
|
|
|
|
+
|
|
|
|
|
+private struct MapPressableButtonStyle: ButtonStyle {
|
|
|
|
|
+ func makeBody(configuration: Configuration) -> some View {
|
|
|
|
|
+ configuration.label
|
|
|
|
|
+ .scaleEffect(configuration.isPressed ? 0.94 : 1)
|
|
|
|
|
+ .opacity(configuration.isPressed ? 0.88 : 1)
|
|
|
|
|
+ .animation(.easeOut(duration: 0.12), value: configuration.isPressed)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private struct MapSearchGoButton: View {
|
|
|
|
|
+ let action: () -> Void
|
|
|
|
|
+ @State private var isHovering = false
|
|
|
|
|
+
|
|
|
|
|
+ var body: some View {
|
|
|
|
|
+ Button(action: action) {
|
|
|
|
|
+ Text("Search")
|
|
|
|
|
+ .font(.system(size: 12, weight: .bold))
|
|
|
|
|
+ .foregroundStyle(Color(red: 0.06, green: 0.08, blue: 0.1))
|
|
|
|
|
+ .padding(.horizontal, 14)
|
|
|
|
|
+ .padding(.vertical, 8)
|
|
|
|
|
+ .background(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
|
|
|
+ .fill(isHovering ? Color.green.opacity(0.92) : Color.white.opacity(0.9))
|
|
|
|
|
+ )
|
|
|
|
|
+ .overlay(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
|
|
|
+ .stroke(Color.white.opacity(0.4), lineWidth: 1)
|
|
|
|
|
+ )
|
|
|
|
|
+ .contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
|
|
|
|
+ }
|
|
|
|
|
+ .buttonStyle(MapPressableButtonStyle())
|
|
|
|
|
+ .onHover { isHovering = $0 }
|
|
|
|
|
+ .help("Search for this place (or press Return)")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private struct MapToolbarButton: View {
|
|
|
|
|
+ let title: String
|
|
|
|
|
+ let systemImage: String
|
|
|
|
|
+ var isSelected: Bool = false
|
|
|
|
|
+ var isEmphasized: Bool = false
|
|
|
|
|
+ let helpText: String
|
|
|
|
|
+ let action: () -> Void
|
|
|
|
|
+
|
|
|
|
|
+ @State private var isHovering = false
|
|
|
|
|
+
|
|
|
|
|
+ var body: some View {
|
|
|
|
|
+ Button(action: action) {
|
|
|
|
|
+ HStack(spacing: 6) {
|
|
|
|
|
+ Image(systemName: systemImage)
|
|
|
|
|
+ .font(.system(size: 12, weight: .semibold))
|
|
|
|
|
+ .symbolRenderingMode(.hierarchical)
|
|
|
|
|
+ Text(title)
|
|
|
|
|
+ .font(.system(size: 11, weight: .semibold))
|
|
|
|
|
+ .lineLimit(1)
|
|
|
|
|
+ }
|
|
|
|
|
+ .foregroundStyle(foregroundColor)
|
|
|
|
|
+ .padding(.horizontal, 12)
|
|
|
|
|
+ .padding(.vertical, 8)
|
|
|
|
|
+ .frame(minHeight: 32)
|
|
|
|
|
+ .background(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
|
|
|
+ .fill(backgroundColor)
|
|
|
|
|
+ )
|
|
|
|
|
+ .overlay(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
|
|
|
+ .stroke(borderColor, lineWidth: isSelected ? 1.5 : 1)
|
|
|
|
|
+ )
|
|
|
|
|
+ .shadow(color: shadowColor, radius: isHovering ? 5 : 2, x: 0, y: isHovering ? 3 : 1)
|
|
|
|
|
+ .contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
|
|
|
|
+ }
|
|
|
|
|
+ .buttonStyle(MapPressableButtonStyle())
|
|
|
|
|
+ .onHover { isHovering = $0 }
|
|
|
|
|
+ .help(helpText)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var foregroundColor: Color {
|
|
|
|
|
+ if isSelected || isEmphasized { return .white }
|
|
|
|
|
+ return Color.white.opacity(0.94)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var backgroundColor: Color {
|
|
|
|
|
+ if isSelected { return Color.green.opacity(0.48) }
|
|
|
|
|
+ if isEmphasized { return Color.blue.opacity(0.45) }
|
|
|
|
|
+ if isHovering { return Color.white.opacity(0.24) }
|
|
|
|
|
+ return Color.white.opacity(0.12)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var borderColor: Color {
|
|
|
|
|
+ if isSelected { return Color.green.opacity(0.7) }
|
|
|
|
|
+ if isEmphasized { return Color.blue.opacity(0.6) }
|
|
|
|
|
+ return Color.white.opacity(isHovering ? 0.32 : 0.16)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var shadowColor: Color {
|
|
|
|
|
+ Color.black.opacity(isHovering ? 0.3 : 0.14)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private struct MapQuickPlaceChip: View {
|
|
|
|
|
+ let title: String
|
|
|
|
|
+ let icon: String
|
|
|
|
|
+ let helpText: String
|
|
|
|
|
+ let action: () -> Void
|
|
|
|
|
+
|
|
|
|
|
+ @State private var isHovering = false
|
|
|
|
|
+
|
|
|
|
|
+ var body: some View {
|
|
|
|
|
+ Button(action: action) {
|
|
|
|
|
+ HStack(spacing: 5) {
|
|
|
|
|
+ Image(systemName: icon)
|
|
|
|
|
+ .font(.system(size: 11, weight: .semibold))
|
|
|
|
|
+ Text(title)
|
|
|
|
|
+ .font(.system(size: 11, weight: .semibold))
|
|
|
|
|
+ }
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.95))
|
|
|
|
|
+ .padding(.horizontal, 12)
|
|
|
|
|
+ .padding(.vertical, 9)
|
|
|
|
|
+ .frame(minWidth: 72, minHeight: 34)
|
|
|
|
|
+ .background(
|
|
|
|
|
+ Capsule(style: .continuous)
|
|
|
|
|
+ .fill(isHovering ? Color.white.opacity(0.2) : Color.black.opacity(0.45))
|
|
|
|
|
+ )
|
|
|
|
|
+ .overlay(
|
|
|
|
|
+ Capsule(style: .continuous)
|
|
|
|
|
+ .stroke(Color.white.opacity(isHovering ? 0.38 : 0.2), lineWidth: 1)
|
|
|
|
|
+ )
|
|
|
|
|
+ .shadow(color: Color.black.opacity(isHovering ? 0.25 : 0.1), radius: isHovering ? 4 : 2, x: 0, y: 1)
|
|
|
|
|
+ .contentShape(Capsule())
|
|
|
|
|
+ }
|
|
|
|
|
+ // Plain avoids the default NSButton bezel on macOS, which can shrink the hit target.
|
|
|
|
|
+ .buttonStyle(.plain)
|
|
|
|
|
+ .onHover { isHovering = $0 }
|
|
|
|
|
+ .help(helpText)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Location (recenter map)
|
|
|
|
|
+
|
|
|
|
|
+private final class MapsWidgetLocationHelper: NSObject, ObservableObject, CLLocationManagerDelegate {
|
|
|
|
|
+ private let manager = CLLocationManager()
|
|
|
|
|
+ private var onFix: ((CLLocationCoordinate2D) -> Void)?
|
|
|
|
|
+
|
|
|
|
|
+ func requestLocation(_ handler: @escaping (CLLocationCoordinate2D) -> Void) {
|
|
|
|
|
+ onFix = handler
|
|
|
|
|
+ manager.delegate = self
|
|
|
|
|
+ manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
|
|
|
|
+ switch manager.authorizationStatus {
|
|
|
|
|
+ case .notDetermined:
|
|
|
|
|
+ manager.requestWhenInUseAuthorization()
|
|
|
|
|
+ case .authorized, .authorizedAlways, .authorizedWhenInUse:
|
|
|
|
|
+ manager.requestLocation()
|
|
|
|
|
+ default:
|
|
|
|
|
+ break
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
|
|
|
|
+ guard let c = locations.last?.coordinate else { return }
|
|
|
|
|
+ onFix?(c)
|
|
|
|
|
+ onFix = nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {}
|
|
|
|
|
+
|
|
|
|
|
+ func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
|
|
|
|
+ switch manager.authorizationStatus {
|
|
|
|
|
+ case .authorized, .authorizedAlways, .authorizedWhenInUse:
|
|
|
|
|
+ manager.requestLocation()
|
|
|
|
|
+ default:
|
|
|
|
|
+ break
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - WKWebView (clipped container so the map cannot overlap or steal hits from controls above)
|
|
|
|
|
+
|
|
|
|
|
+private final class MapsWebViewClippingContainer: NSView {
|
|
|
|
|
+ override var isFlipped: Bool { true }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private struct MapsWebWidgetView: NSViewRepresentable {
|
|
|
|
|
+ @Binding var url: URL
|
|
|
|
|
+
|
|
|
|
|
+ func makeCoordinator() -> Coordinator {
|
|
|
|
|
+ Coordinator(self)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func makeNSView(context: Context) -> NSView {
|
|
|
|
|
+ let container = MapsWebViewClippingContainer()
|
|
|
|
|
+ container.wantsLayer = true
|
|
|
|
|
+ container.layer?.masksToBounds = true
|
|
|
|
|
+ container.layer?.cornerRadius = 12
|
|
|
|
|
+ container.layer?.borderWidth = 1
|
|
|
|
|
+ container.layer?.borderColor = NSColor.white.withAlphaComponent(0.08).cgColor
|
|
|
|
|
+
|
|
|
|
|
+ let config = WKWebViewConfiguration()
|
|
|
|
|
+ config.defaultWebpagePreferences.allowsContentJavaScript = true
|
|
|
|
|
+ let wv = WKWebView(frame: .zero, configuration: config)
|
|
|
|
|
+ wv.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ wv.customUserAgent =
|
|
|
|
|
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"
|
|
|
|
|
+ wv.navigationDelegate = context.coordinator
|
|
|
|
|
+ wv.uiDelegate = context.coordinator
|
|
|
|
|
+
|
|
|
|
|
+ container.addSubview(wv)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ wv.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
|
|
|
|
+ wv.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
|
|
|
|
+ wv.topAnchor.constraint(equalTo: container.topAnchor),
|
|
|
|
|
+ wv.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ context.coordinator.webView = wv
|
|
|
|
|
+ context.coordinator.container = container
|
|
|
|
|
+ context.coordinator.lastLoadedURLString = url.absoluteString
|
|
|
|
|
+ wv.load(URLRequest(url: url))
|
|
|
|
|
+ return container
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func updateNSView(_ container: NSView, context: Context) {
|
|
|
|
|
+ guard let wv = context.coordinator.webView else { return }
|
|
|
|
|
+ let next = url.absoluteString
|
|
|
|
|
+ guard context.coordinator.lastLoadedURLString != next else { return }
|
|
|
|
|
+ context.coordinator.lastLoadedURLString = next
|
|
|
|
|
+ wv.load(URLRequest(url: url))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate {
|
|
|
|
|
+ var parent: MapsWebWidgetView
|
|
|
|
|
+ weak var webView: WKWebView?
|
|
|
|
|
+ weak var container: NSView?
|
|
|
|
|
+ /// Compared as strings so equivalent URLs (e.g. query reorder) still trigger reload when the binding changes.
|
|
|
|
|
+ var lastLoadedURLString: String?
|
|
|
|
|
+
|
|
|
|
|
+ init(_ parent: MapsWebWidgetView) {
|
|
|
|
|
+ self.parent = parent
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func webView(
|
|
|
|
|
+ _ webView: WKWebView,
|
|
|
|
|
+ requestGeolocationPermissionFor origin: WKSecurityOrigin,
|
|
|
|
|
+ initiatedByFrame frame: WKFrameInfo,
|
|
|
|
|
+ decisionHandler: @escaping (WKPermissionDecision) -> Void
|
|
|
|
|
+ ) {
|
|
|
|
|
+ decisionHandler(.grant)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|