|
@@ -7,87 +7,76 @@ struct AppTileView: View {
|
|
|
|
|
|
|
|
@State private var isHovering = false
|
|
@State private var isHovering = false
|
|
|
@State private var isPressing = 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
|
|
private let tileCornerRadius: CGFloat = 14
|
|
|
|
|
|
|
|
var body: some View {
|
|
var body: some View {
|
|
|
- Button(action: onTap) {
|
|
|
|
|
- VStack(spacing: 11) {
|
|
|
|
|
- AppIconView(app: app, size: 64)
|
|
|
|
|
- .shadow(
|
|
|
|
|
- color: .black.opacity(isHovering ? 0.34 : 0.2),
|
|
|
|
|
- radius: isHovering ? 14 : 8,
|
|
|
|
|
- x: 0,
|
|
|
|
|
- y: isHovering ? 10 : 5
|
|
|
|
|
- )
|
|
|
|
|
- .scaleEffect(isPressing ? 0.96 : 1.0)
|
|
|
|
|
- .offset(y: isHovering ? -1 : 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.5 : 0)
|
|
|
|
|
|
|
+ ZStack(alignment: .topTrailing) {
|
|
|
|
|
+ Button(action: onTap) {
|
|
|
|
|
+ tileContent
|
|
|
}
|
|
}
|
|
|
- .padding(.horizontal, 7)
|
|
|
|
|
- .padding(.vertical, 10)
|
|
|
|
|
- .background(
|
|
|
|
|
- RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous)
|
|
|
|
|
- .fill(
|
|
|
|
|
- LinearGradient(
|
|
|
|
|
- colors: isHovering
|
|
|
|
|
- ? [Color.white.opacity(0.16), Color.white.opacity(0.06)]
|
|
|
|
|
- : [Color.white.opacity(0.03), Color.white.opacity(0.015)],
|
|
|
|
|
- startPoint: .topLeading,
|
|
|
|
|
- endPoint: .bottomTrailing
|
|
|
|
|
- )
|
|
|
|
|
- )
|
|
|
|
|
- )
|
|
|
|
|
- .overlay(
|
|
|
|
|
- // Soft spotlight makes hover feel richer without harsh contrast.
|
|
|
|
|
- RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous)
|
|
|
|
|
- .fill(
|
|
|
|
|
- RadialGradient(
|
|
|
|
|
- colors: isHovering
|
|
|
|
|
- ? [Color.white.opacity(0.16), Color.clear]
|
|
|
|
|
- : [Color.clear, Color.clear],
|
|
|
|
|
- center: .top,
|
|
|
|
|
- startRadius: 0,
|
|
|
|
|
- endRadius: 80
|
|
|
|
|
- )
|
|
|
|
|
- )
|
|
|
|
|
- .allowsHitTesting(false)
|
|
|
|
|
- )
|
|
|
|
|
- .overlay(
|
|
|
|
|
- RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous)
|
|
|
|
|
- .stroke(
|
|
|
|
|
- isHovering ? Color.white.opacity(0.3) : Color.white.opacity(0.06),
|
|
|
|
|
- lineWidth: isHovering ? 1.3 : 1
|
|
|
|
|
|
|
+ .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(
|
|
|
|
|
- RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous)
|
|
|
|
|
- .inset(by: 1)
|
|
|
|
|
- .stroke(
|
|
|
|
|
- isHovering ? Color.white.opacity(0.12) : Color.clear,
|
|
|
|
|
- lineWidth: 1
|
|
|
|
|
|
|
+ .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)
|
|
.allowsHitTesting(false)
|
|
|
- )
|
|
|
|
|
- .scaleEffect(isPressing ? 0.985 : (isHovering ? 1.02 : 1.0))
|
|
|
|
|
- .offset(y: isPressing ? 0 : (isHovering ? -2 : 0))
|
|
|
|
|
- .shadow(
|
|
|
|
|
- color: .black.opacity(isHovering ? 0.22 : 0),
|
|
|
|
|
- radius: isHovering ? 18 : 0,
|
|
|
|
|
- x: 0,
|
|
|
|
|
- y: isHovering ? 12 : 0
|
|
|
|
|
- )
|
|
|
|
|
- .contentShape(RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous))
|
|
|
|
|
- .animation(.spring(response: 0.24, dampingFraction: 0.88), value: isHovering)
|
|
|
|
|
- .animation(.easeOut(duration: 0.12), value: isPressing)
|
|
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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() }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
- .buttonStyle(.plain)
|
|
|
|
|
.onHover { hovering in
|
|
.onHover { hovering in
|
|
|
isHovering = hovering
|
|
isHovering = hovering
|
|
|
if !hovering {
|
|
if !hovering {
|
|
@@ -95,6 +84,186 @@ struct AppTileView: View {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ private var tileContent: some View {
|
|
|
|
|
+ VStack(spacing: 11) {
|
|
|
|
|
+ AppIconView(app: app, size: 64)
|
|
|
|
|
+ .shadow(
|
|
|
|
|
+ color: .black.opacity(isHovering ? 0.34 : 0.2),
|
|
|
|
|
+ radius: isHovering ? 14 : 8,
|
|
|
|
|
+ x: 0,
|
|
|
|
|
+ y: isHovering ? 10 : 5
|
|
|
|
|
+ )
|
|
|
|
|
+ .scaleEffect(isPressing ? 0.96 : 1.0)
|
|
|
|
|
+ .offset(y: isHovering ? -1 : 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.5 : 0)
|
|
|
|
|
+ }
|
|
|
|
|
+ .padding(.horizontal, 7)
|
|
|
|
|
+ .padding(.vertical, 10)
|
|
|
|
|
+ .background(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous)
|
|
|
|
|
+ .fill(
|
|
|
|
|
+ LinearGradient(
|
|
|
|
|
+ colors: isHovering
|
|
|
|
|
+ ? [Color.white.opacity(0.16), Color.white.opacity(0.06)]
|
|
|
|
|
+ : [Color.white.opacity(0.03), Color.white.opacity(0.015)],
|
|
|
|
|
+ startPoint: .topLeading,
|
|
|
|
|
+ endPoint: .bottomTrailing
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ .overlay(
|
|
|
|
|
+ // Soft spotlight makes hover feel richer without harsh contrast.
|
|
|
|
|
+ RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous)
|
|
|
|
|
+ .fill(
|
|
|
|
|
+ RadialGradient(
|
|
|
|
|
+ colors: isHovering
|
|
|
|
|
+ ? [Color.white.opacity(0.16), Color.clear]
|
|
|
|
|
+ : [Color.clear, Color.clear],
|
|
|
|
|
+ center: .top,
|
|
|
|
|
+ startRadius: 0,
|
|
|
|
|
+ endRadius: 80
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ .allowsHitTesting(false)
|
|
|
|
|
+ )
|
|
|
|
|
+ .overlay(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous)
|
|
|
|
|
+ .stroke(
|
|
|
|
|
+ isHovering ? Color.white.opacity(0.3) : Color.white.opacity(0.06),
|
|
|
|
|
+ lineWidth: isHovering ? 1.3 : 1
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ .overlay(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous)
|
|
|
|
|
+ .inset(by: 1)
|
|
|
|
|
+ .stroke(
|
|
|
|
|
+ isHovering ? Color.white.opacity(0.12) : Color.clear,
|
|
|
|
|
+ lineWidth: 1
|
|
|
|
|
+ )
|
|
|
|
|
+ .allowsHitTesting(false)
|
|
|
|
|
+ )
|
|
|
|
|
+ .scaleEffect(isPressing ? 0.985 : (isHovering ? 1.02 : 1.0))
|
|
|
|
|
+ .offset(y: isPressing ? 0 : (isHovering ? -2 : 0))
|
|
|
|
|
+ .shadow(
|
|
|
|
|
+ color: .black.opacity(isHovering ? 0.22 : 0),
|
|
|
|
|
+ radius: isHovering ? 18 : 0,
|
|
|
|
|
+ x: 0,
|
|
|
|
|
+ y: isHovering ? 12 : 0
|
|
|
|
|
+ )
|
|
|
|
|
+ .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 {
|
|
private struct AppIconView: View {
|