diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index fb0bdcb..d661847 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -44,6 +44,14 @@ struct ContentView: View { var dynamicNotchSize: CGSize { 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 { return baseSize } diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 526be2c..cfec521 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -277,8 +277,16 @@ class AppDelegate: NSObject, NSApplicationDelegate { return CGSize(width: inlineSneakPeekWidth, height: vm.effectiveClosedNotchHeight) } - // Use minimalistic or normal size based on settings, then adjust for stats layout - let baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize + // Use minimalistic or normal size based on settings + 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( from: baseSize, isStatsTabActive: coordinator.currentView == .stats, @@ -366,9 +374,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Setup Privacy Indicator Manager (camera and microphone monitoring) 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 - self?.debouncedUpdateWindowSize() + self?.updateWindowSizeIfNeeded() }.store(in: &cancellables) // Observe stats settings changes - use debounced updates diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 32ec5e0..78cf581 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -884,6 +884,10 @@ } } }, + "%lld chars" : { + "comment" : "A small, rounded badge displaying the number of characters in the note.", + "isCommentAutoGenerated" : true + }, "%lld colors" : { "localizations" : { "fr" : { @@ -2633,6 +2637,9 @@ } } } + }, + "All" : { + }, "All-day" : { "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" : { "comment" : "A hint text displayed below the graph in the Stats Card, indicating that tapping it will reveal more details.", "isCommentAutoGenerated" : true, @@ -5942,6 +5945,9 @@ } } } + }, + "Create from Clipboard" : { + }, "Current custom timer:" : { "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" : { "localizations" : { "ar" : { @@ -8209,10 +8219,6 @@ }, "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" : { "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" : { "comment" : "A placeholder text for a search bar in the settings view.", "isCommentAutoGenerated" : true diff --git a/DynamicIsland/components/Notch/NotchNotesView.swift b/DynamicIsland/components/Notch/NotchNotesView.swift index 7247a9d..a26c9c8 100644 --- a/DynamicIsland/components/Notch/NotchNotesView.swift +++ b/DynamicIsland/components/Notch/NotchNotesView.swift @@ -20,6 +20,7 @@ struct NotchNotesView: View { // Editor State @State private var editorTitle: String = "" @State private var editorContent: String = "" + @State private var editorImageData: Data? = nil @State private var editorColorIndex: Int = 0 @State private var editorNoteId: UUID? @@ -47,6 +48,7 @@ struct NotchNotesView: View { NoteEditorView( title: $editorTitle, content: $editorContent, + imageData: $editorImageData, colorIndex: $editorColorIndex, onSave: saveNote, onCancel: cancelEdit, @@ -60,7 +62,9 @@ struct NotchNotesView: View { onCreate: createNote, onDelete: deleteNote, onDeleteItem: deleteNoteItem, - onClearAll: clearAllNotes + onClearAll: clearAllNotes, + onTogglePin: togglePin, + onCreateFromClipboard: createNoteFromClipboard ) .transition(.asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .leading))) } @@ -69,15 +73,81 @@ struct NotchNotesView: View { .clipped() // Prevent overflow during transition .animation(.spring(response: 0.35, dampingFraction: 0.8), value: isEditingNewNote) .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 + 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() { editorTitle = "" editorContent = "" + editorImageData = nil editorColorIndex = 0 // Default Yellow editorNoteId = UUID() isEditingNewNote = true @@ -86,6 +156,7 @@ struct NotchNotesView: View { private func selectNote(_ note: NoteItem) { editorTitle = note.title editorContent = note.content + editorImageData = note.getImageData() // Load from disk editorColorIndex = note.colorIndex editorNoteId = note.id selectedNoteId = note.id @@ -93,29 +164,90 @@ struct NotchNotesView: View { } private func saveNote() { + var notes = savedNotes + let now = Date() + if let id = editorNoteId { - let newNote = NoteItem( - id: id, - title: editorTitle.isEmpty ? "Untitled Note" : editorTitle, - content: editorContent, - creationDate: Date(), - colorIndex: editorColorIndex - ) - - var notes = savedNotes + // Priority: Save image to disk if it exists + var fileName: String? = nil + if let data = editorImageData { + let name = "note_image_\(id.uuidString).png" + let fileURL = NoteItem.noteImageDataDirectory.appendingPathComponent(name) + try? data.write(to: fileURL) + fileName = name + } + 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 { + // 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) } - savedNotes = notes + savedNotes = notes // Persistence fix: save back to @Default } 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) { 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) savedNotes = notes } @@ -123,12 +255,24 @@ struct NotchNotesView: View { private func deleteNoteItem(_ note: NoteItem) { var notes = savedNotes 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) savedNotes = notes } } 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() } @@ -136,6 +280,18 @@ struct NotchNotesView: View { 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() { isEditingNewNote = false selectedNoteId = nil @@ -193,7 +349,7 @@ struct NotchClipboardList: View { .foregroundStyle(.secondary.opacity(0.7)) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .offset(y: -20) + .padding(.vertical, 30) // Add padding so it's not touching header } else { ScrollView { 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 { HStack(spacing: 12) { - Image(systemName: justCopied ? "checkmark.circle.fill" : item.type.icon) - .font(.system(size: 14)) - .foregroundColor(justCopied ? .green : .blue) - .frame(width: 20) + if item.type == .image, let data = item.getImageData(), let nsImage = NSImage(data: data) { + Image(nsImage: nsImage) + .resizable() + .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) { Text(item.preview) @@ -277,26 +443,87 @@ struct NoteListView: View { let notes: [NoteItem] let onSelect: (NoteItem) -> Void let onCreate: () -> Void - let onDelete: (IndexSet) -> Void // Keep for swipe support - let onDeleteItem: (NoteItem) -> Void // New for button support + let onDelete: (IndexSet) -> Void + let onDeleteItem: (NoteItem) -> 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 { VStack(spacing: 0) { // Header HStack { Text("Notes") - .font(.system(size: 18, weight: .semibold, design: .rounded)) // Slightly smaller, cleaner + .font(.system(size: 16, weight: .semibold, design: .rounded)) .foregroundStyle(.white) 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 { Button(action: onClearAll) { Image(systemName: "trash") .font(.system(size: 14)) + .frame(width: 16, height: 16) .foregroundStyle(.white.opacity(0.6)) - .padding(6) + .padding(5) .background(Color.white.opacity(0.1)) .clipShape(Circle()) } @@ -305,141 +532,295 @@ struct NoteListView: View { Button(action: onCreate) { 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) - .padding(6) + .padding(5) .background(Color.white.opacity(0.1)) .clipShape(Circle()) } .buttonStyle(PlainButtonStyle()) } - .padding(.horizontal, 20) - .padding(.top, 12) // Reduced padding to move header higher - .padding(.bottom, 12) - - if notes.isEmpty { - VStack(spacing: 8) { - Image(systemName: "note.text") - .font(.system(size: 32)) - .foregroundStyle(.secondary.opacity(0.3)) - Text("No notes yet") - .font(.headline) - .foregroundStyle(.secondary) - Text("Click + to create a note") - .font(.caption) - .foregroundStyle(.secondary.opacity(0.7)) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .offset(y: -20) // Visually center slightly upwards - } else { - ScrollView { - LazyVStack(spacing: 4) { // Matched tighter spacing - ForEach(notes) { note in - NoteRow(note: note, onDelete: { onDeleteItem(note) }) - .contentShape(Rectangle()) - .onTapGesture { - onSelect(note) + .padding(.horizontal, 16) // Reduced from 20 + .padding(.top, 8) + .padding(.bottom, isSearchExpanded ? 4 : 2) + + if isSearchExpanded { + VStack(spacing: 6) { + if enableNoteSearch && !notes.isEmpty { + HStack { + Image(systemName: "magnifyingglass") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + TextField("Search notes...", text: $searchText) + .textFieldStyle(.plain) + .font(.system(size: 12)) + if !searchText.isEmpty { + Button(action: { searchText = "" }) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 10)) + .foregroundStyle(.secondary) } + .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.. 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) } } } + .frame(maxHeight: .infinity) } } struct NoteRow: View { let note: NoteItem let onDelete: () -> Void + let onTogglePin: () -> Void + let isCompact: Bool @State private var isHovered = false @State private var isCopied = false + @Default(.enableNotePinning) var enableNotePinning var body: some View { - HStack(spacing: 12) { // Matched spacing - // Color Indicator - RoundedRectangle(cornerRadius: 3) - .fill(note.color) - .frame(width: 4) - .padding(.vertical, 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) + ZStack(alignment: .topTrailing) { + HStack(spacing: isCompact ? 8 : 12) { + // Color Indicator + RoundedRectangle(cornerRadius: 3) + .fill(note.color) + .frame(width: isCompact ? 3 : 4) + .padding(.vertical, isCompact ? 4 : 2) - Text(note.content.isEmpty ? "No content" : note.content) - .font(.system(size: 11)) // Slightly smaller for secondary text - .foregroundStyle(.secondary) - .lineLimit(1) // Single line description like clipboard - .multilineTextAlignment(.leading) - } - - Spacer() - - if isHovered { - HStack(spacing: 4) { - Button(action: { - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(note.content, forType: .string) - - withAnimation { - isCopied = true + VStack(alignment: .leading, spacing: isCompact ? 1 : 4) { + HStack(spacing: 4) { + if note.isPinned { + Image(systemName: "pin.fill") + .font(.system(size: isCompact ? 7 : 8)) + .foregroundStyle(note.color) } - - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - withAnimation { - isCopied = false - } - } - }) { - Image(systemName: isCopied ? "checkmark" : "doc.on.doc") - .font(.system(size: 12)) // Matched icon size - .foregroundStyle(isCopied ? .green : .white.opacity(0.8)) - .padding(6) + Text(note.title) + .font(.system(size: isCompact ? 12 : 14, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) } - .buttonStyle(PlainButtonStyle()) - - Button(action: onDelete) { - Image(systemName: "trash") - .font(.system(size: 12)) // Matched icon size - .foregroundStyle(.red.opacity(0.8)) - .padding(6) - } - .buttonStyle(PlainButtonStyle()) + + Text(note.content.isEmpty ? "No content" : note.content) + .font(.system(size: isCompact ? 10 : 12)) + .foregroundStyle(.white.opacity(0.7)) + .lineLimit(isCompact ? 1 : 2) + .multilineTextAlignment(.leading) } - .transition(.opacity.animation(.easeInOut(duration: 0.2))) - } else { - Text(note.creationDate.formatted(.dateTime.day().month())) - .font(.system(size: 10)) // Matched timestamp size - .foregroundStyle(.secondary.opacity(0.5)) + .animation(.easeInOut(duration: 0.2), value: isHovered) + + Spacer(minLength: 0) } + .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 withAnimation(.easeInOut(duration: 0.2)) { 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 { @Binding var title: String @Binding var content: String + @Binding var imageData: Data? @Binding var colorIndex: Int let onSave: () -> Void let cancelAction: () -> Void let isNew: Bool - init(title: Binding, content: Binding, colorIndex: Binding, onSave: @escaping () -> Void, onCancel: @escaping () -> Void, isNew: Bool) { + init(title: Binding, content: Binding, imageData: Binding, colorIndex: Binding, onSave: @escaping () -> Void, onCancel: @escaping () -> Void, isNew: Bool) { self._title = title self._content = content + self._imageData = imageData self._colorIndex = colorIndex self.onSave = onSave self.cancelAction = onCancel @@ -479,7 +860,7 @@ struct NoteEditorView: View { .font(.system(size: 17, weight: .bold, design: .rounded)) .textFieldStyle(.plain) - Spacer() // Push colors to the right + Spacer() // Color Picker ScrollView(.horizontal, showsIndicators: false) { @@ -500,17 +881,19 @@ struct NoteEditorView: View { } } } - .padding(.vertical, 4) // Prevent vertical selection ring clipping - .padding(.horizontal, 4) // Prevent horizontal selection ring clipping + .padding(.vertical, 4) + .padding(.horizontal, 4) } - .frame(maxWidth: 160) // Limit width so title gets space + .frame(maxWidth: 160) } .padding(.leading, 16) - .padding(.trailing, 4) // Reduced trailing padding to move circles right + .padding(.trailing, 4) .padding(.bottom, 8) Divider() .background(Color.white.opacity(0.1)) + + // Content Input ZStack(alignment: .topLeading) { @@ -524,14 +907,66 @@ struct NoteEditorView: View { } TextEditor(text: $content) - .font(.system(size: 13, design: .rounded)) // Reduced from 14 + .font(.system(size: 13, design: .rounded)) .foregroundStyle(.white) .lineSpacing(4) .scrollContentBackground(.hidden) .padding(8) + .padding(.bottom, 20) + .padding(.trailing, imageData != nil ? 75 : 0) // Reduced from 100 .focused($isContentFocused) .frame(maxHeight: .infinity) .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 diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 4a8dff1..d6e632d 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -4714,10 +4714,17 @@ struct NotesSettingsView: View { Form { Section { 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: { Text("General") } 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) .foregroundStyle(.secondary) } diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 30f7e85..18a11e9 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -386,15 +386,78 @@ struct NoteItem: Codable, Identifiable, Defaults.Serializable, Hashable { var content: String var creationDate: Date 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] + // 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 { if colorIndex >= 0 && colorIndex < NoteItem.colors.count { return NoteItem.colors[colorIndex] } 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 { @@ -661,6 +724,11 @@ extension Defaults.Keys { // MARK: Notes Feature static let enableNotes = Key("enableNotes", default: false) + static let enableNotePinning = Key("enableNotePinning", default: true) + static let enableNoteSearch = Key("enableNoteSearch", default: false) + static let enableNoteColorFiltering = Key("enableNoteColorFiltering", default: false) + static let enableCreateFromClipboard = Key("enableCreateFromClipboard", default: true) + static let enableNoteCharCount = Key("enableNoteCharCount", default: true) static let savedNotes = Key<[NoteItem]>("savedNotes", default: []) // Helper to determine the default media controller based on macOS version diff --git a/DynamicIsland/sizing/matters.swift b/DynamicIsland/sizing/matters.swift index 6e1c505..fbfb05d 100644 --- a/DynamicIsland/sizing/matters.swift +++ b/DynamicIsland/sizing/matters.swift @@ -16,7 +16,7 @@ let batterySneakSize: CGSize = .init(width: 160, height: 1) var openNotchSize: CGSize { 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 minimalisticLyricsExtraHeight: CGFloat = 40