hoping the final version of the notes view (and clipboard)

This commit is contained in:
delli 2025-12-24 12:07:28 +01:00
commit 9ab8a81af2
7 changed files with 657 additions and 121 deletions

View file

@ -44,6 +44,14 @@ struct ContentView: View {
var dynamicNotchSize: CGSize { var dynamicNotchSize: CGSize {
var baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize var baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize
if coordinator.currentView == .timer {
return CGSize(width: baseSize.width, height: 250) // Extra height for timer presets
}
if coordinator.currentView == .notes || coordinator.currentView == .clipboard {
return CGSize(width: baseSize.width, height: 230) // Consistent height for split views
}
guard coordinator.currentView == .stats else { guard coordinator.currentView == .stats else {
return baseSize return baseSize
} }

View file

@ -277,8 +277,16 @@ class AppDelegate: NSObject, NSApplicationDelegate {
return CGSize(width: inlineSneakPeekWidth, height: vm.effectiveClosedNotchHeight) return CGSize(width: inlineSneakPeekWidth, height: vm.effectiveClosedNotchHeight)
} }
// Use minimalistic or normal size based on settings, then adjust for stats layout // Use minimalistic or normal size based on settings
let baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize var baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize
// Use a consistent height for different view types
if coordinator.currentView == .timer {
baseSize.height = 250 // Extra space for timer presets
} else if coordinator.currentView == .notes || coordinator.currentView == .clipboard {
baseSize.height = 230
}
let adjustedContentSize = statsAdjustedNotchSize( let adjustedContentSize = statsAdjustedNotchSize(
from: baseSize, from: baseSize,
isStatsTabActive: coordinator.currentView == .stats, isStatsTabActive: coordinator.currentView == .stats,
@ -366,9 +374,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// Setup Privacy Indicator Manager (camera and microphone monitoring) // Setup Privacy Indicator Manager (camera and microphone monitoring)
PrivacyIndicatorManager.shared.startMonitoring() PrivacyIndicatorManager.shared.startMonitoring()
// Observe tab changes - use debounced updates // Observe tab changes - use immediate updates to prevent clipping
coordinator.$currentView.sink { [weak self] newView in coordinator.$currentView.sink { [weak self] newView in
self?.debouncedUpdateWindowSize() self?.updateWindowSizeIfNeeded()
}.store(in: &cancellables) }.store(in: &cancellables)
// Observe stats settings changes - use debounced updates // Observe stats settings changes - use debounced updates

View file

@ -884,6 +884,10 @@
} }
} }
}, },
"%lld chars" : {
"comment" : "A small, rounded badge displaying the number of characters in the note.",
"isCommentAutoGenerated" : true
},
"%lld colors" : { "%lld colors" : {
"localizations" : { "localizations" : {
"fr" : { "fr" : {
@ -2633,6 +2637,9 @@
} }
} }
} }
},
"All" : {
}, },
"All-day" : { "All-day" : {
"localizations" : { "localizations" : {
@ -5340,10 +5347,6 @@
} }
} }
}, },
"Click + to create a note" : {
"comment" : "A description below the \"+\" button in the note list view, instructing the user to create a new note.",
"isCommentAutoGenerated" : true
},
"Click for details" : { "Click for details" : {
"comment" : "A hint text displayed below the graph in the Stats Card, indicating that tapping it will reveal more details.", "comment" : "A hint text displayed below the graph in the Stats Card, indicating that tapping it will reveal more details.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@ -5942,6 +5945,9 @@
} }
} }
} }
},
"Create from Clipboard" : {
}, },
"Current custom timer:" : { "Current custom timer:" : {
"extractionState" : "stale", "extractionState" : "stale",
@ -6478,6 +6484,10 @@
} }
} }
}, },
"Customize how you organize and create notes. Enabling color filtering and search helps manage large lists." : {
"comment" : "A footer text that describes the purpose of the \"Customize how you organize and create notes\" section in the settings view.",
"isCommentAutoGenerated" : true
},
"Customize in Settings" : { "Customize in Settings" : {
"localizations" : { "localizations" : {
"ar" : { "ar" : {
@ -8209,10 +8219,6 @@
}, },
"Enable timer live activity" : { "Enable timer live activity" : {
},
"Enabling notes will add a quick access button to the Dynamic Island header." : {
"comment" : "A footer description for the \"Enable Notes\" toggle in the Notes settings view.",
"isCommentAutoGenerated" : true
}, },
"Engine Utilization" : { "Engine Utilization" : {
"comment" : "A label describing the utilization of the GPU's engine components.", "comment" : "A label describing the utilization of the GPU's engine components.",
@ -16231,6 +16237,10 @@
} }
} }
}, },
"Search notes..." : {
"comment" : "A placeholder text for a search bar in the note list view.",
"isCommentAutoGenerated" : true
},
"Search Settings" : { "Search Settings" : {
"comment" : "A placeholder text for a search bar in the settings view.", "comment" : "A placeholder text for a search bar in the settings view.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true

View file

@ -20,6 +20,7 @@ struct NotchNotesView: View {
// Editor State // Editor State
@State private var editorTitle: String = "" @State private var editorTitle: String = ""
@State private var editorContent: String = "" @State private var editorContent: String = ""
@State private var editorImageData: Data? = nil
@State private var editorColorIndex: Int = 0 @State private var editorColorIndex: Int = 0
@State private var editorNoteId: UUID? @State private var editorNoteId: UUID?
@ -47,6 +48,7 @@ struct NotchNotesView: View {
NoteEditorView( NoteEditorView(
title: $editorTitle, title: $editorTitle,
content: $editorContent, content: $editorContent,
imageData: $editorImageData,
colorIndex: $editorColorIndex, colorIndex: $editorColorIndex,
onSave: saveNote, onSave: saveNote,
onCancel: cancelEdit, onCancel: cancelEdit,
@ -60,7 +62,9 @@ struct NotchNotesView: View {
onCreate: createNote, onCreate: createNote,
onDelete: deleteNote, onDelete: deleteNote,
onDeleteItem: deleteNoteItem, onDeleteItem: deleteNoteItem,
onClearAll: clearAllNotes onClearAll: clearAllNotes,
onTogglePin: togglePin,
onCreateFromClipboard: createNoteFromClipboard
) )
.transition(.asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .leading))) .transition(.asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .leading)))
} }
@ -69,15 +73,81 @@ struct NotchNotesView: View {
.clipped() // Prevent overflow during transition .clipped() // Prevent overflow during transition
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: isEditingNewNote) .animation(.spring(response: 0.35, dampingFraction: 0.8), value: isEditingNewNote)
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: selectedNoteId) .animation(.spring(response: 0.35, dampingFraction: 0.8), value: selectedNoteId)
.overlay {
// Hidden button for Cmd+V paste listener
Button("") {
handlePaste()
}
.keyboardShortcut("v", modifiers: .command)
.opacity(0)
.allowsHitTesting(false)
}
} }
} }
.frame(maxHeight: .infinity)
} }
// MARK: - Actions // MARK: - Actions
private func handlePaste() {
let pasteboard = NSPasteboard.general
// Check for file URLs first (higher quality source than icons)
if let fileURLs = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL] {
let imageExtensions = ["jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp", "heic", "heif"]
if let firstImageURL = fileURLs.first(where: { imageExtensions.contains($0.pathExtension.lowercased()) }),
let imageData = try? Data(contentsOf: firstImageURL) {
updateImageData(imageData)
return
}
}
// Then direct image data
if let tiffData = pasteboard.data(forType: .tiff) {
updateImageData(tiffData)
return
} else if let pngData = pasteboard.data(forType: .png) {
updateImageData(pngData)
return
}
// Fallback to text if not editing and clipboard has text
if !isEditingNewNote && selectedNoteId == nil {
if let text = pasteboard.string(forType: .string) {
createNoteWithContent(text)
}
}
}
private func updateImageData(_ data: Data) {
withAnimation {
if isEditingNewNote || selectedNoteId != nil {
editorImageData = data
} else {
// If not editing, create a new note with this image
editorTitle = ""
editorContent = ""
editorImageData = data
editorColorIndex = 0
editorNoteId = UUID()
isEditingNewNote = true
}
}
}
private func createNoteWithContent(_ content: String) {
editorTitle = ""
editorContent = content
editorImageData = nil
editorColorIndex = 0
editorNoteId = UUID()
isEditingNewNote = true
}
private func createNote() { private func createNote() {
editorTitle = "" editorTitle = ""
editorContent = "" editorContent = ""
editorImageData = nil
editorColorIndex = 0 // Default Yellow editorColorIndex = 0 // Default Yellow
editorNoteId = UUID() editorNoteId = UUID()
isEditingNewNote = true isEditingNewNote = true
@ -86,6 +156,7 @@ struct NotchNotesView: View {
private func selectNote(_ note: NoteItem) { private func selectNote(_ note: NoteItem) {
editorTitle = note.title editorTitle = note.title
editorContent = note.content editorContent = note.content
editorImageData = note.getImageData() // Load from disk
editorColorIndex = note.colorIndex editorColorIndex = note.colorIndex
editorNoteId = note.id editorNoteId = note.id
selectedNoteId = note.id selectedNoteId = note.id
@ -93,29 +164,90 @@ struct NotchNotesView: View {
} }
private func saveNote() { private func saveNote() {
var notes = savedNotes
let now = Date()
if let id = editorNoteId { if let id = editorNoteId {
let newNote = NoteItem( // Priority: Save image to disk if it exists
id: id, var fileName: String? = nil
title: editorTitle.isEmpty ? "Untitled Note" : editorTitle, if let data = editorImageData {
content: editorContent, let name = "note_image_\(id.uuidString).png"
creationDate: Date(), let fileURL = NoteItem.noteImageDataDirectory.appendingPathComponent(name)
colorIndex: editorColorIndex try? data.write(to: fileURL)
) fileName = name
}
var notes = savedNotes
if let index = notes.firstIndex(where: { $0.id == id }) { if let index = notes.firstIndex(where: { $0.id == id }) {
notes[index] = newNote // Update
// If there was an old image and it's being replaced or removed, delete old file
if let oldFileName = notes[index].imageFileName, oldFileName != fileName {
let oldFileURL = NoteItem.noteImageDataDirectory.appendingPathComponent(oldFileName)
try? FileManager.default.removeItem(at: oldFileURL)
}
notes[index].title = editorTitle
notes[index].content = editorContent
notes[index].imageFileName = fileName
notes[index].colorIndex = editorColorIndex
} else { } else {
// Create
let newNote = NoteItem(
id: id,
title: editorTitle.isEmpty ? "Untitled Note" : editorTitle,
content: editorContent,
creationDate: now,
colorIndex: editorColorIndex,
isPinned: false,
imageFileName: fileName
)
notes.insert(newNote, at: 0) notes.insert(newNote, at: 0)
} }
savedNotes = notes savedNotes = notes // Persistence fix: save back to @Default
} }
closeEditor() closeEditor()
} }
private func createNoteFromClipboard() {
let pasteboard = NSPasteboard.general
// Priority 1: Check for real image files (highest quality)
if let fileURLs = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL] {
let imageExtensions = ["jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp", "heic", "heif"]
if let firstImageURL = fileURLs.first(where: { imageExtensions.contains($0.pathExtension.lowercased()) }),
let imageData = try? Data(contentsOf: firstImageURL) {
createNoteWithContent("")
editorImageData = imageData
return
}
}
// Priority 2: Direct image data
if let tiffData = pasteboard.data(forType: .tiff) {
createNoteWithContent("")
editorImageData = tiffData
return
} else if let pngData = pasteboard.data(forType: .png) {
createNoteWithContent("")
editorImageData = pngData
return
}
if let text = pasteboard.string(forType: .string) {
createNoteWithContent(text)
}
}
private func deleteNote(_ indexSet: IndexSet) { private func deleteNote(_ indexSet: IndexSet) {
var notes = savedNotes var notes = savedNotes
for index in indexSet {
if index < notes.count {
if let fileName = notes[index].imageFileName {
let fileURL = NoteItem.noteImageDataDirectory.appendingPathComponent(fileName)
try? FileManager.default.removeItem(at: fileURL)
}
}
}
notes.remove(atOffsets: indexSet) notes.remove(atOffsets: indexSet)
savedNotes = notes savedNotes = notes
} }
@ -123,12 +255,24 @@ struct NotchNotesView: View {
private func deleteNoteItem(_ note: NoteItem) { private func deleteNoteItem(_ note: NoteItem) {
var notes = savedNotes var notes = savedNotes
if let index = notes.firstIndex(where: { $0.id == note.id }) { if let index = notes.firstIndex(where: { $0.id == note.id }) {
// Clean up image file
if let fileName = notes[index].imageFileName {
let fileURL = NoteItem.noteImageDataDirectory.appendingPathComponent(fileName)
try? FileManager.default.removeItem(at: fileURL)
}
notes.remove(at: index) notes.remove(at: index)
savedNotes = notes savedNotes = notes
} }
} }
private func clearAllNotes() { private func clearAllNotes() {
// Clean up all image files
for note in savedNotes {
if let fileName = note.imageFileName {
let fileURL = NoteItem.noteImageDataDirectory.appendingPathComponent(fileName)
try? FileManager.default.removeItem(at: fileURL)
}
}
savedNotes.removeAll() savedNotes.removeAll()
} }
@ -136,6 +280,18 @@ struct NotchNotesView: View {
closeEditor() closeEditor()
} }
private func togglePin(_ note: NoteItem) {
var notes = savedNotes
if let index = notes.firstIndex(where: { $0.id == note.id }) {
notes[index].isPinned.toggle()
savedNotes = notes
}
}
private func scaleDownEditor() {
closeEditor()
}
private func closeEditor() { private func closeEditor() {
isEditingNewNote = false isEditingNewNote = false
selectedNoteId = nil selectedNoteId = nil
@ -193,7 +349,7 @@ struct NotchClipboardList: View {
.foregroundStyle(.secondary.opacity(0.7)) .foregroundStyle(.secondary.opacity(0.7))
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.offset(y: -20) .padding(.vertical, 30) // Add padding so it's not touching header
} else { } else {
ScrollView { ScrollView {
LazyVStack(spacing: 4) { // Tighter spacing LazyVStack(spacing: 4) { // Tighter spacing
@ -227,6 +383,7 @@ struct NotchClipboardList: View {
} }
} }
} }
.frame(maxHeight: .infinity)
} }
} }
@ -237,10 +394,19 @@ struct NotchClipboardItemRow: View {
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: justCopied ? "checkmark.circle.fill" : item.type.icon) if item.type == .image, let data = item.getImageData(), let nsImage = NSImage(data: data) {
.font(.system(size: 14)) Image(nsImage: nsImage)
.foregroundColor(justCopied ? .green : .blue) .resizable()
.frame(width: 20) .aspectRatio(contentMode: .fill)
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.white.opacity(0.1), lineWidth: 0.5))
} else {
Image(systemName: justCopied ? "checkmark.circle.fill" : item.type.icon)
.font(.system(size: 14))
.foregroundColor(justCopied ? .green : .blue)
.frame(width: 20)
}
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(item.preview) Text(item.preview)
@ -277,26 +443,87 @@ struct NoteListView: View {
let notes: [NoteItem] let notes: [NoteItem]
let onSelect: (NoteItem) -> Void let onSelect: (NoteItem) -> Void
let onCreate: () -> Void let onCreate: () -> Void
let onDelete: (IndexSet) -> Void // Keep for swipe support let onDelete: (IndexSet) -> Void
let onDeleteItem: (NoteItem) -> Void // New for button support let onDeleteItem: (NoteItem) -> Void
let onClearAll: () -> Void let onClearAll: () -> Void
let onTogglePin: (NoteItem) -> Void
let onCreateFromClipboard: () -> Void
@Default(.enableNoteSearch) var enableNoteSearch
@Default(.enableNoteColorFiltering) var enableNoteColorFiltering
@Default(.enableCreateFromClipboard) var enableCreateFromClipboard
@State private var searchText = ""
@State private var selectedColorFilter: Int? = nil
@State private var isSearchExpanded = false
var sortedNotes: [NoteItem] {
var filtered = searchText.isEmpty ? notes : notes.filter {
$0.title.localizedCaseInsensitiveContains(searchText) ||
$0.content.localizedCaseInsensitiveContains(searchText)
}
if let colorIndex = selectedColorFilter {
filtered = filtered.filter { $0.colorIndex == colorIndex }
}
return filtered.sorted {
if $0.isPinned != $1.isPinned {
return $0.isPinned
}
return $0.creationDate > $1.creationDate
}
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Header // Header
HStack { HStack {
Text("Notes") Text("Notes")
.font(.system(size: 18, weight: .semibold, design: .rounded)) // Slightly smaller, cleaner .font(.system(size: 16, weight: .semibold, design: .rounded))
.foregroundStyle(.white) .foregroundStyle(.white)
Spacer() Spacer()
if enableNoteSearch || enableNoteColorFiltering {
Button(action: {
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
isSearchExpanded.toggle()
}
}) {
Image(systemName: "magnifyingglass")
.font(.system(size: 14, weight: isSearchExpanded ? .bold : .medium))
.symbolVariant(isSearchExpanded ? .circle.fill : .none)
.frame(width: 16, height: 16) // Fixed frame to stabilize
.foregroundStyle(isSearchExpanded ? .blue : .white.opacity(0.6))
.padding(5)
.background(Color.white.opacity(0.1))
.clipShape(Circle())
}
.buttonStyle(PlainButtonStyle())
}
if enableCreateFromClipboard {
Button(action: onCreateFromClipboard) {
Image(systemName: "doc.on.clipboard")
.font(.system(size: 13))
.frame(width: 16, height: 16)
.foregroundStyle(.white.opacity(0.6))
.padding(5)
.background(Color.white.opacity(0.1))
.clipShape(Circle())
}
.buttonStyle(PlainButtonStyle())
.help("Create from Clipboard")
}
if !notes.isEmpty { if !notes.isEmpty {
Button(action: onClearAll) { Button(action: onClearAll) {
Image(systemName: "trash") Image(systemName: "trash")
.font(.system(size: 14)) .font(.system(size: 14))
.frame(width: 16, height: 16)
.foregroundStyle(.white.opacity(0.6)) .foregroundStyle(.white.opacity(0.6))
.padding(6) .padding(5)
.background(Color.white.opacity(0.1)) .background(Color.white.opacity(0.1))
.clipShape(Circle()) .clipShape(Circle())
} }
@ -305,141 +532,295 @@ struct NoteListView: View {
Button(action: onCreate) { Button(action: onCreate) {
Image(systemName: "plus") Image(systemName: "plus")
.font(.system(size: 14, weight: .semibold)) // Reduced size to match trash .font(.system(size: 14, weight: .bold))
.frame(width: 16, height: 16)
.foregroundStyle(.white) .foregroundStyle(.white)
.padding(6) .padding(5)
.background(Color.white.opacity(0.1)) .background(Color.white.opacity(0.1))
.clipShape(Circle()) .clipShape(Circle())
} }
.buttonStyle(PlainButtonStyle()) .buttonStyle(PlainButtonStyle())
} }
.padding(.horizontal, 20) .padding(.horizontal, 16) // Reduced from 20
.padding(.top, 12) // Reduced padding to move header higher .padding(.top, 8)
.padding(.bottom, 12) .padding(.bottom, isSearchExpanded ? 4 : 2)
if notes.isEmpty { if isSearchExpanded {
VStack(spacing: 8) { VStack(spacing: 6) {
Image(systemName: "note.text") if enableNoteSearch && !notes.isEmpty {
.font(.system(size: 32)) HStack {
.foregroundStyle(.secondary.opacity(0.3)) Image(systemName: "magnifyingglass")
Text("No notes yet") .font(.system(size: 10))
.font(.headline) .foregroundStyle(.secondary)
.foregroundStyle(.secondary) TextField("Search notes...", text: $searchText)
Text("Click + to create a note") .textFieldStyle(.plain)
.font(.caption) .font(.system(size: 12))
.foregroundStyle(.secondary.opacity(0.7)) if !searchText.isEmpty {
} Button(action: { searchText = "" }) {
.frame(maxWidth: .infinity, maxHeight: .infinity) Image(systemName: "xmark.circle.fill")
.offset(y: -20) // Visually center slightly upwards .font(.system(size: 10))
} else { .foregroundStyle(.secondary)
ScrollView {
LazyVStack(spacing: 4) { // Matched tighter spacing
ForEach(notes) { note in
NoteRow(note: note, onDelete: { onDeleteItem(note) })
.contentShape(Rectangle())
.onTapGesture {
onSelect(note)
} }
.buttonStyle(.plain)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 4) // Reduced from 6
.background(Color.white.opacity(0.05))
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(.horizontal, 16)
}
if enableNoteColorFiltering && !notes.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) { // Reduced from 8
Button(action: { selectedColorFilter = nil }) {
Text("All")
.font(.system(size: 10, weight: .medium))
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(selectedColorFilter == nil ? Color.blue.opacity(0.3) : Color.white.opacity(0.1))
.clipShape(Capsule())
}
.buttonStyle(.plain)
ForEach(0..<NoteItem.colors.count, id: \.self) { index in
Circle()
.fill(NoteItem.colors[index])
.frame(width: 14, height: 14)
.overlay(
Circle()
.stroke(Color.white, lineWidth: selectedColorFilter == index ? 2 : 0)
.padding(-2)
)
.onTapGesture {
withAnimation {
selectedColorFilter = (selectedColorFilter == index) ? nil : index
}
}
}
}
.padding(.horizontal, 16)
} }
} }
.padding(.horizontal, 16) // Matched padding }
.transition(.move(edge: .top).combined(with: .opacity))
.padding(.bottom, 10)
}
if sortedNotes.isEmpty {
VStack {
Spacer()
HStack(spacing: 8) {
Image(systemName: searchText.isEmpty ? "note.text" : "magnifyingglass")
.font(.system(size: 16))
.foregroundStyle(.secondary.opacity(0.4))
Text(searchText.isEmpty ? "No notes yet" : "No results found")
.font(.system(size: 12, weight: .medium, design: .rounded))
.foregroundStyle(.secondary.opacity(0.8))
}
.padding(.top, 10)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
let useGrid = sortedNotes.count > 3
let columns = useGrid ? [GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8)] : [GridItem(.flexible())]
LazyVGrid(columns: columns, spacing: 8) {
ForEach(sortedNotes) { note in
NoteRow(
note: note,
onDelete: { onDeleteItem(note) },
onTogglePin: { onTogglePin(note) },
isCompact: useGrid
)
.contentShape(Rectangle())
.onTapGesture {
onSelect(note)
}
}
}
.padding(.horizontal, 16)
.padding(.bottom, 20) .padding(.bottom, 20)
} }
} }
} }
.frame(maxHeight: .infinity)
} }
} }
struct NoteRow: View { struct NoteRow: View {
let note: NoteItem let note: NoteItem
let onDelete: () -> Void let onDelete: () -> Void
let onTogglePin: () -> Void
let isCompact: Bool
@State private var isHovered = false @State private var isHovered = false
@State private var isCopied = false @State private var isCopied = false
@Default(.enableNotePinning) var enableNotePinning
var body: some View { var body: some View {
HStack(spacing: 12) { // Matched spacing ZStack(alignment: .topTrailing) {
// Color Indicator HStack(spacing: isCompact ? 8 : 12) {
RoundedRectangle(cornerRadius: 3) // Color Indicator
.fill(note.color) RoundedRectangle(cornerRadius: 3)
.frame(width: 4) .fill(note.color)
.padding(.vertical, 2) .frame(width: isCompact ? 3 : 4)
.padding(.vertical, isCompact ? 4 : 2)
VStack(alignment: .leading, spacing: 4) { // Matched spacing
Text(note.title)
.font(.system(size: 13, weight: .medium, design: .rounded)) // Matched size
.foregroundStyle(.white)
.lineLimit(1)
Text(note.content.isEmpty ? "No content" : note.content) VStack(alignment: .leading, spacing: isCompact ? 1 : 4) {
.font(.system(size: 11)) // Slightly smaller for secondary text HStack(spacing: 4) {
.foregroundStyle(.secondary) if note.isPinned {
.lineLimit(1) // Single line description like clipboard Image(systemName: "pin.fill")
.multilineTextAlignment(.leading) .font(.system(size: isCompact ? 7 : 8))
} .foregroundStyle(note.color)
Spacer()
if isHovered {
HStack(spacing: 4) {
Button(action: {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(note.content, forType: .string)
withAnimation {
isCopied = true
} }
Text(note.title)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { .font(.system(size: isCompact ? 12 : 14, weight: .semibold, design: .rounded))
withAnimation { .foregroundStyle(.white)
isCopied = false .lineLimit(1)
}
}
}) {
Image(systemName: isCopied ? "checkmark" : "doc.on.doc")
.font(.system(size: 12)) // Matched icon size
.foregroundStyle(isCopied ? .green : .white.opacity(0.8))
.padding(6)
} }
.buttonStyle(PlainButtonStyle())
Text(note.content.isEmpty ? "No content" : note.content)
Button(action: onDelete) { .font(.system(size: isCompact ? 10 : 12))
Image(systemName: "trash") .foregroundStyle(.white.opacity(0.7))
.font(.system(size: 12)) // Matched icon size .lineLimit(isCompact ? 1 : 2)
.foregroundStyle(.red.opacity(0.8)) .multilineTextAlignment(.leading)
.padding(6)
}
.buttonStyle(PlainButtonStyle())
} }
.transition(.opacity.animation(.easeInOut(duration: 0.2))) .animation(.easeInOut(duration: 0.2), value: isHovered)
} else {
Text(note.creationDate.formatted(.dateTime.day().month())) Spacer(minLength: 0)
.font(.system(size: 10)) // Matched timestamp size
.foregroundStyle(.secondary.opacity(0.5))
} }
.padding(.trailing, isCompact ? 50 : 130) // Standardized padding for alignment
.padding(isCompact ? 8 : 12)
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.white.opacity(isHovered ? 0.12 : 0.08))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(note.color.opacity(0.2), lineWidth: 1)
)
)
.clipShape(RoundedRectangle(cornerRadius: 12)) // Ensure everything stays inside the frame
.overlay(alignment: .trailing) {
HStack(spacing: 8) {
if isHovered {
HStack(spacing: isCompact ? 3 : 4) {
if enableNotePinning {
Button(action: onTogglePin) {
Image(systemName: note.isPinned ? "pin.slash.fill" : "pin.fill")
.font(.system(size: isCompact ? 9 : 11))
.foregroundStyle(.white)
.padding(isCompact ? 5 : 6)
.background(Color.white.opacity(0.2))
.clipShape(Circle())
}
.buttonStyle(PlainButtonStyle())
}
Button(action: {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(note.content, forType: .string)
// Also copy image data if present
if let imageData = note.getImageData(), let tiffData = NSImage(data: imageData)?.tiffRepresentation {
pasteboard.setData(tiffData, forType: .tiff)
}
withAnimation {
isCopied = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation {
isCopied = false
}
}
}) {
Image(systemName: isCopied ? "checkmark" : "doc.on.doc")
.font(.system(size: isCompact ? 9 : 11))
.foregroundStyle(isCopied ? .green : .white)
.padding(isCompact ? 5 : 6)
.background(Color.white.opacity(0.2))
.clipShape(Circle())
}
.buttonStyle(PlainButtonStyle())
Button(action: onDelete) {
Image(systemName: "trash")
.font(.system(size: isCompact ? 9 : 11))
.foregroundStyle(.red)
.padding(isCompact ? 5 : 6)
.background(Color.white.opacity(0.2))
.clipShape(Circle())
}
.buttonStyle(PlainButtonStyle())
}
.padding(.horizontal, isCompact ? 6 : 8)
.padding(.vertical, isCompact ? 2 : 3)
.background(
Capsule()
.fill(Color(white: 0.12)) // Softer dark gray, not solid black
.overlay(
Capsule()
.stroke(Color.white.opacity(0.1), lineWidth: 1)
)
.shadow(color: .black.opacity(0.3), radius: 3)
)
.padding(.vertical, 4) // Ensure it stays within note borders
.transition(.asymmetric(insertion: .opacity.combined(with: .move(edge: .trailing)), removal: .opacity))
} else if !isCompact {
Text(timeAgoString(from: note.creationDate))
.font(.system(size: 10))
.foregroundStyle(.secondary.opacity(0.6))
}
if let imageData = note.getImageData(), let nsImage = NSImage(data: imageData) {
Image(nsImage: nsImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: isCompact ? 32 : 50, height: isCompact ? 32 : 50)
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.white.opacity(0.2), lineWidth: 1))
.shadow(color: .black.opacity(0.3), radius: 3, x: 0, y: 1)
.padding(.trailing, isCompact ? 4 : 8)
}
}
.padding(.trailing, isCompact ? 2 : 4)
} }
.padding(10) // Matched padding
.background(Color(nsColor: .controlBackgroundColor).opacity(isHovered ? 0.5 : 0.3))
.clipShape(RoundedRectangle(cornerRadius: 8)) // Matched corner radius
.onHover { hovering in .onHover { hovering in
withAnimation(.easeInOut(duration: 0.2)) { withAnimation(.easeInOut(duration: 0.2)) {
isHovered = hovering isHovered = hovering
} }
} }
} }
private func timeAgoString(from date: Date) -> String {
let interval = Date().timeIntervalSince(date)
if interval < 60 { return "Just now" }
if interval < 3600 { return "\(Int(interval/60))m" }
if interval < 86400 { return "\(Int(interval/3600))h" }
return date.formatted(.dateTime.day().month())
}
} }
struct NoteEditorView: View { struct NoteEditorView: View {
@Binding var title: String @Binding var title: String
@Binding var content: String @Binding var content: String
@Binding var imageData: Data?
@Binding var colorIndex: Int @Binding var colorIndex: Int
let onSave: () -> Void let onSave: () -> Void
let cancelAction: () -> Void let cancelAction: () -> Void
let isNew: Bool let isNew: Bool
init(title: Binding<String>, content: Binding<String>, colorIndex: Binding<Int>, onSave: @escaping () -> Void, onCancel: @escaping () -> Void, isNew: Bool) { init(title: Binding<String>, content: Binding<String>, imageData: Binding<Data?>, colorIndex: Binding<Int>, onSave: @escaping () -> Void, onCancel: @escaping () -> Void, isNew: Bool) {
self._title = title self._title = title
self._content = content self._content = content
self._imageData = imageData
self._colorIndex = colorIndex self._colorIndex = colorIndex
self.onSave = onSave self.onSave = onSave
self.cancelAction = onCancel self.cancelAction = onCancel
@ -479,7 +860,7 @@ struct NoteEditorView: View {
.font(.system(size: 17, weight: .bold, design: .rounded)) .font(.system(size: 17, weight: .bold, design: .rounded))
.textFieldStyle(.plain) .textFieldStyle(.plain)
Spacer() // Push colors to the right Spacer()
// Color Picker // Color Picker
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
@ -500,17 +881,19 @@ struct NoteEditorView: View {
} }
} }
} }
.padding(.vertical, 4) // Prevent vertical selection ring clipping .padding(.vertical, 4)
.padding(.horizontal, 4) // Prevent horizontal selection ring clipping .padding(.horizontal, 4)
} }
.frame(maxWidth: 160) // Limit width so title gets space .frame(maxWidth: 160)
} }
.padding(.leading, 16) .padding(.leading, 16)
.padding(.trailing, 4) // Reduced trailing padding to move circles right .padding(.trailing, 4)
.padding(.bottom, 8) .padding(.bottom, 8)
Divider() Divider()
.background(Color.white.opacity(0.1)) .background(Color.white.opacity(0.1))
// Content Input // Content Input
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
@ -524,14 +907,66 @@ struct NoteEditorView: View {
} }
TextEditor(text: $content) TextEditor(text: $content)
.font(.system(size: 13, design: .rounded)) // Reduced from 14 .font(.system(size: 13, design: .rounded))
.foregroundStyle(.white) .foregroundStyle(.white)
.lineSpacing(4) .lineSpacing(4)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.padding(8) .padding(8)
.padding(.bottom, 20)
.padding(.trailing, imageData != nil ? 75 : 0) // Reduced from 100
.focused($isContentFocused) .focused($isContentFocused)
.frame(maxHeight: .infinity) .frame(maxHeight: .infinity)
.background(Color.white.opacity(0.05)) .background(Color.white.opacity(0.05))
// Image Overlay in Bottom Right
if let data = imageData, let nsImage = NSImage(data: data) {
VStack {
Spacer()
HStack {
Spacer()
ZStack(alignment: .topTrailing) {
Image(nsImage: nsImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 55, height: 55) // Reduced from 80
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.white.opacity(0.2), lineWidth: 1))
.shadow(color: .black.opacity(0.5), radius: 5, x: 0, y: 2)
Button(action: {
withAnimation {
imageData = nil
}
}) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 14))
.foregroundStyle(.white.opacity(0.9), .black.opacity(0.6))
}
.buttonStyle(.plain)
.offset(x: 4, y: -4) // Move out slightly for better accessibility
}
.padding(12)
.padding(.bottom, 20) // Moved higher as requested
}
}
}
if Defaults[.enableNoteCharCount] {
VStack {
Spacer()
HStack {
Spacer()
Text("\(content.count) chars")
.font(.system(size: 10, weight: .medium, design: .monospaced))
.foregroundStyle(.secondary.opacity(0.5))
.padding(4)
.background(Color.black.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 4))
.padding(8)
}
}
.allowsHitTesting(false)
}
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) // Ensure VStack takes full space .frame(maxWidth: .infinity, maxHeight: .infinity) // Ensure VStack takes full space

View file

@ -4714,10 +4714,17 @@ struct NotesSettingsView: View {
Form { Form {
Section { Section {
Defaults.Toggle("Enable Notes", key: .enableNotes) Defaults.Toggle("Enable Notes", key: .enableNotes)
if Defaults[.enableNotes] {
Defaults.Toggle("Enable Note Pinning", key: .enableNotePinning)
Defaults.Toggle("Enable Note Search", key: .enableNoteSearch)
Defaults.Toggle("Enable Color Filtering", key: .enableNoteColorFiltering)
Defaults.Toggle("Enable Create from Clipboard", key: .enableCreateFromClipboard)
Defaults.Toggle("Show Character Count", key: .enableNoteCharCount)
}
} header: { } header: {
Text("General") Text("General")
} footer: { } footer: {
Text("Enabling notes will add a quick access button to the Dynamic Island header.") Text("Customize how you organize and create notes. Enabling color filtering and search helps manage large lists.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }

View file

@ -386,15 +386,78 @@ struct NoteItem: Codable, Identifiable, Defaults.Serializable, Hashable {
var content: String var content: String
var creationDate: Date var creationDate: Date
var colorIndex: Int // 0: Yellow, 1: Blue, 2: Red, 3: Green var colorIndex: Int // 0: Yellow, 1: Blue, 2: Red, 3: Green
var isPinned: Bool = false
var imageFileName: String? = nil // Store filename instead of raw data
// Internal property for migration
private enum CodingKeys: String, CodingKey {
case id, title, content, creationDate, colorIndex, isPinned, imageFileName, imageData
}
init(id: UUID = UUID(), title: String, content: String, creationDate: Date, colorIndex: Int, isPinned: Bool = false, imageFileName: String? = nil) {
self.id = id
self.title = title
self.content = content
self.creationDate = creationDate
self.colorIndex = colorIndex
self.isPinned = isPinned
self.imageFileName = imageFileName
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
title = try container.decode(String.self, forKey: .title)
content = try container.decode(String.self, forKey: .content)
creationDate = try container.decode(Date.self, forKey: .creationDate)
colorIndex = try container.decode(Int.self, forKey: .colorIndex)
isPinned = try container.decode(Bool.self, forKey: .isPinned)
// Migration logic: if imageData exists but imageFileName doesn't, save it to disk
if let data = try container.decodeIfPresent(Data.self, forKey: .imageData) {
let fileName = "note_image_\(id.uuidString).png"
let fileURL = NoteItem.noteImageDataDirectory.appendingPathComponent(fileName)
try? data.write(to: fileURL)
imageFileName = fileName
} else {
imageFileName = try container.decodeIfPresent(String.self, forKey: .imageFileName)
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(title, forKey: .title)
try container.encode(content, forKey: .content)
try container.encode(creationDate, forKey: .creationDate)
try container.encode(colorIndex, forKey: .colorIndex)
try container.encode(isPinned, forKey: .isPinned)
try container.encode(imageFileName, forKey: .imageFileName)
}
static let colors: [Color] = [.yellow, .blue, .red, .green, .purple, .orange] static let colors: [Color] = [.yellow, .blue, .red, .green, .purple, .orange]
// Directory for storing note image files
static let noteImageDataDirectory: URL = {
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
let notesDir = documentsPath.appendingPathComponent("NoteImages")
try? FileManager.default.createDirectory(at: notesDir, withIntermediateDirectories: true)
return notesDir
}()
var color: Color { var color: Color {
if colorIndex >= 0 && colorIndex < NoteItem.colors.count { if colorIndex >= 0 && colorIndex < NoteItem.colors.count {
return NoteItem.colors[colorIndex] return NoteItem.colors[colorIndex]
} }
return .yellow return .yellow
} }
// Helper to get image data from file
func getImageData() -> Data? {
guard let fileName = imageFileName else { return nil }
let fileURL = NoteItem.noteImageDataDirectory.appendingPathComponent(fileName)
return try? Data(contentsOf: fileURL)
}
} }
extension Defaults.Keys { extension Defaults.Keys {
@ -661,6 +724,11 @@ extension Defaults.Keys {
// MARK: Notes Feature // MARK: Notes Feature
static let enableNotes = Key<Bool>("enableNotes", default: false) static let enableNotes = Key<Bool>("enableNotes", default: false)
static let enableNotePinning = Key<Bool>("enableNotePinning", default: true)
static let enableNoteSearch = Key<Bool>("enableNoteSearch", default: false)
static let enableNoteColorFiltering = Key<Bool>("enableNoteColorFiltering", default: false)
static let enableCreateFromClipboard = Key<Bool>("enableCreateFromClipboard", default: true)
static let enableNoteCharCount = Key<Bool>("enableNoteCharCount", default: true)
static let savedNotes = Key<[NoteItem]>("savedNotes", default: []) static let savedNotes = Key<[NoteItem]>("savedNotes", default: [])
// Helper to determine the default media controller based on macOS version // Helper to determine the default media controller based on macOS version

View file

@ -16,7 +16,7 @@ let batterySneakSize: CGSize = .init(width: 160, height: 1)
var openNotchSize: CGSize { var openNotchSize: CGSize {
let width = Defaults[.openNotchWidth] let width = Defaults[.openNotchWidth]
return .init(width: width, height: 190) return .init(width: width, height: 200) // Adjusted to a better middle ground
} }
private let minimalisticBaseOpenNotchSize: CGSize = .init(width: 420, height: 180) private let minimalisticBaseOpenNotchSize: CGSize = .init(width: 420, height: 180)
private let minimalisticLyricsExtraHeight: CGFloat = 40 private let minimalisticLyricsExtraHeight: CGFloat = 40