From 4106e845ee3986ec21b2d31c55102f8a12758eff Mon Sep 17 00:00:00 2001 From: delli Date: Sun, 14 Dec 2025 23:24:04 +0100 Subject: [PATCH 1/2] added a separate tab view for clipboard and added the possibility to write notes --- DynamicIsland/ContentView.swift | 4 + DynamicIsland/DynamicIslandApp.swift | 11 + DynamicIsland/Localizable.xcstrings | 49 +- .../Notch/DynamicIslandHeader.swift | 10 + .../components/Notch/NotchNotesView.swift | 545 ++++++++++++++++++ .../components/Settings/SettingsView.swift | 39 +- .../components/Tabs/TabSelectionView.swift | 6 + DynamicIsland/enums/generic.swift | 2 + DynamicIsland/models/Constants.swift | 24 + .../models/DynamicIslandViewModel.swift | 4 + 10 files changed, 688 insertions(+), 6 deletions(-) create mode 100644 DynamicIsland/components/Notch/NotchNotesView.swift diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 60fd3ea..fb0bdcb 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -565,6 +565,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..526be2c 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -578,6 +578,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 4d9a948..32ec5e0 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -5340,6 +5340,10 @@ } } }, + "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, @@ -5909,6 +5913,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, @@ -7102,6 +7110,10 @@ }, "Do Not Disturb" : { + }, + "Done" : { + "comment" : "A button label that indicates that the user has finished editing a note.", + "isCommentAutoGenerated" : true }, "Download" : { "localizations" : { @@ -8197,6 +8209,10 @@ }, "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.", @@ -13675,6 +13691,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" : { @@ -14105,6 +14125,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.", @@ -14657,6 +14680,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 @@ -14924,8 +14951,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." : { @@ -15077,8 +15105,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." : { @@ -16598,6 +16627,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, @@ -18230,6 +18263,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." : { }, @@ -18903,6 +18940,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..7247a9d --- /dev/null +++ b/DynamicIsland/components/Notch/NotchNotesView.swift @@ -0,0 +1,545 @@ +// +// 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 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, + 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 + ) + .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) + } + } + } + + // MARK: - Actions + + private func createNote() { + editorTitle = "" + editorContent = "" + editorColorIndex = 0 // Default Yellow + editorNoteId = UUID() + isEditingNewNote = true + } + + private func selectNote(_ note: NoteItem) { + editorTitle = note.title + editorContent = note.content + editorColorIndex = note.colorIndex + editorNoteId = note.id + selectedNoteId = note.id + isEditingNewNote = false + } + + private func saveNote() { + if let id = editorNoteId { + let newNote = NoteItem( + id: id, + title: editorTitle.isEmpty ? "Untitled Note" : editorTitle, + content: editorContent, + creationDate: Date(), + colorIndex: editorColorIndex + ) + + var notes = savedNotes + if let index = notes.firstIndex(where: { $0.id == id }) { + notes[index] = newNote + } else { + notes.insert(newNote, at: 0) + } + savedNotes = notes + } + + closeEditor() + } + + private func deleteNote(_ indexSet: IndexSet) { + var notes = savedNotes + notes.remove(atOffsets: indexSet) + savedNotes = notes + } + + private func deleteNoteItem(_ note: NoteItem) { + var notes = savedNotes + if let index = notes.firstIndex(where: { $0.id == note.id }) { + notes.remove(at: index) + savedNotes = notes + } + } + + private func clearAllNotes() { + savedNotes.removeAll() + } + + private func cancelEdit() { + 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) + .offset(y: -20) + } 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) + } + } + } + } +} + +struct NotchClipboardItemRow: View { + let item: ClipboardItem + let isHovered: Bool + let justCopied: Bool + + 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) + + 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 // Keep for swipe support + let onDeleteItem: (NoteItem) -> Void // New for button support + let onClearAll: () -> Void + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("Notes") + .font(.system(size: 18, weight: .semibold, design: .rounded)) // Slightly smaller, cleaner + .foregroundStyle(.white) + + Spacer() + + if !notes.isEmpty { + Button(action: onClearAll) { + Image(systemName: "trash") + .font(.system(size: 14)) + .foregroundStyle(.white.opacity(0.6)) + .padding(6) + .background(Color.white.opacity(0.1)) + .clipShape(Circle()) + } + .buttonStyle(PlainButtonStyle()) + } + + Button(action: onCreate) { + Image(systemName: "plus") + .font(.system(size: 14, weight: .semibold)) // Reduced size to match trash + .foregroundStyle(.white) + .padding(6) + .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) // Matched padding + .padding(.bottom, 20) + } + } + } + } +} + +struct NoteRow: View { + let note: NoteItem + let onDelete: () -> Void + @State private var isHovered = false + @State private var isCopied = false + + 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) + + 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 + } + + 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) + } + .buttonStyle(PlainButtonStyle()) + + Button(action: onDelete) { + Image(systemName: "trash") + .font(.system(size: 12)) // Matched icon size + .foregroundStyle(.red.opacity(0.8)) + .padding(6) + } + .buttonStyle(PlainButtonStyle()) + } + .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)) + } + } + .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 + } + } + } +} + +struct NoteEditorView: View { + @Binding var title: String + @Binding var content: String + @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) { + self._title = title + self._content = content + 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() // Push colors to the right + + // 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) + } header: { + Text("General") + } footer: { + Text("Enabling notes will add a quick access button to the Dynamic Island header.") + .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 4d6dc3d..30f7e85 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,23 @@ 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 + + static let colors: [Color] = [.yellow, .blue, .red, .green, .purple, .orange] + + var color: Color { + if colorIndex >= 0 && colorIndex < NoteItem.colors.count { + return NoteItem.colors[colorIndex] + } + return .yellow + } +} + extension Defaults.Keys { // MARK: General static let menubarIcon = Key("menubarIcon", default: true) @@ -639,6 +659,10 @@ 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 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 c55a852..1193bfa 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, From 9ab8a81af280644b0dac098b70452572c7bb7a8a Mon Sep 17 00:00:00 2001 From: delli Date: Wed, 24 Dec 2025 12:07:28 +0100 Subject: [PATCH 2/2] hoping the final version of the notes view (and clipboard) --- DynamicIsland/ContentView.swift | 8 + DynamicIsland/DynamicIslandApp.swift | 16 +- DynamicIsland/Localizable.xcstrings | 26 +- .../components/Notch/NotchNotesView.swift | 665 +++++++++++++++--- .../components/Settings/SettingsView.swift | 9 +- DynamicIsland/models/Constants.swift | 68 ++ DynamicIsland/sizing/matters.swift | 2 +- 7 files changed, 665 insertions(+), 129 deletions(-) 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