| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359 |
- import SwiftUI
- import AppKit
- struct AppTileView: View {
- let app: LauncherApp
- let onTap: () -> Void
- @State private var isHovering = false
- @State private var isPressing = false
- @AppStorage("pinnedTileIDsData") private var pinnedTileIDsData = ""
- @AppStorage("statusBarAppIDsData") private var statusBarAppIDsData = ""
- @AppStorage("widgetAppIDsData") private var widgetAppIDsData = ""
- private let tileCornerRadius: CGFloat = 14
- var body: some View {
- ZStack(alignment: .topTrailing) {
- Button(action: onTap) {
- tileContent
- }
- .buttonStyle(.plain)
- if isPinned {
- Image(systemName: "pin.fill")
- .font(.system(size: 12, weight: .semibold))
- .foregroundStyle(.white.opacity(0.92))
- .frame(width: 26, height: 26)
- .background(
- Circle()
- .fill(Color.black.opacity(isHovering ? 0.35 : 0.2))
- )
- .overlay(
- Circle()
- .stroke(Color.white.opacity(isHovering ? 0.18 : 0.12), lineWidth: 1)
- )
- .shadow(color: .black.opacity(0.22), radius: 10, x: 0, y: 6)
- .padding(8)
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
- .allowsHitTesting(false)
- }
- if !app.isCreateNew {
- Menu {
- Button(isPinned ? "Unpin" : "Pin") { togglePin() }
- Divider()
- Button("Add to StatusBar") { addToStatusBar() }
- Button("Add to desktop") { addToDesktop() }
- Button("Add Widget") { addWidget() }
- } label: {
- Image(systemName: "ellipsis")
- .font(.system(size: 13, weight: .semibold))
- .rotationEffect(.degrees(90))
- .foregroundStyle(.white.opacity(isHovering ? 0.92 : 0.75))
- .frame(width: 26, height: 26)
- .background(
- Circle()
- .fill(Color.black.opacity(isHovering ? 0.35 : 0.2))
- )
- .overlay(
- Circle()
- .stroke(Color.white.opacity(isHovering ? 0.18 : 0.12), lineWidth: 1)
- )
- .shadow(color: .black.opacity(0.25), radius: 10, x: 0, y: 6)
- }
- .menuStyle(.borderlessButton)
- .modifier(HideMenuIndicatorIfAvailable())
- .padding(8)
- .opacity(isHovering ? 1 : 0.0)
- .animation(.easeOut(duration: 0.12), value: isHovering)
- }
- }
- .contextMenu {
- if !app.isCreateNew {
- Button(isPinned ? "Unpin" : "Pin") { togglePin() }
- Divider()
- Button("Add to StatusBar") { addToStatusBar() }
- Button("Add to desktop") { addToDesktop() }
- Button("Add Widget") { addWidget() }
- }
- }
- .onHover { hovering in
- isHovering = hovering
- if !hovering {
- isPressing = false
- }
- }
- }
- private var tileContent: some View {
- VStack(spacing: 9) {
- AppIconView(
- app: app,
- size: 48,
- showAppBackground: false,
- iconPaddingFactor: 0.07
- )
- .shadow(
- color: .black.opacity(isHovering ? 0.24 : 0.06),
- radius: isHovering ? 12 : 5,
- x: 0,
- y: isHovering ? 8 : 2
- )
- .scaleEffect(isPressing ? 0.97 : (isHovering ? 1.02 : 1.0))
- .offset(y: isHovering ? -0.5 : 0)
- Text(app.name)
- .font(.system(size: 13, weight: .medium))
- .foregroundStyle(.white.opacity(isHovering ? 1.0 : 0.95))
- .lineLimit(1)
- .truncationMode(.tail)
- .frame(maxWidth: .infinity)
- .offset(y: isHovering ? -0.25 : 0)
- }
- .padding(.horizontal, 10)
- .padding(.vertical, 12)
- .background(
- RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous)
- .fill(Color.white.opacity(isHovering ? 0.07 : 0.02))
- )
- .overlay(
- RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous)
- .fill(RadialGradient(
- colors: [
- Color.white.opacity(isHovering ? 0.16 : 0.06),
- .clear
- ],
- center: .top,
- startRadius: 12,
- endRadius: 100
- ))
- .allowsHitTesting(false)
- )
- .overlay(
- RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous)
- .stroke(
- isHovering ? Color.white.opacity(0.28) : Color.white.opacity(0.10),
- lineWidth: isHovering ? 1.2 : 1
- )
- )
- .overlay(
- RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous)
- .inset(by: 1)
- .stroke(
- isHovering ? Color.white.opacity(0.16) : Color.white.opacity(0.04),
- lineWidth: 1
- )
- .allowsHitTesting(false)
- )
- .scaleEffect(isPressing ? 0.99 : (isHovering ? 1.02 : 1.0))
- .offset(y: isPressing ? 0 : (isHovering ? -1 : 0))
- .shadow(
- color: .black.opacity(isHovering ? 0.22 : 0),
- radius: isHovering ? 18 : 0,
- x: 0,
- y: isHovering ? 12 : 0
- )
- // Ensure tiles look consistent across the adaptive grid.
- .frame(maxWidth: .infinity, minHeight: 94)
- .contentShape(RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous))
- .animation(.spring(response: 0.24, dampingFraction: 0.88), value: isHovering)
- .animation(.easeOut(duration: 0.12), value: isPressing)
- }
- private var isPinned: Bool {
- pinnedIDs.contains(app.id)
- }
- private var pinnedIDs: Set<UUID> {
- decodeUUIDSet(from: pinnedTileIDsData)
- }
- private func togglePin() {
- var updated = pinnedIDs
- if updated.contains(app.id) {
- updated.remove(app.id)
- } else {
- updated.insert(app.id)
- }
- pinnedTileIDsData = encodeUUIDSet(updated)
- }
- private func addToStatusBar() {
- var updated = decodeUUIDSet(from: statusBarAppIDsData)
- updated.insert(app.id)
- statusBarAppIDsData = encodeUUIDSet(updated)
- showAlert(title: "Added", message: "Added “\(app.name)” to Status Bar shortcuts.")
- }
- private func addWidget() {
- var updated = decodeUUIDSet(from: widgetAppIDsData)
- updated.insert(app.id)
- widgetAppIDsData = encodeUUIDSet(updated)
- showAlert(title: "Added", message: "Added “\(app.name)” to Widgets.")
- }
- private func addToDesktop() {
- guard let url = app.webURL else {
- showAlert(title: "Unavailable", message: "This tile doesn’t have a web link to create a desktop shortcut.")
- return
- }
- guard let desktopURL = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first else {
- showAlert(title: "Error", message: "Couldn’t find your Desktop folder.")
- return
- }
- let fileName = sanitizedFileName(app.name).isEmpty ? "Shortcut" : sanitizedFileName(app.name)
- let targetURL = desktopURL.appendingPathComponent("\(fileName).webloc")
- let payload: [String: Any] = ["URL": url.absoluteString]
- do {
- let data = try PropertyListSerialization.data(fromPropertyList: payload, format: .xml, options: 0)
- try data.write(to: targetURL, options: .atomic)
- showAlert(title: "Created", message: "Desktop shortcut created: \(targetURL.lastPathComponent)")
- } catch {
- showAlert(title: "Error", message: "Couldn’t create the desktop shortcut.")
- }
- }
- private func decodeUUIDSet(from dataString: String) -> Set<UUID> {
- guard !dataString.isEmpty, let data = dataString.data(using: .utf8) else { return [] }
- do {
- let strings = try JSONDecoder().decode([String].self, from: data)
- return Set(strings.compactMap(UUID.init(uuidString:)))
- } catch {
- return []
- }
- }
- private func encodeUUIDSet(_ set: Set<UUID>) -> String {
- do {
- let strings = set.map(\.uuidString).sorted()
- let data = try JSONEncoder().encode(strings)
- return String(decoding: data, as: UTF8.self)
- } catch {
- return ""
- }
- }
- private func sanitizedFileName(_ input: String) -> String {
- let invalid = CharacterSet(charactersIn: "/:\\?%*|\"<>")
- let cleaned = input.components(separatedBy: invalid).joined(separator: " ")
- return cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
- .replacingOccurrences(of: " +", with: " ", options: .regularExpression)
- }
- private func showAlert(title: String, message: String) {
- let alert = NSAlert()
- alert.messageText = title
- alert.informativeText = message
- alert.addButton(withTitle: "OK")
- alert.alertStyle = .informational
- alert.runModal()
- }
- }
- private struct HideMenuIndicatorIfAvailable: ViewModifier {
- func body(content: Content) -> some View {
- if #available(macOS 13.0, *) {
- content.menuIndicator(.hidden)
- } else {
- content
- }
- }
- }
- private struct AppIconView: View {
- let app: LauncherApp
- let size: CGFloat
- var showAppBackground: Bool = true
- var iconPaddingFactor: CGFloat = 0.12
- var body: some View {
- ZStack {
- if app.isCreateNew {
- RoundedRectangle(cornerRadius: 18, style: .continuous)
- .stroke(Color.white.opacity(0.35), lineWidth: 2)
- .background(
- RoundedRectangle(cornerRadius: 18, style: .continuous)
- .fill(Color.white.opacity(0.03))
- )
- Image(systemName: "plus")
- .font(.system(size: size * 0.34, weight: .regular))
- .foregroundStyle(.white.opacity(0.9))
- } else {
- if showAppBackground {
- RoundedRectangle(cornerRadius: 18, style: .continuous)
- .fill(LinearGradient(colors: gradientColors, startPoint: .topLeading, endPoint: .bottomTrailing))
- RoundedRectangle(cornerRadius: 18, style: .continuous)
- .stroke(Color.white.opacity(0.08), lineWidth: 1)
- }
- if let webURL = app.webURL {
- WebSiteFaviconView(
- webURL: webURL,
- size: size,
- assetIconName: app.assetIconName,
- fallbackSymbolName: app.fallbackSymbolName,
- iconPaddingFactor: iconPaddingFactor
- )
- } else if let iconImage = NSImage(named: app.assetIconName) {
- Image(nsImage: iconImage)
- .resizable()
- .scaledToFit()
- .padding(size * iconPaddingFactor)
- } else {
- Image(systemName: app.fallbackSymbolName)
- .font(.system(size: size * 0.32, weight: .semibold))
- .foregroundStyle(.white)
- .padding(size * iconPaddingFactor)
- }
- }
- }
- .frame(width: size, height: size)
- }
- private var gradientColors: [Color] {
- switch app.name {
- case let name where name.contains("Gmail"):
- return [Color.red.opacity(0.85), Color.orange.opacity(0.8)]
- case let name where name.contains("Docs"):
- return [Color.blue.opacity(0.9), Color.indigo.opacity(0.85)]
- case let name where name.contains("Drive"):
- return [Color.green.opacity(0.9), Color.blue.opacity(0.75)]
- case let name where name.contains("YouTube"):
- return [Color.red.opacity(0.95), Color.pink.opacity(0.75)]
- case let name where name.contains("Maps"):
- return [Color.green.opacity(0.85), Color.teal.opacity(0.75)]
- default:
- return [Color.blue.opacity(0.85), Color.cyan.opacity(0.7)]
- }
- }
- }
- struct AppDetailView: View {
- let app: LauncherApp
- @Environment(\.dismiss) private var dismiss
- var body: some View {
- ZStack {
- Color(red: 0.09, green: 0.09, blue: 0.11)
- .ignoresSafeArea()
- VStack(spacing: 14) {
- AppIconView(app: app, size: 76)
- Text(app.name)
- .font(.system(size: 22, weight: .semibold))
- .foregroundStyle(.white)
- Text(app.description)
- .font(.system(size: 14))
- .foregroundStyle(.white.opacity(0.82))
- Button("Close") {
- dismiss()
- }
- .buttonStyle(.borderedProminent)
- }
- .padding(24)
- }
- }
- }
|