diff --git a/freewrite.xcodeproj/project.pbxproj b/freewrite.xcodeproj/project.pbxproj index f89a773..d940013 100644 --- a/freewrite.xcodeproj/project.pbxproj +++ b/freewrite.xcodeproj/project.pbxproj @@ -402,7 +402,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"freewrite/Preview Content\""; - DEVELOPMENT_TEAM = 2UDAY4J48G; + DEVELOPMENT_TEAM = 8Y3ZVN9AH6; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -444,7 +444,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"freewrite/Preview Content\""; - DEVELOPMENT_TEAM = 2UDAY4J48G; + DEVELOPMENT_TEAM = 8Y3ZVN9AH6; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; diff --git a/freewrite/ContentView.swift b/freewrite/ContentView.swift deleted file mode 100644 index cc4113a..0000000 --- a/freewrite/ContentView.swift +++ /dev/null @@ -1,1308 +0,0 @@ -// Swift 5.0 -// -// ContentView.swift -// freewrite -// -// Created by thorfinn on 2/14/25. -// - -import SwiftUI -import AppKit -import UniformTypeIdentifiers -import PDFKit - -struct HumanEntry: Identifiable { - let id: UUID - let date: String - let filename: String - var previewText: String - - static func createNew() -> HumanEntry { - let id = UUID() - let now = Date() - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" - let dateString = dateFormatter.string(from: now) - - // For display - dateFormatter.dateFormat = "MMM d" - let displayDate = dateFormatter.string(from: now) - - return HumanEntry( - id: id, - date: displayDate, - filename: "[\(id)]-[\(dateString)].md", - previewText: "" - ) - } -} - -struct HeartEmoji: Identifiable { - let id = UUID() - var position: CGPoint - var offset: CGFloat = 0 -} - -struct ContentView: View { - private let headerString = "\n\n" - @State private var entries: [HumanEntry] = [] - @State private var text: String = "" // Remove initial welcome text since we'll handle it in createNewEntry - - @State private var isFullscreen = false - @State private var selectedFont: String = "Lato-Regular" - @State private var currentRandomFont: String = "" - @State private var timeRemaining: Int = 900 // Changed to 900 seconds (15 minutes) - @State private var timerIsRunning = false - @State private var isHoveringTimer = false - @State private var isHoveringFullscreen = false - @State private var hoveredFont: String? = nil - @State private var isHoveringSize = false - @State private var fontSize: CGFloat = 18 - @State private var blinkCount = 0 - @State private var isBlinking = false - @State private var opacity: Double = 1.0 - @State private var shouldShowGray = true // New state to control color - @State private var lastClickTime: Date? = nil - @State private var bottomNavOpacity: Double = 1.0 - @State private var isHoveringBottomNav = false - @State private var selectedEntryIndex: Int = 0 - @State private var scrollOffset: CGFloat = 0 - @State private var selectedEntryId: UUID? = nil - @State private var hoveredEntryId: UUID? = nil - @State private var isHoveringChat = false // Add this state variable - @State private var showingChatMenu = false - @State private var chatMenuAnchor: CGPoint = .zero - @State private var showingSidebar = false // Add this state variable - @State private var hoveredTrashId: UUID? = nil - @State private var hoveredExportId: UUID? = nil - @State private var placeholderText: String = "" // Add this line - @State private var isHoveringNewEntry = false - @State private var isHoveringClock = false - @State private var isHoveringHistory = false - @State private var isHoveringHistoryText = false - @State private var isHoveringHistoryPath = false - @State private var isHoveringHistoryArrow = false - @State private var colorScheme: ColorScheme = .light // Add state for color scheme - @State private var isHoveringThemeToggle = false // Add state for theme toggle hover - let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() - let entryHeight: CGFloat = 40 - - let availableFonts = NSFontManager.shared.availableFontFamilies - let standardFonts = ["Lato-Regular", "Arial", ".AppleSystemUIFont", "Times New Roman"] - let fontSizes: [CGFloat] = [16, 18, 20, 22, 24, 26] - let placeholderOptions = [ - "\n\nBegin writing", - "\n\nPick a thought and go", - "\n\nStart typing", - "\n\nWhat's on your mind", - "\n\nJust start", - "\n\nType your first thought", - "\n\nStart with one sentence", - "\n\nJust say it" - ] - - // Add file manager and save timer - private let fileManager = FileManager.default - private let saveTimer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect() - - // Add cached documents directory - private let documentsDirectory: URL = { - let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("Freewrite") - - // Create Freewrite directory if it doesn't exist - if !FileManager.default.fileExists(atPath: directory.path) { - do { - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - print("Successfully created Freewrite directory") - } catch { - print("Error creating directory: \(error)") - } - } - - return directory - }() - - // Add shared prompt constant - private let aiChatPrompt = """ - below is my journal entry. wyt? talk through it with me like a friend. don't therpaize me and give me a whole breakdown, don't repeat my thoughts with headings. really take all of this, and tell me back stuff truly as if you're an old homie. - - Keep it casual, dont say yo, help me make new connections i don't see, comfort, validate, challenge, all of it. dont be afraid to say a lot. format with markdown headings if needed. - - do not just go through every single thing i say, and say it back to me. you need to proccess everythikng is say, make connections i don't see it, and deliver it all back to me as a story that makes me feel what you think i wanna feel. thats what the best therapists do. - - ideally, you're style/tone should sound like the user themselves. it's as if the user is hearing their own tone but it should still feel different, because you have different things to say and don't just repeat back they say. - - else, start by saying, "hey, thanks for showing me this. my thoughts:" - - my entry: - """ - - private let claudePrompt = """ - Take a look at my journal entry below. I'd like you to analyze it and respond with deep insight that feels personal, not clinical. - Imagine you're not just a friend, but a mentor who truly gets both my tech background and my psychological patterns. I want you to uncover the deeper meaning and emotional undercurrents behind my scattered thoughts. - Keep it casual, dont say yo, help me make new connections i don't see, comfort, validate, challenge, all of it. dont be afraid to say a lot. format with markdown headings if needed. - Use vivid metaphors and powerful imagery to help me see what I'm really building. Organize your thoughts with meaningful headings that create a narrative journey through my ideas. - Don't just validate my thoughts - reframe them in a way that shows me what I'm really seeking beneath the surface. Go beyond the product concepts to the emotional core of what I'm trying to solve. - Be willing to be profound and philosophical without sounding like you're giving therapy. I want someone who can see the patterns I can't see myself and articulate them in a way that feels like an epiphany. - Start with 'hey, thanks for showing me this. my thoughts:' and then use markdown headings to structure your response. - - Here's my journal entry: - """ - - // Initialize with saved theme preference if available - init() { - // Load saved color scheme preference - let savedScheme = UserDefaults.standard.string(forKey: "colorScheme") ?? "light" - _colorScheme = State(initialValue: savedScheme == "dark" ? .dark : .light) - } - - // Modify getDocumentsDirectory to use cached value - private func getDocumentsDirectory() -> URL { - return documentsDirectory - } - - // Add function to save text - private func saveText() { - let documentsDirectory = getDocumentsDirectory() - let fileURL = documentsDirectory.appendingPathComponent("entry.md") - - print("Attempting to save file to: \(fileURL.path)") - - do { - try text.write(to: fileURL, atomically: true, encoding: .utf8) - print("Successfully saved file") - } catch { - print("Error saving file: \(error)") - print("Error details: \(error.localizedDescription)") - } - } - - // Add function to load text - private func loadText() { - let documentsDirectory = getDocumentsDirectory() - let fileURL = documentsDirectory.appendingPathComponent("entry.md") - - print("Attempting to load file from: \(fileURL.path)") - - do { - if fileManager.fileExists(atPath: fileURL.path) { - text = try String(contentsOf: fileURL, encoding: .utf8) - print("Successfully loaded file") - } else { - print("File does not exist yet") - } - } catch { - print("Error loading file: \(error)") - print("Error details: \(error.localizedDescription)") - } - } - - // Add function to load existing entries - private func loadExistingEntries() { - let documentsDirectory = getDocumentsDirectory() - print("Looking for entries in: \(documentsDirectory.path)") - - do { - let fileURLs = try fileManager.contentsOfDirectory(at: documentsDirectory, includingPropertiesForKeys: nil) - let mdFiles = fileURLs.filter { $0.pathExtension == "md" } - - print("Found \(mdFiles.count) .md files") - - // Process each file - let entriesWithDates = mdFiles.compactMap { fileURL -> (entry: HumanEntry, date: Date, content: String)? in - let filename = fileURL.lastPathComponent - print("Processing: \(filename)") - - // Extract UUID and date from filename - pattern [uuid]-[yyyy-MM-dd-HH-mm-ss].md - guard let uuidMatch = filename.range(of: "\\[(.*?)\\]", options: .regularExpression), - let dateMatch = filename.range(of: "\\[(\\d{4}-\\d{2}-\\d{2}-\\d{2}-\\d{2}-\\d{2})\\]", options: .regularExpression), - let uuid = UUID(uuidString: String(filename[uuidMatch].dropFirst().dropLast())) else { - print("Failed to extract UUID or date from filename: \(filename)") - return nil - } - - // Parse the date string - let dateString = String(filename[dateMatch].dropFirst().dropLast()) - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" - - guard let fileDate = dateFormatter.date(from: dateString) else { - print("Failed to parse date from filename: \(filename)") - return nil - } - - // Read file contents for preview - do { - let content = try String(contentsOf: fileURL, encoding: .utf8) - let preview = content - .replacingOccurrences(of: "\n", with: " ") - .trimmingCharacters(in: .whitespacesAndNewlines) - let truncated = preview.isEmpty ? "" : (preview.count > 30 ? String(preview.prefix(30)) + "..." : preview) - - // Format display date - dateFormatter.dateFormat = "MMM d" - let displayDate = dateFormatter.string(from: fileDate) - - return ( - entry: HumanEntry( - id: uuid, - date: displayDate, - filename: filename, - previewText: truncated - ), - date: fileDate, - content: content // Store the full content to check for welcome message - ) - } catch { - print("Error reading file: \(error)") - return nil - } - } - - // Sort and extract entries - entries = entriesWithDates - .sorted { $0.date > $1.date } // Sort by actual date from filename - .map { $0.entry } - - print("Successfully loaded and sorted \(entries.count) entries") - - // Check if we need to create a new entry - let calendar = Calendar.current - let today = Date() - let todayStart = calendar.startOfDay(for: today) - - // Check if there's an empty entry from today - let hasEmptyEntryToday = entries.contains { entry in - // Convert the display date (e.g. "Mar 14") to a Date object - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "MMM d" - if let entryDate = dateFormatter.date(from: entry.date) { - // Set year component to current year since our stored dates don't include year - var components = calendar.dateComponents([.year, .month, .day], from: entryDate) - components.year = calendar.component(.year, from: today) - - // Get start of day for the entry date - if let entryDateWithYear = calendar.date(from: components) { - let entryDayStart = calendar.startOfDay(for: entryDateWithYear) - return calendar.isDate(entryDayStart, inSameDayAs: todayStart) && entry.previewText.isEmpty - } - } - return false - } - - // Check if we have only one entry and it's the welcome message - let hasOnlyWelcomeEntry = entries.count == 1 && entriesWithDates.first?.content.contains("Welcome to Freewrite.") == true - - if entries.isEmpty { - // First time user - create entry with welcome message - print("First time user, creating welcome entry") - createNewEntry() - } else if !hasEmptyEntryToday && !hasOnlyWelcomeEntry { - // No empty entry for today and not just the welcome entry - create new entry - print("No empty entry for today, creating new entry") - createNewEntry() - } else { - // Select the most recent empty entry from today or the welcome entry - if let todayEntry = entries.first(where: { entry in - // Convert the display date (e.g. "Mar 14") to a Date object - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "MMM d" - if let entryDate = dateFormatter.date(from: entry.date) { - // Set year component to current year since our stored dates don't include year - var components = calendar.dateComponents([.year, .month, .day], from: entryDate) - components.year = calendar.component(.year, from: today) - - // Get start of day for the entry date - if let entryDateWithYear = calendar.date(from: components) { - let entryDayStart = calendar.startOfDay(for: entryDateWithYear) - return calendar.isDate(entryDayStart, inSameDayAs: todayStart) && entry.previewText.isEmpty - } - } - return false - }) { - selectedEntryId = todayEntry.id - loadEntry(entry: todayEntry) - } else if hasOnlyWelcomeEntry { - // If we only have the welcome entry, select it - selectedEntryId = entries[0].id - loadEntry(entry: entries[0]) - } - } - - } catch { - print("Error loading directory contents: \(error)") - print("Creating default entry after error") - createNewEntry() - } - } - - var randomButtonTitle: String { - return currentRandomFont.isEmpty ? "Random" : "Random [\(currentRandomFont)]" - } - - var timerButtonTitle: String { - if !timerIsRunning && timeRemaining == 900 { - return "15:00" - } - let minutes = timeRemaining / 60 - let seconds = timeRemaining % 60 - return String(format: "%d:%02d", minutes, seconds) - } - - var timerColor: Color { - if timerIsRunning { - return isHoveringTimer ? (colorScheme == .light ? .black : .white) : .gray.opacity(0.8) - } else { - return isHoveringTimer ? (colorScheme == .light ? .black : .white) : (colorScheme == .light ? .gray : .gray.opacity(0.8)) - } - } - - var lineHeight: CGFloat { - let font = NSFont(name: selectedFont, size: fontSize) ?? .systemFont(ofSize: fontSize) - let defaultLineHeight = getLineHeight(font: font) - return (fontSize * 1.5) - defaultLineHeight - } - - var fontSizeButtonTitle: String { - return "\(Int(fontSize))px" - } - - var placeholderOffset: CGFloat { - // Instead of using calculated line height, use a simple offset - return fontSize / 2 - } - - // Add a color utility computed property - var popoverBackgroundColor: Color { - return colorScheme == .light ? Color(NSColor.controlBackgroundColor) : Color(NSColor.darkGray) - } - - var popoverTextColor: Color { - return colorScheme == .light ? Color.primary : Color.white - } - - var body: some View { - let buttonBackground = colorScheme == .light ? Color.white : Color.black - let navHeight: CGFloat = 68 - let textColor = colorScheme == .light ? Color.gray : Color.gray.opacity(0.8) - let textHoverColor = colorScheme == .light ? Color.black : Color.white - - HStack(spacing: 0) { - // Main content - ZStack { - Color(colorScheme == .light ? .white : .black) - .ignoresSafeArea() - - TextEditor(text: Binding( - get: { text }, - set: { newValue in - // Ensure the text always starts with two newlines - if !newValue.hasPrefix("\n\n") { - text = "\n\n" + newValue.trimmingCharacters(in: .newlines) - } else { - text = newValue - } - } - )) - .background(Color(colorScheme == .light ? .white : .black)) - .font(.custom(selectedFont, size: fontSize)) - .foregroundColor(colorScheme == .light ? Color(red: 0.20, green: 0.20, blue: 0.20) : Color(red: 0.9, green: 0.9, blue: 0.9)) - .scrollContentBackground(.hidden) - .scrollIndicators(.never) - .lineSpacing(lineHeight) - .frame(maxWidth: 650) - .id("\(selectedFont)-\(fontSize)-\(colorScheme)") - .padding(.bottom, bottomNavOpacity > 0 ? navHeight : 0) - .ignoresSafeArea() - .colorScheme(colorScheme) - .onAppear { - placeholderText = placeholderOptions.randomElement() ?? "\n\nBegin writing" - // Removed findSubview code which was causing errors - } - .overlay( - ZStack(alignment: .topLeading) { - if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - Text(placeholderText) - .font(.custom(selectedFont, size: fontSize)) - .foregroundColor(colorScheme == .light ? .gray.opacity(0.5) : .gray.opacity(0.6)) - // .padding(.top, 8) - // .padding(.leading, 8) - .allowsHitTesting(false) - .offset(x: 5, y: placeholderOffset) - } - }, alignment: .topLeading - ) - - VStack { - Spacer() - HStack { - // Font buttons (moved to left) - HStack(spacing: 8) { - Button(fontSizeButtonTitle) { - if let currentIndex = fontSizes.firstIndex(of: fontSize) { - let nextIndex = (currentIndex + 1) % fontSizes.count - fontSize = fontSizes[nextIndex] - } - } - .buttonStyle(.plain) - .foregroundColor(isHoveringSize ? textHoverColor : textColor) - .onHover { hovering in - isHoveringSize = hovering - isHoveringBottomNav = hovering - if hovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - - Text("•") - .foregroundColor(.gray) - - Button("Lato") { - selectedFont = "Lato-Regular" - currentRandomFont = "" - } - .buttonStyle(.plain) - .foregroundColor(hoveredFont == "Lato" ? textHoverColor : textColor) - .onHover { hovering in - hoveredFont = hovering ? "Lato" : nil - isHoveringBottomNav = hovering - if hovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - - Text("•") - .foregroundColor(.gray) - - Button("Arial") { - selectedFont = "Arial" - currentRandomFont = "" - } - .buttonStyle(.plain) - .foregroundColor(hoveredFont == "Arial" ? textHoverColor : textColor) - .onHover { hovering in - hoveredFont = hovering ? "Arial" : nil - isHoveringBottomNav = hovering - if hovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - - Text("•") - .foregroundColor(.gray) - - Button("System") { - selectedFont = ".AppleSystemUIFont" - currentRandomFont = "" - } - .buttonStyle(.plain) - .foregroundColor(hoveredFont == "System" ? textHoverColor : textColor) - .onHover { hovering in - hoveredFont = hovering ? "System" : nil - isHoveringBottomNav = hovering - if hovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - - Text("•") - .foregroundColor(.gray) - - Button("Serif") { - selectedFont = "Times New Roman" - currentRandomFont = "" - } - .buttonStyle(.plain) - .foregroundColor(hoveredFont == "Serif" ? textHoverColor : textColor) - .onHover { hovering in - hoveredFont = hovering ? "Serif" : nil - isHoveringBottomNav = hovering - if hovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - - Text("•") - .foregroundColor(.gray) - - Button(randomButtonTitle) { - if let randomFont = availableFonts.randomElement() { - selectedFont = randomFont - currentRandomFont = randomFont - } - } - .buttonStyle(.plain) - .foregroundColor(hoveredFont == "Random" ? textHoverColor : textColor) - .onHover { hovering in - hoveredFont = hovering ? "Random" : nil - isHoveringBottomNav = hovering - if hovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - } - .padding(8) - .cornerRadius(6) - .onHover { hovering in - isHoveringBottomNav = hovering - } - - Spacer() - - // Utility buttons (moved to right) - HStack(spacing: 8) { - Button(timerButtonTitle) { - let now = Date() - if let lastClick = lastClickTime, - now.timeIntervalSince(lastClick) < 0.3 { - timeRemaining = 900 - timerIsRunning = false - lastClickTime = nil - } else { - timerIsRunning.toggle() - lastClickTime = now - } - } - .buttonStyle(.plain) - .foregroundColor(timerColor) - .onHover { hovering in - isHoveringTimer = hovering - isHoveringBottomNav = hovering - if hovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - .onAppear { - NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in - if isHoveringTimer { - let scrollBuffer = event.deltaY * 0.25 - - if abs(scrollBuffer) >= 0.1 { - let currentMinutes = timeRemaining / 60 - NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .now) - let direction = -scrollBuffer > 0 ? 5 : -5 - let newMinutes = currentMinutes + direction - let roundedMinutes = (newMinutes / 5) * 5 - let newTime = roundedMinutes * 60 - timeRemaining = min(max(newTime, 0), 2700) - } - } - return event - } - } - - Text("•") - .foregroundColor(.gray) - - Button("Chat") { - showingChatMenu = true - } - .buttonStyle(.plain) - .foregroundColor(isHoveringChat ? textHoverColor : textColor) - .onHover { hovering in - isHoveringChat = hovering - isHoveringBottomNav = hovering - if hovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - .popover(isPresented: $showingChatMenu, attachmentAnchor: .point(UnitPoint(x: 0.5, y: 0)), arrowEdge: .top) { - if text.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("hi. my name is farza.") { - Text("Yo. Sorry, you can't chat with the guide lol. Please write your own entry.") - .font(.system(size: 14)) - .foregroundColor(popoverTextColor) - .frame(width: 250) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(popoverBackgroundColor) - .cornerRadius(8) - .shadow(color: Color.black.opacity(0.1), radius: 4, y: 2) - } else if text.count < 350 { - Text("Please free write for at minimum 5 minutes first. Then click this. Trust.") - .font(.system(size: 14)) - .foregroundColor(popoverTextColor) - .frame(width: 250) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(popoverBackgroundColor) - .cornerRadius(8) - .shadow(color: Color.black.opacity(0.1), radius: 4, y: 2) - } else { - VStack(spacing: 0) { - Button(action: { - showingChatMenu = false - openChatGPT() - }) { - Text("ChatGPT") - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .padding(.vertical, 8) - } - .buttonStyle(.plain) - .foregroundColor(popoverTextColor) - - Divider() - - Button(action: { - showingChatMenu = false - openClaude() - }) { - Text("Claude") - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .padding(.vertical, 8) - } - .buttonStyle(.plain) - .foregroundColor(popoverTextColor) - } - .frame(width: 120) - .background(popoverBackgroundColor) - .cornerRadius(8) - .shadow(color: Color.black.opacity(0.1), radius: 4, y: 2) - } - } - - Text("•") - .foregroundColor(.gray) - - Button(isFullscreen ? "Minimize" : "Fullscreen") { - if let window = NSApplication.shared.windows.first { - window.toggleFullScreen(nil) - } - } - .buttonStyle(.plain) - .foregroundColor(isHoveringFullscreen ? textHoverColor : textColor) - .onHover { hovering in - isHoveringFullscreen = hovering - isHoveringBottomNav = hovering - if hovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - - Text("•") - .foregroundColor(.gray) - - Button(action: { - createNewEntry() - }) { - Text("New Entry") - .font(.system(size: 13)) - } - .buttonStyle(.plain) - .foregroundColor(isHoveringNewEntry ? textHoverColor : textColor) - .onHover { hovering in - isHoveringNewEntry = hovering - isHoveringBottomNav = hovering - if hovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - - Text("•") - .foregroundColor(.gray) - - // Theme toggle button - Button(action: { - colorScheme = colorScheme == .light ? .dark : .light - // Save preference - UserDefaults.standard.set(colorScheme == .light ? "light" : "dark", forKey: "colorScheme") - }) { - Image(systemName: colorScheme == .light ? "moon.fill" : "sun.max.fill") - .foregroundColor(isHoveringThemeToggle ? textHoverColor : textColor) - } - .buttonStyle(.plain) - .onHover { hovering in - isHoveringThemeToggle = hovering - isHoveringBottomNav = hovering - if hovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - - Text("•") - .foregroundColor(.gray) - - // Version history button - Button(action: { - withAnimation(.easeInOut(duration: 0.2)) { - showingSidebar.toggle() - } - }) { - Image(systemName: "clock.arrow.circlepath") - .foregroundColor(isHoveringClock ? textHoverColor : textColor) - } - .buttonStyle(.plain) - .onHover { hovering in - isHoveringClock = hovering - isHoveringBottomNav = hovering - if hovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - } - .padding(8) - .cornerRadius(6) - .onHover { hovering in - isHoveringBottomNav = hovering - } - } - .padding() - .background(Color(colorScheme == .light ? .white : .black)) - .opacity(bottomNavOpacity) - .onHover { hovering in - isHoveringBottomNav = hovering - if hovering { - withAnimation(.easeOut(duration: 0.2)) { - bottomNavOpacity = 1.0 - } - } else if timerIsRunning { - withAnimation(.easeIn(duration: 1.0)) { - bottomNavOpacity = 0.0 - } - } - } - } - } - - // Right sidebar - if showingSidebar { - Divider() - - VStack(spacing: 0) { - // Header - Button(action: { - NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: getDocumentsDirectory().path) - }) { - HStack { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 4) { - Text("History") - .font(.system(size: 13)) - .foregroundColor(isHoveringHistory ? textHoverColor : textColor) - Image(systemName: "arrow.up.right") - .font(.system(size: 10)) - .foregroundColor(isHoveringHistory ? textHoverColor : textColor) - } - Text(getDocumentsDirectory().path) - .font(.system(size: 10)) - .foregroundColor(.secondary) - .lineLimit(1) - } - Spacer() - } - } - .buttonStyle(.plain) - .padding(.horizontal, 16) - .padding(.vertical, 12) - .onHover { hovering in - isHoveringHistory = hovering - } - - Divider() - - // Entries List - ScrollView { - LazyVStack(spacing: 0) { - ForEach(entries) { entry in - Button(action: { - if selectedEntryId != entry.id { - // Save current entry before switching - if let currentId = selectedEntryId, - let currentEntry = entries.first(where: { $0.id == currentId }) { - saveEntry(entry: currentEntry) - } - - selectedEntryId = entry.id - loadEntry(entry: entry) - } - }) { - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(entry.previewText) - .font(.system(size: 13)) - .lineLimit(1) - .foregroundColor(.primary) - - Spacer() - - // Export/Trash icons that appear on hover - if hoveredEntryId == entry.id { - HStack(spacing: 8) { - // Export PDF button - Button(action: { - exportEntryAsPDF(entry: entry) - }) { - Image(systemName: "arrow.down.circle") - .font(.system(size: 11)) - .foregroundColor(hoveredExportId == entry.id ? - (colorScheme == .light ? .black : .white) : - (colorScheme == .light ? .gray : .gray.opacity(0.8))) - } - .buttonStyle(.plain) - .help("Export entry as PDF") - .onHover { hovering in - withAnimation(.easeInOut(duration: 0.2)) { - hoveredExportId = hovering ? entry.id : nil - } - if hovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - - // Trash icon - Button(action: { - deleteEntry(entry: entry) - }) { - Image(systemName: "trash") - .font(.system(size: 11)) - .foregroundColor(hoveredTrashId == entry.id ? .red : .gray) - } - .buttonStyle(.plain) - .onHover { hovering in - withAnimation(.easeInOut(duration: 0.2)) { - hoveredTrashId = hovering ? entry.id : nil - } - if hovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - } - } - } - - Text(entry.date) - .font(.system(size: 12)) - .foregroundColor(.secondary) - } - } - .frame(maxWidth: .infinity) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 4) - .fill(backgroundColor(for: entry)) - ) - } - .buttonStyle(PlainButtonStyle()) - .contentShape(Rectangle()) - .onHover { hovering in - withAnimation(.easeInOut(duration: 0.2)) { - hoveredEntryId = hovering ? entry.id : nil - } - } - .onAppear { - NSCursor.pop() // Reset cursor when button appears - } - .help("Click to select this entry") // Add tooltip - - if entry.id != entries.last?.id { - Divider() - } - } - } - } - .scrollIndicators(.never) - } - .frame(width: 200) - .background(Color(colorScheme == .light ? .white : NSColor.black)) - } - } - .frame(minWidth: 1100, minHeight: 600) - .animation(.easeInOut(duration: 0.2), value: showingSidebar) - .preferredColorScheme(colorScheme) - .onAppear { - showingSidebar = false // Hide sidebar by default - loadExistingEntries() - } - .onChange(of: text) { _ in - // Save current entry when text changes - if let currentId = selectedEntryId, - let currentEntry = entries.first(where: { $0.id == currentId }) { - saveEntry(entry: currentEntry) - } - } - .onReceive(timer) { _ in - if timerIsRunning && timeRemaining > 0 { - timeRemaining -= 1 - } else if timeRemaining == 0 { - timerIsRunning = false - if !isHoveringBottomNav { - withAnimation(.easeOut(duration: 1.0)) { - bottomNavOpacity = 1.0 - } - } - } - } - .onReceive(NotificationCenter.default.publisher(for: NSWindow.willEnterFullScreenNotification)) { _ in - isFullscreen = true - } - .onReceive(NotificationCenter.default.publisher(for: NSWindow.willExitFullScreenNotification)) { _ in - isFullscreen = false - } - } - - private func backgroundColor(for entry: HumanEntry) -> Color { - if entry.id == selectedEntryId { - return Color.gray.opacity(0.1) // More subtle selection highlight - } else if entry.id == hoveredEntryId { - return Color.gray.opacity(0.05) // Even more subtle hover state - } else { - return Color.clear - } - } - - private func updatePreviewText(for entry: HumanEntry) { - let documentsDirectory = getDocumentsDirectory() - let fileURL = documentsDirectory.appendingPathComponent(entry.filename) - - do { - let content = try String(contentsOf: fileURL, encoding: .utf8) - let preview = content - .replacingOccurrences(of: "\n", with: " ") - .trimmingCharacters(in: .whitespacesAndNewlines) - let truncated = preview.isEmpty ? "" : (preview.count > 30 ? String(preview.prefix(30)) + "..." : preview) - - // Find and update the entry in the entries array - if let index = entries.firstIndex(where: { $0.id == entry.id }) { - entries[index].previewText = truncated - } - } catch { - print("Error updating preview text: \(error)") - } - } - - private func saveEntry(entry: HumanEntry) { - let documentsDirectory = getDocumentsDirectory() - let fileURL = documentsDirectory.appendingPathComponent(entry.filename) - - do { - try text.write(to: fileURL, atomically: true, encoding: .utf8) - print("Successfully saved entry: \(entry.filename)") - updatePreviewText(for: entry) // Update preview after saving - } catch { - print("Error saving entry: \(error)") - } - } - - private func loadEntry(entry: HumanEntry) { - let documentsDirectory = getDocumentsDirectory() - let fileURL = documentsDirectory.appendingPathComponent(entry.filename) - - do { - if fileManager.fileExists(atPath: fileURL.path) { - text = try String(contentsOf: fileURL, encoding: .utf8) - print("Successfully loaded entry: \(entry.filename)") - } - } catch { - print("Error loading entry: \(error)") - } - } - - private func createNewEntry() { - let newEntry = HumanEntry.createNew() - entries.insert(newEntry, at: 0) // Add to the beginning - selectedEntryId = newEntry.id - - // If this is the first entry (entries was empty before adding this one) - if entries.count == 1 { - // Read welcome message from default.md - if let defaultMessageURL = Bundle.main.url(forResource: "default", withExtension: "md"), - let defaultMessage = try? String(contentsOf: defaultMessageURL, encoding: .utf8) { - text = "\n\n" + defaultMessage - } - // Save the welcome message immediately - saveEntry(entry: newEntry) - // Update the preview text - updatePreviewText(for: newEntry) - } else { - // Regular new entry starts with newlines - text = "\n\n" - // Randomize placeholder text for new entry - placeholderText = placeholderOptions.randomElement() ?? "\n\nBegin writing" - // Save the empty entry - saveEntry(entry: newEntry) - } - } - - private func openChatGPT() { - let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) - let fullText = aiChatPrompt + "\n\n" + trimmedText - - if let encodedText = fullText.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), - let url = URL(string: "https://chat.openai.com/?m=" + encodedText) { - NSWorkspace.shared.open(url) - } - } - - private func openClaude() { - let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) - let fullText = claudePrompt + "\n\n" + trimmedText - - if let encodedText = fullText.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), - let url = URL(string: "https://claude.ai/new?q=" + encodedText) { - NSWorkspace.shared.open(url) - } - } - - private func deleteEntry(entry: HumanEntry) { - // Delete the file from the filesystem - let documentsDirectory = getDocumentsDirectory() - let fileURL = documentsDirectory.appendingPathComponent(entry.filename) - - do { - try fileManager.removeItem(at: fileURL) - print("Successfully deleted file: \(entry.filename)") - - // Remove the entry from the entries array - if let index = entries.firstIndex(where: { $0.id == entry.id }) { - entries.remove(at: index) - - // If the deleted entry was selected, select the first entry or create a new one - if selectedEntryId == entry.id { - if let firstEntry = entries.first { - selectedEntryId = firstEntry.id - loadEntry(entry: firstEntry) - } else { - createNewEntry() - } - } - } - } catch { - print("Error deleting file: \(error)") - } - } - - // Extract a title from entry content for PDF export - private func extractTitleFromContent(_ content: String, date: String) -> String { - // Clean up content by removing leading/trailing whitespace and newlines - let trimmedContent = content.trimmingCharacters(in: .whitespacesAndNewlines) - - // If content is empty, just use the date - if trimmedContent.isEmpty { - return "Entry \(date)" - } - - // Split content into words, ignoring newlines and removing punctuation - let words = trimmedContent - .replacingOccurrences(of: "\n", with: " ") - .components(separatedBy: .whitespaces) - .filter { !$0.isEmpty } - .map { word in - word.trimmingCharacters(in: CharacterSet(charactersIn: ".,!?;:\"'()[]{}<>")) - .lowercased() - } - .filter { !$0.isEmpty } - - // If we have at least 4 words, use them - if words.count >= 4 { - return "\(words[0])-\(words[1])-\(words[2])-\(words[3])" - } - - // If we have fewer than 4 words, use what we have - if !words.isEmpty { - return words.joined(separator: "-") - } - - // Fallback to date if no words found - return "Entry \(date)" - } - - private func exportEntryAsPDF(entry: HumanEntry) { - // First make sure the current entry is saved - if selectedEntryId == entry.id { - saveEntry(entry: entry) - } - - // Get entry content - let documentsDirectory = getDocumentsDirectory() - let fileURL = documentsDirectory.appendingPathComponent(entry.filename) - - do { - // Read the content of the entry - let entryContent = try String(contentsOf: fileURL, encoding: .utf8) - - // Extract a title from the entry content and add .pdf extension - let suggestedFilename = extractTitleFromContent(entryContent, date: entry.date) + ".pdf" - - // Create save panel - let savePanel = NSSavePanel() - savePanel.allowedContentTypes = [UTType.pdf] - savePanel.nameFieldStringValue = suggestedFilename - savePanel.isExtensionHidden = false // Make sure extension is visible - - // Show save dialog - if savePanel.runModal() == .OK, let url = savePanel.url { - // Create PDF data - if let pdfData = createPDFFromText(text: entryContent) { - try pdfData.write(to: url) - print("Successfully exported PDF to: \(url.path)") - } - } - } catch { - print("Error in PDF export: \(error)") - } - } - - private func createPDFFromText(text: String) -> Data? { - // Letter size page dimensions - let pageWidth: CGFloat = 612.0 // 8.5 x 72 - let pageHeight: CGFloat = 792.0 // 11 x 72 - let margin: CGFloat = 72.0 // 1-inch margins - - // Calculate content area - let contentRect = CGRect( - x: margin, - y: margin, - width: pageWidth - (margin * 2), - height: pageHeight - (margin * 2) - ) - - // Create PDF data container - let pdfData = NSMutableData() - - // Configure text formatting attributes - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.lineSpacing = lineHeight - - let font = NSFont(name: selectedFont, size: fontSize) ?? .systemFont(ofSize: fontSize) - let textAttributes: [NSAttributedString.Key: Any] = [ - .font: font, - .foregroundColor: NSColor(red: 0.20, green: 0.20, blue: 0.20, alpha: 1.0), - .paragraphStyle: paragraphStyle - ] - - // Trim the initial newlines before creating the PDF - let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) - - // Create the attributed string with formatting - let attributedString = NSAttributedString(string: trimmedText, attributes: textAttributes) - - // Create a Core Text framesetter for text layout - let framesetter = CTFramesetterCreateWithAttributedString(attributedString) - - // Create a PDF context with the data consumer - guard let pdfContext = CGContext(consumer: CGDataConsumer(data: pdfData as CFMutableData)!, mediaBox: nil, nil) else { - print("Failed to create PDF context") - return nil - } - - // Track position within text - var currentRange = CFRange(location: 0, length: 0) - var pageIndex = 0 - - // Create a path for the text frame - let framePath = CGMutablePath() - framePath.addRect(contentRect) - - // Continue creating pages until all text is processed - while currentRange.location < attributedString.length { - // Begin a new PDF page - pdfContext.beginPage(mediaBox: nil) - - // Fill the page with white background - pdfContext.setFillColor(NSColor.white.cgColor) - pdfContext.fill(CGRect(x: 0, y: 0, width: pageWidth, height: pageHeight)) - - // Create a frame for this page's text - let frame = CTFramesetterCreateFrame( - framesetter, - currentRange, - framePath, - nil - ) - - // Draw the text frame - CTFrameDraw(frame, pdfContext) - - // Get the range of text that was actually displayed in this frame - let visibleRange = CTFrameGetVisibleStringRange(frame) - - // Move to the next block of text for the next page - currentRange.location += visibleRange.length - - // Finish the page - pdfContext.endPage() - pageIndex += 1 - - // Safety check - don't allow infinite loops - if pageIndex > 1000 { - print("Safety limit reached - stopping PDF generation") - break - } - } - - // Finalize the PDF document - pdfContext.closePDF() - - return pdfData as Data - } -} - -// Helper function to calculate line height -func getLineHeight(font: NSFont) -> CGFloat { - return font.ascender - font.descender + font.leading -} - -// Add helper extension to find NSTextView -extension NSView { - func findTextView() -> NSView? { - if self is NSTextView { - return self - } - for subview in subviews { - if let textView = subview.findTextView() { - return textView - } - } - return nil - } -} - -// Add helper extension for finding subviews of a specific type -extension NSView { - func findSubview(ofType type: T.Type) -> T? { - if let typedSelf = self as? T { - return typedSelf - } - for subview in subviews { - if let found = subview.findSubview(ofType: type) { - return found - } - } - return nil - } -} - -#Preview { - ContentView() -} \ No newline at end of file diff --git a/freewrite/Helpers/GetLineHeight.swift b/freewrite/Helpers/GetLineHeight.swift new file mode 100644 index 0000000..57fae4b --- /dev/null +++ b/freewrite/Helpers/GetLineHeight.swift @@ -0,0 +1,14 @@ +// +// GetLineHeight.swift +// freewrite +// +// Created by Gaspar Dolcemascolo on 18-04-25. +// + +import Foundation +import AppKit + +// Helper function to calculate line height +func getLineHeight(font: NSFont) -> CGFloat { + return font.ascender - font.descender + font.leading +} diff --git a/freewrite/Helpers/NSView+Extension.swift b/freewrite/Helpers/NSView+Extension.swift new file mode 100644 index 0000000..3f11156 --- /dev/null +++ b/freewrite/Helpers/NSView+Extension.swift @@ -0,0 +1,39 @@ +// +// NSView+Extension.swift +// freewrite +// +// Created by Gaspar Dolcemascolo on 18-04-25. +// + +import Foundation +import AppKit + +// Add helper extension to find NSTextView +extension NSView { + func findTextView() -> NSView? { + if self is NSTextView { + return self + } + for subview in subviews { + if let textView = subview.findTextView() { + return textView + } + } + return nil + } +} + +// Add helper extension for finding subviews of a specific type +extension NSView { + func findSubview(ofType type: T.Type) -> T? { + if let typedSelf = self as? T { + return typedSelf + } + for subview in subviews { + if let found = subview.findSubview(ofType: type) { + return found + } + } + return nil + } +} diff --git a/freewrite/Managers/AIChatManager.swift b/freewrite/Managers/AIChatManager.swift new file mode 100644 index 0000000..79493f4 --- /dev/null +++ b/freewrite/Managers/AIChatManager.swift @@ -0,0 +1,60 @@ +// +// AIChatManager.swift +// freewrite +// +// Created by Gaspar Dolcemascolo on 18-04-25. +// + +import Foundation +import AppKit + +final class AIChatManager { + static let shared: AIChatManager = AIChatManager() + + // Add shared prompt constant + private let aiChatPrompt = """ + below is my journal entry. wyt? talk through it with me like a friend. don't therpaize me and give me a whole breakdown, don't repeat my thoughts with headings. really take all of this, and tell me back stuff truly as if you're an old homie. + + Keep it casual, dont say yo, help me make new connections i don't see, comfort, validate, challenge, all of it. dont be afraid to say a lot. format with markdown headings if needed. + + do not just go through every single thing i say, and say it back to me. you need to proccess everythikng is say, make connections i don't see it, and deliver it all back to me as a story that makes me feel what you think i wanna feel. thats what the best therapists do. + + ideally, you're style/tone should sound like the user themselves. it's as if the user is hearing their own tone but it should still feel different, because you have different things to say and don't just repeat back they say. + + else, start by saying, "hey, thanks for showing me this. my thoughts:" + + my entry: + """ + + private let claudePrompt = """ + Take a look at my journal entry below. I'd like you to analyze it and respond with deep insight that feels personal, not clinical. + Imagine you're not just a friend, but a mentor who truly gets both my tech background and my psychological patterns. I want you to uncover the deeper meaning and emotional undercurrents behind my scattered thoughts. + Keep it casual, dont say yo, help me make new connections i don't see, comfort, validate, challenge, all of it. dont be afraid to say a lot. format with markdown headings if needed. + Use vivid metaphors and powerful imagery to help me see what I'm really building. Organize your thoughts with meaningful headings that create a narrative journey through my ideas. + Don't just validate my thoughts - reframe them in a way that shows me what I'm really seeking beneath the surface. Go beyond the product concepts to the emotional core of what I'm trying to solve. + Be willing to be profound and philosophical without sounding like you're giving therapy. I want someone who can see the patterns I can't see myself and articulate them in a way that feels like an epiphany. + Start with 'hey, thanks for showing me this. my thoughts:' and then use markdown headings to structure your response. + + Here's my journal entry: + """ + + func openChatGPT(text: String) { + let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) + let fullText = aiChatPrompt + "\n\n" + trimmedText + + if let encodedText = fullText.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: "https://chat.openai.com/?m=" + encodedText) { + NSWorkspace.shared.open(url) + } + } + + func openClaude(text: String) { + let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) + let fullText = claudePrompt + "\n\n" + trimmedText + + if let encodedText = fullText.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: "https://claude.ai/new?q=" + encodedText) { + NSWorkspace.shared.open(url) + } + } +} diff --git a/freewrite/Managers/FreewriterFileManager.swift b/freewrite/Managers/FreewriterFileManager.swift new file mode 100644 index 0000000..75e724e --- /dev/null +++ b/freewrite/Managers/FreewriterFileManager.swift @@ -0,0 +1,337 @@ +// +// FreewriterFileManager.swift +// freewrite +// +// Created by Gaspar Dolcemascolo on 17-04-25. +// + +import Foundation +import PDFKit +import AppKit +import UniformTypeIdentifiers + +protocol FreewriterFileManagerProtocole { + func createNewEntry() -> HumanEntry + func saveEntry(entry: HumanEntry) -> URL + func deleteEntry(entry: HumanEntry) -> UUID? + func updatePreviewText(for: HumanEntry) -> URL + func loadEntry(entry: HumanEntry) -> URL? + func exportEntryAsPDF(entry: HumanEntry, font: String, fontSize: CGFloat) -> Void + func loadExistingEntries() -> [EntryWithDate]? +} + +final class FreewriterFileManager: FreewriterFileManagerProtocole { + + static let shared: FreewriterFileManager = FreewriterFileManager() + private let fileManager = FileManager.default + + private let documentsDirectory: URL = { + let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("Freewrite") + + // Create Freewrite directory if it doesn't exist + if !FileManager.default.fileExists(atPath: directory.path) { + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + print("Successfully created Freewrite directory") + } catch { + print("Error creating directory: \(error)") + } + } + + return directory + }() + + // Modify getDocumentsDirectory to use cached value + func getDocumentsDirectory() -> URL { + return documentsDirectory + } + + + // MARK: - LOAD EXISTING ENTIRES + + + func loadExistingEntries() -> [EntryWithDate]? { + let documentsDirectory = getDocumentsDirectory() + print("Looking for entries in: \(documentsDirectory.path)") + + do { + let fileURLs = try fileManager.contentsOfDirectory(at: documentsDirectory, includingPropertiesForKeys: nil) + let mdFiles = fileURLs.filter { $0.pathExtension == "md" } + + print("Found \(mdFiles.count) .md files") + + // Process each file + let entriesWithDates = mdFiles.compactMap { fileURL -> EntryWithDate? in + let filename = fileURL.lastPathComponent + print("Processing: \(filename)") + + // Extract UUID and date from filename - pattern [uuid]-[yyyy-MM-dd-HH-mm-ss].md + guard let uuidMatch = filename.range(of: "\\[(.*?)\\]", options: .regularExpression), + let dateMatch = filename.range(of: "\\[(\\d{4}-\\d{2}-\\d{2}-\\d{2}-\\d{2}-\\d{2})\\]", options: .regularExpression), + let uuid = UUID(uuidString: String(filename[uuidMatch].dropFirst().dropLast())) else { + print("Failed to extract UUID or date from filename: \(filename)") + return nil + } + + // Parse the date string + let dateString = String(filename[dateMatch].dropFirst().dropLast()) + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" + + guard let fileDate = dateFormatter.date(from: dateString) else { + print("Failed to parse date from filename: \(filename)") + return nil + } + + // Read file contents for preview + do { + let content = try String(contentsOf: fileURL, encoding: .utf8) + let preview = content + .replacingOccurrences(of: "\n", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + let truncated = preview.isEmpty ? "" : (preview.count > 30 ? String(preview.prefix(30)) + "..." : preview) + + // Format display date + dateFormatter.dateFormat = "MMM d" + let displayDate = dateFormatter.string(from: fileDate) + + let entry = HumanEntry(id: uuid, date: displayDate, filename: filename, previewText: truncated) + + return EntryWithDate(entry: entry, date: fileDate, content: content) + + } catch { + print("Error reading file: \(error)") + return nil + } + } + return entriesWithDates + } catch { + print("Error reading file: \(error)") + return nil + } + } + + // MARK: - UPDATE PREVIEW + + func updatePreviewText(for entry: HumanEntry) -> URL { + let documentsDirectory = getDocumentsDirectory() + let fileURL = documentsDirectory.appendingPathComponent(entry.filename) + return fileURL + } + + + // MARK: - SAVE ENTRY + + func saveEntry(entry: HumanEntry) -> URL { + let documentsDirectory = getDocumentsDirectory() + let fileURL = documentsDirectory.appendingPathComponent(entry.filename) + return fileURL + } + + + // MARK: - CREATE NEW ENTRY + + func createNewEntry() -> HumanEntry { + let newEntry = HumanEntry.createNew() + return newEntry + } + + + // MARK: - DELETE ENTRY + + func deleteEntry(entry: HumanEntry) -> UUID? { + // Delete the file from the filesystem + let documentsDirectory = getDocumentsDirectory() + let fileURL = documentsDirectory.appendingPathComponent(entry.filename) + + do { + try fileManager.removeItem(at: fileURL) + print("Successfully deleted file: \(entry.filename)") + return entry.id + } catch { + print("Error deleting file: \(error)") + } + return nil + } + + + // MARK: - LOAD ENTRY + + func loadEntry(entry: HumanEntry) -> URL? { + let documentsDirectory = getDocumentsDirectory() + let fileURL = documentsDirectory.appendingPathComponent(entry.filename) + + if fileManager.fileExists(atPath: fileURL.path) { + return fileURL + } + + return nil + } + + + // MARK: - EXPORT AS PDF + + func exportEntryAsPDF(entry: HumanEntry, font: String, fontSize: CGFloat) { + let documentsDirectory = getDocumentsDirectory() + let fileURL = documentsDirectory.appendingPathComponent(entry.filename) + + do { + // Read the content of the entry + let entryContent = try String(contentsOf: fileURL, encoding: .utf8) + + // Extract a title from the entry content and add .pdf extension + let suggestedFilename = extractTitleFromContent(entryContent, date: entry.date) + ".pdf" + + // Create save panel + let savePanel = NSSavePanel() + savePanel.allowedContentTypes = [UTType.pdf] + savePanel.nameFieldStringValue = suggestedFilename + savePanel.isExtensionHidden = false // Make sure extension is visible + + // Show save dialog + if savePanel.runModal() == .OK, let url = savePanel.url { + // Create PDF data + if let pdfData = createPDFFromText(text: entryContent, font: font, fontSize: fontSize) { + try pdfData.write(to: url) + print("Successfully exported PDF to: \(url.path)") + } + } + } catch { + print("Error in PDF export: \(error)") + } + } + + private func extractTitleFromContent(_ content: String, date: String) -> String { + // Clean up content by removing leading/trailing whitespace and newlines + let trimmedContent = content.trimmingCharacters(in: .whitespacesAndNewlines) + + // If content is empty, just use the date + if trimmedContent.isEmpty { + return "Entry \(date)" + } + + // Split content into words, ignoring newlines and removing punctuation + let words = trimmedContent + .replacingOccurrences(of: "\n", with: " ") + .components(separatedBy: .whitespaces) + .filter { !$0.isEmpty } + .map { word in + word.trimmingCharacters(in: CharacterSet(charactersIn: ".,!?;:\"'()[]{}<>")) + .lowercased() + } + .filter { !$0.isEmpty } + + // If we have at least 4 words, use them + if words.count >= 4 { + return "\(words[0])-\(words[1])-\(words[2])-\(words[3])" + } + + // If we have fewer than 4 words, use what we have + if !words.isEmpty { + return words.joined(separator: "-") + } + + // Fallback to date if no words found + return "Entry \(date)" + } + + private func createPDFFromText(text: String, font: String, fontSize: CGFloat) -> Data? { + // Letter size page dimensions + let pageWidth: CGFloat = 612.0 // 8.5 x 72 + let pageHeight: CGFloat = 792.0 // 11 x 72 + let margin: CGFloat = 72.0 // 1-inch margins + var lineHeight: CGFloat { + let font = NSFont(name: font, size: fontSize) ?? .systemFont(ofSize: fontSize) + let defaultLineHeight = getLineHeight(font: font) + return (fontSize * 1.5) - defaultLineHeight + } + + // Calculate content area + let contentRect = CGRect( + x: margin, + y: margin, + width: pageWidth - (margin * 2), + height: pageHeight - (margin * 2) + ) + + // Create PDF data container + let pdfData = NSMutableData() + + // Configure text formatting attributes + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineSpacing = lineHeight + + let font = NSFont(name: font, size: fontSize) ?? .systemFont(ofSize: fontSize) + let textAttributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: NSColor(red: 0.20, green: 0.20, blue: 0.20, alpha: 1.0), + .paragraphStyle: paragraphStyle + ] + + // Trim the initial newlines before creating the PDF + let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) + + // Create the attributed string with formatting + let attributedString = NSAttributedString(string: trimmedText, attributes: textAttributes) + + // Create a Core Text framesetter for text layout + let framesetter = CTFramesetterCreateWithAttributedString(attributedString) + + // Create a PDF context with the data consumer + guard let pdfContext = CGContext(consumer: CGDataConsumer(data: pdfData as CFMutableData)!, mediaBox: nil, nil) else { + print("Failed to create PDF context") + return nil + } + + // Track position within text + var currentRange = CFRange(location: 0, length: 0) + var pageIndex = 0 + + // Create a path for the text frame + let framePath = CGMutablePath() + framePath.addRect(contentRect) + + // Continue creating pages until all text is processed + while currentRange.location < attributedString.length { + // Begin a new PDF page + pdfContext.beginPage(mediaBox: nil) + + // Fill the page with white background + pdfContext.setFillColor(NSColor.white.cgColor) + pdfContext.fill(CGRect(x: 0, y: 0, width: pageWidth, height: pageHeight)) + + // Create a frame for this page's text + let frame = CTFramesetterCreateFrame( + framesetter, + currentRange, + framePath, + nil + ) + + // Draw the text frame + CTFrameDraw(frame, pdfContext) + + // Get the range of text that was actually displayed in this frame + let visibleRange = CTFrameGetVisibleStringRange(frame) + + // Move to the next block of text for the next page + currentRange.location += visibleRange.length + + // Finish the page + pdfContext.endPage() + pageIndex += 1 + + // Safety check - don't allow infinite loops + if pageIndex > 1000 { + print("Safety limit reached - stopping PDF generation") + break + } + } + + // Finalize the PDF document + pdfContext.closePDF() + + return pdfData as Data + } + +} diff --git a/freewrite/Models/Chat.swift b/freewrite/Models/Chat.swift new file mode 100644 index 0000000..709a2d5 --- /dev/null +++ b/freewrite/Models/Chat.swift @@ -0,0 +1,13 @@ +// +// Chat.swift +// freewrite +// +// Created by Gaspar Dolcemascolo on 17-04-25. +// + +import Foundation + +enum Chat: String { + case chatGTP = "ChatGPT" + case claude = "Claude" +} diff --git a/freewrite/Models/EntryWithDate.swift b/freewrite/Models/EntryWithDate.swift new file mode 100644 index 0000000..beb71cb --- /dev/null +++ b/freewrite/Models/EntryWithDate.swift @@ -0,0 +1,14 @@ +// +// EntryWithDate.swift +// freewrite +// +// Created by Gaspar Dolcemascolo on 18-04-25. +// + +import Foundation + +struct EntryWithDate { + let entry: HumanEntry + let date: Date + let content: String +} diff --git a/freewrite/Models/HeartEmoji.swift b/freewrite/Models/HeartEmoji.swift new file mode 100644 index 0000000..ea05109 --- /dev/null +++ b/freewrite/Models/HeartEmoji.swift @@ -0,0 +1,14 @@ +// +// HeartEmoji.swift +// freewrite +// +// Created by Gaspar Dolcemascolo on 17-04-25. +// + +import Foundation + +struct HeartEmoji: Identifiable { + let id = UUID() + var position: CGPoint + var offset: CGFloat = 0 +} diff --git a/freewrite/Models/HumanEntry.swift b/freewrite/Models/HumanEntry.swift new file mode 100644 index 0000000..85e8cb8 --- /dev/null +++ b/freewrite/Models/HumanEntry.swift @@ -0,0 +1,34 @@ +// +// HumanEntry.swift +// freewrite +// +// Created by Gaspar Dolcemascolo on 17-04-25. +// + +import Foundation + +struct HumanEntry: Identifiable { + let id: UUID + let date: String + let filename: String + var previewText: String + + static func createNew() -> HumanEntry { + let id = UUID() + let now = Date() + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" + let dateString = dateFormatter.string(from: now) + + // For display + dateFormatter.dateFormat = "MMM d" + let displayDate = dateFormatter.string(from: now) + + return HumanEntry( + id: id, + date: displayDate, + filename: "[\(id)]-[\(dateString)].md", + previewText: "" + ) + } +} diff --git a/freewrite/ViewModels/FreewriteViewModel.swift b/freewrite/ViewModels/FreewriteViewModel.swift new file mode 100644 index 0000000..4b89217 --- /dev/null +++ b/freewrite/ViewModels/FreewriteViewModel.swift @@ -0,0 +1,252 @@ +// +// FreewriteViewModel.swift +// freewrite +// +// Created by Gaspar Dolcemascolo on 17-04-25. +// + +import SwiftUI + +class FreewriteViewModel: ObservableObject { + private let fileManager = FreewriterFileManager.shared + private let aiChatManager = AIChatManager.shared + @Published var entries: [HumanEntry] = [] + @Published var text: String = "" // Remove initial welcome text since we'll handle it in createNewEntry + + @Published var isFullscreen = false + @Published var selectedFont: String = "Lato-Regular" + @Published var currentRandomFont: String = "" + @Published var timeRemaining: Int = 900 // Changed to 900 seconds (15 minutes) + @Published var timerIsRunning = false + @Published var isHoveringTimer = false + @Published var fontSize: CGFloat = 18 + @Published var blinkCount = 0 + @Published var isBlinking = false + @Published var opacity: Double = 1.0 + @Published var shouldShowGray = true // New state to control color + @Published var lastClickTime: Date? = nil + @Published var bottomNavOpacity: Double = 1.0 + @Published var isHoveringBottomNav = false + @Published var selectedEntryIndex: Int = 0 + @Published var scrollOffset: CGFloat = 0 + @Published var selectedEntryId: UUID? = nil + @Published var hoveredEntryId: UUID? = nil + @Published var isHoveringChat = false // Add this state variable + @Published var showingChatMenu = false + @Published var chatMenuAnchor: CGPoint = .zero + @Published var showingSidebar = false // Add this state variable + @Published var placeholderText: String = "" // Add this line + + let placeholderOptions = [ + "\n\nBegin writing", + "\n\nPick a thought and go", + "\n\nStart typing", + "\n\nWhat's on your mind", + "\n\nJust start", + "\n\nType your first thought", + "\n\nStart with one sentence", + "\n\nJust say it" + ] + + + + // MARK: - FILES MANAGE + + // Add function to load existing entries + func loadExistingEntries() { + let entryList = fileManager.loadExistingEntries() + + guard let entriesWithDates = entryList else { + print("Creating default entry after error") + createNewEntry() + return + } + + + // Sort and extract entries + entries = entriesWithDates + .sorted { $0.date > $1.date } // Sort by actual date from filename + .map { $0.entry } + + print("Successfully loaded and sorted \(entries.count) entries") + + // Check if we need to create a new entry + let calendar = Calendar.current + let today = Date() + let todayStart = calendar.startOfDay(for: today) + + // Check if there's an empty entry from today + let hasEmptyEntryToday = entries.contains { entry in + // Convert the display date (e.g. "Mar 14") to a Date object + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "MMM d" + if let entryDate = dateFormatter.date(from: entry.date) { + // Set year component to current year since our stored dates don't include year + var components = calendar.dateComponents([.year, .month, .day], from: entryDate) + components.year = calendar.component(.year, from: today) + + // Get start of day for the entry date + if let entryDateWithYear = calendar.date(from: components) { + let entryDayStart = calendar.startOfDay(for: entryDateWithYear) + return calendar.isDate(entryDayStart, inSameDayAs: todayStart) && entry.previewText.isEmpty + } + } + return false + } + + // Check if we have only one entry and it's the welcome message + let hasOnlyWelcomeEntry = entries.count == 1 && entriesWithDates.first?.content.contains("Welcome to Freewrite.") == true + + if entries.isEmpty { + // First time user - create entry with welcome message + print("First time user, creating welcome entry") + createNewEntry() + } else if !hasEmptyEntryToday && !hasOnlyWelcomeEntry { + // No empty entry for today and not just the welcome entry - create new entry + print("No empty entry for today, creating new entry") + createNewEntry() + } else { + // Select the most recent empty entry from today or the welcome entry + if let todayEntry = entries.first(where: { entry in + // Convert the display date (e.g. "Mar 14") to a Date object + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "MMM d" + if let entryDate = dateFormatter.date(from: entry.date) { + // Set year component to current year since our stored dates don't include year + var components = calendar.dateComponents([.year, .month, .day], from: entryDate) + components.year = calendar.component(.year, from: today) + + // Get start of day for the entry date + if let entryDateWithYear = calendar.date(from: components) { + let entryDayStart = calendar.startOfDay(for: entryDateWithYear) + return calendar.isDate(entryDayStart, inSameDayAs: todayStart) && entry.previewText.isEmpty + } + } + return false + }) { + selectedEntryId = todayEntry.id + loadEntry(entry: todayEntry) + } else if hasOnlyWelcomeEntry { + // If we only have the welcome entry, select it + selectedEntryId = entries[0].id + loadEntry(entry: entries[0]) + } + } + } + + + func getDocumentsDirectory() -> URL { + return fileManager.getDocumentsDirectory() + } + + func updatePreviewText(for entry: HumanEntry) { + let fileURL = fileManager.updatePreviewText(for: entry) + + do { + let content = try String(contentsOf: fileURL, encoding: .utf8) + let preview = content + .replacingOccurrences(of: "\n", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + let truncated = preview.isEmpty ? "" : (preview.count > 30 ? String(preview.prefix(30)) + "..." : preview) + + // Find and update the entry in the entries array + if let index = entries.firstIndex(where: { $0.id == entry.id }) { + entries[index].previewText = truncated + } + } catch { + print("Error updating preview text: \(error)") + } + } + + func saveEntry(entry: HumanEntry) { + let fileURL = fileManager.saveEntry(entry: entry) + + do { + try text.write(to: fileURL, atomically: true, encoding: .utf8) + print("Successfully saved entry: \(entry.filename)") + updatePreviewText(for: entry) // Update preview after saving + } catch { + print("Error saving entry: \(error)") + } + } + + func createNewEntry() { + let newEntry = fileManager.createNewEntry() + entries.insert(newEntry, at: 0) // Add to the beginning + selectedEntryId = newEntry.id + + // If this is the first entry (entries was empty before adding this one) + if entries.count == 1 { + // Read welcome message from default.md + if let defaultMessageURL = Bundle.main.url(forResource: "default", withExtension: "md"), + let defaultMessage = try? String(contentsOf: defaultMessageURL, encoding: .utf8) { + text = "\n\n" + defaultMessage + } + // Save the welcome message immediately + saveEntry(entry: newEntry) + // Update the preview text + updatePreviewText(for: newEntry) + } else { + // Regular new entry starts with newlines + text = "\n\n" + // Randomize placeholder text for new entry + placeholderText = placeholderOptions.randomElement() ?? "\n\nBegin writing" + // Save the empty entry + saveEntry(entry: newEntry) + } + } + + func loadEntry(entry: HumanEntry) { + let fileURL = fileManager.loadEntry(entry: entry) + + guard let fileURL else { return } + + do { + text = try String(contentsOf: fileURL, encoding: .utf8) + print("Successfully loaded entry: \(entry.filename)") + } catch { + print("Error loading entry: \(error)") + } + } + + func deleteEntry(entry: HumanEntry) { + let deletedEntryId = fileManager.deleteEntry(entry: entry) + + if let index = entries.firstIndex(where: { $0.id == deletedEntryId }) { + entries.remove(at: index) + + // If the deleted entry was selected, select the first entry or create a new one + if selectedEntryId == entry.id { + if let firstEntry = entries.first { + selectedEntryId = firstEntry.id + loadEntry(entry: firstEntry) + } else { + createNewEntry() + } + } + } + } + + func exportEntryAsPDF(entry: HumanEntry) { + // First make sure the current entry is saved + if selectedEntryId == entry.id { + saveEntry(entry: entry) + } + + fileManager.exportEntryAsPDF(entry: entry, font: selectedFont, fontSize: fontSize) + } + + + // MARK: / AI CHAT + + func openChatGPT() { + showingChatMenu = false + aiChatManager.openChatGPT(text: text) + } + + func openClaude() { + showingChatMenu = false + aiChatManager.openClaude(text: text) + } + +} diff --git a/freewrite/Views/ChatPopOverView.swift b/freewrite/Views/ChatPopOverView.swift new file mode 100644 index 0000000..c6a3bab --- /dev/null +++ b/freewrite/Views/ChatPopOverView.swift @@ -0,0 +1,79 @@ +// +// ChatPopOverView.swift +// freewrite +// +// Created by Gaspar Dolcemascolo on 17-04-25. +// + +import SwiftUI + +struct ChatPopOverView: View { + @EnvironmentObject private var settings: AppSettings + @Binding var text: String + + var action: (_: Chat) -> Void + + var popoverTextColor: Color { + return settings.colorScheme == .light ? Color.primary : Color.white + } + + var popoverBackgroundColor: Color { + return settings.colorScheme == .light ? Color(NSColor.controlBackgroundColor) : Color(NSColor.darkGray) + } + + + var body: some View { + if text.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("hi. my name is farza.") { + Text("Yo. Sorry, you can't chat with the guide lol. Please write your own entry.") + .font(.system(size: 14)) + .foregroundColor(popoverTextColor) + .frame(width: 250) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(popoverBackgroundColor) + .cornerRadius(8) + .shadow(color: Color.black.opacity(0.1), radius: 4, y: 2) + } else if text.count < 350 { + Text("Please free write for at minimum 5 minutes first. Then click this. Trust.") + .font(.system(size: 14)) + .foregroundColor(popoverTextColor) + .frame(width: 250) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(popoverBackgroundColor) + .cornerRadius(8) + .shadow(color: Color.black.opacity(0.1), radius: 4, y: 2) + } else { + VStack(spacing: 0) { + Button(action: { + action(.chatGTP) + }) { + Text(Chat.chatGTP.rawValue) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + .buttonStyle(.plain) + .foregroundColor(popoverTextColor) + + Divider() + + Button(action: { + action(.claude) + }) { + Text("Claude") + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + .buttonStyle(.plain) + .foregroundColor(popoverTextColor) + } + .frame(width: 120) + .background(popoverBackgroundColor) + .cornerRadius(8) + .shadow(color: Color.black.opacity(0.1), radius: 4, y: 2) + } + } +} + diff --git a/freewrite/Views/Components/BottomNavBar.swift b/freewrite/Views/Components/BottomNavBar.swift new file mode 100644 index 0000000..9feea87 --- /dev/null +++ b/freewrite/Views/Components/BottomNavBar.swift @@ -0,0 +1,248 @@ +// +// BottomNavBar.swift +// freewrite +// +// Created by Gaspar Dolcemascolo on 17-04-25. +// + +import SwiftUI + +struct BottomNavBar: View { + @ObservedObject var viewModel: FreewriteViewModel + @EnvironmentObject private var appSettings: AppSettings + @State private var isHoveringThemeToggle = false + @State private var isHoveringTimer = false + @State private var isHoveringClock = false + + private let availableFonts = NSFontManager.shared.availableFontFamilies + private let fontSizes: [CGFloat] = [16, 18, 20, 22, 24, 26] + + private var fontSizeButtonTitle: String { + return "\(Int(viewModel.fontSize))px" + } + private var randomButtonTitle: String { + return viewModel.currentRandomFont.isEmpty ? "Random" : "Random [\(viewModel.currentRandomFont)]" + } + + private var timerButtonTitle: String { + if !viewModel.timerIsRunning && viewModel.timeRemaining == 900 { + return "15:00" + } + let minutes = viewModel.timeRemaining / 60 + let seconds = viewModel.timeRemaining % 60 + return String(format: "%d:%02d", minutes, seconds) + } + + private var timerColor: Color { + if viewModel.timerIsRunning { + return isHoveringTimer ? (appSettings.colorScheme == .light ? .black : .white) : .gray.opacity(0.8) + } else { + return isHoveringTimer ? (appSettings.colorScheme == .light ? .black : .white) : (appSettings.colorScheme == .light ? .gray : .gray.opacity(0.8)) + } + } + + var body: some View { + let textColor = appSettings.colorScheme == .light ? Color.gray : Color.gray.opacity(0.8) + let textHoverColor = appSettings.colorScheme == .light ? Color.black : Color.white + + VStack { + Spacer() + HStack { + // Font buttons (moved to left) + HStack(spacing: 8) { + OptionButton(title: fontSizeButtonTitle) { + if let currentIndex = fontSizes.firstIndex(of: viewModel.fontSize) { + let nextIndex = (currentIndex + 1) % fontSizes.count + viewModel.fontSize = fontSizes[nextIndex] + } + } + + Text("•") + .foregroundColor(.gray) + + OptionButton(title: "Lato") { + viewModel.selectedFont = "Lato-Regular" + viewModel.currentRandomFont = "" + } + + Text("•") + .foregroundColor(.gray) + + OptionButton(title: "Arial") { + viewModel.selectedFont = "Arial" + viewModel.currentRandomFont = "" + } + + Text("•") + .foregroundColor(.gray) + + OptionButton(title: "System") { + viewModel.selectedFont = ".AppleSystemUIFont" + viewModel.currentRandomFont = "" + } + + Text("•") + .foregroundColor(.gray) + + OptionButton(title: "Serif") { + viewModel.selectedFont = "Times New Roman" + viewModel.currentRandomFont = "" + } + + Text("•") + .foregroundColor(.gray) + + OptionButton(title: randomButtonTitle) { + if let randomFont = availableFonts.randomElement() { + viewModel.selectedFont = randomFont + viewModel.currentRandomFont = randomFont + } + } + } + .padding(8) + .cornerRadius(6) + + Spacer() + + // Utility buttons (moved to right) + HStack(spacing: 8) { + Button(timerButtonTitle) { + let now = Date() + if let lastClick = viewModel.lastClickTime, + now.timeIntervalSince(lastClick) < 0.3 { + viewModel.timeRemaining = 900 + viewModel.timerIsRunning = false + viewModel.lastClickTime = nil + } else { + viewModel.timerIsRunning.toggle() + viewModel.lastClickTime = now + } + } + .buttonStyle(.plain) + .foregroundColor(timerColor) + .onHover { hovering in + isHoveringTimer = hovering + viewModel.isHoveringBottomNav = hovering + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + .onAppear { + NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in + if isHoveringTimer { + let scrollBuffer = event.deltaY * 0.25 + + if abs(scrollBuffer) >= 0.1 { + let currentMinutes = viewModel.timeRemaining / 60 + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .now) + let direction = -scrollBuffer > 0 ? 5 : -5 + let newMinutes = currentMinutes + direction + let roundedMinutes = (newMinutes / 5) * 5 + let newTime = roundedMinutes * 60 + viewModel.timeRemaining = min(max(newTime, 0), 2700) + } + } + return event + } + } + + Text("•") + .foregroundColor(.gray) + + OptionButton(title: "Chat") { + viewModel.showingChatMenu = true + }.popover(isPresented: $viewModel.showingChatMenu, attachmentAnchor: .point(UnitPoint(x: 0.5, y: 0)), arrowEdge: .top) { + ChatPopOverView(text: $viewModel.text) { text in + if text == Chat.chatGTP { + viewModel.openChatGPT() + } else { + viewModel.openClaude() + } + + } + + } + + Text("•") + .foregroundColor(.gray) + + OptionButton(title: viewModel.isFullscreen ? "Minimize" : "Fullscreen") { + if let window = NSApplication.shared.windows.first { + window.toggleFullScreen(nil) + } + } + + Text("•") + .foregroundColor(.gray) + + OptionButton(title: "New Entry") { + viewModel.createNewEntry() + } + + Text("•") + .foregroundColor(.gray) + + // Theme toggle button + Button(action: { + appSettings.updateColorScheme(appSettings.colorScheme == .light ? .dark : .light) + }) { + Image(systemName: appSettings.colorScheme == .light ? "moon.fill" : "sun.max.fill") + .foregroundColor(isHoveringThemeToggle ? textHoverColor : textColor) + } + .buttonStyle(.plain) + .onHover { hovering in + isHoveringThemeToggle = hovering + viewModel.isHoveringBottomNav = hovering + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + + Text("•") + .foregroundColor(.gray) + + // Version history button + Button(action: { + withAnimation(.easeInOut(duration: 0.2)) { + viewModel.showingSidebar.toggle() + } + }) { + Image(systemName: "clock.arrow.circlepath") + .foregroundColor(isHoveringClock ? textHoverColor : textColor) + } + .buttonStyle(.plain) + .onHover { hovering in + isHoveringClock = hovering + viewModel.isHoveringBottomNav = hovering + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + } + .padding(8) + .cornerRadius(6) + } + .padding() + .background(Color(appSettings.colorScheme == .light ? .white : .black)) + .opacity(viewModel.bottomNavOpacity) + .onHover { hovering in + viewModel.isHoveringBottomNav = hovering + if hovering { + withAnimation(.easeOut(duration: 0.2)) { + viewModel.bottomNavOpacity = 1.0 + } + } else if viewModel.timerIsRunning { + withAnimation(.easeIn(duration: 1.0)) { + viewModel.bottomNavOpacity = 0.0 + } + } + } + } + } +} diff --git a/freewrite/Views/Components/EntryCard.swift b/freewrite/Views/Components/EntryCard.swift new file mode 100644 index 0000000..5335db8 --- /dev/null +++ b/freewrite/Views/Components/EntryCard.swift @@ -0,0 +1,141 @@ +// +// EntryCard.swift +// freewrite +// +// Created by Gaspar Dolcemascolo on 18-04-25. +// + +import SwiftUI + +struct EntryCard: View { + @ObservedObject var viewModel: FreewriteViewModel + @EnvironmentObject private var settings: AppSettings + @State private var hoveredExportId: UUID? + @State private var hoveredTrashId: UUID? + + var entry: HumanEntry + + private var imageHoverColor: Color { + return settings.colorScheme == .light ? .black : .white + } + + private var imageColor: Color { + return settings.colorScheme == .light ? .gray : .gray.opacity(0.8) + } + + private func backgroundColor(for entry: HumanEntry) -> Color { + if entry.id == viewModel.selectedEntryId { + return Color.gray.opacity(0.1) // More subtle selection highlight + } else if entry.id == viewModel.hoveredEntryId { + return Color.gray.opacity(0.05) // Even more subtle hover state + } else { + return Color.clear + } + } + + @ViewBuilder + var EntryActionButtons: some View { + HStack(spacing: 8) { + // Export PDF button + Button(action: { + viewModel.exportEntryAsPDF(entry: entry) + }) { + Image(systemName: "arrow.down.circle") + .font(.system(size: 11)) + .foregroundColor(hoveredExportId == entry.id ? imageHoverColor : imageColor) + } + .buttonStyle(.plain) + .help("Export entry as PDF") + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.2)) { + hoveredExportId = hovering ? entry.id : nil + } + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + + // Trash icon + Button(action: { + viewModel.deleteEntry(entry: entry) + }) { + Image(systemName: "trash") + .font(.system(size: 11)) + .foregroundColor(hoveredTrashId == entry.id ? .red : .gray) + } + .buttonStyle(.plain) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.2)) { + hoveredTrashId = hovering ? entry.id : nil + } + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + } + } + + var body: some View { + Button(action: { + if viewModel.selectedEntryId != entry.id { + // Save current entry before switching + if let currentId = viewModel.selectedEntryId, + let currentEntry = viewModel.entries.first(where: { $0.id == currentId }) { + viewModel.saveEntry(entry: currentEntry) + } + + viewModel.selectedEntryId = entry.id + viewModel.loadEntry(entry: entry) + } + }) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(entry.previewText) + .font(.system(size: 13)) + .lineLimit(1) + .foregroundColor(.primary) + + Spacer() + + // Export/Trash icons that appear on hover + if viewModel.hoveredEntryId == entry.id { + EntryActionButtons + } + } + + Text(entry.date) + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(backgroundColor(for: entry)) + ) + } + .buttonStyle(PlainButtonStyle()) + .contentShape(Rectangle()) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.2)) { + viewModel.hoveredEntryId = hovering ? entry.id : nil + } + } + .onAppear { + NSCursor.pop() // Reset cursor when button appears + } + .help("Click to select this entry") + + if entry.id != viewModel.entries.last?.id { + Divider() + } + } +} + diff --git a/freewrite/Views/Components/OptionButton.swift b/freewrite/Views/Components/OptionButton.swift new file mode 100644 index 0000000..438214a --- /dev/null +++ b/freewrite/Views/Components/OptionButton.swift @@ -0,0 +1,50 @@ +// +// OptionButton.swift +// freewrite +// +// Created by Gaspar Dolcemascolo on 17-04-25. +// + +import SwiftUI + + +struct OptionButton: View { + + @EnvironmentObject private var settings: AppSettings + @State private var isHover: Bool = false + + private var textColor: Color { + return settings.colorScheme == .light ? Color.gray : Color.gray.opacity(0.8) + } + private var textHoverColor: Color { + return settings.colorScheme == .light ? Color.black : Color.white + } + + private let action: () -> Void + private let title: String + + init(title: String, action: @escaping () -> Void) { + self.title = title + self.action = action + } + + var body: some View { + Button(title, action: action) + .buttonStyle(.plain) + .foregroundColor(isHover ? textHoverColor : textColor) + .onHover { hovering in + isHover = hovering + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + } +} + +#Preview { + OptionButton(title: "This is a button", action: { + print("Hello, World!") + }) +} diff --git a/freewrite/Views/ContentView.swift b/freewrite/Views/ContentView.swift new file mode 100644 index 0000000..79fb0a9 --- /dev/null +++ b/freewrite/Views/ContentView.swift @@ -0,0 +1,125 @@ +// Swift 5.0 +// +// ContentView.swift +// freewrite +// +// Created by thorfinn on 2/14/25. +// + +import SwiftUI +import UniformTypeIdentifiers +import PDFKit + +struct ContentView: View { + private let headerString = "\n\n" + @EnvironmentObject private var settings: AppSettings + @ObservedObject var viewModel: FreewriteViewModel + + private var colorScheme: ColorScheme { + return settings.colorScheme + } + + let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + var lineHeight: CGFloat { + let font = NSFont(name: viewModel.selectedFont, size: viewModel.fontSize) ?? .systemFont(ofSize: viewModel.fontSize) + let defaultLineHeight = getLineHeight(font: font) + return (viewModel.fontSize * 1.5) - defaultLineHeight + } + + var placeholderOffset: CGFloat { + // Instead of using calculated line height, use a simple offset + return viewModel.fontSize / 2 + } + + + var body: some View { + + HStack(spacing: 0) { + // Main content + ZStack { + Color(colorScheme == .light ? .white : .black) + .ignoresSafeArea() + + TextEditor(text: Binding( + get: { viewModel.text }, + set: { newValue in + if !newValue.hasPrefix("\n\n") { + viewModel.text = "\n\n" + newValue.trimmingCharacters(in: .newlines) + } else { + viewModel.text = newValue + } + } + )) + .background(Color(colorScheme == .light ? .white : .black)) + .font(.custom(viewModel.selectedFont, size: viewModel.fontSize)) + .foregroundColor(colorScheme == .light ? Color(red: 0.20, green: 0.20, blue: 0.20) : Color(red: 0.9, green: 0.9, blue: 0.9)) + .scrollContentBackground(.hidden) + .scrollIndicators(.never) + .lineSpacing(lineHeight) + .frame(maxWidth: 650) + .id("\(viewModel.selectedFont)-\(viewModel.fontSize)-\(colorScheme)") + .padding(.bottom, viewModel.bottomNavOpacity > 0 ? 68 : 0) + .ignoresSafeArea() + .colorScheme(colorScheme) + .overlay( + ZStack(alignment: .topLeading) { + if viewModel.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text(viewModel.placeholderText) + .font(.custom(viewModel.selectedFont, size: viewModel.fontSize)) + .foregroundColor(colorScheme == .light ? .gray.opacity(0.5) : .gray.opacity(0.6)) + .offset(x: 5, y: placeholderOffset) + } + }, alignment: .topLeading + ) + + BottomNavBar(viewModel: viewModel) + + } + + // Right sidebar + if viewModel.showingSidebar { + Divider() + + SidebarView(viewModel: viewModel) + } + } + .frame(minWidth: 1100, minHeight: 600) + .animation(.easeInOut(duration: 0.2), value: viewModel.showingSidebar) + .preferredColorScheme(colorScheme) + .onAppear { + viewModel.showingSidebar = false // Hide sidebar by default + viewModel.loadExistingEntries() + } + .onChange(of: viewModel.text) { _ in + // Save current entry when text changes + if let currentId = viewModel.selectedEntryId, + let currentEntry = viewModel.entries.first(where: { $0.id == currentId }) { + viewModel.saveEntry(entry: currentEntry) + } + } + .onReceive(timer) { _ in + if viewModel.timerIsRunning && viewModel.timeRemaining > 0 { + viewModel.timeRemaining -= 1 + } else if viewModel.timeRemaining == 0 { + viewModel.timerIsRunning = false + if !viewModel.isHoveringBottomNav { + withAnimation(.easeOut(duration: 1.0)) { + viewModel.bottomNavOpacity = 1.0 + } + } + } + } + .onReceive(NotificationCenter.default.publisher(for: NSWindow.willEnterFullScreenNotification)) { _ in + viewModel.isFullscreen = true + } + .onReceive(NotificationCenter.default.publisher(for: NSWindow.willExitFullScreenNotification)) { _ in + viewModel.isFullscreen = false + } + } + +} + +#Preview { + ContentView(viewModel: FreewriteViewModel()) +} diff --git a/freewrite/Views/SidebarView.swift b/freewrite/Views/SidebarView.swift new file mode 100644 index 0000000..6525558 --- /dev/null +++ b/freewrite/Views/SidebarView.swift @@ -0,0 +1,93 @@ +// +// SidebarView.swift +// freewrite +// +// Created by Gaspar Dolcemascolo on 18-04-25. +// + +import SwiftUI + +struct SidebarView: View { + @EnvironmentObject private var settings: AppSettings + @ObservedObject var viewModel: FreewriteViewModel + @State private var isHoveringHistory = false + @State private var isHoveringHistoryText = false + @State private var isHoveringHistoryPath = false + + private var textColor: Color { + return settings.colorScheme == .light ? Color.gray : Color.gray.opacity(0.8) + } + + private var textHoverColor: Color { + return settings.colorScheme == .light ? Color.black : Color.white + } + + + + private func backgroundColor(for entry: HumanEntry) -> Color { + if entry.id == viewModel.selectedEntryId { + return Color.gray.opacity(0.1) // More subtle selection highlight + } else if entry.id == viewModel.hoveredEntryId { + return Color.gray.opacity(0.05) // Even more subtle hover state + } else { + return Color.clear + } + } + + // MARK: - HEADER + + @ViewBuilder + private var Header: some View { + Button(action: { + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: viewModel.getDocumentsDirectory().path) + }) { + HStack { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Text("History") + .font(.system(size: 13)) + .foregroundColor(isHoveringHistory ? textHoverColor : textColor) + Image(systemName: "arrow.up.right") + .font(.system(size: 10)) + .foregroundColor(isHoveringHistory ? textHoverColor : textColor) + } + Text(viewModel.getDocumentsDirectory().path) + .font(.system(size: 10)) + .foregroundColor(.secondary) + .lineLimit(1) + } + Spacer() + } + } + .buttonStyle(.plain) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .onHover { hovering in + isHoveringHistory = hovering + } + } + + + var body: some View { + VStack(spacing: 0) { + Header + Divider() + ScrollView { + LazyVStack(spacing: 0) { + ForEach(viewModel.entries) { entry in + // Add tooltip + + EntryCard(viewModel: viewModel, entry: entry) + } + } + } + .scrollIndicators(.never) + } + .frame(width: 200) + .background(Color(settings.colorScheme == .light ? .white : NSColor.black)) + } +} + +#Preview { + SidebarView(viewModel: FreewriteViewModel()) +} diff --git a/freewrite/freewriteApp.swift b/freewrite/freewriteApp.swift index 9966f1a..bf7a507 100644 --- a/freewrite/freewriteApp.swift +++ b/freewrite/freewriteApp.swift @@ -11,6 +11,8 @@ import SwiftUI struct freewriteApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @AppStorage("colorScheme") private var colorSchemeString: String = "light" + @StateObject private var settings = AppSettings() + @StateObject private var viewModel: FreewriteViewModel = FreewriteViewModel() init() { // Register Lato font @@ -21,9 +23,10 @@ struct freewriteApp: App { var body: some Scene { WindowGroup { - ContentView() + ContentView(viewModel: viewModel) .toolbar(.hidden, for: .windowToolbar) .preferredColorScheme(colorSchemeString == "dark" ? .dark : .light) + .environmentObject(settings) } .windowStyle(.hiddenTitleBar) .defaultSize(width: 1100, height: 600) @@ -46,3 +49,19 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } } + + +class AppSettings: ObservableObject { + @Published var colorScheme: ColorScheme = .light + + init() { + // Load saved color scheme preference + let savedScheme = UserDefaults.standard.string(forKey: "colorScheme") ?? "light" + colorScheme = savedScheme == "dark" ? .dark : .light + } + + func updateColorScheme(_ scheme: ColorScheme) { + UserDefaults.standard.set(scheme == .dark ? "dark" : "light", forKey: "colorScheme") + colorScheme = scheme + } +}