Fully working colorpicker

This commit is contained in:
Hariharan Mudaliar 2025-08-23 23:50:04 +05:30
commit 6a8477758b
16 changed files with 1668 additions and 1 deletions

View file

@ -0,0 +1,66 @@
//
// ColorPickedFeedbackView.swift
// DynamicIsland
//
// Created by GitHub Copilot on 14/08/25.
//
import SwiftUI
import Defaults
struct ColorPickedFeedbackView: View {
let color: PickedColor
@Binding var isShowing: Bool
var body: some View {
VStack(spacing: 12) {
// Color preview
RoundedRectangle(cornerRadius: 12)
.fill(color.color)
.frame(width: 60, height: 60)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.white.opacity(0.8), lineWidth: 2)
)
.shadow(color: .black.opacity(0.3), radius: 8)
// Color info
VStack(spacing: 4) {
Text("Color Picked!")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.primary)
Text(color.hexString)
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundColor(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.gray.opacity(0.2))
.cornerRadius(6)
}
}
.padding(16)
.background(VisualEffectView(material: .hudWindow, blendingMode: .behindWindow))
.cornerRadius(16)
.shadow(color: .black.opacity(0.3), radius: 12)
.scaleEffect(isShowing ? 1.0 : 0.8)
.opacity(isShowing ? 1.0 : 0.0)
.animation(.spring(response: 0.4, dampingFraction: 0.7), value: isShowing)
.onTapGesture {
// Copy to clipboard on tap
ColorPickerManager.shared.copyToClipboard(color.hexString)
if Defaults[.enableHaptics] {
NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default)
}
}
}
}
#Preview {
ColorPickedFeedbackView(
color: PickedColor(nsColor: NSColor.blue, point: CGPoint(x: 100, y: 100)),
isShowing: .constant(true)
)
.frame(width: 300, height: 200)
}

View file

@ -0,0 +1,612 @@
//
// ColorPickerPanel.swift
// DynamicIsland
//
// Created by GitHub Copilot on 14/08/25.
//
import SwiftUI
import Cocoa
import Defaults
class ColorPickerPanelManager: ObservableObject {
static let shared = ColorPickerPanelManager()
private var panel: ColorPickerPanel?
private init() {}
func showColorPickerPanel() {
hideColorPickerPanel() // Close any existing panel
let newPanel = ColorPickerPanel()
newPanel.positionPanel()
self.panel = newPanel
// Make the panel key and order front to ensure it can receive focus
newPanel.makeKeyAndOrderFront(nil)
newPanel.orderFrontRegardless()
// Activate the app to ensure proper focus handling
NSApp.activate(ignoringOtherApps: true)
// Ensure the panel becomes the key window for input
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
newPanel.makeKey()
}
print("ColorPicker: Panel shown and positioned")
}
func hideColorPickerPanel() {
panel?.close()
panel = nil
print("ColorPicker: Panel hidden")
}
func toggleColorPickerPanel() {
if let panel = panel, panel.isVisible {
hideColorPickerPanel()
} else {
showColorPickerPanel()
}
}
var isPanelVisible: Bool {
return panel?.isVisible ?? false
}
}
class ColorPickerPanel: NSPanel {
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { false }
init() {
super.init(
contentRect: NSRect(x: 0, y: 0, width: 450, height: 600),
styleMask: [.titled, .closable, .fullSizeContentView],
backing: .buffered,
defer: false
)
setupPanel()
}
private func setupPanel() {
title = "Color Picker"
level = .floating
isMovableByWindowBackground = true
backgroundColor = NSColor.clear
isOpaque = false
hasShadow = true
titlebarAppearsTransparent = true
titleVisibility = .hidden
let panelView = ColorPickerPanelView {
ColorPickerPanelManager.shared.hideColorPickerPanel()
}
contentView = NSHostingView(rootView: panelView)
}
func positionPanel() {
guard let screen = NSScreen.main else { return }
let screenFrame = screen.visibleFrame
let panelFrame = frame
let xPosition = screenFrame.midX - panelFrame.width / 2
let yPosition = screenFrame.midY - panelFrame.height / 2
setFrameOrigin(NSPoint(x: xPosition, y: yPosition))
print("ColorPicker: Panel positioned at \(xPosition), \(yPosition)")
}
}
struct ColorPickerPanelView: View {
let onClose: () -> Void
@ObservedObject var colorPickerManager = ColorPickerManager.shared
@State private var searchText = ""
@State private var selectedColor: PickedColor?
@State private var showingDeleteAlert = false
@State private var colorToDelete: PickedColor?
var filteredColors: [PickedColor] {
if searchText.isEmpty {
return colorPickerManager.colorHistory
} else {
return colorPickerManager.colorHistory.filter { color in
color.hexString.localizedCaseInsensitiveContains(searchText) ||
color.rgbString.localizedCaseInsensitiveContains(searchText) ||
color.hslString.localizedCaseInsensitiveContains(searchText)
}
}
}
var body: some View {
VStack(spacing: 0) {
headerSection
Divider()
.background(Color.gray.opacity(0.3))
contentSection
}
.background(ColorPickerVisualEffectView(material: .hudWindow, blendingMode: .behindWindow))
.cornerRadius(12)
.alert("Delete Color", isPresented: $showingDeleteAlert) {
Button("Cancel", role: .cancel) { }
Button("Delete", role: .destructive) {
if let color = colorToDelete {
colorPickerManager.removeColor(color)
}
}
} message: {
Text("Are you sure you want to delete this color from your history?")
}
}
private var headerSection: some View {
VStack(spacing: 12) {
// Title and controls
HStack {
HStack(spacing: 8) {
Image(systemName: "eyedropper.halffull")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.primary)
Text("Color Picker")
.font(.system(size: 16, weight: .semibold))
.foregroundColor(.primary)
}
Spacer()
// Controls
HStack(spacing: 8) {
// Pick Color Button
Button(action: {
colorPickerManager.startColorPicking()
onClose() // Close panel when starting to pick
}) {
HStack(spacing: 6) {
Image(systemName: "plus.circle.fill")
.font(.system(size: 14, weight: .medium))
Text("Pick Color")
.font(.system(size: 14, weight: .medium))
}
.foregroundColor(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.blue)
.cornerRadius(8)
}
.buttonStyle(PlainButtonStyle())
// Clear History Button
Button(action: {
colorPickerManager.clearHistory()
}) {
Image(systemName: "trash")
.font(.system(size: 14))
.foregroundColor(.red)
}
.buttonStyle(PlainButtonStyle())
.disabled(colorPickerManager.colorHistory.isEmpty)
// Close Button
Button(action: onClose) {
Image(systemName: "xmark")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.secondary)
}
.buttonStyle(PlainButtonStyle())
}
}
// Search bar
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
.font(.system(size: 14))
TextField("Search colors...", text: $searchText)
.textFieldStyle(PlainTextFieldStyle())
.font(.system(size: 14))
if !searchText.isEmpty {
Button(action: { searchText = "" }) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
.font(.system(size: 12))
}
.buttonStyle(PlainButtonStyle())
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.gray.opacity(0.1))
.cornerRadius(8)
}
.padding(16)
}
private var contentSection: some View {
HSplitView {
// Left side - Color list
colorListSection
.frame(minWidth: 200)
// Right side - Color details
colorDetailsSection
.frame(minWidth: 220)
}
.frame(maxHeight: .infinity)
}
private var colorListSection: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Recent Colors")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.primary)
Spacer()
Text("\(filteredColors.count)")
.font(.system(size: 12, weight: .medium))
.foregroundColor(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.gray.opacity(0.2))
.cornerRadius(4)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color.gray.opacity(0.05))
Divider()
.background(Color.gray.opacity(0.3))
// Color list
if filteredColors.isEmpty {
emptyStateView
} else {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(filteredColors) { color in
ColorListRow(
color: color,
isSelected: selectedColor?.id == color.id,
onSelect: { selectedColor = color },
onDelete: {
colorToDelete = color
showingDeleteAlert = true
}
)
}
}
}
}
}
}
private var colorDetailsSection: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Color Details")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.primary)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color.gray.opacity(0.05))
Divider()
.background(Color.gray.opacity(0.3))
// Details content
if let color = selectedColor {
ColorDetailsView(color: color)
} else {
noSelectionView
}
}
}
private var emptyStateView: some View {
VStack(spacing: 16) {
Spacer()
Image(systemName: "eyedropper")
.font(.system(size: 32))
.foregroundColor(.secondary)
VStack(spacing: 8) {
Text("No Colors Found")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.primary)
if searchText.isEmpty {
Text("Start picking colors to build your history")
.font(.system(size: 12))
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
} else {
Text("No colors match '\(searchText)'")
.font(.system(size: 12))
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
}
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
private var noSelectionView: some View {
VStack(spacing: 16) {
Spacer()
Image(systemName: "hand.point.up.left")
.font(.system(size: 32))
.foregroundColor(.secondary)
Text("Select a color to view details")
.font(.system(size: 14))
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
}
struct ColorListRow: View {
let color: PickedColor
let isSelected: Bool
let onSelect: () -> Void
let onDelete: () -> Void
@State private var isHovered = false
var body: some View {
HStack(spacing: 12) {
// Color preview
Circle()
.fill(color.color)
.frame(width: 32, height: 32)
.overlay(
Circle()
.stroke(Color.white.opacity(0.8), lineWidth: 1)
)
.overlay(
Circle()
.stroke(Color.black.opacity(0.2), lineWidth: 1)
)
// Color info
VStack(alignment: .leading, spacing: 2) {
Text(color.hexString)
.font(.system(size: 14, weight: .medium, design: .monospaced))
.foregroundColor(.primary)
Text(timeAgoString(from: color.timestamp))
.font(.system(size: 12))
.foregroundColor(.secondary)
}
Spacer()
// Actions (shown on hover)
if isHovered {
HStack(spacing: 4) {
Button(action: {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(color.hexString, forType: .string)
if Defaults[.enableHaptics] {
NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default)
}
}) {
Image(systemName: "doc.on.doc")
.font(.system(size: 12))
.foregroundColor(.blue)
}
.buttonStyle(PlainButtonStyle())
Button(action: onDelete) {
Image(systemName: "trash")
.font(.system(size: 12))
.foregroundColor(.red)
}
.buttonStyle(PlainButtonStyle())
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(
Rectangle()
.fill(isSelected ? Color.blue.opacity(0.2) : (isHovered ? Color.gray.opacity(0.1) : Color.clear))
)
.onHover { hovering in
withAnimation(.easeInOut(duration: 0.2)) {
isHovered = hovering
}
}
.onTapGesture {
onSelect()
}
}
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"
}
}
}
struct ColorDetailsView: View {
let color: PickedColor
@Default(.showColorFormats) var showColorFormats
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
// Large color preview
VStack(spacing: 12) {
Rectangle()
.fill(color.color)
.frame(height: 80)
.overlay(
Rectangle()
.stroke(Color.white.opacity(0.8), lineWidth: 2)
)
.cornerRadius(8)
.shadow(color: .black.opacity(0.2), radius: 4)
Text("Picked \(timeAgoString(from: color.timestamp))")
.font(.system(size: 12))
.foregroundColor(.secondary)
}
// Color formats
VStack(alignment: .leading, spacing: 8) {
Text("Color Formats")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.primary)
ForEach(color.allFormats) { format in
ColorFormatDetailRow(format: format, color: color)
}
}
Spacer()
}
.padding(16)
}
}
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) minute\(minutes == 1 ? "" : "s") ago"
} else if interval < 86400 {
let hours = Int(interval / 3600)
return "\(hours) hour\(hours == 1 ? "" : "s") ago"
} else {
let days = Int(interval / 86400)
return "\(days) day\(days == 1 ? "" : "s") ago"
}
}
}
struct ColorFormatDetailRow: View {
let format: ColorFormat
let color: PickedColor
@State private var isHovered = false
@State private var justCopied = false
var body: some View {
HStack(spacing: 8) {
Text(format.name)
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundColor(.secondary)
.frame(width: 60, alignment: .leading)
Text(format.value)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.primary)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
Button(action: copyFormat) {
Image(systemName: justCopied ? "checkmark.circle.fill" : "doc.on.doc")
.font(.system(size: 12))
.foregroundColor(justCopied ? .green : .blue)
}
.buttonStyle(PlainButtonStyle())
.opacity(isHovered || justCopied ? 1 : 0)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(isHovered ? Color.white.opacity(0.1) : Color.clear)
)
.onHover { hovering in
withAnimation(.easeInOut(duration: 0.2)) {
isHovered = hovering
}
}
.onTapGesture {
copyFormat()
}
}
private func copyFormat() {
ColorPickerManager.shared.copyColorToClipboard(color, format: format)
withAnimation(.easeInOut(duration: 0.2)) {
justCopied = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation(.easeInOut(duration: 0.2)) {
justCopied = false
}
}
}
}
// Helper view for visual effects
struct ColorPickerVisualEffectView: NSViewRepresentable {
let material: NSVisualEffectView.Material
let blendingMode: NSVisualEffectView.BlendingMode
func makeNSView(context: Context) -> NSVisualEffectView {
let view = NSVisualEffectView()
view.material = material
view.blendingMode = blendingMode
view.state = .active
return view
}
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {}
}
#Preview {
ColorPickerPanelView {
print("Close panel")
}
.frame(width: 450, height: 600)
.onAppear {
ColorPickerManager.shared.colorHistory = PickedColor.sampleColors
}
}

View file

@ -0,0 +1,130 @@
//
// ColorPickerPopover.swift
// DynamicIsland
//
// Created by GitHub Copilot on 14/08/25.
//
import SwiftUI
import Defaults
struct ColorPickerPopover: View {
@ObservedObject var colorPickerManager = ColorPickerManager.shared
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 16) {
// Header
HStack {
HStack(spacing: 8) {
Image(systemName: "eyedropper.halffull")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.primary)
Text("Color Picker")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.primary)
}
Spacer()
Button(action: {
dismiss()
}) {
Image(systemName: "xmark")
.font(.system(size: 12, weight: .medium))
.foregroundColor(.secondary)
}
.buttonStyle(PlainButtonStyle())
}
// Main actions
VStack(spacing: 12) {
// Pick Color Button
Button(action: {
colorPickerManager.startColorPicking()
dismiss() // Close popover when starting to pick
}) {
HStack(spacing: 8) {
Image(systemName: "plus.circle.fill")
.font(.system(size: 14, weight: .medium))
Text("Pick Color")
.font(.system(size: 14, weight: .medium))
}
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.blue)
.cornerRadius(8)
}
.buttonStyle(PlainButtonStyle())
// Show Panel Button
Button(action: {
ColorPickerPanelManager.shared.showColorPickerPanel()
dismiss()
}) {
HStack(spacing: 8) {
Image(systemName: "rectangle.grid.2x2")
.font(.system(size: 14, weight: .medium))
Text("Show Color History")
.font(.system(size: 14, weight: .medium))
}
.foregroundColor(.primary)
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.gray.opacity(0.2))
.cornerRadius(8)
}
.buttonStyle(PlainButtonStyle())
}
// Recent colors preview (if any)
if !colorPickerManager.colorHistory.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Recent Colors")
.font(.system(size: 12, weight: .medium))
.foregroundColor(.secondary)
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 4), count: 6), spacing: 4) {
ForEach(Array(colorPickerManager.colorHistory.prefix(12))) { color in
Button(action: {
colorPickerManager.copyToClipboard(color.hexString)
if Defaults[.enableHaptics] {
NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default)
}
dismiss()
}) {
Circle()
.fill(color.color)
.frame(width: 24, height: 24)
.overlay(
Circle()
.stroke(Color.white.opacity(0.8), lineWidth: 1)
)
.overlay(
Circle()
.stroke(Color.black.opacity(0.2), lineWidth: 1)
)
}
.buttonStyle(PlainButtonStyle())
}
}
}
}
}
.padding(16)
.frame(width: 240)
.background(VisualEffectView(material: .hudWindow, blendingMode: .behindWindow))
.cornerRadius(12)
}
}
#Preview {
ColorPickerPopover()
.onAppear {
ColorPickerManager.shared.colorHistory = PickedColor.sampleColors
}
}

View file

@ -92,6 +92,24 @@ struct DynamicIslandHeader: View {
}
}
// ColorPicker button
if Defaults[.enableColorPickerFeature] {
Button(action: {
ColorPickerPanelManager.shared.toggleColorPickerPanel()
}) {
Capsule()
.fill(.black)
.frame(width: 30, height: 30)
.overlay {
Image(systemName: "eyedropper")
.foregroundColor(.white)
.padding()
.imageScale(.medium)
}
}
.buttonStyle(PlainButtonStyle())
}
if Defaults[.settingsIconInNotch] {
Button(action: {
SettingsWindowController.shared.showWindow()

View file

@ -0,0 +1,351 @@
//
// NotchColorPickerView.swift
// DynamicIsland
//
// Created by GitHub Copilot on 14/08/25.
//
import SwiftUI
import Defaults
struct NotchColorPickerView: View {
@ObservedObject var colorPickerManager = ColorPickerManager.shared
@Default(.enableColorPickerFeature) var enableColorPickerFeature
@State private var hoveredColorId: UUID?
@State private var showColorInfo: Bool = false
@State private var selectedColor: PickedColor?
var body: some View {
if !enableColorPickerFeature {
disabledStateView
} else {
colorPickerContent
}
}
private var disabledStateView: some View {
VStack(spacing: 12) {
Image(systemName: "eyedropper.halffull")
.font(.system(size: 32))
.foregroundColor(.secondary)
Text("ColorPicker Disabled")
.font(.headline)
.foregroundColor(.primary)
Text("Enable ColorPicker in Settings → ColorPicker")
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var colorPickerContent: some View {
VStack(spacing: 16) {
headerSection
if colorPickerManager.colorHistory.isEmpty {
emptyStateView
} else {
colorHistoryGrid
}
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay(
colorInfoOverlay,
alignment: .topTrailing
)
}
private var headerSection: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Image(systemName: "eyedropper.halffull")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.primary)
Text("Recent Colors")
.font(.system(size: 16, weight: .semibold))
.foregroundColor(.primary)
}
Text("\(colorPickerManager.colorHistory.count) colors")
.font(.system(size: 12))
.foregroundColor(.secondary)
}
Spacer()
// Pick Color Button
Button(action: {
colorPickerManager.startColorPicking()
}) {
HStack(spacing: 6) {
Image(systemName: "plus.circle.fill")
.font(.system(size: 12, weight: .medium))
Text("Pick Color")
.font(.system(size: 12, weight: .medium))
}
.foregroundColor(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.blue)
.cornerRadius(8)
}
.buttonStyle(PlainButtonStyle())
.onHover { isHovering in
if isHovering {
NSCursor.pointingHand.push()
} else {
NSCursor.pop()
}
}
}
}
private var emptyStateView: some View {
VStack(spacing: 12) {
Image(systemName: "eyedropper")
.font(.system(size: 28))
.foregroundColor(.secondary)
Text("No Colors Yet")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.primary)
Text("Click 'Pick Color' or use Cmd+Shift+P to start picking colors")
.font(.system(size: 12))
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 20)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
}
private var colorHistoryGrid: some View {
LazyVGrid(columns: gridColumns, spacing: 12) {
ForEach(colorPickerManager.colorHistory.prefix(15)) { color in
ColorCircleView(
color: color,
isHovered: hoveredColorId == color.id
)
.onHover { isHovering in
withAnimation(.easeInOut(duration: 0.2)) {
if isHovering {
hoveredColorId = color.id
selectedColor = color
showColorInfo = true
} else {
hoveredColorId = nil
if selectedColor?.id == color.id {
showColorInfo = false
selectedColor = nil
}
}
}
}
.onTapGesture {
// Copy hex color to clipboard
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(color.hexString, forType: .string)
// Provide haptic feedback
if Defaults[.enableHaptics] {
NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default)
}
}
}
}
.padding(.horizontal, 8)
}
private var gridColumns: [GridItem] {
let availableWidth: CGFloat = 400 // Approximate notch content width
let circleSize: CGFloat = 32
let spacing: CGFloat = 12
let maxColumns = Int((availableWidth + spacing) / (circleSize + spacing))
let columns = min(maxColumns, 8) // Maximum 8 columns
return Array(repeating: GridItem(.fixed(circleSize), spacing: spacing), count: columns)
}
private var colorInfoOverlay: some View {
Group {
if showColorInfo, let color = selectedColor {
ColorInfoPopup(color: color)
.transition(.asymmetric(
insertion: .scale.combined(with: .opacity),
removal: .opacity
))
}
}
}
}
struct ColorCircleView: View {
let color: PickedColor
let isHovered: Bool
var body: some View {
ZStack {
// Color circle
Circle()
.fill(color.color)
.frame(width: 32, height: 32)
.overlay(
Circle()
.stroke(Color.white.opacity(0.8), lineWidth: isHovered ? 2 : 1)
)
.overlay(
Circle()
.stroke(Color.black.opacity(0.2), lineWidth: 1)
)
.shadow(color: .black.opacity(0.3), radius: isHovered ? 4 : 2, x: 0, y: isHovered ? 2 : 1)
.scaleEffect(isHovered ? 1.1 : 1.0)
.animation(.easeInOut(duration: 0.2), value: isHovered)
}
.frame(width: 32, height: 32)
}
}
struct ColorInfoPopup: View {
let color: PickedColor
@Default(.showColorFormats) var showColorFormats
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Header with color preview
HStack(spacing: 8) {
Circle()
.fill(color.color)
.frame(width: 24, height: 24)
.overlay(
Circle()
.stroke(Color.white.opacity(0.8), lineWidth: 1)
)
VStack(alignment: .leading, spacing: 2) {
Text("Picked Color")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.primary)
Text(timeAgoString(from: color.timestamp))
.font(.system(size: 10))
.foregroundColor(.secondary)
}
Spacer()
}
Divider()
.background(Color.gray.opacity(0.3))
// Color formats
if showColorFormats {
VStack(alignment: .leading, spacing: 6) {
ForEach(color.allFormats.prefix(4)) { format in
ColorFormatRow(format: format, color: color)
}
}
} else {
ColorFormatRow(
format: ColorFormat(name: "HEX", value: color.hexString, copyValue: color.hexString),
color: color
)
}
}
.padding(12)
.frame(width: 220)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.black.opacity(0.9))
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
)
.shadow(color: .black.opacity(0.4), radius: 15, x: 0, y: 8)
}
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"
}
}
}
struct ColorFormatRow: View {
let format: ColorFormat
let color: PickedColor
@State private var isHovered: Bool = false
var body: some View {
HStack(spacing: 8) {
Text(format.name)
.font(.system(size: 10, weight: .medium, design: .monospaced))
.foregroundColor(.secondary)
.frame(width: 40, alignment: .leading)
Text(format.value)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.primary)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
if isHovered {
Button(action: {
ColorPickerManager.shared.copyColorToClipboard(color, format: format)
}) {
Image(systemName: "doc.on.doc")
.font(.system(size: 10))
.foregroundColor(.blue)
}
.buttonStyle(PlainButtonStyle())
}
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(isHovered ? Color.white.opacity(0.1) : Color.clear)
)
.onHover { hovering in
withAnimation(.easeInOut(duration: 0.2)) {
isHovered = hovering
}
}
.onTapGesture {
ColorPickerManager.shared.copyColorToClipboard(color, format: format)
}
}
}
#Preview {
NotchColorPickerView()
.frame(width: 500, height: 200)
.background(Color.black)
.onAppear {
// Add some sample colors for preview
let manager = ColorPickerManager.shared
manager.colorHistory = PickedColor.sampleColors
}
}

View file

@ -19,6 +19,7 @@ struct TabSelectionView: View {
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@Default(.enableTimerFeature) var enableTimerFeature
@Default(.enableStatsFeature) var enableStatsFeature
@Default(.enableColorPickerFeature) var enableColorPickerFeature
@Namespace var animation
var availableTabs: [TabModel] {
@ -35,6 +36,10 @@ struct TabSelectionView: View {
tabs.append(TabModel(label: "Stats", icon: "chart.line.uptrend.xyaxis", view: .stats))
}
if enableColorPickerFeature {
tabs.append(TabModel(label: "ColorPicker", icon: "eyedropper", view: .colorPicker))
}
return tabs
}
var body: some View {