AppTileView.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  1. import SwiftUI
  2. import AppKit
  3. struct AppTileView: View {
  4. let app: LauncherApp
  5. let onTap: () -> Void
  6. @State private var isHovering = false
  7. @State private var isPressing = false
  8. @AppStorage("pinnedTileIDsData") private var pinnedTileIDsData = ""
  9. @AppStorage("statusBarAppIDsData") private var statusBarAppIDsData = ""
  10. @AppStorage("widgetAppIDsData") private var widgetAppIDsData = ""
  11. private let tileCornerRadius: CGFloat = 14
  12. var body: some View {
  13. ZStack(alignment: .topTrailing) {
  14. Button(action: onTap) {
  15. tileContent
  16. }
  17. .buttonStyle(.plain)
  18. if isPinned {
  19. Image(systemName: "pin.fill")
  20. .font(.system(size: 12, weight: .semibold))
  21. .foregroundStyle(.white.opacity(0.92))
  22. .frame(width: 26, height: 26)
  23. .background(
  24. Circle()
  25. .fill(Color.black.opacity(isHovering ? 0.35 : 0.2))
  26. )
  27. .overlay(
  28. Circle()
  29. .stroke(Color.white.opacity(isHovering ? 0.18 : 0.12), lineWidth: 1)
  30. )
  31. .shadow(color: .black.opacity(0.22), radius: 10, x: 0, y: 6)
  32. .padding(8)
  33. .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
  34. .allowsHitTesting(false)
  35. }
  36. if !app.isCreateNew {
  37. Menu {
  38. Button(isPinned ? "Unpin" : "Pin") { togglePin() }
  39. Divider()
  40. Button("Add to StatusBar") { addToStatusBar() }
  41. Button("Add to desktop") { addToDesktop() }
  42. Button("Add Widget") { addWidget() }
  43. } label: {
  44. Image(systemName: "ellipsis")
  45. .font(.system(size: 13, weight: .semibold))
  46. .rotationEffect(.degrees(90))
  47. .foregroundStyle(.white.opacity(isHovering ? 0.92 : 0.75))
  48. .frame(width: 26, height: 26)
  49. .background(
  50. Circle()
  51. .fill(Color.black.opacity(isHovering ? 0.35 : 0.2))
  52. )
  53. .overlay(
  54. Circle()
  55. .stroke(Color.white.opacity(isHovering ? 0.18 : 0.12), lineWidth: 1)
  56. )
  57. .shadow(color: .black.opacity(0.25), radius: 10, x: 0, y: 6)
  58. }
  59. .menuStyle(.borderlessButton)
  60. .modifier(HideMenuIndicatorIfAvailable())
  61. .padding(8)
  62. .opacity(isHovering ? 1 : 0.0)
  63. .animation(.easeOut(duration: 0.12), value: isHovering)
  64. }
  65. }
  66. .contextMenu {
  67. if !app.isCreateNew {
  68. Button(isPinned ? "Unpin" : "Pin") { togglePin() }
  69. Divider()
  70. Button("Add to StatusBar") { addToStatusBar() }
  71. Button("Add to desktop") { addToDesktop() }
  72. Button("Add Widget") { addWidget() }
  73. }
  74. }
  75. .onHover { hovering in
  76. isHovering = hovering
  77. if !hovering {
  78. isPressing = false
  79. }
  80. }
  81. }
  82. private var tileContent: some View {
  83. VStack(spacing: 9) {
  84. AppIconView(
  85. app: app,
  86. size: 48,
  87. showAppBackground: false,
  88. iconPaddingFactor: 0.07
  89. )
  90. .shadow(
  91. color: .black.opacity(isHovering ? 0.24 : 0.06),
  92. radius: isHovering ? 12 : 5,
  93. x: 0,
  94. y: isHovering ? 8 : 2
  95. )
  96. .scaleEffect(isPressing ? 0.97 : (isHovering ? 1.02 : 1.0))
  97. .offset(y: isHovering ? -0.5 : 0)
  98. Text(app.name)
  99. .font(.system(size: 13, weight: .medium))
  100. .foregroundStyle(.white.opacity(isHovering ? 1.0 : 0.95))
  101. .lineLimit(1)
  102. .truncationMode(.tail)
  103. .frame(maxWidth: .infinity)
  104. .offset(y: isHovering ? -0.25 : 0)
  105. }
  106. .padding(.horizontal, 10)
  107. .padding(.vertical, 12)
  108. .background(
  109. RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous)
  110. .fill(Color.white.opacity(isHovering ? 0.07 : 0.02))
  111. )
  112. .overlay(
  113. RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous)
  114. .fill(RadialGradient(
  115. colors: [
  116. Color.white.opacity(isHovering ? 0.16 : 0.06),
  117. .clear
  118. ],
  119. center: .top,
  120. startRadius: 12,
  121. endRadius: 100
  122. ))
  123. .allowsHitTesting(false)
  124. )
  125. .overlay(
  126. RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous)
  127. .stroke(
  128. isHovering ? Color.white.opacity(0.28) : Color.white.opacity(0.10),
  129. lineWidth: isHovering ? 1.2 : 1
  130. )
  131. )
  132. .overlay(
  133. RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous)
  134. .inset(by: 1)
  135. .stroke(
  136. isHovering ? Color.white.opacity(0.16) : Color.white.opacity(0.04),
  137. lineWidth: 1
  138. )
  139. .allowsHitTesting(false)
  140. )
  141. .scaleEffect(isPressing ? 0.99 : (isHovering ? 1.02 : 1.0))
  142. .offset(y: isPressing ? 0 : (isHovering ? -1 : 0))
  143. .shadow(
  144. color: .black.opacity(isHovering ? 0.22 : 0),
  145. radius: isHovering ? 18 : 0,
  146. x: 0,
  147. y: isHovering ? 12 : 0
  148. )
  149. // Ensure tiles look consistent across the adaptive grid.
  150. .frame(maxWidth: .infinity, minHeight: 94)
  151. .contentShape(RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous))
  152. .animation(.spring(response: 0.24, dampingFraction: 0.88), value: isHovering)
  153. .animation(.easeOut(duration: 0.12), value: isPressing)
  154. }
  155. private var isPinned: Bool {
  156. pinnedIDs.contains(app.id)
  157. }
  158. private var pinnedIDs: Set<UUID> {
  159. decodeUUIDSet(from: pinnedTileIDsData)
  160. }
  161. private func togglePin() {
  162. var updated = pinnedIDs
  163. if updated.contains(app.id) {
  164. updated.remove(app.id)
  165. } else {
  166. updated.insert(app.id)
  167. }
  168. pinnedTileIDsData = encodeUUIDSet(updated)
  169. }
  170. private func addToStatusBar() {
  171. var updated = decodeUUIDSet(from: statusBarAppIDsData)
  172. updated.insert(app.id)
  173. statusBarAppIDsData = encodeUUIDSet(updated)
  174. showAlert(title: "Added", message: "Added “\(app.name)” to Status Bar shortcuts.")
  175. }
  176. private func addWidget() {
  177. var updated = decodeUUIDSet(from: widgetAppIDsData)
  178. updated.insert(app.id)
  179. widgetAppIDsData = encodeUUIDSet(updated)
  180. showAlert(title: "Added", message: "Added “\(app.name)” to Widgets.")
  181. }
  182. private func addToDesktop() {
  183. guard let url = app.webURL else {
  184. showAlert(title: "Unavailable", message: "This tile doesn’t have a web link to create a desktop shortcut.")
  185. return
  186. }
  187. guard let desktopURL = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first else {
  188. showAlert(title: "Error", message: "Couldn’t find your Desktop folder.")
  189. return
  190. }
  191. let fileName = sanitizedFileName(app.name).isEmpty ? "Shortcut" : sanitizedFileName(app.name)
  192. let targetURL = desktopURL.appendingPathComponent("\(fileName).webloc")
  193. let payload: [String: Any] = ["URL": url.absoluteString]
  194. do {
  195. let data = try PropertyListSerialization.data(fromPropertyList: payload, format: .xml, options: 0)
  196. try data.write(to: targetURL, options: .atomic)
  197. showAlert(title: "Created", message: "Desktop shortcut created: \(targetURL.lastPathComponent)")
  198. } catch {
  199. showAlert(title: "Error", message: "Couldn’t create the desktop shortcut.")
  200. }
  201. }
  202. private func decodeUUIDSet(from dataString: String) -> Set<UUID> {
  203. guard !dataString.isEmpty, let data = dataString.data(using: .utf8) else { return [] }
  204. do {
  205. let strings = try JSONDecoder().decode([String].self, from: data)
  206. return Set(strings.compactMap(UUID.init(uuidString:)))
  207. } catch {
  208. return []
  209. }
  210. }
  211. private func encodeUUIDSet(_ set: Set<UUID>) -> String {
  212. do {
  213. let strings = set.map(\.uuidString).sorted()
  214. let data = try JSONEncoder().encode(strings)
  215. return String(decoding: data, as: UTF8.self)
  216. } catch {
  217. return ""
  218. }
  219. }
  220. private func sanitizedFileName(_ input: String) -> String {
  221. let invalid = CharacterSet(charactersIn: "/:\\?%*|\"<>")
  222. let cleaned = input.components(separatedBy: invalid).joined(separator: " ")
  223. return cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
  224. .replacingOccurrences(of: " +", with: " ", options: .regularExpression)
  225. }
  226. private func showAlert(title: String, message: String) {
  227. let alert = NSAlert()
  228. alert.messageText = title
  229. alert.informativeText = message
  230. alert.addButton(withTitle: "OK")
  231. alert.alertStyle = .informational
  232. alert.runModal()
  233. }
  234. }
  235. private struct HideMenuIndicatorIfAvailable: ViewModifier {
  236. func body(content: Content) -> some View {
  237. if #available(macOS 13.0, *) {
  238. content.menuIndicator(.hidden)
  239. } else {
  240. content
  241. }
  242. }
  243. }
  244. private struct AppIconView: View {
  245. let app: LauncherApp
  246. let size: CGFloat
  247. var showAppBackground: Bool = true
  248. var iconPaddingFactor: CGFloat = 0.12
  249. var body: some View {
  250. ZStack {
  251. if app.isCreateNew {
  252. RoundedRectangle(cornerRadius: 18, style: .continuous)
  253. .stroke(Color.white.opacity(0.35), lineWidth: 2)
  254. .background(
  255. RoundedRectangle(cornerRadius: 18, style: .continuous)
  256. .fill(Color.white.opacity(0.03))
  257. )
  258. Image(systemName: "plus")
  259. .font(.system(size: size * 0.34, weight: .regular))
  260. .foregroundStyle(.white.opacity(0.9))
  261. } else {
  262. if showAppBackground {
  263. RoundedRectangle(cornerRadius: 18, style: .continuous)
  264. .fill(LinearGradient(colors: gradientColors, startPoint: .topLeading, endPoint: .bottomTrailing))
  265. RoundedRectangle(cornerRadius: 18, style: .continuous)
  266. .stroke(Color.white.opacity(0.08), lineWidth: 1)
  267. }
  268. if let webURL = app.webURL {
  269. WebSiteFaviconView(
  270. webURL: webURL,
  271. size: size,
  272. assetIconName: app.assetIconName,
  273. fallbackSymbolName: app.fallbackSymbolName,
  274. iconPaddingFactor: iconPaddingFactor
  275. )
  276. } else if let iconImage = NSImage(named: app.assetIconName) {
  277. Image(nsImage: iconImage)
  278. .resizable()
  279. .scaledToFit()
  280. .padding(size * iconPaddingFactor)
  281. } else {
  282. Image(systemName: app.fallbackSymbolName)
  283. .font(.system(size: size * 0.32, weight: .semibold))
  284. .foregroundStyle(.white)
  285. .padding(size * iconPaddingFactor)
  286. }
  287. }
  288. }
  289. .frame(width: size, height: size)
  290. }
  291. private var gradientColors: [Color] {
  292. switch app.name {
  293. case let name where name.contains("Gmail"):
  294. return [Color.red.opacity(0.85), Color.orange.opacity(0.8)]
  295. case let name where name.contains("Docs"):
  296. return [Color.blue.opacity(0.9), Color.indigo.opacity(0.85)]
  297. case let name where name.contains("Drive"):
  298. return [Color.green.opacity(0.9), Color.blue.opacity(0.75)]
  299. case let name where name.contains("YouTube"):
  300. return [Color.red.opacity(0.95), Color.pink.opacity(0.75)]
  301. case let name where name.contains("Maps"):
  302. return [Color.green.opacity(0.85), Color.teal.opacity(0.75)]
  303. default:
  304. return [Color.blue.opacity(0.85), Color.cyan.opacity(0.7)]
  305. }
  306. }
  307. }
  308. struct AppDetailView: View {
  309. let app: LauncherApp
  310. @Environment(\.dismiss) private var dismiss
  311. var body: some View {
  312. ZStack {
  313. Color(red: 0.09, green: 0.09, blue: 0.11)
  314. .ignoresSafeArea()
  315. VStack(spacing: 14) {
  316. AppIconView(app: app, size: 76)
  317. Text(app.name)
  318. .font(.system(size: 22, weight: .semibold))
  319. .foregroundStyle(.white)
  320. Text(app.description)
  321. .font(.system(size: 14))
  322. .foregroundStyle(.white.opacity(0.82))
  323. Button("Close") {
  324. dismiss()
  325. }
  326. .buttonStyle(.borderedProminent)
  327. }
  328. .padding(24)
  329. }
  330. }
  331. }