diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index d5f5500..09f52ee 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -45,6 +45,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 } @@ -580,6 +588,10 @@ struct ContentView: View { NotchStatsView() case .colorPicker: NotchColorPickerView() + case .notes: + NotchNotesView() + case .clipboard: + NotchNotesView() } } .transition(.asymmetric( diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 08c92c9..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 @@ -578,6 +586,17 @@ class AppDelegate: NSObject, NSApplicationDelegate { } else { NotificationCenter.default.post(name: NSNotification.Name("ToggleClipboardPopover"), object: nil) } + case .separateTab: + if self.vm.notchState == .closed { + self.vm.open() + self.coordinator.currentView = .notes + } else { + if self.coordinator.currentView == .notes { + self.vm.close() + } else { + self.coordinator.currentView = .notes + } + } } } diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 285e6c8..efa85a2 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" : { @@ -5921,6 +5928,10 @@ } } }, + "Copy text to see it here" : { + "comment" : "A description below the \"No copies yet\" text, instructing the user to copy text to see it in the clipboard.", + "isCommentAutoGenerated" : true + }, "Core %lld" : { "comment" : "A label showing the ID of a CPU core. The argument is the ID of the CPU core.", "isCommentAutoGenerated" : true, @@ -5946,6 +5957,9 @@ } } } + }, + "Create from Clipboard" : { + }, "Current custom timer:" : { "extractionState" : "stale", @@ -6475,6 +6489,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" : { @@ -7110,6 +7128,10 @@ }, "Do Not Disturb" : { + }, + "Done" : { + "comment" : "A button label that indicates that the user has finished editing a note.", + "isCommentAutoGenerated" : true }, "Download" : { "localizations" : { @@ -13693,6 +13715,10 @@ }, "No Colors Yet" : { + }, + "No copies yet" : { + "comment" : "A message displayed when the clipboard is empty, indicating that there are no copied items.", + "isCommentAutoGenerated" : true }, "No custom animation available" : { "localizations" : { @@ -14123,6 +14149,9 @@ "No Graphs Enabled" : { "comment" : "A label displayed when no graphs are enabled in the Stats tab.", "isCommentAutoGenerated" : true + }, + "No notes yet" : { + }, "No presets configured. Add a preset to make it appear in the timer popover." : { "comment" : "A message displayed when there are no timer presets configured, encouraging the user to add one.", @@ -14675,6 +14704,10 @@ "comment" : "A section header for adjusting the expanded notch width.", "isCommentAutoGenerated" : true }, + "Notes" : { + "comment" : "The title of the Notes settings page.", + "isCommentAutoGenerated" : true + }, "Notify before" : { "comment" : "A label describing the slider that allows the user to set a reminder lead time.", "isCommentAutoGenerated" : true @@ -14950,8 +14983,9 @@ "comment" : "A label indicating that the timer is running overtime.", "isCommentAutoGenerated" : true }, - "Panel mode shows clipboard in a floating window near the notch. Popover mode shows clipboard as a dropdown attached to the clipboard button." : { - + "Panel mode shows clipboard in a floating window near the notch." : { + "comment" : "A description of the \"Panel\" clipboard display mode.", + "isCommentAutoGenerated" : true }, "Panel mode shows color picker in a floating window. Popover mode shows color picker as a dropdown attached to the color picker button." : { @@ -15103,8 +15137,9 @@ } } }, - "Popover mode shows clipboard as a dropdown attached to the clipboard button. Panel mode shows clipboard in a floating window near the notch." : { - + "Popover mode shows clipboard as a dropdown attached to the clipboard button." : { + "comment" : "A description of the \"Popover mode\" in the Clipboard Settings view.", + "isCommentAutoGenerated" : true }, "Popover mode shows color picker as a dropdown attached to the color picker button. Panel mode shows color picker in a floating window." : { @@ -16234,6 +16269,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 @@ -16630,6 +16669,10 @@ } } }, + "Separate Tab mode integrates Copied Items and Notes into a single view. If both are enabled, Notes appear on the right and Clipboard on the left." : { + "comment" : "A description of the \"Separate Tab\" clipboard display mode.", + "isCommentAutoGenerated" : true + }, "Session · ↓ %@ · ↑ %@" : { "comment" : "A row displaying the total downloaded and uploaded bytes for the network session.", "isCommentAutoGenerated" : true, @@ -18271,6 +18314,10 @@ } } }, + "Start typing..." : { + "comment" : "A placeholder text shown when a note's content is empty.", + "isCommentAutoGenerated" : true + }, "Starts a 5-minute demo timer to test the timer live activity feature. Only works when timer feature is enabled." : { }, @@ -18951,6 +18998,10 @@ "comment" : "A section header for the timer widget settings.", "isCommentAutoGenerated" : true }, + "Title" : { + "comment" : "A label for the title input field in the note editor.", + "isCommentAutoGenerated" : true + }, "Toggle Notch Open:" : { "localizations" : { "ar" : { diff --git a/DynamicIsland/components/Notch/DynamicIslandHeader.swift b/DynamicIsland/components/Notch/DynamicIslandHeader.swift index 88c1536..0bef420 100644 --- a/DynamicIsland/components/Notch/DynamicIslandHeader.swift +++ b/DynamicIsland/components/Notch/DynamicIslandHeader.swift @@ -76,6 +76,8 @@ struct DynamicIslandHeader: View { ClipboardPanelManager.shared.toggleClipboardPanel() case .popover: showClipboardPopover.toggle() + case .separateTab: + coordinator.currentView = .notes } }) { Capsule() @@ -207,6 +209,8 @@ struct DynamicIslandHeader: View { .transition(.opacity) } + + if Defaults[.showBatteryIndicator] { DynamicIslandBatteryView( batteryWidth: 30, @@ -238,6 +242,12 @@ struct DynamicIslandHeader: View { ClipboardPanelManager.shared.toggleClipboardPanel() case .popover: showClipboardPopover.toggle() + case .separateTab: + if coordinator.currentView == .notes { + coordinator.currentView = .home + } else { + coordinator.currentView = .notes + } } } } diff --git a/DynamicIsland/components/Notch/NotchNotesView.swift b/DynamicIsland/components/Notch/NotchNotesView.swift new file mode 100644 index 0000000..a26c9c8 --- /dev/null +++ b/DynamicIsland/components/Notch/NotchNotesView.swift @@ -0,0 +1,980 @@ +// +// NotchNotesView.swift +// DynamicIsland +// +// Created by Assistant +// + +import SwiftUI +import Defaults + +struct NotchNotesView: View { + @FocusState private var isFocused: Bool + @Default(.savedNotes) var savedNotes + @Default(.clipboardDisplayMode) var clipboardDisplayMode + @Default(.enableClipboardManager) var enableClipboardManager + + @State private var selectedNoteId: UUID? + @State private var isEditingNewNote = false + + // 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? + + @Default(.enableNotes) var enableNotes + + var showSplitView: Bool { + return enableClipboardManager && clipboardDisplayMode == .separateTab + } + + var body: some View { + HStack(spacing: 0) { + if showSplitView { + NotchClipboardList() + .frame(maxWidth: .infinity) + + if enableNotes { + Divider() + .background(Color.white.opacity(0.15)) + } + } + + if enableNotes { + ZStack { + if isEditingNewNote || selectedNoteId != nil { + NoteEditorView( + title: $editorTitle, + content: $editorContent, + imageData: $editorImageData, + colorIndex: $editorColorIndex, + onSave: saveNote, + onCancel: cancelEdit, + isNew: isEditingNewNote + ) + .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .trailing))) + } else { + NoteListView( + notes: savedNotes, + onSelect: selectNote, + onCreate: createNote, + onDelete: deleteNote, + onDeleteItem: deleteNoteItem, + onClearAll: clearAllNotes, + onTogglePin: togglePin, + onCreateFromClipboard: createNoteFromClipboard + ) + .transition(.asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .leading))) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .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 + } + + 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 + isEditingNewNote = false + } + + private func saveNote() { + var notes = savedNotes + let now = Date() + + if let id = editorNoteId { + // 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 }) { + // 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 // 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 + } + + 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() + } + + private func cancelEdit() { + 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 + // Tiny delay to clear state after animation + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + editorTitle = "" + editorContent = "" + } + } +} + +// MARK: - Subviews + +struct NotchClipboardList: View { + @ObservedObject var clipboardManager = ClipboardManager.shared + @State private var hoveredItemId: UUID? + @State private var justCopiedId: UUID? + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("Clipboard") + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + + Spacer() + + if !clipboardManager.clipboardHistory.isEmpty { + Button(action: { clipboardManager.clearHistory() }) { + Image(systemName: "trash") + .font(.system(size: 14)) + .foregroundStyle(.white.opacity(0.6)) + .padding(6) + .background(Color.white.opacity(0.1)) + .clipShape(Circle()) + } + .buttonStyle(PlainButtonStyle()) + } + } + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 12) + + if clipboardManager.clipboardHistory.isEmpty { + VStack(spacing: 8) { + Image(systemName: "doc.on.clipboard") + .font(.system(size: 32)) + .foregroundStyle(.secondary.opacity(0.3)) + Text("No copies yet") + .font(.headline) + .foregroundStyle(.secondary) + Text("Copy text to see it here") + .font(.caption) + .foregroundStyle(.secondary.opacity(0.7)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.vertical, 30) // Add padding so it's not touching header + } else { + ScrollView { + LazyVStack(spacing: 4) { // Tighter spacing + ForEach(clipboardManager.clipboardHistory) { item in + NotchClipboardItemRow( + item: item, + isHovered: hoveredItemId == item.id, + justCopied: justCopiedId == item.id + ) + .contentShape(Rectangle()) + .onHover { isHovered in + hoveredItemId = isHovered ? item.id : nil + } + .onTapGesture { + clipboardManager.copyToClipboard(item) + withAnimation { + justCopiedId = item.id + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + if justCopiedId == item.id { + withAnimation { + justCopiedId = nil + } + } + } + } + } + } + .padding(.horizontal, 16) + .padding(.bottom, 20) + } + } + } + .frame(maxHeight: .infinity) + } +} + +struct NotchClipboardItemRow: View { + let item: ClipboardItem + let isHovered: Bool + let justCopied: Bool + + var body: some View { + HStack(spacing: 12) { + 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) + .font(.system(size: 13)) + .foregroundStyle(.white) + .lineLimit(1) + + HStack { + Text(item.type.displayName) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + Spacer() + Text(timeAgoString(from: item.timestamp)) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + } + } + .padding(10) + .background(Color(nsColor: .controlBackgroundColor).opacity(isHovered ? 0.3 : 0.15)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + 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" } + return "\(Int(interval/3600))h" + } +} + + +struct NoteListView: View { + let notes: [NoteItem] + let onSelect: (NoteItem) -> Void + let onCreate: () -> Void + 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: 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(5) + .background(Color.white.opacity(0.1)) + .clipShape(Circle()) + } + .buttonStyle(PlainButtonStyle()) + } + + Button(action: onCreate) { + Image(systemName: "plus") + .font(.system(size: 14, weight: .bold)) + .frame(width: 16, height: 16) + .foregroundStyle(.white) + .padding(5) + .background(Color.white.opacity(0.1)) + .clipShape(Circle()) + } + .buttonStyle(PlainButtonStyle()) + } + .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 { + 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) + + 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) + } + Text(note.title) + .font(.system(size: isCompact ? 12 : 14, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + } + + 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) + } + .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) + } + .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, 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 + self.isNew = isNew + } + + @FocusState private var isContentFocused: Bool + + var body: some View { + VStack(spacing: 0) { + // Toolbar + HStack { + Button(action: cancelAction) { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + Text("Notes") + } + .font(.system(size: 15, weight: .medium, design: .rounded)) + .foregroundStyle(.white) + } + .buttonStyle(PlainButtonStyle()) + + Spacer() + + Button(action: onSave) { + Text("Done") + .font(.system(size: 15, weight: .bold, design: .rounded)) + .foregroundStyle(NoteItem.colors[colorIndex]) + } + .buttonStyle(PlainButtonStyle()) + } + .padding(16) + + // Title & Color Picker Row + HStack(alignment: .center, spacing: 12) { + TextField("Title", text: $title) + .font(.system(size: 17, weight: .bold, design: .rounded)) + .textFieldStyle(.plain) + + Spacer() + + // Color Picker + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(0.. String { + SettingsTab.notes.highlightID(for: title) + } + + var body: some 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("Customize how you organize and create notes. Enabling color filtering and search helps manage large lists.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .navigationTitle("Notes") + } +} diff --git a/DynamicIsland/components/Tabs/TabSelectionView.swift b/DynamicIsland/components/Tabs/TabSelectionView.swift index 9d552a9..7916761 100644 --- a/DynamicIsland/components/Tabs/TabSelectionView.swift +++ b/DynamicIsland/components/Tabs/TabSelectionView.swift @@ -40,6 +40,12 @@ struct TabSelectionView: View { if Defaults[.enableStatsFeature] { tabsArray.append(TabModel(label: "Stats", icon: "chart.xyaxis.line", view: .stats)) } + + if Defaults[.enableNotes] || (Defaults[.enableClipboardManager] && Defaults[.clipboardDisplayMode] == .separateTab) { + let label = Defaults[.enableNotes] ? "Notes" : "Clipboard" + let icon = Defaults[.enableNotes] ? "note.text" : "doc.on.clipboard" + tabsArray.append(TabModel(label: label, icon: icon, view: .notes)) + } return tabsArray } diff --git a/DynamicIsland/enums/generic.swift b/DynamicIsland/enums/generic.swift index cbcaa27..14080ea 100644 --- a/DynamicIsland/enums/generic.swift +++ b/DynamicIsland/enums/generic.swift @@ -30,6 +30,8 @@ public enum NotchViews { case timer case stats case colorPicker + case notes + case clipboard } enum SettingsEnum { diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 0a5c9f5..918e2b1 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -105,11 +105,13 @@ enum CalendarSelectionState: Codable, Defaults.Serializable { enum ClipboardDisplayMode: String, CaseIterable, Codable, Defaults.Serializable { case popover = "popover" // Traditional popover attached to button case panel = "panel" // Floating panel near notch + case separateTab = "separateTab" // Separate tab in Dynamic Island var displayName: String { switch self { case .popover: return "Popover" case .panel: return "Panel" + case .separateTab: return "Separate Tab" } } @@ -117,6 +119,7 @@ enum ClipboardDisplayMode: String, CaseIterable, Codable, Defaults.Serializable switch self { case .popover: return "Shows clipboard as a dropdown attached to the clipboard button" case .panel: return "Shows clipboard in a floating panel near the notch" + case .separateTab: return "Shows copied items in a separate tab within the Dynamic Island (merges with Notes if enabled)" } } } @@ -377,6 +380,86 @@ struct AIModel: Codable, Identifiable, Defaults.Serializable { } } +struct NoteItem: Codable, Identifiable, Defaults.Serializable, Hashable { + var id: UUID = UUID() + var title: String + 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 { // MARK: General static let menubarIcon = Key("menubarIcon", default: true) @@ -662,6 +745,15 @@ extension Defaults.Keys { // MARK: Lyrics Feature static let enableLyrics = Key("enableLyrics", default: true) + // 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 static var defaultMediaController: MediaControllerType { if #available(macOS 15.4, *) { diff --git a/DynamicIsland/models/DynamicIslandViewModel.swift b/DynamicIsland/models/DynamicIslandViewModel.swift index c5de241..5b64b7e 100644 --- a/DynamicIsland/models/DynamicIslandViewModel.swift +++ b/DynamicIsland/models/DynamicIslandViewModel.swift @@ -269,6 +269,10 @@ class DynamicIslandViewModel: NSObject, ObservableObject { private func calculateDynamicNotchSize() -> CGSize { // Use minimalistic size if minimalistic UI is enabled let baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize + if DynamicIslandViewCoordinator.shared.currentView == .notes { + return CGSize(width: baseSize.width, height: 360) + } + return statsAdjustedNotchSize( from: baseSize, isStatsTabActive: DynamicIslandViewCoordinator.shared.currentView == .stats, 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