Feature: Favourites

This commit is contained in:
Hariharan Mudaliar 2025-08-12 14:52:03 +05:30
commit 5321352184
7 changed files with 627 additions and 32 deletions

View file

@ -0,0 +1,363 @@
//
// ClipboardWindow.swift
// DynamicIsland
//
// Created by GitHub Copilot on 12/08/25.
//
import SwiftUI
import Defaults
enum ClipboardTab: String, CaseIterable {
case history = "History"
case favorites = "Favorites"
var icon: String {
switch self {
case .history: return "clock"
case .favorites: return "heart.fill"
}
}
}
struct ClipboardWindow: View {
@ObservedObject var clipboardManager = ClipboardManager.shared
@State private var selectedTab: ClipboardTab = .history
@State private var searchText = ""
var body: some View {
VStack(spacing: 0) {
// Header with tabs and search
ClipboardWindowHeader(selectedTab: $selectedTab, searchText: $searchText)
Divider()
.background(Color.gray.opacity(0.3))
// Content area
ClipboardWindowContent(selectedTab: selectedTab, searchText: searchText)
}
.frame(width: 400, height: 300)
.background(VisualEffectView(material: .hudWindow, blendingMode: .behindWindow))
.cornerRadius(12)
.shadow(color: .black.opacity(0.4), radius: 20, x: 0, y: 10)
}
}
struct ClipboardWindowHeader: View {
@Binding var selectedTab: ClipboardTab
@Binding var searchText: String
@ObservedObject var clipboardManager = ClipboardManager.shared
var body: some View {
VStack(spacing: 0) {
// Title and controls
HStack {
Image(systemName: "doc.on.clipboard")
.foregroundColor(.primary)
.font(.system(size: 16, weight: .medium))
Text("Clipboard Manager")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.primary)
Spacer()
// Clear button
Button(action: {
if selectedTab == .history {
clipboardManager.clearHistory()
} else {
// Clear favorites
clipboardManager.pinnedItems.removeAll()
clipboardManager.savePinnedItemsToDefaults()
}
}) {
Image(systemName: "trash")
.foregroundColor(.red)
.font(.system(size: 12))
}
.buttonStyle(PlainButtonStyle())
.disabled(selectedTab == .history ? clipboardManager.clipboardHistory.isEmpty : clipboardManager.pinnedItems.isEmpty)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
// Tab selector
HStack(spacing: 0) {
ForEach(ClipboardTab.allCases, id: \.self) { tab in
ClipboardTabButton(tab: tab, isSelected: selectedTab == tab) {
withAnimation(.easeInOut(duration: 0.2)) {
selectedTab = tab
}
}
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.bottom, 8)
// Search bar
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
.font(.system(size: 12))
TextField("Search clipboard...", text: $searchText)
.textFieldStyle(PlainTextFieldStyle())
.font(.system(size: 12))
if !searchText.isEmpty {
Button(action: { searchText = "" }) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
.font(.system(size: 10))
}
.buttonStyle(PlainButtonStyle())
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.gray.opacity(0.1))
.cornerRadius(6)
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
}
}
struct ClipboardTabButton: View {
let tab: ClipboardTab
let isSelected: Bool
let action: () -> Void
@ObservedObject var clipboardManager = ClipboardManager.shared
var itemCount: Int {
switch tab {
case .history:
return clipboardManager.regularHistory.count
case .favorites:
return clipboardManager.pinnedItems.count
}
}
var body: some View {
Button(action: action) {
HStack(spacing: 6) {
Image(systemName: tab.icon)
.font(.system(size: 11))
Text(tab.rawValue)
.font(.system(size: 11, weight: .medium))
if itemCount > 0 {
Text("\(itemCount)")
.font(.system(size: 9, weight: .bold))
.foregroundColor(isSelected ? .white : .secondary)
.padding(.horizontal, 4)
.padding(.vertical, 1)
.background(
Capsule()
.fill(isSelected ? Color.white.opacity(0.3) : Color.gray.opacity(0.2))
)
}
}
.foregroundColor(isSelected ? .white : .secondary)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(isSelected ? Color.blue : Color.clear)
)
}
.buttonStyle(PlainButtonStyle())
}
}
struct ClipboardWindowContent: View {
let selectedTab: ClipboardTab
let searchText: String
@ObservedObject var clipboardManager = ClipboardManager.shared
var filteredItems: [ClipboardItem] {
let items = selectedTab == .history ? clipboardManager.regularHistory : clipboardManager.pinnedItems
if searchText.isEmpty {
return items
} else {
return items.filter { item in
item.preview.localizedCaseInsensitiveContains(searchText) ||
item.type.displayName.localizedCaseInsensitiveContains(searchText)
}
}
}
var body: some View {
if filteredItems.isEmpty {
ClipboardEmptyState(tab: selectedTab, hasSearch: !searchText.isEmpty)
} else {
ScrollView {
LazyVStack(spacing: 1) {
ForEach(filteredItems) { item in
ClipboardWindowItemRow(item: item, tab: selectedTab)
}
}
.padding(.vertical, 8)
}
}
}
}
struct ClipboardEmptyState: View {
let tab: ClipboardTab
let hasSearch: Bool
var body: some View {
VStack(spacing: 12) {
Image(systemName: hasSearch ? "magnifyingglass" : tab.icon)
.font(.system(size: 32))
.foregroundColor(.secondary)
if hasSearch {
Text("No results found")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.primary)
Text("Try adjusting your search terms")
.font(.system(size: 12))
.foregroundColor(.secondary)
} else {
Text(tab == .history ? "No clipboard history" : "No favorites")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.primary)
Text(tab == .history ? "Copy something to get started" : "Pin items from history to save them here")
.font(.system(size: 12))
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 20)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct ClipboardWindowItemRow: View {
let item: ClipboardItem
let tab: ClipboardTab
@ObservedObject var clipboardManager = ClipboardManager.shared
@State private var isHovering = false
var isPinned: Bool {
item.isPinned || clipboardManager.pinnedItems.contains(where: { $0.id == item.id })
}
var body: some View {
HStack(alignment: .top, spacing: 12) {
// Type icon
Image(systemName: item.type.icon)
.font(.system(size: 14))
.foregroundColor(.blue)
.frame(width: 20)
// Content
VStack(alignment: .leading, spacing: 4) {
Text(item.preview)
.font(.system(size: 12))
.foregroundColor(.primary)
.lineLimit(2)
.multilineTextAlignment(.leading)
HStack {
Text(item.type.displayName)
.font(.system(size: 10))
.foregroundColor(.secondary)
Spacer()
Text(timeAgoString(from: item.timestamp))
.font(.system(size: 10))
.foregroundColor(.secondary)
}
}
Spacer()
// Action buttons (shown on hover)
if isHovering {
HStack(spacing: 6) {
// Pin/Unpin button
Button(action: {
clipboardManager.togglePin(for: item)
}) {
Image(systemName: isPinned ? "heart.fill" : "heart")
.font(.system(size: 11))
.foregroundColor(isPinned ? .red : .gray)
}
.buttonStyle(PlainButtonStyle())
// Copy button
Button(action: {
clipboardManager.copyToClipboard(item)
}) {
Image(systemName: "doc.on.doc")
.font(.system(size: 11))
.foregroundColor(.green)
}
.buttonStyle(PlainButtonStyle())
// Delete button
Button(action: {
if isPinned {
clipboardManager.unpinItem(item)
} else {
clipboardManager.deleteItem(item)
}
}) {
Image(systemName: "trash")
.font(.system(size: 11))
.foregroundColor(.red)
}
.buttonStyle(PlainButtonStyle())
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(isHovering ? Color.gray.opacity(0.1) : Color.clear)
)
.onHover { hovering in
withAnimation(.easeInOut(duration: 0.2)) {
isHovering = hovering
}
}
.onTapGesture {
clipboardManager.copyToClipboard(item)
}
}
private func timeAgoString(from date: Date) -> String {
let interval = Date().timeIntervalSince(date)
if interval < 60 {
return "Just now"
} else if interval < 3600 {
let minutes = Int(interval / 60)
return "\(minutes)m ago"
} else if interval < 86400 {
let hours = Int(interval / 3600)
return "\(hours)h ago"
} else {
let days = Int(interval / 86400)
return "\(days)d ago"
}
}
}
#Preview {
ClipboardWindow()
.frame(width: 400, height: 300)
.background(Color.gray.opacity(0.3))
}

View file

@ -1589,6 +1589,7 @@ struct ClipboardSettings: View {
@Default(.enableClipboardManager) var enableClipboardManager
@Default(.clipboardHistorySize) var clipboardHistorySize
@Default(.showClipboardIcon) var showClipboardIcon
@Default(.clipboardWindowMode) var clipboardWindowMode
var body: some View {
Form {
@ -1611,6 +1612,8 @@ struct ClipboardSettings: View {
Section {
Defaults.Toggle("Show Clipboard Icon", key: .showClipboardIcon)
Defaults.Toggle("Window Mode", key: .clipboardWindowMode)
HStack {
Text("History Size")
Spacer()
@ -1631,6 +1634,13 @@ struct ClipboardSettings: View {
.foregroundColor(.secondary)
}
HStack {
Text("Pinned Items")
Spacer()
Text("\(clipboardManager.pinnedItems.count)")
.foregroundColor(.secondary)
}
HStack {
Text("Monitoring Status")
Spacer()
@ -1640,7 +1650,7 @@ struct ClipboardSettings: View {
} header: {
Text("Settings")
} footer: {
Text("The clipboard icon appears in the header next to the settings gear when enabled. Use the global shortcut Cmd+Shift+V or click the icon to access history.")
Text("Window Mode shows clipboard in a moveable window like Raycast with tabs for history and favorites. When disabled, clipboard appears in a popover attached to the Dynamic Island.")
}
Section {
@ -1649,10 +1659,17 @@ struct ClipboardSettings: View {
}
.foregroundColor(.red)
.disabled(clipboardManager.clipboardHistory.isEmpty)
Button("Clear Pinned Items") {
clipboardManager.pinnedItems.removeAll()
clipboardManager.savePinnedItemsToDefaults()
}
.foregroundColor(.red)
.disabled(clipboardManager.pinnedItems.isEmpty)
} header: {
Text("Actions")
} footer: {
Text("This will permanently delete all stored clipboard history. The clipboard button is located to the left of the settings gear in the header.")
Text("Clear clipboard history removes recent copies. Clear pinned items removes your favorites. Both actions are permanent.")
}
if !clipboardManager.clipboardHistory.isEmpty {