AppTileView.swift 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. import SwiftUI
  2. import AppKit
  3. struct AppTileView: View {
  4. let app: LauncherApp
  5. let canAddWidget: Bool
  6. let onTap: () -> Void
  7. let onHide: (() -> Void)?
  8. let onPremiumRequired: (() -> Void)?
  9. @State private var isHovering = false
  10. @AppStorage("pinnedTileIDsData") private var pinnedTileIDsData = ""
  11. @AppStorage("statusBarAppIDsData") private var statusBarAppIDsData = ""
  12. @AppStorage("widgetAppIDsData") private var widgetAppIDsData = ""
  13. private let tileCornerRadius: CGFloat = 16
  14. var body: some View {
  15. ZStack(alignment: .topTrailing) {
  16. Button(action: onTap) {
  17. tileContent
  18. }
  19. .buttonStyle(.plain)
  20. if isPinned {
  21. Image(systemName: "pin.fill")
  22. .font(.system(size: 12, weight: .semibold))
  23. .foregroundStyle(.white.opacity(0.92))
  24. .frame(width: 26, height: 26)
  25. .background(
  26. Circle()
  27. .fill(Color.black.opacity(isHovering ? 0.35 : 0.2))
  28. )
  29. .overlay(
  30. Circle()
  31. .stroke(Color.white.opacity(isHovering ? 0.18 : 0.12), lineWidth: 1)
  32. )
  33. .shadow(color: .black.opacity(0.22), radius: 10, x: 0, y: 6)
  34. .padding(8)
  35. .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
  36. .allowsHitTesting(false)
  37. }
  38. if !app.isCreateNew {
  39. Menu {
  40. Button(isPinned ? "Unpin" : "Pin") { togglePin() }
  41. Divider()
  42. Button("Add to StatusBar") { addToStatusBar() }
  43. Button("Add to desktop") { addToDesktop() }
  44. Button("Add Widget") { addWidget() }
  45. Divider()
  46. Button("Remove from View") { onHide?() }
  47. } label: {
  48. Image(systemName: "ellipsis")
  49. .font(.system(size: 13, weight: .semibold))
  50. .rotationEffect(.degrees(90))
  51. .foregroundStyle(.white.opacity(isHovering ? 0.92 : 0.75))
  52. .frame(width: 26, height: 26)
  53. .background(
  54. Circle()
  55. .fill(Color.black.opacity(isHovering ? 0.35 : 0.2))
  56. )
  57. .overlay(
  58. Circle()
  59. .stroke(Color.white.opacity(isHovering ? 0.18 : 0.12), lineWidth: 1)
  60. )
  61. .shadow(color: .black.opacity(0.25), radius: 10, x: 0, y: 6)
  62. }
  63. .menuStyle(.borderlessButton)
  64. .modifier(HideMenuIndicatorIfAvailable())
  65. .padding(8)
  66. .opacity(isHovering ? 1 : 0.0)
  67. .animation(.easeOut(duration: 0.12), value: isHovering)
  68. }
  69. }
  70. .contextMenu {
  71. if !app.isCreateNew {
  72. Button(isPinned ? "Unpin" : "Pin") { togglePin() }
  73. Divider()
  74. Button("Add to StatusBar") { addToStatusBar() }
  75. Button("Add to desktop") { addToDesktop() }
  76. Button("Add Widget") { addWidget() }
  77. Divider()
  78. Button("Remove from View") { onHide?() }
  79. }
  80. }
  81. .onHover { hovering in
  82. isHovering = hovering
  83. }
  84. }
  85. private var tileContent: some View {
  86. VStack(spacing: 8) {
  87. AppIconView(
  88. app: app,
  89. size: 48,
  90. showAppBackground: false,
  91. iconPaddingFactor: 0.07
  92. )
  93. // Subtle depth like an app-store tile.
  94. .shadow(color: .black.opacity(0.14), radius: 8, x: 0, y: 4)
  95. Text(app.name)
  96. .font(.system(size: 14, weight: .semibold))
  97. .foregroundStyle(.white.opacity(0.95))
  98. .lineLimit(1)
  99. .truncationMode(.tail)
  100. .frame(maxWidth: .infinity)
  101. }
  102. .padding(.horizontal, 10)
  103. .padding(.vertical, 12)
  104. .background(
  105. RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous)
  106. .fill(Color.white.opacity(isHovering ? 0.06 : 0.025))
  107. )
  108. .overlay(
  109. RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous)
  110. .stroke(Color.white.opacity(isHovering ? 0.18 : 0.08), lineWidth: 1)
  111. )
  112. .shadow(
  113. color: .black.opacity(isHovering ? 0.18 : 0.10),
  114. radius: isHovering ? 16 : 10,
  115. x: 0,
  116. y: isHovering ? 10 : 6
  117. )
  118. // Ensure tiles look consistent across the adaptive grid.
  119. .frame(maxWidth: .infinity, minHeight: 94)
  120. .contentShape(RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous))
  121. .animation(.easeOut(duration: 0.12), value: isHovering)
  122. }
  123. private var isPinned: Bool {
  124. pinnedIDs.contains(app.id)
  125. }
  126. private var pinnedIDs: Set<UUID> {
  127. decodeUUIDSet(from: pinnedTileIDsData)
  128. }
  129. private func togglePin() {
  130. var updated = pinnedIDs
  131. if updated.contains(app.id) {
  132. updated.remove(app.id)
  133. } else {
  134. updated.insert(app.id)
  135. }
  136. pinnedTileIDsData = encodeUUIDSet(updated)
  137. }
  138. private func addToStatusBar() {
  139. var updated = decodeUUIDSet(from: statusBarAppIDsData)
  140. updated.insert(app.id)
  141. statusBarAppIDsData = encodeUUIDSet(updated)
  142. showAlert(title: "Added", message: "Added “\(app.name)” to Status Bar shortcuts.")
  143. }
  144. private func addWidget() {
  145. if canAddWidget {
  146. var updated = decodeUUIDSet(from: widgetAppIDsData)
  147. updated.insert(app.id)
  148. widgetAppIDsData = encodeUUIDSet(updated)
  149. }
  150. NotificationCenter.default.post(
  151. name: .openWidgetsPage,
  152. object: nil,
  153. userInfo: ["appID": app.id]
  154. )
  155. }
  156. private func addToDesktop() {
  157. let widgetVariants = WidgetTemplates.variants(for: app)
  158. guard let variant = widgetVariants.first else {
  159. showAlert(title: "Unavailable", message: "No desktop widget is available for this app.")
  160. return
  161. }
  162. var updated = decodeUUIDSet(from: widgetAppIDsData)
  163. updated.insert(app.id)
  164. widgetAppIDsData = encodeUUIDSet(updated)
  165. let instance = WidgetInstance(
  166. appID: app.id,
  167. variantID: variant.id,
  168. size: variant.size,
  169. origin: nil
  170. )
  171. DesktopWidgetWindowManager.shared.show(instance: instance, appProvider: { id in
  172. id == app.id ? app : nil
  173. })
  174. }
  175. private func decodeUUIDSet(from dataString: String) -> Set<UUID> {
  176. guard !dataString.isEmpty, let data = dataString.data(using: .utf8) else { return [] }
  177. do {
  178. let strings = try JSONDecoder().decode([String].self, from: data)
  179. return Set(strings.compactMap(UUID.init(uuidString:)))
  180. } catch {
  181. return []
  182. }
  183. }
  184. private func encodeUUIDSet(_ set: Set<UUID>) -> String {
  185. do {
  186. let strings = set.map(\.uuidString).sorted()
  187. let data = try JSONEncoder().encode(strings)
  188. return String(decoding: data, as: UTF8.self)
  189. } catch {
  190. return ""
  191. }
  192. }
  193. private func showAlert(title: String, message: String) {
  194. let alert = NSAlert()
  195. alert.messageText = title
  196. alert.informativeText = message
  197. alert.addButton(withTitle: "OK")
  198. alert.alertStyle = .informational
  199. alert.runModal()
  200. }
  201. }
  202. extension AppTileView {
  203. init(app: LauncherApp, onTap: @escaping () -> Void) {
  204. self.app = app
  205. self.canAddWidget = false
  206. self.onTap = onTap
  207. self.onHide = nil
  208. self.onPremiumRequired = nil
  209. }
  210. init(app: LauncherApp, onTap: @escaping () -> Void, onHide: @escaping () -> Void) {
  211. self.app = app
  212. self.canAddWidget = false
  213. self.onTap = onTap
  214. self.onHide = onHide
  215. self.onPremiumRequired = nil
  216. }
  217. init(
  218. app: LauncherApp,
  219. canAddWidget: Bool,
  220. onTap: @escaping () -> Void,
  221. onHide: @escaping () -> Void,
  222. onPremiumRequired: @escaping () -> Void
  223. ) {
  224. self.app = app
  225. self.canAddWidget = canAddWidget
  226. self.onTap = onTap
  227. self.onHide = onHide
  228. self.onPremiumRequired = onPremiumRequired
  229. }
  230. }
  231. private struct HideMenuIndicatorIfAvailable: ViewModifier {
  232. func body(content: Content) -> some View {
  233. if #available(macOS 13.0, *) {
  234. content.menuIndicator(.hidden)
  235. } else {
  236. content
  237. }
  238. }
  239. }
  240. struct AppDetailView: View {
  241. let app: LauncherApp
  242. @Environment(\.dismiss) private var dismiss
  243. var body: some View {
  244. ZStack {
  245. Color(red: 0.09, green: 0.09, blue: 0.11)
  246. .ignoresSafeArea()
  247. VStack(spacing: 14) {
  248. AppIconView(app: app, size: 76)
  249. Text(app.name)
  250. .font(.system(size: 22, weight: .semibold))
  251. .foregroundStyle(.white)
  252. Text(app.description)
  253. .font(.system(size: 14))
  254. .foregroundStyle(.white.opacity(0.82))
  255. Button("Close") {
  256. dismiss()
  257. }
  258. .buttonStyle(.borderedProminent)
  259. }
  260. .padding(24)
  261. }
  262. }
  263. }