| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257 |
- import SwiftUI
- import AppKit
- struct DesktopWidgetView: View {
- let app: LauncherApp
- let variant: WidgetVariant
- var isPreview: Bool = false
- /// Gmail: account slot for `/mail/u/{{u}}/…` in URLs. Ignored for other apps.
- var gmailAccountSlot: Int = 0
- 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 {
- Group {
- if case .gmail(let gmailMode) = layoutMode {
- GmailDesktopWidgetView(app: app, mode: gmailMode, gmailAccountSlot: gmailAccountSlot, isPreview: isPreview)
- } else if case .drive(let driveMode) = layoutMode {
- DriveDesktopWidgetView(app: app, mode: driveMode, isPreview: isPreview)
- } else if case .keep(let keepMode) = layoutMode {
- KeepDesktopWidgetView(app: app, mode: keepMode, isPreview: isPreview)
- } else if case .translate(let translateMode) = layoutMode {
- TranslateDesktopWidgetView(app: app, mode: translateMode, isPreview: isPreview)
- } else if case .earth(let earthMode) = layoutMode {
- EarthDesktopWidgetView(app: app, mode: earthMode, isPreview: isPreview)
- } else if case .googleSearch(let searchMode) = layoutMode {
- GoogleSearchDesktopWidgetView(app: app, mode: searchMode, isPreview: isPreview)
- } else if case .calendar(let calendarMode) = layoutMode {
- CalendarDesktopWidgetView(app: app, mode: calendarMode, isPreview: isPreview)
- } else if case .books(let booksMode) = layoutMode {
- BooksDesktopWidgetView(app: app, mode: booksMode, isPreview: isPreview)
- } else if case .travel(let travelMode) = layoutMode {
- TravelDesktopWidgetView(app: app, mode: travelMode, isPreview: isPreview)
- } else if case .shopping(let shoppingMode) = layoutMode {
- ShoppingDesktopWidgetView(app: app, mode: shoppingMode, isPreview: isPreview)
- } else if case .youtube(let youtubeMode) = layoutMode {
- YouTubeDesktopWidgetView(app: app, mode: youtubeMode, isPreview: isPreview)
- } else if case .meet(let meetMode) = layoutMode {
- MeetDesktopWidgetView(app: app, mode: meetMode, isPreview: isPreview)
- } else if case .news(let newsMode) = layoutMode {
- NewsDesktopWidgetView(app: app, mode: newsMode, isPreview: isPreview)
- } else if case .forms(let formsMode) = layoutMode {
- FormsDesktopWidgetView(app: app, mode: formsMode, isPreview: isPreview)
- } else if case .docs(let docsMode) = layoutMode {
- DocsDesktopWidgetView(app: app, mode: docsMode, isPreview: isPreview)
- } else if case .sheets(let sheetsMode) = layoutMode {
- SheetsDesktopWidgetView(app: app, mode: sheetsMode, isPreview: isPreview)
- } else if case .slides(let slidesMode) = layoutMode {
- SlidesDesktopWidgetView(app: app, mode: slidesMode, isPreview: isPreview)
- } else if case .googlePlay(let playMode) = layoutMode {
- GooglePlayDesktopWidgetView(app: app, mode: playMode, isPreview: isPreview)
- } else if case .gemini(let geminiMode) = layoutMode {
- GeminiDesktopWidgetView(app: app, mode: geminiMode, isPreview: isPreview)
- } else if case .interactiveMap = layoutMode {
- MapsInteractiveWidgetView(app: app, widgetSize: size, isPreview: isPreview)
- } else {
- 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(
- 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)
- case .actionsRow:
- if showHeader {
- header
- }
- rowActions(maxActions: 3)
- case .actionsGrid(let columns):
- if showHeader {
- header
- }
- gridActions(columns: columns, maxActions: maxActionsForSize)
- case .interactiveMap, .gmail, .drive, .keep, .translate, .earth, .googleSearch, .calendar, .books, .travel, .shopping, .youtube, .meet, .news, .forms, .docs, .sheets, .slides, .googlePlay, .gemini:
- EmptyView()
- }
- }
- .padding(16)
- }
- .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
- }
- }
- }
- private var header: some View {
- HStack(spacing: 10) {
- AppIconView(app: app, size: 34, showAppBackground: false, iconPaddingFactor: 0.07)
- VStack(alignment: .leading, spacing: 2) {
- Text(app.name)
- .font(.system(size: 16, weight: .bold))
- .foregroundStyle(.white.opacity(0.92))
- .lineLimit(1)
- }
- Spacer(minLength: 0)
- }
- }
- @ViewBuilder
- private func iconOnly(title: String) -> some View {
- let fontSize: CGFloat = {
- switch size {
- case .small: return 16
- case .medium: return 22
- case .large: return 18
- }
- }()
- let content = VStack(spacing: 10) {
- AppIconView(app: app, size: 44, showAppBackground: false, iconPaddingFactor: 0.05)
- Text(title)
- .font(.system(size: fontSize, weight: .bold))
- .foregroundStyle(.white.opacity(0.92))
- .lineLimit(1)
- }
- if isPreview || app.webURL == nil {
- content
- } else {
- Button(action: openBase) {
- content
- }
- .buttonStyle(.plain)
- }
- }
- 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
- actionChip(action: action)
- }
- }
- }
- private func gridActions(columns: Int, maxActions: Int) -> some View {
- let grid = Array(repeating: GridItem(.flexible(minimum: 0), spacing: 10), count: columns)
- return LazyVGrid(columns: grid, spacing: 10) {
- ForEach(actions.prefix(maxActions)) { action in
- actionTile(action: action)
- }
- }
- }
- private func actionChip(action: WidgetAction) -> some View {
- Button(action: { open(action: action) }) {
- HStack(spacing: 8) {
- Image(systemName: action.systemImage)
- .font(.system(size: 12, weight: .semibold))
- .foregroundStyle(foregroundColor(for: action))
- Text(action.title)
- .font(.system(size: 12, weight: .bold))
- .foregroundStyle(foregroundColor(for: action))
- .lineLimit(1)
- }
- .padding(.horizontal, 12)
- .padding(.vertical, 9)
- .background(
- RoundedRectangle(cornerRadius: 999, style: .continuous)
- .fill(backgroundColor(for: action))
- )
- .overlay(
- RoundedRectangle(cornerRadius: 999, style: .continuous)
- .stroke(Color.white.opacity(isPrimary(action) ? 0.22 : 0.12), lineWidth: 1)
- )
- }
- .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)
- }
- private func actionTile(action: WidgetAction) -> some View {
- Button(action: { open(action: action) }) {
- HStack(spacing: 10) {
- Image(systemName: action.systemImage)
- .font(.system(size: 12, weight: .semibold))
- .foregroundStyle(foregroundColor(for: action))
- Text(action.title)
- .font(.system(size: 12, weight: .bold))
- .foregroundStyle(foregroundColor(for: action))
- .lineLimit(1)
- Spacer(minLength: 0)
- }
- .padding(.horizontal, 12)
- .padding(.vertical, 10)
- .background(
- RoundedRectangle(cornerRadius: 14, style: .continuous)
- .fill(backgroundColor(for: action))
- )
- .overlay(
- RoundedRectangle(cornerRadius: 14, style: .continuous)
- .stroke(Color.white.opacity(isPrimary(action) ? 0.22 : 0.12), lineWidth: 1)
- )
- }
- .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)
- }
- private func openBase() {
- guard !isPreview, let url = app.webURL else { return }
- InAppBrowserWindowManager.shared.open(url: url, title: app.name)
- }
- private func open(action: WidgetAction) {
- guard !isPreview, let base = app.webURL else { return }
- let target = WidgetDeepLinkURL.resolved(base: base, actionPath: action.urlPath, accountSlot: gmailAccountSlot)
- if target.scheme?.lowercased() == "mailto" {
- NSWorkspace.shared.open(target)
- } else {
- InAppBrowserWindowManager.shared.open(url: target, title: app.name)
- }
- }
- private func isPrimary(_ action: WidgetAction) -> Bool {
- actions.first?.id == action.id
- }
- private func backgroundColor(for action: WidgetAction) -> Color {
- isPrimary(action) ? Color.white.opacity(0.38) : Color.black.opacity(0.66)
- }
- private func foregroundColor(for action: WidgetAction) -> Color {
- isPrimary(action) ? Color.white.opacity(0.95) : Color.white.opacity(0.88)
- }
- }
|