Sfoglia il codice sorgente

Add interactive Google Maps desktop widget with WKWebView

- Embed live Maps in MapsInteractiveWidgetView (search, toolbar, quick chips)
- Add interactiveMap layout mode and larger panel sizes for Maps variants
- InteractiveMapsWidgetPanel becomes key-capable for typing; location entitlement
- Quick place chips use Maps search API URLs; plain button style and reload fixes

Made-with: Cursor
huzaifahayat12 3 mesi fa
parent
commit
21ea511dbf

+ 2 - 0
google_apps.xcodeproj/project.pbxproj

@@ -256,6 +256,7 @@
 				ENABLE_USER_SELECTED_FILES = readonly;
 				GENERATE_INFOPLIST_FILE = YES;
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
+				INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Google Maps uses your location to center the map and suggest nearby places.";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
 				LD_RUNPATH_SEARCH_PATHS = (
@@ -288,6 +289,7 @@
 				ENABLE_USER_SELECTED_FILES = readonly;
 				GENERATE_INFOPLIST_FILE = YES;
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
+				INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Google Maps uses your location to center the map and suggest nearby places.";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
 				LD_RUNPATH_SEARCH_PATHS = (

+ 38 - 29
google_apps/Widgets/DesktopWidgetView.swift

@@ -14,42 +14,51 @@ struct DesktopWidgetView: View {
     private var size: WidgetSize { variant.size }
 
     var body: some View {
-        ZStack {
-            RoundedRectangle(cornerRadius: 22, style: .continuous)
-                .fill(
-                    LinearGradient(
-                        colors: [Color(red: 0.18, green: 0.24, blue: 0.31), Color(red: 0.18, green: 0.47, blue: 0.46)],
-                        startPoint: .topLeading,
-                        endPoint: .bottomTrailing
-                    )
-                )
-                .shadow(color: .black.opacity(0.25), radius: 14, x: 0, y: 8)
-                .overlay(
+        Group {
+            if case .interactiveMap = layoutMode {
+                MapsInteractiveWidgetView(app: app, widgetSize: size, isPreview: isPreview)
+            } else {
+                ZStack {
                     RoundedRectangle(cornerRadius: 22, style: .continuous)
-                        .stroke(Color.white.opacity(0.12), lineWidth: 1)
-                )
+                        .fill(
+                            LinearGradient(
+                                colors: [Color(red: 0.18, green: 0.24, blue: 0.31), Color(red: 0.18, green: 0.47, blue: 0.46)],
+                                startPoint: .topLeading,
+                                endPoint: .bottomTrailing
+                            )
+                        )
+                        .shadow(color: .black.opacity(0.25), radius: 14, x: 0, y: 8)
+                        .overlay(
+                            RoundedRectangle(cornerRadius: 22, style: .continuous)
+                                .stroke(Color.white.opacity(0.12), lineWidth: 1)
+                        )
 
-            VStack(alignment: .leading, spacing: 12) {
-                switch layoutMode {
-                case .iconOnly(let title):
-                    iconOnly(title: title)
+                    VStack(alignment: .leading, spacing: 12) {
+                        switch layoutMode {
+                        case .iconOnly(let title):
+                            iconOnly(title: title)
 
-                case .actionsRow:
-                    if showHeader {
-                        header
-                    }
-                    rowActions(maxActions: 3)
+                        case .actionsRow:
+                            if showHeader {
+                                header
+                            }
+                            rowActions(maxActions: 3)
+
+                        case .actionsGrid(let columns):
+                            if showHeader {
+                                header
+                            }
+                            gridActions(columns: columns, maxActions: maxActionsForSize)
 
-                case .actionsGrid(let columns):
-                    if showHeader {
-                        header
+                        case .interactiveMap:
+                            EmptyView()
+                        }
                     }
-                    gridActions(columns: columns, maxActions: maxActionsForSize)
+                    .padding(16)
                 }
+                .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
             }
-            .padding(16)
         }
-        .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
     }
 
     private var header: some View {
@@ -99,7 +108,7 @@ struct DesktopWidgetView: View {
         case .medium: return 4
         case .large:
             switch WidgetAppProfile.from(app: app) {
-            case .gmail, .drive, .maps: return 15
+            case .gmail, .drive: return 15
             default: return 8
             }
         }

+ 49 - 14
google_apps/Widgets/DesktopWidgetWindowManager.swift

@@ -22,7 +22,7 @@ final class DesktopWidgetWindowManager: ObservableObject {
         let resolvedVariant = WidgetTemplates.variant(for: app, variantID: instance.variantID)
         var resolvedInstance = instance
         if resolvedInstance.origin == nil {
-            resolvedInstance.origin = nextAutoPlacement(for: resolvedVariant.size)
+            resolvedInstance.origin = nextAutoPlacement(for: resolvedVariant, appProvider: appProvider)
         }
 
         let panel = makePanel(for: resolvedInstance, app: app, variant: resolvedVariant)
@@ -97,17 +97,29 @@ final class DesktopWidgetWindowManager: ObservableObject {
     // MARK: - Panel construction
 
     private func makePanel(for instance: WidgetInstance, app: LauncherApp, variant: WidgetVariant) -> NSPanel {
-        let size = panelSize(for: variant.size)
-        let panel = DesktopWidgetPanel(
-            contentRect: NSRect(x: 0, y: 0, width: size.width, height: size.height),
-            styleMask: [.nonactivatingPanel, .borderless],
-            backing: .buffered,
-            defer: false
-        )
+        let size = panelSize(for: variant)
+        let panel: NSPanel
+        if variant.layoutMode == .interactiveMap {
+            // Key-capable so the embedded Maps search field and controls receive clicks and typing reliably.
+            panel = InteractiveMapsWidgetPanel(
+                contentRect: NSRect(x: 0, y: 0, width: size.width, height: size.height),
+                styleMask: [.nonactivatingPanel, .borderless],
+                backing: .buffered,
+                defer: false
+            )
+            panel.becomesKeyOnlyIfNeeded = true
+        } else {
+            panel = DesktopWidgetPanel(
+                contentRect: NSRect(x: 0, y: 0, width: size.width, height: size.height),
+                styleMask: [.nonactivatingPanel, .borderless],
+                backing: .buffered,
+                defer: false
+            )
+            panel.becomesKeyOnlyIfNeeded = false
+        }
 
         panel.isReleasedWhenClosed = false
         panel.isFloatingPanel = false
-        panel.becomesKeyOnlyIfNeeded = false
         panel.isMovable = true
         // One level above desktop icons so Finder desktop clicks do not leave our widget behind the desktop layer.
         let desktopIconLevel = Int(CGWindowLevelForKey(.desktopIconWindow))
@@ -160,8 +172,18 @@ final class DesktopWidgetWindowManager: ObservableObject {
         return panel
     }
 
-    private func panelSize(for size: WidgetSize) -> CGSize {
-        switch size {
+    private func panelSize(for variant: WidgetVariant) -> CGSize {
+        if variant.layoutMode == .interactiveMap {
+            switch variant.size {
+            case .small:
+                return CGSize(width: 340, height: 420)
+            case .medium:
+                return CGSize(width: 460, height: 400)
+            case .large:
+                return CGSize(width: 520, height: 560)
+            }
+        }
+        switch variant.size {
         case .small:
             return CGSize(width: 220, height: 210)
         case .medium:
@@ -186,8 +208,8 @@ final class DesktopWidgetWindowManager: ObservableObject {
         )
     }
 
-    private func nextAutoPlacement(for size: WidgetSize) -> WidgetInstance.Origin {
-        let panelSizeForNew = panelSize(for: size)
+    private func nextAutoPlacement(for variant: WidgetVariant, appProvider: (UUID) -> LauncherApp?) -> WidgetInstance.Origin {
+        let panelSizeForNew = panelSize(for: variant)
         let frame = NSScreen.main?.visibleFrame ?? NSRect(x: 100, y: 100, width: 1200, height: 800)
         let systemWidgetZone = reservedSystemDesktopWidgetZone(in: frame)
 
@@ -203,7 +225,9 @@ final class DesktopWidgetWindowManager: ObservableObject {
         let liveRects: [NSRect] = windowsByInstanceID.values.map { $0.frame }
         let persistedRects: [NSRect] = loadInstances().compactMap { instance in
             guard let origin = instance.origin else { return nil }
-            let s = panelSize(for: instance.size)
+            guard let app = appProvider(instance.appID) else { return nil }
+            let v = WidgetTemplates.variant(for: app, variantID: instance.variantID)
+            let s = panelSize(for: v)
             return NSRect(x: origin.x, y: origin.y, width: s.width, height: s.height)
         }
         let existingRects = liveRects + persistedRects
@@ -332,6 +356,17 @@ private final class DesktopWidgetPanel: NSPanel {
     }
 }
 
+/// Used for the interactive Google Maps widget so `TextField` and toolbar buttons can become first responder.
+private final class InteractiveMapsWidgetPanel: NSPanel {
+    override var canBecomeKey: Bool { true }
+    override var canBecomeMain: Bool { false }
+
+    override func mouseDown(with event: NSEvent) {
+        orderFront(nil)
+        super.mouseDown(with: event)
+    }
+}
+
 private struct DesktopWidgetHostView: View {
     let app: LauncherApp
     let variant: WidgetVariant

+ 553 - 0
google_apps/Widgets/MapsInteractiveWidgetView.swift

@@ -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)
+        }
+    }
+}

+ 10 - 0
google_apps/Widgets/WidgetPreviewCard.swift

@@ -15,6 +15,16 @@ struct WidgetPreviewCard: View {
     }
 
     private var previewSize: CGSize {
+        if variant.layoutMode == .interactiveMap {
+            switch variant.size {
+            case .small:
+                return CGSize(width: 280, height: 320)
+            case .medium:
+                return CGSize(width: 400, height: 320)
+            case .large:
+                return CGSize(width: 440, height: 420)
+            }
+        }
         switch variant.size {
         case .small:
             return CGSize(width: 190, height: 190)

+ 7 - 31
google_apps/Widgets/WidgetTemplates.swift

@@ -19,6 +19,8 @@ enum WidgetLayoutMode: Hashable {
     case iconOnly(title: String)
     case actionsRow
     case actionsGrid(columns: Int)
+    /// Embedded Google Maps (`WKWebView`) with search, traffic, and navigation shortcuts.
+    case interactiveMap
 }
 
 struct WidgetVariant: Identifiable, Hashable {
@@ -221,37 +223,11 @@ enum WidgetTemplates {
                 ]),
             ]
         case .maps:
-            // Universal Maps URLs use api=1 per https://developers.google.com/maps/documentation/urls/get-started
-            func m(_ path: String) -> String { path }
-            return [
-                v("maps_small", "Navigate", .small, false, .actionsRow, [
-                    a("drive", "Drive", "car.fill", m("https://www.google.com/maps/dir/?api=1&travelmode=driving")),
-                    a("traffic", "Traffic", "road.lanes", m("https://www.google.com/maps/@?api=1&map_action=map&layer=traffic")),
-                    a("search", "Explore", "magnifyingglass", m("https://www.google.com/maps/search/?api=1&query=restaurants")),
-                ]),
-                v("maps_medium", "Routes & layers", .medium, true, .actionsGrid(columns: 2), [
-                    a("drive", "Driving", "car.fill", m("https://www.google.com/maps/dir/?api=1&travelmode=driving")),
-                    a("transit", "Transit", "tram.fill", m("https://www.google.com/maps/dir/?api=1&travelmode=transit")),
-                    a("traffic", "Traffic", "road.lanes", m("https://www.google.com/maps/@?api=1&map_action=map&layer=traffic")),
-                    a("saved", "Saved", "bookmark.fill", m("https://www.google.com/maps/saved")),
-                ]),
-                v("maps_large", "Maps hub", .large, true, .actionsGrid(columns: 2), [
-                    a("drive", "Drive", "car.fill", m("https://www.google.com/maps/dir/?api=1&travelmode=driving")),
-                    a("transitdir", "Transit", "tram.fill", m("https://www.google.com/maps/dir/?api=1&travelmode=transit")),
-                    a("walk", "Walk", "figure.walk", m("https://www.google.com/maps/dir/?api=1&travelmode=walking")),
-                    a("bike", "Bike", "bicycle", m("https://www.google.com/maps/dir/?api=1&travelmode=bicycling")),
-                    a("traffic", "Traffic", "road.lanes", m("https://www.google.com/maps/@?api=1&map_action=map&layer=traffic")),
-                    a("transitmap", "Transit map", "map.circle.fill", m("https://www.google.com/maps/@?api=1&map_action=map&layer=transit")),
-                    a("bikepaths", "Bike paths", "bicycle.circle", m("https://www.google.com/maps/@?api=1&map_action=map&layer=bicycling")),
-                    a("satellite", "Satellite", "globe.americas.fill", m("https://www.google.com/maps/@?api=1&map_action=map&basemap=satellite")),
-                    a("saved", "Saved", "bookmark.fill", m("https://www.google.com/maps/saved")),
-                    a("food", "Restaurants", "fork.knife", m("https://www.google.com/maps/search/?api=1&query=restaurants")),
-                    a("gas", "Gas", "fuelpump.fill", m("https://www.google.com/maps/search/?api=1&query=gas+stations")),
-                    a("hotels", "Hotels", "bed.double.fill", m("https://www.google.com/maps/search/?api=1&query=hotels")),
-                    a("coffee", "Coffee", "cup.and.saucer.fill", m("https://www.google.com/maps/search/?api=1&query=coffee")),
-                    a("parking", "Parking", "parkingsign.circle.fill", m("https://www.google.com/maps/search/?api=1&query=parking")),
-                    a("map", "My map", "map.fill", m("https://www.google.com/maps/@?api=1&map_action=map")),
-                ]),
+            // Live map: `MapsInteractiveWidgetView` (embedded `WKWebView`); actions unused.
+            return [
+                v("maps_small", "Live map", .small, true, .interactiveMap, []),
+                v("maps_medium", "Live map", .medium, true, .interactiveMap, []),
+                v("maps_large", "Live map", .large, true, .interactiveMap, []),
             ]
         case .youtube:
             return [

+ 2 - 0
google_apps/google_apps.entitlements

@@ -10,5 +10,7 @@
 	<true/>
 	<key>com.apple.security.files.user-selected.read-write</key>
 	<true/>
+	<key>com.apple.security.personal-information.location</key>
+	<true/>
 </dict>
 </plist>