ソースを参照

Toggle widgets via card clicks

Allow users to click anywhere on a widget preview card to add/remove a widget instance, showing plus/minus state and a removal toast.

Made-with: Cursor
huzaifahayat12 3 ヶ月 前
コミット
cc9ee87ffe

+ 6 - 0
google_apps/Widgets/DesktopWidgetView.swift

@@ -140,6 +140,9 @@ struct DesktopWidgetView: View {
         }
         .buttonStyle(.plain)
         .disabled(isPreview || app.webURL == nil)
+        // In the widget picker, we want clicks to toggle the whole card.
+        // Ensure preview buttons do not intercept hit testing.
+        .allowsHitTesting(!(isPreview || app.webURL == nil))
         .opacity((isPreview || app.webURL == nil) ? 0.6 : 1)
     }
 
@@ -168,6 +171,9 @@ struct DesktopWidgetView: View {
         }
         .buttonStyle(.plain)
         .disabled(isPreview || app.webURL == nil)
+        // In the widget picker, we want clicks to toggle the whole card.
+        // Ensure preview buttons do not intercept hit testing.
+        .allowsHitTesting(!(isPreview || app.webURL == nil))
         .opacity((isPreview || app.webURL == nil) ? 0.6 : 1)
     }
 

+ 23 - 0
google_apps/Widgets/DesktopWidgetWindowManager.swift

@@ -37,6 +37,29 @@ final class DesktopWidgetWindowManager {
         panel.orderFrontRegardless()
     }
 
+    /// Returns `true` if there is already a persisted widget instance for the given `appID + variantID`.
+    /// This is used by the widget picker UI to toggle `+`/`-` for each widget card.
+    func hasInstance(appID: UUID, variantID: String) -> Bool {
+        return loadInstances().contains { $0.appID == appID && $0.variantID == variantID }
+    }
+
+    /// Removes any widget instances matching the given `appID + variantID`.
+    /// - Returns: number of instances removed.
+    func removeInstances(appID: UUID, variantID: String) -> Int {
+        let instances = loadInstances()
+        let removed = instances.filter { $0.appID == appID && $0.variantID == variantID }
+        let remaining = instances.filter { !($0.appID == appID && $0.variantID == variantID) }
+
+        for instance in removed {
+            if let win = windowsByInstanceID.removeValue(forKey: instance.id) {
+                win.close()
+            }
+        }
+
+        saveInstances(remaining)
+        return removed.count
+    }
+
     func restore(appProvider: (UUID) -> LauncherApp?) {
         let instances = loadInstances()
         for instance in instances {

+ 0 - 1
google_apps/Widgets/WidgetPreviewCard.swift

@@ -7,7 +7,6 @@ struct WidgetPreviewCard: View {
     var body: some View {
         DesktopWidgetView(app: app, variant: variant, isPreview: true)
             .frame(width: previewSize.width, height: previewSize.height)
-            .allowsHitTesting(false)
             .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
             .overlay(
                 RoundedRectangle(cornerRadius: 18, style: .continuous)

+ 97 - 34
google_apps/Widgets/WidgetsRootView.swift

@@ -13,6 +13,11 @@ struct WidgetsRootView: View {
     @ObservedObject private var premiumStore = PremiumStore.shared
     @State private var query = ""
     @State private var toastMessage: String?
+    enum ToastKind {
+        case added
+        case removed
+    }
+    @State private var toastKind: ToastKind?
     @State private var toastTask: Task<Void, Never>?
     @State private var showingPremiumScreen = false
 
@@ -46,8 +51,8 @@ struct WidgetsRootView: View {
         .padding(.horizontal, 10)
         .padding(.bottom, 12)
         .overlay(alignment: .top) {
-            if let toastMessage {
-                AddedToDesktopToast(message: toastMessage)
+            if let toastMessage, let toastKind {
+                DesktopWidgetToast(message: toastMessage, kind: toastKind)
                     .padding(.top, 6)
                     .transition(.move(edge: .top).combined(with: .opacity))
             }
@@ -128,8 +133,9 @@ struct WidgetsRootView: View {
                         WidgetPreviewAddCard(
                             app: selectedApp,
                             variant: variant,
+                            isAdded: isWidgetAdded(appID: selectedApp.id, variantID: variant.id),
                             isPremiumUnlocked: premiumStore.isPremiumUnlocked,
-                            onAdd: { handleAddWidgetTap(app: selectedApp, variant: variant) }
+                            onToggle: { handleWidgetTap(app: selectedApp, variant: variant) }
                         )
                     }
                     Spacer(minLength: 0)
@@ -160,7 +166,7 @@ struct WidgetsRootView: View {
                 Text(app.name)
                     .font(.system(size: 20, weight: .bold))
                     .foregroundStyle(.white.opacity(0.94))
-                Text("Click the green + to add widgets to your desktop.")
+                Text("Click a widget card to add it. Click again to remove it.")
                     .font(.system(size: 12.5, weight: .medium))
                     .foregroundStyle(.white.opacity(0.65))
             }
@@ -190,6 +196,9 @@ struct WidgetsRootView: View {
     }
 
     private func addToDesktop(app: LauncherApp, variant: WidgetVariant) {
+        guard !DesktopWidgetWindowManager.shared.hasInstance(appID: app.id, variantID: variant.id) else {
+            return
+        }
         ensureInWidgetLibrary(appID: app.id)
         let instance = WidgetInstance(
             appID: app.id,
@@ -200,24 +209,52 @@ struct WidgetsRootView: View {
         DesktopWidgetWindowManager.shared.show(instance: instance, appProvider: { id in
             apps.first { $0.id == id }
         })
-        showAddedToast(for: app)
+        showAddedToast(for: app, variant: variant)
     }
 
-    private func handleAddWidgetTap(app: LauncherApp, variant: WidgetVariant) {
-        guard premiumStore.isPremiumUnlocked else {
-            showingPremiumScreen = true
-            return
+    private func isWidgetAdded(appID: UUID, variantID: String) -> Bool {
+        DesktopWidgetWindowManager.shared.hasInstance(appID: appID, variantID: variantID)
+    }
+
+    private func handleWidgetTap(app: LauncherApp, variant: WidgetVariant) {
+        if isWidgetAdded(appID: app.id, variantID: variant.id) {
+            removeFromDesktop(app: app, variant: variant)
+        } else {
+            guard premiumStore.isPremiumUnlocked else {
+                showingPremiumScreen = true
+                return
+            }
+            addToDesktop(app: app, variant: variant)
         }
-        addToDesktop(app: app, variant: variant)
     }
 
-    private func showAddedToast(for app: LauncherApp) {
+    private func removeFromDesktop(app: LauncherApp, variant: WidgetVariant) {
+        let removedCount = DesktopWidgetWindowManager.shared.removeInstances(appID: app.id, variantID: variant.id)
+        guard removedCount > 0 else { return }
+        showRemovedToast(for: app)
+    }
+
+    private func showAddedToast(for app: LauncherApp, variant: WidgetVariant) {
         toastTask?.cancel()
-        toastMessage = "The \"\(app.name)\" widget has been added to the desktop."
+        toastKind = .added
+        toastMessage = "The \"\(app.name)\" \"\(variant.title)\" widget has been added to the desktop."
         toastTask = Task { @MainActor in
             try? await Task.sleep(nanoseconds: 2_000_000_000)
             guard !Task.isCancelled else { return }
             toastMessage = nil
+            toastKind = nil
+        }
+    }
+
+    private func showRemovedToast(for app: LauncherApp) {
+        toastTask?.cancel()
+        toastKind = .removed
+        toastMessage = "The \"\(app.name)\" widget has been removed from the desktop."
+        toastTask = Task { @MainActor in
+            try? await Task.sleep(nanoseconds: 2_000_000_000)
+            guard !Task.isCancelled else { return }
+            toastMessage = nil
+            toastKind = nil
         }
     }
 }
@@ -264,42 +301,68 @@ private struct WidgetSidebarRow: View {
 private struct WidgetPreviewAddCard: View {
     let app: LauncherApp
     let variant: WidgetVariant
+    let isAdded: Bool
     let isPremiumUnlocked: Bool
-    let onAdd: () -> Void
+    let onToggle: () -> Void
 
     @State private var hovering = false
 
     var body: some View {
-        WidgetPreviewCard(app: app, variant: variant)
-            .overlay(alignment: .topTrailing) {
-                Button(action: onAdd) {
-                    Image(systemName: "plus")
-                        .font(.system(size: 12, weight: .bold))
-                        .foregroundStyle(isPremiumUnlocked ? .black.opacity(0.85) : .white.opacity(0.82))
-                        .frame(width: 26, height: 26)
-                        .background(
-                            Circle()
-                                .fill(isPremiumUnlocked ? Color.green.opacity(0.95) : Color.gray.opacity(0.7))
-                        )
-                        .shadow(color: .black.opacity(0.28), radius: 10, x: 0, y: 6)
+        Button(action: onToggle) {
+            WidgetPreviewCard(app: app, variant: variant)
+                .contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
+                .overlay(alignment: .topTrailing) {
+                    ZStack {
+                        Image(systemName: isAdded ? "minus" : "plus")
+                            .font(.system(size: 12, weight: .bold))
+                            .foregroundStyle(iconForegroundStyle)
+                            .frame(width: 26, height: 26)
+                            .background(
+                                Circle()
+                                    .fill(iconBackgroundFill)
+                            )
+                            .shadow(color: .black.opacity(0.28), radius: 10, x: 0, y: 6)
+                    }
+                    .offset(x: 10, y: -10)
+                    .allowsHitTesting(false)
+                    .opacity(isAdded ? 1 : (hovering ? 1 : 0))
+                    .animation(.easeOut(duration: 0.12), value: hovering)
                 }
-                .buttonStyle(.plain)
-                .offset(x: 10, y: -10)
-                .opacity(hovering ? 1 : 0)
-                .animation(.easeOut(duration: 0.12), value: hovering)
-            }
-            .onHover { hovering = $0 }
+        }
+        .buttonStyle(.plain)
+        .onHover { hovering = $0 }
+    }
+
+    private var iconForegroundStyle: some ShapeStyle {
+        if isAdded { return .white.opacity(0.92) }
+        return isPremiumUnlocked ? .black.opacity(0.85) : .white.opacity(0.82)
+    }
+
+    private var iconBackgroundFill: Color {
+        if isAdded { return Color.red.opacity(0.92) }
+        return isPremiumUnlocked ? Color.green.opacity(0.95) : Color.gray.opacity(0.7)
     }
 }
 
-private struct AddedToDesktopToast: View {
+private struct DesktopWidgetToast: View {
     let message: String
+    let kind: WidgetsRootView.ToastKind
+
+    private var iconAndTint: (String, Color) {
+        switch kind {
+        case .added:
+            return ("checkmark.circle", Color.green.opacity(0.92))
+        case .removed:
+            return ("minus.circle", Color.red.opacity(0.92))
+        }
+    }
 
     var body: some View {
+        let (systemImage, tint) = iconAndTint
         HStack(spacing: 12) {
-            Image(systemName: "checkmark.circle")
+            Image(systemName: systemImage)
                 .font(.system(size: 18, weight: .semibold))
-                .foregroundStyle(Color.green.opacity(0.92))
+                .foregroundStyle(tint)
 
             Text(message)
                 .font(.system(size: 14, weight: .semibold))