AppTileView.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. import SwiftUI
  2. import AppKit
  3. struct AppTileView: View {
  4. let app: LauncherApp
  5. let onTap: () -> Void
  6. let onHide: (() -> Void)?
  7. @ObservedObject private var premiumStore = PremiumStore.shared
  8. @Environment(\.colorScheme) private var colorScheme
  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(pinForegroundColor)
  24. .frame(width: 26, height: 26)
  25. .background(
  26. Circle()
  27. .fill(controlBackgroundColor)
  28. )
  29. .overlay(
  30. Circle()
  31. .stroke(controlStrokeColor, lineWidth: 1)
  32. )
  33. .shadow(color: shadowColor, 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. if isInStatusBar {
  43. Button("Remove from StatusBar") { removeFromStatusBar() }
  44. } else {
  45. Button("Add to StatusBar") { addToStatusBar() }
  46. .disabled(!premiumStore.isPremiumUnlocked)
  47. }
  48. Button("Add to desktop") { addToDesktop() }
  49. .disabled(!premiumStore.isPremiumUnlocked)
  50. Button("Add Widget") { addWidget() }
  51. Divider()
  52. Button("Remove from View") { onHide?() }
  53. } label: {
  54. Image(systemName: "ellipsis")
  55. .font(.system(size: 13, weight: .semibold))
  56. .rotationEffect(.degrees(90))
  57. .foregroundStyle(controlForegroundColor)
  58. .frame(width: 26, height: 26)
  59. .background(
  60. Circle()
  61. .fill(controlBackgroundColor)
  62. )
  63. .overlay(
  64. Circle()
  65. .stroke(controlStrokeColor, lineWidth: 1)
  66. )
  67. .shadow(color: shadowColor, radius: 10, x: 0, y: 6)
  68. }
  69. .menuStyle(.borderlessButton)
  70. .modifier(HideMenuIndicatorIfAvailable())
  71. .padding(8)
  72. .opacity(isHovering ? 1 : 0.0)
  73. .animation(.easeOut(duration: 0.12), value: isHovering)
  74. }
  75. }
  76. .contextMenu {
  77. if !app.isCreateNew {
  78. Button(isPinned ? "Unpin" : "Pin") { togglePin() }
  79. Divider()
  80. if isInStatusBar {
  81. Button("Remove from StatusBar") { removeFromStatusBar() }
  82. } else {
  83. Button("Add to StatusBar") { addToStatusBar() }
  84. .disabled(!premiumStore.isPremiumUnlocked)
  85. }
  86. Button("Add to desktop") { addToDesktop() }
  87. .disabled(!premiumStore.isPremiumUnlocked)
  88. Button("Add Widget") { addWidget() }
  89. Divider()
  90. Button("Remove from View") { onHide?() }
  91. }
  92. }
  93. .onHover { hovering in
  94. isHovering = hovering
  95. }
  96. }
  97. private var tileContent: some View {
  98. VStack(spacing: 8) {
  99. AppIconView(
  100. app: app,
  101. size: 48,
  102. showAppBackground: false,
  103. iconPaddingFactor: 0.07
  104. )
  105. // Subtle depth like an app-store tile.
  106. .shadow(color: .black.opacity(0.14), radius: 8, x: 0, y: 4)
  107. Text(app.name)
  108. .font(.system(size: 14, weight: .semibold))
  109. .foregroundStyle(titleColor)
  110. .lineLimit(1)
  111. .truncationMode(.tail)
  112. .frame(maxWidth: .infinity)
  113. }
  114. .padding(.horizontal, 10)
  115. .padding(.vertical, 12)
  116. .background(
  117. RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous)
  118. .fill(tileFillColor)
  119. )
  120. .overlay(
  121. RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous)
  122. .stroke(tileStrokeColor, lineWidth: 1)
  123. )
  124. .shadow(
  125. color: shadowColor,
  126. radius: isHovering ? 18 : 10,
  127. x: 0,
  128. y: isHovering ? 12 : 6
  129. )
  130. .scaleEffect(isHovering ? 1.05 : 1.0)
  131. // Ensure tiles look consistent across the adaptive grid.
  132. .frame(maxWidth: .infinity, minHeight: 94)
  133. .contentShape(RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous))
  134. .animation(.spring(response: 0.22, dampingFraction: 0.82), value: isHovering)
  135. }
  136. private var isPinned: Bool {
  137. pinnedIDs.contains(app.id)
  138. }
  139. private var isInStatusBar: Bool {
  140. statusBarIDs.contains(app.id)
  141. }
  142. private var pinnedIDs: Set<UUID> {
  143. decodeUUIDSet(from: pinnedTileIDsData)
  144. }
  145. private var statusBarIDs: Set<UUID> {
  146. decodeUUIDSet(from: statusBarAppIDsData)
  147. }
  148. private func togglePin() {
  149. var updated = pinnedIDs
  150. if updated.contains(app.id) {
  151. updated.remove(app.id)
  152. } else {
  153. updated.insert(app.id)
  154. }
  155. pinnedTileIDsData = encodeUUIDSet(updated)
  156. }
  157. private func addToStatusBar() {
  158. var updated = decodeUUIDSet(from: statusBarAppIDsData)
  159. guard updated.insert(app.id).inserted else {
  160. showAlert(title: "Already added", message: "“\(app.name)” is already in the menu bar.")
  161. return
  162. }
  163. statusBarAppIDsData = encodeUUIDSet(updated)
  164. LauncherApp.notifyStatusBarShortcutIdentifiersChanged()
  165. showAlert(
  166. title: "Added to Status Bar",
  167. message: "“\(app.name)” now has its own icon in the menu bar. Click it to open, or right‑click to remove."
  168. )
  169. }
  170. private func removeFromStatusBar() {
  171. var updated = decodeUUIDSet(from: statusBarAppIDsData)
  172. guard updated.remove(app.id) != nil else { return }
  173. statusBarAppIDsData = encodeUUIDSet(updated)
  174. LauncherApp.notifyStatusBarShortcutIdentifiersChanged()
  175. }
  176. private func addWidget() {
  177. var updated = decodeUUIDSet(from: widgetAppIDsData)
  178. updated.insert(app.id)
  179. widgetAppIDsData = encodeUUIDSet(updated)
  180. NotificationCenter.default.post(
  181. name: .openWidgetsPage,
  182. object: nil,
  183. userInfo: ["appID": app.id]
  184. )
  185. }
  186. private func addToDesktop() {
  187. guard let webURL = app.webURL else {
  188. showAlert(title: "Unavailable", message: "Desktop shortcuts need a web address.")
  189. return
  190. }
  191. DesktopWeblocShortcutPresenter.beginSavePanel(forAppNamed: app.name, webURL: webURL)
  192. }
  193. private func decodeUUIDSet(from dataString: String) -> Set<UUID> {
  194. guard !dataString.isEmpty, let data = dataString.data(using: .utf8) else { return [] }
  195. do {
  196. let strings = try JSONDecoder().decode([String].self, from: data)
  197. return Set(strings.compactMap(UUID.init(uuidString:)))
  198. } catch {
  199. return []
  200. }
  201. }
  202. private func encodeUUIDSet(_ set: Set<UUID>) -> String {
  203. do {
  204. let strings = set.map(\.uuidString).sorted()
  205. let data = try JSONEncoder().encode(strings)
  206. return String(decoding: data, as: UTF8.self)
  207. } catch {
  208. return ""
  209. }
  210. }
  211. private func showAlert(title: String, message: String) {
  212. let alert = NSAlert()
  213. alert.messageText = title
  214. alert.informativeText = message
  215. alert.addButton(withTitle: "OK")
  216. alert.alertStyle = .informational
  217. alert.runModal()
  218. }
  219. private var titleColor: Color {
  220. colorScheme == .dark ? .white.opacity(0.95) : .primary.opacity(0.94)
  221. }
  222. private var pinForegroundColor: Color {
  223. colorScheme == .dark ? .white.opacity(0.92) : .primary.opacity(0.88)
  224. }
  225. private var controlForegroundColor: Color {
  226. colorScheme == .dark
  227. ? .white.opacity(isHovering ? 0.92 : 0.75)
  228. : .primary.opacity(isHovering ? 0.90 : 0.68)
  229. }
  230. private var controlBackgroundColor: Color {
  231. colorScheme == .dark
  232. ? .black.opacity(isHovering ? 0.35 : 0.2)
  233. : .black.opacity(isHovering ? 0.08 : 0.04)
  234. }
  235. private var controlStrokeColor: Color {
  236. colorScheme == .dark
  237. ? .white.opacity(isHovering ? 0.18 : 0.12)
  238. : .black.opacity(isHovering ? 0.12 : 0.07)
  239. }
  240. private var tileFillColor: Color {
  241. colorScheme == .dark
  242. ? .white.opacity(isHovering ? 0.06 : 0.025)
  243. : .white.opacity(isHovering ? 0.72 : 0.52)
  244. }
  245. private var tileStrokeColor: Color {
  246. colorScheme == .dark
  247. ? .white.opacity(isHovering ? 0.18 : 0.08)
  248. : .black.opacity(isHovering ? 0.10 : 0.05)
  249. }
  250. private var shadowColor: Color {
  251. .black.opacity(colorScheme == .dark ? (isHovering ? 0.20 : 0.10) : (isHovering ? 0.08 : 0.03))
  252. }
  253. }
  254. extension AppTileView {
  255. init(app: LauncherApp, onTap: @escaping () -> Void) {
  256. self.app = app
  257. self.onTap = onTap
  258. self.onHide = nil
  259. }
  260. init(app: LauncherApp, onTap: @escaping () -> Void, onHide: @escaping () -> Void) {
  261. self.app = app
  262. self.onTap = onTap
  263. self.onHide = onHide
  264. }
  265. }
  266. private struct HideMenuIndicatorIfAvailable: ViewModifier {
  267. func body(content: Content) -> some View {
  268. if #available(macOS 13.0, *) {
  269. content.menuIndicator(.hidden)
  270. } else {
  271. content
  272. }
  273. }
  274. }
  275. struct AppDetailView: View {
  276. let app: LauncherApp
  277. @Environment(\.dismiss) private var dismiss
  278. @Environment(\.colorScheme) private var colorScheme
  279. var body: some View {
  280. ZStack {
  281. Group {
  282. if colorScheme == .dark {
  283. Color(red: 0.09, green: 0.09, blue: 0.11)
  284. } else {
  285. Color(nsColor: .windowBackgroundColor)
  286. }
  287. }
  288. .ignoresSafeArea()
  289. VStack(spacing: 14) {
  290. AppIconView(app: app, size: 76)
  291. Text(app.name)
  292. .font(.system(size: 22, weight: .semibold))
  293. .foregroundStyle(.primary)
  294. Text(app.description)
  295. .font(.system(size: 14))
  296. .foregroundStyle(.secondary)
  297. Button("Close") {
  298. dismiss()
  299. }
  300. .buttonStyle(.borderedProminent)
  301. }
  302. .padding(24)
  303. }
  304. }
  305. }