Pārlūkot izejas kodu

Redesign widget variants and desktop placement flow

Introduces app-specific widget variants with explicit IDs, updates widget rendering to be variant-driven, and improves desktop add reliability with deterministic auto-placement and persisted variant metadata.

Made-with: Cursor
huzaifahayat12 3 mēneši atpakaļ
vecāks
revīzija
1e69a20597

+ 29 - 18
google_apps/Widgets/DesktopWidgetView.swift

@@ -2,16 +2,13 @@ import SwiftUI
 
 struct DesktopWidgetView: View {
     let app: LauncherApp
-    let size: WidgetSize
+    let variant: WidgetVariant
     var isPreview: Bool = false
 
-    private var actions: [WidgetAction] {
-        WidgetTemplates.actions(for: app, kind: .quickActions)
-    }
-
-    private var layout: WidgetLayoutConfig {
-        WidgetTemplates.layout(for: app, size: size)
-    }
+    private var actions: [WidgetAction] { variant.actions }
+    private var layoutMode: WidgetLayoutMode { variant.layoutMode }
+    private var showHeader: Bool { variant.showHeader }
+    private var size: WidgetSize { variant.size }
 
     var body: some View {
         ZStack {
@@ -24,21 +21,21 @@ struct DesktopWidgetView: View {
                 )
 
             VStack(alignment: .leading, spacing: 12) {
-                switch layout.mode {
+                switch layoutMode {
                 case .iconOnly(let title):
                     iconOnly(title: title)
 
-                case .actionsRow(let maxActions, let columns):
-                    if layout.showHeader {
+                case .actionsRow:
+                    if showHeader {
                         header
                     }
-                    rowActions(maxActions: maxActions, columns: columns)
+                    rowActions(maxActions: 3)
 
-                case .actionsGrid(let columns, let maxActions):
-                    if layout.showHeader {
+                case .actionsGrid(let columns):
+                    if showHeader {
                         header
                     }
-                    gridActions(columns: columns, maxActions: maxActions)
+                    gridActions(columns: columns, maxActions: maxActionsForSize)
                 }
             }
             .padding(16)
@@ -87,7 +84,15 @@ struct DesktopWidgetView: View {
         }
     }
 
-    private func rowActions(maxActions: Int, columns: Int) -> some View {
+    private var maxActionsForSize: Int {
+        switch size {
+        case .small: return 3
+        case .medium: return 4
+        case .large: return 8
+        }
+    }
+
+    private func rowActions(maxActions: Int) -> some View {
         let prefix = actions.prefix(maxActions)
         return HStack(spacing: 10) {
             ForEach(prefix) { action in
@@ -118,7 +123,10 @@ struct DesktopWidgetView: View {
             }
             .padding(.horizontal, 12)
             .padding(.vertical, 9)
-            .background(Color.white)
+            .background(
+                RoundedRectangle(cornerRadius: 999, style: .continuous)
+                    .fill(Color.white)
+            )
             .overlay(
                 RoundedRectangle(cornerRadius: 999, style: .continuous)
                     .stroke(Color.black.opacity(0.12), lineWidth: 1)
@@ -143,7 +151,10 @@ struct DesktopWidgetView: View {
             }
             .padding(.horizontal, 12)
             .padding(.vertical, 10)
-            .background(Color.white)
+            .background(
+                RoundedRectangle(cornerRadius: 14, style: .continuous)
+                    .fill(Color.white)
+            )
             .overlay(
                 RoundedRectangle(cornerRadius: 14, style: .continuous)
                     .stroke(Color.black.opacity(0.12), lineWidth: 1)

+ 39 - 10
google_apps/Widgets/DesktopWidgetWindowManager.swift

@@ -11,13 +11,18 @@ final class DesktopWidgetWindowManager {
 
     func show(instance: WidgetInstance, appProvider: (UUID) -> LauncherApp?) {
         guard let app = appProvider(instance.appID) else { return }
+        let resolvedVariant = WidgetTemplates.variant(for: app, variantID: instance.variantID)
+        var resolvedInstance = instance
+        if resolvedInstance.origin == nil {
+            resolvedInstance.origin = nextAutoPlacement(for: resolvedVariant.size)
+        }
 
-        let panel = makePanel(for: instance, app: app)
+        let panel = makePanel(for: resolvedInstance, app: app, variant: resolvedVariant)
         windowsByInstanceID[instance.id] = panel
 
-        persistUpsert(instance: instance)
+        persistUpsert(instance: resolvedInstance)
 
-        if let origin = instance.origin {
+        if let origin = resolvedInstance.origin {
             panel.setFrameOrigin(NSPoint(x: origin.x, y: origin.y))
         } else {
             panel.center()
@@ -50,8 +55,8 @@ final class DesktopWidgetWindowManager {
 
     // MARK: - Panel construction
 
-    private func makePanel(for instance: WidgetInstance, app: LauncherApp) -> NSPanel {
-        let size = panelSize(for: instance.size)
+    private func makePanel(for instance: WidgetInstance, app: LauncherApp, variant: WidgetVariant) -> NSPanel {
+        let size = panelSize(for: variant.size)
         let panel = NSPanel(
             contentRect: NSRect(x: 0, y: 0, width: size.width, height: size.height),
             styleMask: [.nonactivatingPanel, .titled, .fullSizeContentView],
@@ -73,7 +78,7 @@ final class DesktopWidgetWindowManager {
             .transient,
         ]
 
-        let root = DesktopWidgetHostView(app: app, instanceID: instance.id, size: instance.size) {
+        let root = DesktopWidgetHostView(app: app, variant: variant, instanceID: instance.id) {
             self.close(instanceID: instance.id)
         }
 
@@ -110,14 +115,38 @@ final class DesktopWidgetWindowManager {
     private func panelSize(for size: WidgetSize) -> CGSize {
         switch size {
         case .small:
-            return CGSize(width: 220, height: 220)
+            return CGSize(width: 220, height: 210)
         case .medium:
-            return CGSize(width: 390, height: 220)
+            return CGSize(width: 390, height: 210)
         case .large:
             return CGSize(width: 390, height: 300)
         }
     }
 
+    private func nextAutoPlacement(for size: WidgetSize) -> WidgetInstance.Origin {
+        let panel = panelSize(for: size)
+        let frame = NSScreen.main?.visibleFrame ?? NSRect(x: 100, y: 100, width: 1200, height: 800)
+        let margin: CGFloat = 18
+        let stepX: CGFloat = 28
+        let stepY: CGFloat = 24
+        let columns = max(1, Int((frame.width - margin * 2) / max(panel.width + margin, 1)))
+        let existing = loadInstances().count
+        let row = existing / columns
+        let col = existing % columns
+
+        let maxX = frame.maxX - panel.width - margin
+        let minX = frame.minX + margin
+        let baseX = minX + CGFloat(col) * (panel.width + stepX)
+        let x = min(max(baseX, minX), maxX)
+
+        let maxY = frame.maxY - panel.height - margin
+        let minY = frame.minY + margin
+        let baseY = maxY - CGFloat(row) * (panel.height + stepY)
+        let y = min(max(baseY, minY), maxY)
+
+        return WidgetInstance.Origin(x: x, y: y)
+    }
+
     private func close(instanceID: UUID) {
         if let win = windowsByInstanceID.removeValue(forKey: instanceID) {
             win.close()
@@ -166,15 +195,15 @@ final class DesktopWidgetWindowManager {
 
 private struct DesktopWidgetHostView: View {
     let app: LauncherApp
+    let variant: WidgetVariant
     let instanceID: UUID
-    let size: WidgetSize
     let onClose: () -> Void
 
     @State private var hovering = false
 
     var body: some View {
         ZStack(alignment: .topTrailing) {
-            DesktopWidgetView(app: app, size: size, isPreview: false)
+            DesktopWidgetView(app: app, variant: variant, isPreview: false)
 
             Button(action: onClose) {
                 Image(systemName: "xmark")

+ 3 - 1
google_apps/Widgets/WidgetInstance.swift

@@ -9,12 +9,14 @@ struct WidgetInstance: Identifiable, Codable, Hashable {
 
     let id: UUID
     let appID: UUID
+    let variantID: String
     var size: WidgetSize
     var origin: Origin?
 
-    init(id: UUID = UUID(), appID: UUID, size: WidgetSize, origin: Origin?) {
+    init(id: UUID = UUID(), appID: UUID, variantID: String, size: WidgetSize, origin: Origin?) {
         self.id = id
         self.appID = appID
+        self.variantID = variantID
         self.size = size
         self.origin = origin
     }

+ 7 - 4
google_apps/Widgets/WidgetPreviewCard.swift

@@ -2,10 +2,10 @@ import SwiftUI
 
 struct WidgetPreviewCard: View {
     let app: LauncherApp
-    let size: WidgetSize
+    let variant: WidgetVariant
 
     var body: some View {
-        DesktopWidgetView(app: app, size: size, isPreview: true)
+        DesktopWidgetView(app: app, variant: variant, isPreview: true)
             .frame(width: previewSize.width, height: previewSize.height)
             .allowsHitTesting(false)
             .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
@@ -16,7 +16,7 @@ struct WidgetPreviewCard: View {
     }
 
     private var previewSize: CGSize {
-        switch size {
+        switch variant.size {
         case .small:
             return CGSize(width: 190, height: 190)
         case .medium:
@@ -30,7 +30,10 @@ struct WidgetPreviewCard: View {
 #Preview {
     ZStack {
         Color.black
-        WidgetPreviewCard(app: LauncherApp.sampleApps[1], size: .medium)
+        WidgetPreviewCard(
+            app: LauncherApp.sampleApps[1],
+            variant: WidgetTemplates.variants(for: LauncherApp.sampleApps[1])[0]
+        )
             .frame(width: 520, height: 360)
             .padding()
     }

+ 326 - 188
google_apps/Widgets/WidgetTemplates.swift

@@ -15,229 +15,367 @@ struct WidgetAction: Identifiable, Hashable {
     }
 }
 
-enum WidgetTemplateKind: String, CaseIterable, Codable, Hashable {
-    case quickActions
-}
-
 enum WidgetLayoutMode: Hashable {
     case iconOnly(title: String)
-    case actionsRow(maxActions: Int, columns: Int = 3)
-    case actionsGrid(columns: Int, maxActions: Int)
+    case actionsRow
+    case actionsGrid(columns: Int)
 }
 
-struct WidgetLayoutConfig: Hashable {
-    let mode: WidgetLayoutMode
+struct WidgetVariant: Identifiable, Hashable {
+    let id: String
+    let title: String
+    let size: WidgetSize
     let showHeader: Bool
+    let layoutMode: WidgetLayoutMode
+    let actions: [WidgetAction]
 }
 
 enum WidgetTemplates {
-    static func actions(for app: LauncherApp, kind: WidgetTemplateKind = .quickActions) -> [WidgetAction] {
-        guard kind == .quickActions else { return defaultActions(for: app) }
-
-        let name = app.name.lowercased()
-        let host = app.webURL?.host?.lowercased() ?? ""
+    static func variants(for app: LauncherApp) -> [WidgetVariant] {
+        let base = app.webURL
 
-        if name.contains("photos") || host.contains("photos.google.com") {
-            // Matches the example UI.
-            return [
-                WidgetAction(id: "photos", title: "Photos", systemImage: "photo.on.rectangle", urlPath: "/"),
-                WidgetAction(id: "albums", title: "Albums", systemImage: "rectangle.stack", urlPath: "/albums"),
-                WidgetAction(id: "explore", title: "Explore", systemImage: "magnifyingglass", urlPath: "/search"),
-                WidgetAction(id: "archive", title: "Archive", systemImage: "archivebox", urlPath: "/archive"),
-                WidgetAction(id: "sharing", title: "Sharing", systemImage: "person.2", urlPath: "/sharing"),
-                WidgetAction(id: "locked", title: "Locked Folder", systemImage: "lock", urlPath: "/lockedfolder"),
-                WidgetAction(id: "favorites", title: "Favorites", systemImage: "star", urlPath: "/favorites"),
-                WidgetAction(id: "trash", title: "Trash", systemImage: "trash", urlPath: "/trash"),
-            ]
+        func v(_ id: String, _ title: String, _ size: WidgetSize, _ showHeader: Bool, _ layout: WidgetLayoutMode, _ actions: [WidgetAction]) -> WidgetVariant {
+            WidgetVariant(id: id, title: title, size: size, showHeader: showHeader, layoutMode: layout, actions: actions)
         }
 
-        if name.contains("gmail") || host.contains("mail.google.com") {
+        let profile = appProfile(for: app)
+        switch profile {
+        case .photos:
             return [
-                WidgetAction(id: "compose", title: "Compose", systemImage: "square.and.pencil", urlPath: "/mail/u/0/#inbox?compose=new"),
-                WidgetAction(id: "inbox", title: "Inbox", systemImage: "tray", urlPath: "/mail/u/0/#inbox"),
-                WidgetAction(id: "starred", title: "Starred", systemImage: "star", urlPath: "/mail/u/0/#starred"),
-                WidgetAction(id: "sent", title: "Sent", systemImage: "paperplane", urlPath: "/mail/u/0/#sent"),
-                WidgetAction(id: "drafts", title: "Drafts", systemImage: "doc.plaintext", urlPath: "/mail/u/0/#drafts"),
-                WidgetAction(id: "snoozed", title: "Snoozed", systemImage: "clock", urlPath: "/mail/u/0/#snoozed"),
-                WidgetAction(id: "important", title: "Important", systemImage: "exclamationmark.circle", urlPath: "/mail/u/0/#imp"),
-                WidgetAction(id: "trash", title: "Trash", systemImage: "trash", urlPath: "/mail/u/0/#trash"),
+                v("photos_small", "Photo", .small, false, .iconOnly(title: "Photo"), [a("photos", "Photos", "photo.on.rectangle", "/")]),
+                v("photos_medium", "Google Photo", .medium, false, .iconOnly(title: "Google Photo"), [a("albums", "Albums", "rectangle.stack", "/albums")]),
+                v("photos_large", "Photos Library", .large, true, .actionsGrid(columns: 2), [
+                    a("photos", "Photos", "photo.on.rectangle", "/"),
+                    a("albums", "Albums", "rectangle.stack", "/albums"),
+                    a("explore", "Explore", "magnifyingglass", "/search"),
+                    a("archive", "Archive", "archivebox", "/archive"),
+                    a("sharing", "Sharing", "person.2", "/sharing"),
+                    a("locked", "Locked Folder", "lock", "/lockedfolder"),
+                    a("favorites", "Favorites", "star", "/favorites"),
+                    a("trash", "Trash", "trash", "/trash"),
+                ]),
             ]
-        }
-
-        if name.contains("drive") || host.contains("drive.google.com") {
+        case .gmail:
             return [
-                WidgetAction(id: "myDrive", title: "My Drive", systemImage: "externaldrive", urlPath: "/drive/my-drive"),
-                WidgetAction(id: "recent", title: "Recent", systemImage: "clock.arrow.circlepath", urlPath: "/drive/recent"),
-                WidgetAction(id: "starred", title: "Starred", systemImage: "star", urlPath: "/drive/starred"),
-                WidgetAction(id: "shared", title: "Shared", systemImage: "person.2", urlPath: "/drive/shared-with-me"),
-                WidgetAction(id: "trash", title: "Trash", systemImage: "trash", urlPath: "/drive/trash"),
-                WidgetAction(id: "new", title: "New", systemImage: "plus", urlPath: "/drive/my-drive"),
+                v("gmail_small", "Inbox Quick", .small, false, .actionsRow, [
+                    a("compose", "Compose", "square.and.pencil", "/mail/u/0/#inbox?compose=new"),
+                    a("inbox", "Inbox", "tray", "/mail/u/0/#inbox"),
+                    a("starred", "Starred", "star", "/mail/u/0/#starred"),
+                ]),
+                v("gmail_medium", "Mailbox", .medium, true, .actionsGrid(columns: 2), [
+                    a("inbox", "Inbox", "tray", "/mail/u/0/#inbox"),
+                    a("important", "Important", "exclamationmark.circle", "/mail/u/0/#imp"),
+                    a("sent", "Sent", "paperplane", "/mail/u/0/#sent"),
+                    a("drafts", "Drafts", "doc.plaintext", "/mail/u/0/#drafts"),
+                ]),
+                v("gmail_large", "Gmail Actions", .large, true, .actionsGrid(columns: 2), [
+                    a("compose", "Compose", "square.and.pencil", "/mail/u/0/#inbox?compose=new"),
+                    a("inbox", "Inbox", "tray", "/mail/u/0/#inbox"),
+                    a("starred", "Starred", "star", "/mail/u/0/#starred"),
+                    a("sent", "Sent", "paperplane", "/mail/u/0/#sent"),
+                    a("drafts", "Drafts", "doc.plaintext", "/mail/u/0/#drafts"),
+                    a("snoozed", "Snoozed", "clock", "/mail/u/0/#snoozed"),
+                    a("important", "Important", "exclamationmark.circle", "/mail/u/0/#imp"),
+                    a("trash", "Trash", "trash", "/mail/u/0/#trash"),
+                ]),
             ]
-        }
-
-        if name.contains("docs") || host.contains("docs.google.com") {
+        case .drive:
+            return contentWorkspaceVariants(prefix: "drive", appName: "Drive", rootPath: "/drive/my-drive", searchPath: "/drive/search")
+        case .docs:
+            return contentWorkspaceVariants(prefix: "docs", appName: "Docs", rootPath: "/document/u/0/", searchPath: "/document/u/0/")
+        case .sheets:
+            return contentWorkspaceVariants(prefix: "sheets", appName: "Sheets", rootPath: "/spreadsheets/u/0/", searchPath: "/spreadsheets/u/0/")
+        case .slides:
+            return contentWorkspaceVariants(prefix: "slides", appName: "Slides", rootPath: "/presentation/u/0/", searchPath: "/presentation/u/0/")
+        case .forms:
+            return contentWorkspaceVariants(prefix: "forms", appName: "Forms", rootPath: "/forms/u/0/", searchPath: "/forms/u/0/")
+        case .calendar:
             return [
-                WidgetAction(id: "newDoc", title: "New Doc", systemImage: "doc.badge.plus", urlPath: "/document/u/0/"),
-                WidgetAction(id: "recent", title: "Recent", systemImage: "clock.arrow.circlepath", urlPath: "/document/u/0/"),
-                WidgetAction(id: "templates", title: "Templates", systemImage: "square.grid.2x2", urlPath: "/document/u/0/"),
-                WidgetAction(id: "shared", title: "Shared", systemImage: "person.2", urlPath: "/document/u/0/"),
+                v("calendar_small", "Today", .small, false, .actionsRow, [
+                    a("today", "Today", "calendar", "/calendar/u/0/r"),
+                    a("create", "Create", "plus.circle", "/calendar/u/0/r/eventedit"),
+                ]),
+                v("calendar_medium", "Schedule", .medium, true, .actionsGrid(columns: 2), [
+                    a("today", "Today", "calendar", "/calendar/u/0/r"),
+                    a("week", "Week", "calendar.badge.clock", "/calendar/u/0/r"),
+                    a("create", "New Event", "plus.circle", "/calendar/u/0/r/eventedit"),
+                    a("tasks", "Tasks", "checkmark.square", "/calendar/u/0/r"),
+                ]),
             ]
-        }
-
-        if name.contains("sheets") || host.contains("sheets.google.com") {
+        case .maps:
             return [
-                WidgetAction(id: "newSheet", title: "New Sheet", systemImage: "tablecells.badge.ellipsis", urlPath: "/spreadsheets/u/0/"),
-                WidgetAction(id: "recent", title: "Recent", systemImage: "clock.arrow.circlepath", urlPath: "/spreadsheets/u/0/"),
-                WidgetAction(id: "templates", title: "Templates", systemImage: "square.grid.2x2", urlPath: "/spreadsheets/u/0/"),
+                v("maps_small", "Places", .small, false, .actionsRow, [
+                    a("search", "Search", "magnifyingglass", "/"),
+                    a("nearby", "Nearby", "mappin.and.ellipse", "/"),
+                ]),
+                v("maps_medium", "Routing", .medium, true, .actionsGrid(columns: 2), [
+                    a("search", "Search", "magnifyingglass", "/"),
+                    a("directions", "Directions", "arrow.triangle.turn.up.right.diamond", "/"),
+                    a("saved", "Saved", "bookmark", "/"),
+                    a("traffic", "Traffic", "car", "/"),
+                ]),
             ]
-        }
-
-        if name.contains("calendar") || host.contains("calendar.google.com") {
+        case .youtube:
             return [
-                WidgetAction(id: "today", title: "Today", systemImage: "calendar", urlPath: "/calendar/u/0/r"),
-                WidgetAction(id: "create", title: "Create", systemImage: "plus.circle", urlPath: "/calendar/u/0/r/eventedit"),
-                WidgetAction(id: "schedule", title: "Schedule", systemImage: "list.bullet.rectangle", urlPath: "/calendar/u/0/r"),
+                v("youtube_small", "Quick Watch", .small, false, .actionsRow, [
+                    a("home", "Home", "house", "/"),
+                    a("shorts", "Shorts", "bolt", "/shorts"),
+                    a("subs", "Subs", "play.rectangle", "/feed/subscriptions"),
+                ]),
+                v("youtube_large", "Library", .large, true, .actionsGrid(columns: 2), [
+                    a("home", "Home", "house", "/"),
+                    a("subscriptions", "Subscriptions", "play.rectangle", "/feed/subscriptions"),
+                    a("library", "Library", "books.vertical", "/feed/library"),
+                    a("history", "History", "clock", "/feed/history"),
+                    a("watchLater", "Watch Later", "bookmark", "/playlist?list=WL"),
+                    a("shorts", "Shorts", "bolt", "/shorts"),
+                ]),
             ]
+        case .translate:
+            return utilityVariants(prefix: "translate", appName: "Translate", basePath: "/")
+        case .search:
+            return utilityVariants(prefix: "search", appName: "Search", basePath: "/")
+        case .earth:
+            return utilityVariants(prefix: "earth", appName: "Earth", basePath: "/web/")
+        case .shopping:
+            return mediaVariants(prefix: "shopping", appName: "Shopping", basePath: "/")
+        case .keep:
+            return utilityVariants(prefix: "keep", appName: "Keep", basePath: "/")
+        case .travel:
+            return mediaVariants(prefix: "travel", appName: "Travel", basePath: "/")
+        case .meet:
+            return communicationVariants(prefix: "meet", appName: "Meet", basePath: "/")
+        case .contacts:
+            return utilityVariants(prefix: "contacts", appName: "Contacts", basePath: "/")
+        case .blogger:
+            return mediaVariants(prefix: "blogger", appName: "Blogger", basePath: "/")
+        case .play:
+            return mediaVariants(prefix: "play", appName: "Play", basePath: "/")
+        case .news:
+            return mediaVariants(prefix: "news", appName: "News", basePath: "/")
+        case .chat:
+            return communicationVariants(prefix: "chat", appName: "Chat", basePath: "/")
+        case .finance:
+            return mediaVariants(prefix: "finance", appName: "Finance", basePath: "/finance")
+        case .jamboard:
+            return communicationVariants(prefix: "jamboard", appName: "Jamboard", basePath: "/")
+        case .classroom:
+            return communicationVariants(prefix: "classroom", appName: "Classroom", basePath: "/")
+        case .artsCulture:
+            return mediaVariants(prefix: "arts", appName: "Arts & Culture", basePath: "/")
+        case .voice:
+            return communicationVariants(prefix: "voice", appName: "Voice", basePath: "/")
+        case .chromeWebStore:
+            return mediaVariants(prefix: "webstore", appName: "Web Store", basePath: "/")
+        case .fi:
+            return utilityVariants(prefix: "fi", appName: "Fi", basePath: "/")
+        case .ads:
+            return utilityVariants(prefix: "ads", appName: "Ads", basePath: "/")
+        case .remoteDesktop:
+            return utilityVariants(prefix: "remote", appName: "Remote Desktop", basePath: "/")
+        case .gemini:
+            return communicationVariants(prefix: "gemini", appName: "Gemini", basePath: "/")
+        case .books:
+            return mediaVariants(prefix: "books", appName: "Books", basePath: "/")
+        case .unknown:
+            return utilityVariants(prefix: "generic", appName: app.name, basePath: "/")
         }
+    }
 
-        if name.contains("youtube") || host.contains("youtube.com") {
-            return [
-                WidgetAction(id: "home", title: "Home", systemImage: "house", urlPath: "/"),
-                WidgetAction(id: "subscriptions", title: "Subs", systemImage: "play.rectangle", urlPath: "/feed/subscriptions"),
-                WidgetAction(id: "library", title: "Library", systemImage: "books.vertical", urlPath: "/feed/library"),
-                WidgetAction(id: "history", title: "History", systemImage: "clock", urlPath: "/feed/history"),
-                WidgetAction(id: "watchLater", title: "Watch Later", systemImage: "bookmark", urlPath: "/playlist?list=WL"),
-                WidgetAction(id: "shorts", title: "Shorts", systemImage: "bolt", urlPath: "/shorts"),
-            ]
+    static func variant(for app: LauncherApp, variantID: String?) -> WidgetVariant {
+        let variants = variants(for: app)
+        if let variantID, let match = variants.first(where: { $0.id == variantID }) {
+            return match
         }
+        return variants.first ?? WidgetVariant(
+            id: "fallback_small",
+            title: "Quick Access",
+            size: .small,
+            showHeader: false,
+            layoutMode: .actionsRow,
+            actions: [a("open", "Open", "arrow.up.right", nil)]
+        )
+    }
+}
 
-        if name.contains("maps") || host.contains("maps.google.com") {
-            return [
-                WidgetAction(id: "search", title: "Search", systemImage: "magnifyingglass", urlPath: "/"),
-                WidgetAction(id: "directions", title: "Directions", systemImage: "arrow.triangle.turn.up.right.diamond", urlPath: "/"),
-                WidgetAction(id: "saved", title: "Saved", systemImage: "bookmark", urlPath: "/"),
-            ]
-        }
+private extension WidgetTemplates {
+    enum AppWidgetProfile {
+        case photos, gmail, drive, docs, sheets, slides, forms
+        case calendar, maps, youtube, translate, search, earth, shopping
+        case keep, travel, meet, contacts, blogger, play, news, chat, finance
+        case jamboard, classroom, artsCulture, voice, chromeWebStore, fi, ads
+        case remoteDesktop, gemini, books
+        case unknown
+    }
 
-        return defaultActions(for: app)
+    static func appProfile(for app: LauncherApp) -> AppWidgetProfile {
+        let host = app.webURL?.host?.lowercased() ?? ""
+        let name = app.name.lowercased()
+        if host.contains("photos.google.com") || name.contains("photos") { return .photos }
+        if host.contains("mail.google.com") || name == "gmail" { return .gmail }
+        if host.contains("drive.google.com") { return .drive }
+        if host.contains("docs.google.com") { return .docs }
+        if host.contains("sheets.google.com") { return .sheets }
+        if host.contains("slides.google.com") { return .slides }
+        if host.contains("forms.google.com") { return .forms }
+        if host.contains("calendar.google.com") { return .calendar }
+        if host.contains("maps.google.com") { return .maps }
+        if host.contains("youtube.com") { return .youtube }
+        if host.contains("translate.google.com") { return .translate }
+        if host == "www.google.com" || host == "google.com" { return .search }
+        if host.contains("earth.google.com") { return .earth }
+        if host.contains("shopping.google.com") { return .shopping }
+        if host.contains("keep.google.com") { return .keep }
+        if host.contains("travel.google.com") { return .travel }
+        if host.contains("meet.google.com") { return .meet }
+        if host.contains("contacts.google.com") { return .contacts }
+        if host.contains("blogger.com") { return .blogger }
+        if host.contains("play.google.com") { return .play }
+        if host.contains("news.google.com") { return .news }
+        if host.contains("chat.google.com") { return .chat }
+        if host.contains("/finance") || name.contains("finance") { return .finance }
+        if host.contains("jamboard.google.com") { return .jamboard }
+        if host.contains("classroom.google.com") { return .classroom }
+        if host.contains("artsandculture.google.com") { return .artsCulture }
+        if host.contains("voice.google.com") { return .voice }
+        if host.contains("chrome.google.com") { return .chromeWebStore }
+        if host.contains("fi.google.com") { return .fi }
+        if host.contains("ads.google.com") { return .ads }
+        if host.contains("remotedesktop.google.com") { return .remoteDesktop }
+        if host.contains("gemini.google.com") { return .gemini }
+        if host.contains("books.google.com") { return .books }
+        return .unknown
     }
 
-    static func defaultActions(for app: LauncherApp) -> [WidgetAction] {
+    static func contentWorkspaceVariants(prefix: String, appName: String, rootPath: String, searchPath: String) -> [WidgetVariant] {
         [
-            WidgetAction(id: "open", title: "Open", systemImage: "arrow.up.right", urlPath: nil),
-            WidgetAction(id: "search", title: "Search", systemImage: "magnifyingglass", urlPath: nil),
-            WidgetAction(id: "favorites", title: "Favorites", systemImage: "star", urlPath: nil),
+            WidgetVariant(
+                id: "\(prefix)_small",
+                title: appName,
+                size: .small,
+                showHeader: false,
+                layoutMode: .iconOnly(title: appName),
+                actions: [a("open", "Open", "arrow.up.right", rootPath)]
+            ),
+            WidgetVariant(
+                id: "\(prefix)_medium",
+                title: "Recent",
+                size: .medium,
+                showHeader: true,
+                layoutMode: .actionsGrid(columns: 2),
+                actions: [
+                    a("open", "Open", "arrow.up.right", rootPath),
+                    a("search", "Search", "magnifyingglass", searchPath),
+                    a("favorites", "Favorites", "star", rootPath),
+                    a("shared", "Shared", "person.2", rootPath),
+                ]
+            ),
+            WidgetVariant(
+                id: "\(prefix)_large",
+                title: "Workspace",
+                size: .large,
+                showHeader: true,
+                layoutMode: .actionsGrid(columns: 2),
+                actions: [
+                    a("open", "Open", "arrow.up.right", rootPath),
+                    a("search", "Search", "magnifyingglass", searchPath),
+                    a("new", "New", "plus", rootPath),
+                    a("recent", "Recent", "clock.arrow.circlepath", rootPath),
+                    a("starred", "Starred", "star", rootPath),
+                    a("shared", "Shared", "person.2", rootPath),
+                ]
+            ),
         ]
     }
 
-    static func layout(for app: LauncherApp, size: WidgetSize) -> WidgetLayoutConfig {
-        let name = app.name.lowercased()
-        let host = app.webURL?.host?.lowercased() ?? ""
-        let isPhotos = name.contains("photos") || host.contains("photos.google.com")
-        let isGmail = name.contains("gmail") || host.contains("mail.google.com")
-        let isDrive = name.contains("drive") || host.contains("drive.google.com")
-        let isDocs = name.contains("docs") || host.contains("docs.google.com")
-        let isSheets = name.contains("sheets") || host.contains("sheets.google.com")
-        let isCalendar = name.contains("calendar") || host.contains("calendar.google.com")
-        let isYouTube = name.contains("youtube") || host.contains("youtube.com")
-        let isMaps = name.contains("maps") || host.contains("maps.google.com")
-
-        if isPhotos {
-            switch size {
-            case .small:
-                return WidgetLayoutConfig(mode: .iconOnly(title: "Photo"), showHeader: false)
-            case .medium:
-                return WidgetLayoutConfig(mode: .iconOnly(title: "Google Photo"), showHeader: false)
-            case .large:
-                return WidgetLayoutConfig(mode: .actionsGrid(columns: 2, maxActions: 8), showHeader: true)
-            }
-        }
-
-        if isGmail {
-            switch size {
-            case .small:
-                return WidgetLayoutConfig(mode: .actionsRow(maxActions: 3), showHeader: false)
-            case .medium:
-                return WidgetLayoutConfig(mode: .actionsGrid(columns: 2, maxActions: 6), showHeader: true)
-            case .large:
-                return WidgetLayoutConfig(mode: .actionsGrid(columns: 3, maxActions: 9), showHeader: true)
-            }
-        }
-
-        if isDrive {
-            switch size {
-            case .small:
-                return WidgetLayoutConfig(mode: .iconOnly(title: "Drive"), showHeader: false)
-            case .medium:
-                return WidgetLayoutConfig(mode: .actionsRow(maxActions: 3), showHeader: true)
-            case .large:
-                return WidgetLayoutConfig(mode: .actionsGrid(columns: 2, maxActions: 6), showHeader: true)
-            }
-        }
-
-        if isDocs {
-            switch size {
-            case .small:
-                return WidgetLayoutConfig(mode: .iconOnly(title: "Docs"), showHeader: false)
-            case .medium:
-                return WidgetLayoutConfig(mode: .actionsGrid(columns: 2, maxActions: 4), showHeader: true)
-            case .large:
-                return WidgetLayoutConfig(mode: .actionsGrid(columns: 3, maxActions: 6), showHeader: true)
-            }
-        }
-
-        if isSheets {
-            switch size {
-            case .small:
-                return WidgetLayoutConfig(mode: .iconOnly(title: "Sheets"), showHeader: false)
-            case .medium:
-                return WidgetLayoutConfig(mode: .actionsRow(maxActions: 3), showHeader: true)
-            case .large:
-                return WidgetLayoutConfig(mode: .actionsGrid(columns: 2, maxActions: 6), showHeader: true)
-            }
-        }
-
-        if isCalendar {
-            switch size {
-            case .small:
-                return WidgetLayoutConfig(mode: .actionsRow(maxActions: 2), showHeader: false)
-            case .medium:
-                return WidgetLayoutConfig(mode: .actionsGrid(columns: 2, maxActions: 3), showHeader: true)
-            case .large:
-                return WidgetLayoutConfig(mode: .actionsGrid(columns: 3, maxActions: 6), showHeader: true)
-            }
-        }
+    static func communicationVariants(prefix: String, appName: String, basePath: String) -> [WidgetVariant] {
+        [
+            WidgetVariant(
+                id: "\(prefix)_small",
+                title: "Quick",
+                size: .small,
+                showHeader: false,
+                layoutMode: .actionsRow,
+                actions: [
+                    a("open", "Open", "arrow.up.right", basePath),
+                    a("recent", "Recent", "clock", basePath),
+                    a("contacts", "Contacts", "person.2", basePath),
+                ]
+            ),
+            WidgetVariant(
+                id: "\(prefix)_large",
+                title: appName,
+                size: .large,
+                showHeader: true,
+                layoutMode: .actionsGrid(columns: 2),
+                actions: [
+                    a("open", "Open", "arrow.up.right", basePath),
+                    a("messages", "Messages", "bubble.left.and.bubble.right", basePath),
+                    a("meetings", "Meetings", "video", basePath),
+                    a("contacts", "Contacts", "person.2", basePath),
+                ]
+            ),
+        ]
+    }
 
-        if isYouTube {
-            switch size {
-            case .small:
-                return WidgetLayoutConfig(mode: .iconOnly(title: "YouTube"), showHeader: false)
-            case .medium:
-                return WidgetLayoutConfig(mode: .actionsGrid(columns: 2, maxActions: 4), showHeader: true)
-            case .large:
-                return WidgetLayoutConfig(mode: .actionsGrid(columns: 3, maxActions: 6), showHeader: true)
-            }
-        }
+    static func mediaVariants(prefix: String, appName: String, basePath: String) -> [WidgetVariant] {
+        [
+            WidgetVariant(
+                id: "\(prefix)_small",
+                title: appName,
+                size: .small,
+                showHeader: false,
+                layoutMode: .iconOnly(title: appName),
+                actions: [a("open", "Open", "arrow.up.right", basePath)]
+            ),
+            WidgetVariant(
+                id: "\(prefix)_medium",
+                title: "Browse",
+                size: .medium,
+                showHeader: true,
+                layoutMode: .actionsGrid(columns: 2),
+                actions: [
+                    a("open", "Open", "arrow.up.right", basePath),
+                    a("trending", "Trending", "chart.line.uptrend.xyaxis", basePath),
+                    a("saved", "Saved", "bookmark", basePath),
+                    a("search", "Search", "magnifyingglass", basePath),
+                ]
+            ),
+        ]
+    }
 
-        if isMaps {
-            switch size {
-            case .small:
-                return WidgetLayoutConfig(mode: .actionsRow(maxActions: 3), showHeader: false)
-            case .medium:
-                return WidgetLayoutConfig(mode: .actionsGrid(columns: 2, maxActions: 4), showHeader: true)
-            case .large:
-                return WidgetLayoutConfig(mode: .actionsGrid(columns: 3, maxActions: 6), showHeader: true)
-            }
-        }
+    static func utilityVariants(prefix: String, appName: String, basePath: String) -> [WidgetVariant] {
+        [
+            WidgetVariant(
+                id: "\(prefix)_small",
+                title: "Quick Access",
+                size: .small,
+                showHeader: false,
+                layoutMode: .actionsRow,
+                actions: [
+                    a("open", "Open", "arrow.up.right", basePath),
+                    a("search", "Search", "magnifyingglass", basePath),
+                    a("favorites", "Favorites", "star", basePath),
+                ]
+            ),
+            WidgetVariant(
+                id: "\(prefix)_medium",
+                title: appName,
+                size: .medium,
+                showHeader: true,
+                layoutMode: .actionsGrid(columns: 2),
+                actions: [
+                    a("open", "Open", "arrow.up.right", basePath),
+                    a("search", "Search", "magnifyingglass", basePath),
+                    a("favorites", "Favorites", "star", basePath),
+                    a("recent", "Recent", "clock.arrow.circlepath", basePath),
+                ]
+            ),
+        ]
+    }
 
-        switch size {
-        case .small:
-            return WidgetLayoutConfig(mode: .actionsRow(maxActions: 3), showHeader: false)
-        case .medium:
-            return WidgetLayoutConfig(mode: .actionsGrid(columns: 2, maxActions: 6), showHeader: true)
-        case .large:
-            return WidgetLayoutConfig(mode: .actionsGrid(columns: 3, maxActions: 9), showHeader: true)
-        }
+    static func a(_ id: String, _ title: String, _ image: String, _ path: String?) -> WidgetAction {
+        WidgetAction(id: id, title: title, systemImage: image, urlPath: path)
     }
 }
 

+ 45 - 16
google_apps/Widgets/WidgetsRootView.swift

@@ -23,6 +23,11 @@ struct WidgetsRootView: View {
         return apps.first { $0.id == selectedAppID }
     }
 
+    private var selectedVariants: [WidgetVariant] {
+        guard let selectedApp else { return [] }
+        return WidgetTemplates.variants(for: selectedApp)
+    }
+
     var body: some View {
         HStack(spacing: 14) {
             sidebar
@@ -102,20 +107,12 @@ struct WidgetsRootView: View {
                     .padding(.top, 4)
 
                 HStack(alignment: .top, spacing: 18) {
-                    ForEach(WidgetSize.allCases, id: \.self) { size in
-                        WidgetPreviewCard(app: selectedApp, size: size)
-                            .overlay(alignment: .topTrailing) {
-                                Button(action: { addToDesktop(app: selectedApp, size: size) }) {
-                                    Image(systemName: "plus")
-                                        .font(.system(size: 12, weight: .bold))
-                                        .foregroundStyle(.black.opacity(0.85))
-                                        .frame(width: 26, height: 26)
-                                        .background(Circle().fill(Color.green.opacity(0.95)))
-                                        .shadow(color: .black.opacity(0.28), radius: 10, x: 0, y: 6)
-                                }
-                                .buttonStyle(.plain)
-                                .offset(x: 10, y: -10)
-                            }
+                    ForEach(selectedVariants) { variant in
+                        WidgetPreviewAddCard(
+                            app: selectedApp,
+                            variant: variant,
+                            onAdd: { addToDesktop(app: selectedApp, variant: variant) }
+                        )
                     }
                     Spacer(minLength: 0)
                 }
@@ -174,9 +171,14 @@ struct WidgetsRootView: View {
         widgetLibraryIDsData = encodeUUIDSet(updated)
     }
 
-    private func addToDesktop(app: LauncherApp, size: WidgetSize) {
+    private func addToDesktop(app: LauncherApp, variant: WidgetVariant) {
         ensureInWidgetLibrary(appID: app.id)
-        let instance = WidgetInstance(appID: app.id, size: size, origin: nil)
+        let instance = WidgetInstance(
+            appID: app.id,
+            variantID: variant.id,
+            size: variant.size,
+            origin: nil
+        )
         DesktopWidgetWindowManager.shared.show(instance: instance, appProvider: { id in
             apps.first { $0.id == id }
         })
@@ -222,6 +224,33 @@ private struct WidgetSidebarRow: View {
     }
 }
 
+private struct WidgetPreviewAddCard: View {
+    let app: LauncherApp
+    let variant: WidgetVariant
+    let onAdd: () -> 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(.black.opacity(0.85))
+                        .frame(width: 26, height: 26)
+                        .background(Circle().fill(Color.green.opacity(0.95)))
+                        .shadow(color: .black.opacity(0.28), radius: 10, x: 0, y: 6)
+                }
+                .buttonStyle(.plain)
+                .offset(x: 10, y: -10)
+                .opacity(hovering ? 1 : 0)
+                .animation(.easeOut(duration: 0.12), value: hovering)
+            }
+            .onHover { hovering = $0 }
+    }
+}
+
 private func decodeUUIDSet(from dataString: String) -> Set<UUID> {
     guard !dataString.isEmpty, let data = dataString.data(using: .utf8) else { return [] }
     do {