diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0f0467a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,41 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Freewrite is a minimalist macOS app for freewriting - uninterrupted, stream-of-consciousness writing. Built with SwiftUI, it enforces continuous writing for 15-minute sessions without editing. + +## Build and Development Commands + +- **Build and run**: Open `freewrite.xcodeproj` in Xcode and click Build (⌘+R) +- **Test**: Run tests in Xcode (⌘+U) +- **Clean build**: Product → Clean Build Folder (⌘+Shift+K) + +## Code Architecture + +### Main Structure +- `freewriteApp.swift`: App entry point, font registration, window configuration +- `ContentView.swift`: Core app logic (1,415 lines) - timer, text editor, file management, AI integration +- File storage: `~/Documents/Freewrite/` with pattern `[UUID]-[yyyy-MM-dd-HH-mm-ss].md` + +### Key Components +- **Timer system**: 15-minute default (5-45 min range), scroll-to-adjust +- **Text editor**: Custom SwiftUI TextEditor with enforced leading newlines +- **Entry management**: Auto-save every second, individual markdown files per session +- **AI integration**: Direct ChatGPT/Claude links with custom prompts +- **PDF export**: Custom Core Text implementation + +### State Management +Uses 25+ `@State` variables in ContentView for UI state, preferences, and entry management. The large ContentView could benefit from architectural refactoring into separate components. + +### Dependencies +No external packages - uses only Apple frameworks (SwiftUI, AppKit, PDFKit, Core Text). + +## Development Notes + +- App uses sandbox with user-selected file permissions +- Custom Lato fonts registered at startup +- Current branch: `timeline-story` suggests timeline/story features in development +- No spell check or markdown rendering - intentionally minimal for freewriting flow +- Dark/light theme support via `@AppStorage` \ No newline at end of file diff --git a/freewrite.xcodeproj/project.pbxproj b/freewrite.xcodeproj/project.pbxproj index b4bede5..1fa719a 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 = 74X5W5QTP4; 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 = 74X5W5QTP4; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; diff --git a/freewrite/ContentView.swift b/freewrite/ContentView.swift index cb973af..4477186 100644 --- a/freewrite/ContentView.swift +++ b/freewrite/ContentView.swift @@ -1,143 +1,532 @@ -// 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 + let rawDate: Date var previewText: String - + var summary: String? + var summaryGenerated: Date? + 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) - + + let filenameFormatter = DateFormatter() + filenameFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" + let filename = "[\(id)]-[\(filenameFormatter.string(from: now))].md" + + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "MMM d" + return HumanEntry( id: id, - date: displayDate, - filename: "[\(id)]-[\(dateString)].md", - previewText: "" + date: displayFormatter.string(from: now), + filename: filename, + rawDate: now, + previewText: "", + summary: nil, + summaryGenerated: nil ) } } -struct HeartEmoji: Identifiable { +struct TimelinePoint: Identifiable, Codable { let id = UUID() - var position: CGPoint - var offset: CGFloat = 0 + let date: Date + let happiness: Double + let description: String + let scenario: TimelineScenario + + enum TimelineScenario: String, Codable { + case actual + case best + case darkest + } +} + +struct TimelinePrediction: Codable { + let bestTimeline: [TimelinePoint] + let darkestTimeline: [TimelinePoint] + let analysisDate: Date + let monthsAhead: Int +} + +class ClaudeAPIService: ObservableObject { + @Published var isLoading = false + @Published var error: String? + @Published var isTestingConnection = false + @Published var connectionTestResult: String? + + private let baseURL = "https://api.anthropic.com/v1/messages" + + enum APIError: Error { + case missingToken + case invalidURL + case invalidResponse + case httpError(Int, String) + case encodingError(String) + case decodingError(String) + case networkError(String) + + var localizedDescription: String { + switch self { + case .missingToken: + return "Claude API token is missing." + case .invalidURL: + return "Invalid API URL." + case .invalidResponse: + return "Invalid response from Claude API." + case .httpError(let code, let message): + if code == 401 { return "Invalid API token." } + if code == 429 { return "Rate limit exceeded. Try again later." } + return "HTTP error \(code): \(message)" + case .encodingError(let message): + return "Encoding error: \(message)" + case .decodingError(let message): + return "Decoding error: \(message)" + case .networkError(let message): + return "Network error: \(message)" + } + } + } + + func generateTimelinePrediction(entries: [HumanEntry], apiToken: String, monthsAhead: Int) async throws -> TimelinePrediction { + guard !apiToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw APIError.missingToken + } + + await MainActor.run { + isLoading = true + error = nil + } + + do { + let entriesData = try await prepareEntriesSummary(entries: entries) + guard !entriesData.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw APIError.networkError("No journal entries to analyse.") + } + + let prompt = createPrompt(entriesData: entriesData, monthsAhead: monthsAhead) + let response = try await makeRequest(prompt: prompt, apiToken: apiToken) + let prediction = try parseResponse(response: response, monthsAhead: monthsAhead) + + await MainActor.run { + isLoading = false + } + + return prediction + } catch { + let apiError = error as? APIError ?? APIError.networkError(error.localizedDescription) + await MainActor.run { + isLoading = false + self.error = apiError.localizedDescription + } + throw apiError + } + } + + func testConnection(apiToken: String) async { + await MainActor.run { + isTestingConnection = true + connectionTestResult = nil + } + + do { + let response = try await makeRequest(prompt: "Respond only with the word success.", apiToken: apiToken) + if response.lowercased().contains("success") { + await MainActor.run { + isTestingConnection = false + connectionTestResult = "✅ Connection successful" + } + } else { + await MainActor.run { + isTestingConnection = false + connectionTestResult = "⚠️ Unexpected response" + } + } + } catch { + await MainActor.run { + isTestingConnection = false + connectionTestResult = "❌ Connection failed: \(error.localizedDescription)" + } + } + } + + private func prepareEntriesSummary(entries: [HumanEntry]) async throws -> String { + var combined = "" + let documentsDirectory = FileManager.default + .urls(for: .documentDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Freewrite") + + for entry in entries.prefix(20) { + let url = documentsDirectory.appendingPathComponent(entry.filename) + if let content = try? String(contentsOf: url, encoding: .utf8) { + combined += "Date: \(entry.date)\nEntry: \(content.trimmingCharacters(in: .whitespacesAndNewlines))\n\n" + } + } + return combined + } + + private func createPrompt(entriesData: String, monthsAhead: Int) -> String { + """ + You are a thoughtful life coach analysing journal entries. Using the journal excerpts below, project two possible futures for the next \(monthsAhead) months: + + 1. Best timeline: realistic but optimistic outcomes each month + 2. Darkest timeline: potential challenges to watch for + + For each month, provide a happiness score (1-10) and a short, vivid description. Respond in valid JSON matching this shape: + { + "bestTimeline": [{"month": 1, "happiness": 8.5, "description": "..."}], + "darkestTimeline": [{"month": 1, "happiness": 4.2, "description": "..."}] + } + + Journal entries: + \(entriesData) + """ + } + + private func makeRequest(prompt: String, apiToken: String) async throws -> String { + guard let url = URL(string: baseURL) else { + throw APIError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + request.setValue(apiToken, forHTTPHeaderField: "x-api-key") + + let body = ClaudeAPIRequest( + model: "claude-sonnet-4-20250514", + max_tokens: 4000, + messages: [ClaudeMessage(role: "user", content: prompt)] + ) + + do { + request.httpBody = try JSONEncoder().encode(body) + } catch { + throw APIError.encodingError(error.localizedDescription) + } + + let session = URLSession(configuration: .default) + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + + guard httpResponse.statusCode == 200 else { + let message = String(data: data, encoding: .utf8) ?? "Unknown error" + throw APIError.httpError(httpResponse.statusCode, message) + } + + do { + let decoded = try JSONDecoder().decode(ClaudeAPIResponse.self, from: data) + guard let text = decoded.content.first?.text else { + throw APIError.invalidResponse + } + return text + } catch { + let raw = String(data: data, encoding: .utf8) ?? "" + throw APIError.decodingError("\(error.localizedDescription)\nRaw: \(raw)") + } + } + + private func parseResponse(response: String, monthsAhead: Int) throws -> TimelinePrediction { + guard let jsonStart = response.firstIndex(of: "{"), + let jsonEnd = response.lastIndex(of: "}") else { + throw APIError.decodingError("Claude response did not include JSON block.") + } + + let jsonString = String(response[jsonStart...jsonEnd]) + let data = Data(jsonString.utf8) + let parsed = try JSONDecoder().decode(ClaudeTimelineResponse.self, from: data) + + let calendar = Calendar.current + let today = Date() + + let bestTimeline = parsed.bestTimeline.enumerated().map { offset, item -> TimelinePoint in + let target = calendar.date(byAdding: .month, value: item.month, to: today) ?? today + return TimelinePoint(date: target, happiness: item.happiness, description: item.description, scenario: .best) + } + + let darkestTimeline = parsed.darkestTimeline.enumerated().map { offset, item -> TimelinePoint in + let target = calendar.date(byAdding: .month, value: item.month, to: today) ?? today + return TimelinePoint(date: target, happiness: item.happiness, description: item.description, scenario: .darkest) + } + + return TimelinePrediction( + bestTimeline: bestTimeline, + darkestTimeline: darkestTimeline, + analysisDate: today, + monthsAhead: monthsAhead + ) + } +} + +struct ClaudeAPIRequest: Codable { + let model: String + let max_tokens: Int + let messages: [ClaudeMessage] +} + +struct ClaudeMessage: Codable { + let role: String + let content: String +} + +struct ClaudeAPIResponse: Codable { + let content: [ClaudeContent] +} + +struct ClaudeContent: Codable { + let text: String + let type: String +} + +struct ClaudeTimelineResponse: Codable { + let bestTimeline: [ClaudeTimelineItem] + let darkestTimeline: [ClaudeTimelineItem] +} + +struct ClaudeTimelineItem: Codable { + let month: Int + let happiness: Double + let description: String +} + +struct TimelineChartView: View { + let actualData: [TimelinePoint] + let prediction: TimelinePrediction? + @Binding var selectedPoint: TimelinePoint? + var showsInlineSummary: Bool = true + let onPointSelected: (TimelinePoint) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + if showsInlineSummary, let selectedPoint { + VStack(alignment: .leading, spacing: 4) { + Text(selectedPoint.scenario.displayTitle) + .font(.headline) + .foregroundStyle(selectedPoint.scenario.accentColor) + Text(selectedPoint.description) + .font(.footnote) + .foregroundStyle(.secondary) + } + .transition(.opacity) + } + + CustomChartView(actualData: actualData, prediction: prediction, selectedPoint: $selectedPoint) { point in + selectedPoint = point + onPointSelected(point) + } + .frame(height: 220) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke(Color.white.opacity(0.2), lineWidth: 0.5) + ) + ) + } + .animation(.easeInOut(duration: 0.25), value: selectedPoint?.id) + } + +} + +struct CustomChartView: View { + let actualData: [TimelinePoint] + let prediction: TimelinePrediction? + @Binding var selectedPoint: TimelinePoint? + let onPointSelected: (TimelinePoint) -> Void + + var body: some View { + GeometryReader { geometry in + let width = geometry.size.width + let height = geometry.size.height + let padding: CGFloat = 32 + let chartWidth = width - padding * 2 + let chartHeight = height - padding * 2 + + Canvas { context, size in + let rect = CGRect(x: padding, y: padding, width: chartWidth, height: chartHeight) + + let background = Path(roundedRect: rect, cornerRadius: 16) + context.fill(background, with: .linearGradient( + Gradient(colors: [Color.white.opacity(0.08), Color.white.opacity(0.02)]), + startPoint: CGPoint(x: rect.minX, y: rect.minY), + endPoint: CGPoint(x: rect.maxX, y: rect.maxY) + )) + + context.stroke(background, with: .color(Color.white.opacity(0.12)), lineWidth: 0.5) + + for i in 0...5 { + let fraction = Double(i) / 5.0 + let y = rect.minY + rect.height * (1 - fraction) + var line = Path() + line.move(to: CGPoint(x: rect.minX, y: y)) + line.addLine(to: CGPoint(x: rect.maxX, y: y)) + context.stroke(line, with: .color(Color.white.opacity(0.08)), lineWidth: 0.5) + } + + drawTimeline(points: actualData, in: rect, color: TimelinePoint.TimelineScenario.actual.accentColor, dashed: false, context: &context) + + if let prediction { + drawTimeline(points: prediction.bestTimeline, in: rect, color: TimelinePoint.TimelineScenario.best.accentColor, dashed: true, context: &context) + drawTimeline(points: prediction.darkestTimeline, in: rect, color: TimelinePoint.TimelineScenario.darkest.accentColor, dashed: true, context: &context) + } + } + .overlay( + timelinePointsOverlay(size: geometry.size, padding: padding, chartWidth: chartWidth, chartHeight: chartHeight) + ) + } + } + + private func timelinePointsOverlay(size: CGSize, padding: CGFloat, chartWidth: CGFloat, chartHeight: CGFloat) -> some View { + let allPoints = combinedPoints() + let labelPoints = uniqueAxisPoints(from: allPoints) + + return ZStack { + ForEach(allPoints) { point in + let index = pointIndex(point, in: allPoints) + let x = padding + (chartWidth * CGFloat(index) / CGFloat(max(allPoints.count - 1, 1))) + let y = padding + (chartHeight * CGFloat(10 - point.happiness) / 10) + let isSelected = selectedPoint?.id == point.id + + Circle() + .fill(point.scenario.accentColor) + .frame(width: isSelected ? 14 : 10, height: isSelected ? 14 : 10) + .overlay( + Circle() + .stroke(Color.white.opacity(isSelected ? 0.8 : 0.0), lineWidth: 2) + ) + .position(x: x, y: y) + .shadow(color: isSelected ? point.scenario.accentColor.opacity(0.35) : .clear, radius: 8, x: 0, y: 0) + .onTapGesture { + onPointSelected(point) + } + } + + ForEach(labelPoints) { point in + let index = pointIndex(point, in: allPoints) + let x = padding + (chartWidth * CGFloat(index) / CGFloat(max(allPoints.count - 1, 1))) + let labelY = padding + chartHeight + 16 + + Text(monthYearString(point.date)) + .font(.caption2) + .foregroundStyle(Color.white.opacity(0.65)) + .rotationEffect(.degrees(-25)) + .position(x: x, y: labelY) + .allowsHitTesting(false) + } + } + } + + private func drawTimeline(points: [TimelinePoint], in rect: CGRect, color: Color, dashed: Bool, context: inout GraphicsContext) { + guard points.count > 1 else { return } + let sortedPoints = points.sorted { $0.date < $1.date } + let allPoints = combinedPoints() + + var path = Path() + for (index, point) in sortedPoints.enumerated() { + let position = position(for: point, in: rect, allPoints: allPoints) + if index == 0 { + path.move(to: position) + } else { + path.addLine(to: position) + } + } + + context.stroke(path, with: .color(color.opacity(0.9)), style: StrokeStyle(lineWidth: dashed ? 2 : 3, lineCap: .round, dash: dashed ? [6, 6] : [])) + } + + private func position(for point: TimelinePoint, in rect: CGRect, allPoints: [TimelinePoint]) -> CGPoint { + let index = pointIndex(point, in: allPoints) + let fraction = CGFloat(index) / CGFloat(max(allPoints.count - 1, 1)) + let x = rect.minX + rect.width * fraction + let clamped = max(1, min(10, point.happiness)) + let y = rect.maxY - rect.height * CGFloat((clamped - 1) / 9) + return CGPoint(x: x, y: y) + } + + private func combinedPoints() -> [TimelinePoint] { + var points = actualData + if let prediction { + points.append(contentsOf: prediction.bestTimeline) + points.append(contentsOf: prediction.darkestTimeline) + } + return points.sorted { $0.date < $1.date } + } + + private func uniqueAxisPoints(from points: [TimelinePoint]) -> [TimelinePoint] { + var seenDates: Set = [] + var result: [TimelinePoint] = [] + for point in points { + if !seenDates.contains(point.date) { + seenDates.insert(point.date) + result.append(point) + } + } + return result + } + + private func pointIndex(_ point: TimelinePoint, in all: [TimelinePoint]) -> Int { + all.firstIndex { $0.date == point.date && $0.scenario == point.scenario } ?? 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 + @StateObject private var viewModel = JournalViewModel() + @StateObject private var claudeService = ClaudeAPIService() + + @AppStorage("colorScheme") private var colorSchemeString: String = "light" + @AppStorage("claudeApiToken") private var claudeApiToken: String = "" + @AppStorage("predictionMonths") private var predictionMonths: Int = 6 + @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 - @State private var didCopyPrompt: Bool = false // Add state for copy prompt feedback - 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 + @State private var timeRemaining: Int = 900 + @State private var timerIsRunning = false + @State private var isSettingsPresented = false + @State private var showApiToken = false + @State private var isGeneratingPrediction = false + @State private var isFocusMode = true + @State private var isSidebarVisible = false + @State private var selectedTimelinePoint: TimelinePoint? + @State private var isTimelineDrawerVisible = false + @State private var timerShakeTrigger = 0 + + private enum Tab: String, CaseIterable { + case editor = "Editor" + case timeline = "Timeline" + } + @State private var activeTab: Tab = .editor + + private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + private let availableFonts = NSFontManager.shared.availableFontFamilies + private let fontSizes: [CGFloat] = [16, 18, 20, 22, 24, 26] + private let focusSessionDuration: Int = 900 + 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. + do not just go through every single thing i say, and say it back to me. you need to proccess everythikng i 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. @@ -145,1258 +534,1222 @@ struct ContentView: View { 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. + 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) + + var body: some View { + ZStack { + LiquidGlassBackground() + VStack(spacing: 24) { + if !isFocusMode { + headerSection + .transition(.opacity) + + tabSwitcher + .transition(.opacity) + } + + Group { + switch activeTab { + case .editor: + if isFocusMode { + JournalEditorView( + text: viewModel.editorText, + placeholder: viewModel.placeholderText, + selectedFont: selectedFont, + fontSize: fontSize, + lineSpacing: lineSpacing, + colorScheme: currentColorScheme, + wordCount: viewModel.wordCount, + onTextChange: viewModel.updateEditorText, + showsContext: false + ) + .transition(.opacity.combined(with: .scale)) + } else { + HStack(alignment: .top, spacing: 24) { + if isSidebarVisible { + JournalSidebar( + entries: viewModel.entries, + selectedEntryID: viewModel.selectedEntryID, + onCreate: viewModel.createNewEntry, + onSelect: { viewModel.selectEntry($0) }, + onReveal: { revealInFinder(entry: $0) }, + onExport: { export(entry: $0) }, + onDelete: { viewModel.deleteEntry($0) } + ) + .frame(width: 260) + .transition(.move(edge: .leading).combined(with: .opacity)) + } + + LiquidGlassPanel { + JournalEditorView( + text: viewModel.editorText, + placeholder: viewModel.placeholderText, + selectedFont: selectedFont, + fontSize: fontSize, + lineSpacing: lineSpacing, + colorScheme: currentColorScheme, + wordCount: viewModel.wordCount, + onTextChange: viewModel.updateEditorText, + showsContext: true + ) + } + .frame(maxWidth: .infinity, minHeight: 520) + } + .animation(.easeInOut(duration: 0.3), value: isSidebarVisible) + .transition(.opacity) + } + + case .timeline: + if isFocusMode { + EmptyView() + .transition(.opacity) + } else { + TimelinePage( + entries: viewModel.entries, + actualData: viewModel.actualTimelineData, + prediction: viewModel.timelinePrediction, + timelineError: viewModel.timelineError, + isGeneratingPrediction: isGeneratingPrediction || claudeService.isLoading, + predictionMonths: $predictionMonths, + onGeneratePrediction: generateTimelinePrediction, + onSelectEntry: { + viewModel.selectEntry($0) + handleTabSelection(.editor) + }, + onOpenSettings: { isSettingsPresented = true }, + selectedPoint: $selectedTimelinePoint, + isDrawerVisible: $isTimelineDrawerVisible + ) + .transition(.opacity) + } + } + } + } + .padding(.horizontal, isFocusMode ? 16 : 32) + .padding(.top, isFocusMode ? 16 : 28) + .padding(.bottom, isFocusMode ? 16 : 140) + .animation(.easeInOut(duration: 0.35), value: isFocusMode) + } + .preferredColorScheme(currentColorScheme) + .overlay(alignment: .bottom) { + if !isFocusMode { + bottomUtilityBar + } + } + .overlay(alignment: .topTrailing) { + if shouldShowFocusTimer { + focusTimerBadge + .padding(.top, isFocusMode ? 24 : 20) + .padding(.trailing, isFocusMode ? 28 : 32) + } + } + .overlay(alignment: .bottomTrailing) { + if isFocusMode { + focusRevealButton + } + } + .sheet(isPresented: $isSettingsPresented) { + SettingsSheet( + claudeApiToken: $claudeApiToken, + predictionMonths: $predictionMonths, + showApiToken: $showApiToken, + claudeService: claudeService, + onDismiss: { isSettingsPresented = false } + ) + .frame(minWidth: 480, minHeight: 420) + } + .onReceive(timer) { _ in + guard timerIsRunning else { return } + if timeRemaining > 0 { + timeRemaining -= 1 + } else { + timerIsRunning = false + timeRemaining = 0 + withAnimation(.easeInOut(duration: 0.6)) { + timerShakeTrigger += 1 + } + } + } } - - // Modify getDocumentsDirectory to use cached value - private func getDocumentsDirectory() -> URL { - return documentsDirectory + + private var currentColorScheme: ColorScheme { + colorSchemeString == "dark" ? .dark : .light } - - // 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)") + + private var lineSpacing: CGFloat { + let font = NSFont(name: selectedFont, size: fontSize) ?? .systemFont(ofSize: fontSize) + let base = getLineHeight(font: font) + return max(4, (fontSize * 1.5) - base) + } + + private var formattedTimer: String { + let minutes = timeRemaining / 60 + let seconds = timeRemaining % 60 + return String(format: "%d:%02d", minutes, seconds) + } + + private var focusToggleButton: some View { + Button(action: toggleFocusMode) { + Label(isFocusMode ? "Exit Focus" : "Focus Mode", systemImage: isFocusMode ? "eye" : "eye.slash") + .labelStyle(.titleAndIcon) } + .buttonStyle(GlassControlStyle()) + .animation(.easeInOut(duration: 0.35), value: isFocusMode) } - - // 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") + + private var entriesToggleButton: some View { + Button(action: toggleSidebar) { + Label(isSidebarVisible ? "Hide Entries" : "Show Entries", systemImage: "sidebar.left") + .labelStyle(.titleAndIcon) + } + .buttonStyle(GlassControlStyle()) + .animation(.easeInOut(duration: 0.3), value: isSidebarVisible) + } + + private var focusRevealButton: some View { + Button(action: toggleFocusMode) { + Image(systemName: "slider.horizontal.3") + .font(.title3.weight(.semibold)) + .foregroundStyle(.white) + .padding(16) + .background( + Circle() + .fill(Color.white.opacity(0.18)) + .overlay( + Circle() + .stroke(Color.white.opacity(0.35), lineWidth: 0.9) + ) + .shadow(color: Color.black.opacity(0.35), radius: 18, x: 0, y: 12) + ) + } + .buttonStyle(.plain) + .padding(.trailing, 40) + .padding(.bottom, 40) + .transition(.scale.combined(with: .opacity)) + } + + private var headerSection: some View { + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 4) { + Text("Freewrite Journal") + .font(.title3.weight(.semibold)) + if let date = viewModel.selectedEntry?.date { + Text("Entry for \(date)") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + Text("Start a fresh entry and let it pour out.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + Spacer() + + if isFocusMode { + focusToggleButton } - } 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 + + private var tabSwitcher: some View { + HStack(spacing: 10) { + ForEach(Tab.allCases, id: \.self) { tab in + Button(action: { handleTabSelection(tab) }) { + Text(tab.rawValue) + .font(.callout.weight(.semibold)) + .padding(.horizontal, 20) + .padding(.vertical, 8) + .frame(minWidth: 110) + .background( + Capsule(style: .continuous) + .fill(tab == activeTab ? Color.white.opacity(0.16) : Color.white.opacity(0.04)) + ) + .overlay( + Capsule(style: .continuous) + .stroke(Color.white.opacity(tab == activeTab ? 0.28 : 0.1), lineWidth: 0.8) + ) + .foregroundStyle(tab == activeTab ? Color.white : .secondary) } - - // 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 + .buttonStyle(.plain) + } + + Spacer(minLength: 0) + } + } + + private var shouldShowFocusTimer: Bool { + isFocusMode && (timerIsRunning || timeRemaining != focusSessionDuration) + } + + private var focusTimerBadge: some View { + Button(action: toggleTimer) { + Label(formattedTimer, systemImage: "timer") + .font(.callout.weight(.semibold)) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background( + Capsule(style: .continuous) + .fill(Color.white.opacity(0.18)) + .overlay( + Capsule(style: .continuous) + .stroke(Color.white.opacity(0.35), lineWidth: 0.9) + ) + ) + .foregroundStyle(.white) + } + .buttonStyle(.plain) + .modifier(ShakeEffect(animatableData: CGFloat(timerShakeTrigger))) + .animation(.easeInOut(duration: 0.6), value: timerShakeTrigger) + } + + private var bottomUtilityBar: some View { + HStack(alignment: .center, spacing: 16) { + HStack(spacing: 12) { + Button(action: toggleTimer) { + Label(formattedTimer, systemImage: timerIsRunning ? "pause.circle.fill" : "play.circle.fill") } - - // 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 + .buttonStyle(GlassControlStyle()) + .contextMenu { + Button("Reset Timer", role: .destructive, action: resetTimer) } + + Label("\(viewModel.wordCount) words", systemImage: "character.cursor.ibeam") + .padding(.horizontal, 12) + .padding(.vertical, 8) + .backgroundCapsule() } - - // 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 + + Spacer(minLength: 20) + + HStack(spacing: 12) { + Menu { + Picker("Writing Font", selection: $selectedFont) { + Text("Lato").tag("Lato-Regular") + Text("Arial").tag("Arial") + Text("System").tag(".AppleSystemUIFont") + Text("Serif").tag("Times New Roman") } + Divider() + Button("Randomize") { + if let random = availableFonts.randomElement() { + selectedFont = random + } + } + } label: { + Label(fontDisplayName, systemImage: "textformat") } - 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 + .menuStyle(.borderlessButton) + .buttonStyle(GlassControlStyle()) + + Menu { + ForEach(fontSizes, id: \.self) { size in + Button(action: { fontSize = size }) { + if fontSize == size { + Label("\(Int(size)) pt", systemImage: "checkmark") + } else { + Text("\(Int(size)) pt") + } } } - 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]) + } label: { + Label("\(Int(fontSize)) pt", systemImage: "textformat.size") } + .menuStyle(.borderlessButton) + .buttonStyle(GlassControlStyle()) + + Button(action: toggleColorScheme) { + Image(systemName: currentColorScheme == .dark ? "sun.max.fill" : "moon.fill") + } + .buttonStyle(GlassControlStyle()) + } + + Spacer(minLength: 20) + + HStack(spacing: 12) { + Menu("AI Tools") { + Button("Copy prompt to clipboard", action: copyPromptToClipboard) + Button("Open in ChatGPT", action: openChatGPT) + Button("Open in Claude", action: openClaude) + } + .menuStyle(.borderlessButton) + .buttonStyle(GlassControlStyle()) + + Menu("More") { + Button("Reveal in Finder", action: revealJournalFolder) + Divider() + Button("Export current entry as PDF", action: exportCurrentEntry) + Divider() + Button("Toggle Full Screen") { + toggleFullScreen() + } + } + .menuStyle(.borderlessButton) + .buttonStyle(GlassControlStyle()) + + Button(action: { isSettingsPresented = true }) { + Label("Settings", systemImage: "gearshape") + } + .buttonStyle(GlassControlStyle()) + + if activeTab == .editor { + entriesToggleButton + } + + focusToggleButton } - - } catch { - print("Error loading directory contents: \(error)") - print("Creating default entry after error") - createNewEntry() } + .padding(.horizontal, 22) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: 26, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 26, style: .continuous) + .stroke(Color.white.opacity(0.2), lineWidth: 0.7) + ) + .shadow(color: Color.black.opacity(0.25), radius: 30, x: 0, y: 20) + ) + .padding(.horizontal, 42) + .padding(.bottom, 24) } - - var randomButtonTitle: String { - return currentRandomFont.isEmpty ? "Random" : "Random [\(currentRandomFont)]" - } - - var timerButtonTitle: String { - if !timerIsRunning && timeRemaining == 900 { - return "15:00" + + private var fontDisplayName: String { + switch selectedFont { + case "Lato-Regular": return "Lato" + case "Arial": return "Arial" + case ".AppleSystemUIFont": return "System" + case "Times New Roman": return "Serif" + default: return selectedFont } - let minutes = timeRemaining / 60 - let seconds = timeRemaining % 60 - return String(format: "%d:%02d", minutes, seconds) } - - var timerColor: Color { + + private func toggleTimer() { if timerIsRunning { - return isHoveringTimer ? (colorScheme == .light ? .black : .white) : .gray.opacity(0.8) + timerIsRunning = false } else { - return isHoveringTimer ? (colorScheme == .light ? .black : .white) : (colorScheme == .light ? .gray : .gray.opacity(0.8)) + if timeRemaining == 0 { + timeRemaining = focusSessionDuration + } + timerShakeTrigger = 0 + withAnimation(.easeInOut(duration: 0.35)) { + activeTab = .editor + isSidebarVisible = false + isTimelineDrawerVisible = false + selectedTimelinePoint = nil + isFocusMode = true + } + timerIsRunning = true } } - - var lineHeight: CGFloat { - let font = NSFont(name: selectedFont, size: fontSize) ?? .systemFont(ofSize: fontSize) - let defaultLineHeight = getLineHeight(font: font) - return (fontSize * 1.5) - defaultLineHeight + + private func resetTimer() { + timerIsRunning = false + timeRemaining = focusSessionDuration } - - var fontSizeButtonTitle: String { - return "\(Int(fontSize))px" + + private func toggleSidebar() { + withAnimation(.easeInOut(duration: 0.3)) { + isSidebarVisible.toggle() + } } - - var placeholderOffset: CGFloat { - // Instead of using calculated line height, use a simple offset - return fontSize / 2 + + private func toggleFocusMode() { + withAnimation(.easeInOut(duration: 0.35)) { + if isFocusMode { + isFocusMode = false + } else { + activeTab = .editor + isSidebarVisible = false + isTimelineDrawerVisible = false + selectedTimelinePoint = nil + isFocusMode = true + } + } } - - // Add a color utility computed property - var popoverBackgroundColor: Color { - return colorScheme == .light ? Color(NSColor.controlBackgroundColor) : Color(NSColor.darkGray) + + private func handleTabSelection(_ tab: Tab) { + withAnimation(.easeInOut(duration: 0.25)) { + activeTab = tab + if tab != .editor { + isSidebarVisible = false + } + if tab != .timeline { + isTimelineDrawerVisible = false + selectedTimelinePoint = nil + } + } } - - var popoverTextColor: Color { - return colorScheme == .light ? Color.primary : Color.white + + private func toggleColorScheme() { + colorSchemeString = colorSchemeString == "dark" ? "light" : "dark" + } + + private func toggleFullScreen() { + if let window = NSApplication.shared.windows.first { + window.toggleFullScreen(nil) + } + } + + private func revealJournalFolder() { + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: viewModel.documentsPath.path) + } + + private func copyPromptToClipboard() { + let text = viewModel.editorText.trimmingCharacters(in: .whitespacesAndNewlines) + let slot = aiChatPrompt + "\n\n" + text + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(slot, forType: .string) + } + + private func openChatGPT() { + let trimmed = viewModel.editorText.trimmingCharacters(in: .whitespacesAndNewlines) + let fullText = aiChatPrompt + "\n\n" + trimmed + guard let encoded = fullText.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: "https://chat.openai.com/?m=" + encoded) else { return } + NSWorkspace.shared.open(url) + } + + private func openClaude() { + let trimmed = viewModel.editorText.trimmingCharacters(in: .whitespacesAndNewlines) + let fullText = claudePrompt + "\n\n" + trimmed + guard let encoded = fullText.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: "https://claude.ai/new?q=" + encoded) else { return } + NSWorkspace.shared.open(url) + } + + private func export(entry: HumanEntry) { + guard let data = viewModel.exportEntryAsPDF(entry: entry, fontName: selectedFont, fontSize: fontSize, lineHeight: lineSpacing) else { return } + presentSavePanel(data: data, suggestedName: suggestedFilename(for: entry)) + } + + private func exportCurrentEntry() { + guard let entry = viewModel.selectedEntry, + let data = viewModel.exportCurrentEntryAsPDF(fontName: selectedFont, fontSize: fontSize, lineHeight: lineSpacing) else { return } + presentSavePanel(data: data, suggestedName: suggestedFilename(for: entry)) + } + + private func revealInFinder(entry: HumanEntry) { + let url = viewModel.contentURL(for: entry) + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + + private func presentSavePanel(data: Data, suggestedName: String) { + let panel = NSSavePanel() + panel.allowedContentTypes = [UTType.pdf] + panel.nameFieldStringValue = suggestedName + if panel.runModal() == .OK, let url = panel.url { + try? data.write(to: url) + } } - - 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 - // Ensure didCopyPrompt is reset when opening the menu - didCopyPrompt = false - } - .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) { - VStack(spacing: 0) { // Wrap everything in a VStack for consistent styling and onChange - let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) - - // Calculate potential URL lengths - let gptFullText = aiChatPrompt + "\n\n" + trimmedText - let claudeFullText = claudePrompt + "\n\n" + trimmedText - let encodedGptText = gptFullText.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" - let encodedClaudeText = claudeFullText.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" - - let gptUrlLength = "https://chat.openai.com/?m=".count + encodedGptText.count - let claudeUrlLength = "https://claude.ai/new?q=".count + encodedClaudeText.count - let isUrlTooLong = gptUrlLength > 6000 || claudeUrlLength > 6000 - - if isUrlTooLong { - // View for long text (URL too long) - Text("Hey, your entry is long. It'll break the URL. Instead, copy prompt by clicking below and paste into AI of your choice!") - .font(.system(size: 14)) - .foregroundColor(popoverTextColor) - .lineLimit(nil) - .multilineTextAlignment(.leading) - .frame(width: 200, alignment: .leading) - .padding(.horizontal, 12) - .padding(.vertical, 8) - - Divider() - - Button(action: { - copyPromptToClipboard() - didCopyPrompt = true - }) { - Text(didCopyPrompt ? "Copied!" : "Copy Prompt") - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .padding(.vertical, 8) - } - .buttonStyle(.plain) - .foregroundColor(popoverTextColor) - .onHover { hovering in - if hovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - - } else 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) - } 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) - } else { - // View for normal text length - Button(action: { - showingChatMenu = false - openChatGPT() - }) { - Text("ChatGPT") - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .padding(.vertical, 8) - } - .buttonStyle(.plain) - .foregroundColor(popoverTextColor) - .onHover { hovering in - if hovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - - Divider() - - Button(action: { - showingChatMenu = false - openClaude() - }) { - Text("Claude") - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .padding(.vertical, 8) - } - .buttonStyle(.plain) - .foregroundColor(popoverTextColor) - .onHover { hovering in - if hovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - - Divider() - - Button(action: { - // Don't dismiss menu, just copy and update state - copyPromptToClipboard() - didCopyPrompt = true - }) { - Text(didCopyPrompt ? "Copied!" : "Copy Prompt") - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .padding(.vertical, 8) - } - .buttonStyle(.plain) - .foregroundColor(popoverTextColor) - .onHover { hovering in - if hovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - } - } - .frame(minWidth: 120, maxWidth: 250) // Allow width to adjust - .background(popoverBackgroundColor) - .cornerRadius(8) - .shadow(color: Color.black.opacity(0.1), radius: 4, y: 2) - // Reset copied state when popover dismisses - .onChange(of: showingChatMenu) { newValue in - if !newValue { - didCopyPrompt = false - } - } - } - - 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 - } - } - } + private func suggestedFilename(for entry: HumanEntry) -> String { + let url = viewModel.contentURL(for: entry) + guard let content = try? String(contentsOf: url, encoding: .utf8) else { + return "Entry-\(entry.date).pdf" + } + + let cleaned = content + .replacingOccurrences(of: "\n", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + + let words = cleaned + .split { $0.isWhitespace || ",.!?;:".contains($0) } + .map(String.init) + + if words.count >= 4 { + return words.prefix(4).joined(separator: "-").lowercased() + ".pdf" + } + + if let first = words.first { + return first.lowercased() + "-entry.pdf" + } + + return "Entry-\(entry.date).pdf" + } + + private func generateTimelinePrediction() { + Task { + guard !claudeApiToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + viewModel.timelineError = "Add a Claude API token in Settings to generate predictions." + isSettingsPresented = true + return + } + + guard !viewModel.entries.isEmpty else { + viewModel.timelineError = "Add some journal entries to generate a timeline." + return + } + + viewModel.timelineError = nil + isGeneratingPrediction = true + + do { + let prediction = try await claudeService.generateTimelinePrediction(entries: viewModel.entries, apiToken: claudeApiToken, monthsAhead: predictionMonths) + await MainActor.run { + viewModel.timelinePrediction = prediction + } + } catch { + await MainActor.run { + viewModel.timelineError = error.localizedDescription } } - - // 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() - } + + await MainActor.run { + isGeneratingPrediction = false + } + } + } +} + +struct LiquidGlassBackground: View { + var body: some View { + ZStack { + LinearGradient( + colors: [Color(red: 0.07, green: 0.08, blue: 0.12), Color(red: 0.12, green: 0.14, blue: 0.20)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + + Circle() + .fill(Color(red: 0.30, green: 0.55, blue: 0.95).opacity(0.35)) + .frame(width: 420) + .blur(radius: 160) + .offset(x: -260, y: -200) + + Circle() + .fill(Color(red: 0.95, green: 0.55, blue: 0.85).opacity(0.25)) + .frame(width: 420) + .blur(radius: 180) + .offset(x: 220, y: 240) + + RoundedRectangle(cornerRadius: 100) + .fill(Color.white.opacity(0.08)) + .frame(width: 520, height: 520) + .blur(radius: 200) + } + } +} + +struct JournalSidebar: View { + let entries: [HumanEntry] + let selectedEntryID: UUID? + let onCreate: () -> Void + let onSelect: (HumanEntry) -> Void + let onReveal: (HumanEntry) -> Void + let onExport: (HumanEntry) -> Void + let onDelete: (HumanEntry) -> Void + + var body: some View { + LiquidGlassPanel { + VStack(alignment: .leading, spacing: 20) { + HStack { + Text("Entries") + .font(.headline) + Spacer() + Button(action: onCreate) { + Label("New", systemImage: "plus") } - .buttonStyle(.plain) - .padding(.horizontal, 16) - .padding(.vertical, 12) - .onHover { hovering in - isHoveringHistory = hovering + .buttonStyle(GlassControlStyle()) + } + + if entries.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("No entries yet") + .font(.subheadline) + .foregroundStyle(.secondary) + Text("Click New to capture your first thoughts.") + .font(.footnote) + .foregroundStyle(.tertiary) } - - Divider() - - // Entries List + } else { ScrollView { - LazyVStack(spacing: 0) { + LazyVStack(spacing: 12) { 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() - } + SidebarEntryRow( + entry: entry, + isSelected: entry.id == selectedEntryID, + onSelect: { onSelect(entry) }, + onReveal: { onReveal(entry) }, + onExport: { onExport(entry) }, + onDelete: { onDelete(entry) } + ) } } } - .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) + } +} + +struct SidebarEntryRow: View { + let entry: HumanEntry + let isSelected: Bool + let onSelect: () -> Void + let onReveal: () -> Void + let onExport: () -> Void + let onDelete: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline) { + Text(entry.date) + .font(.subheadline) + .fontWeight(.semibold) + Spacer() + Menu { + Button("Reveal in Finder", action: onReveal) + Button("Export as PDF", action: onExport) + Divider() + Button("Delete", role: .destructive, action: onDelete) + } label: { + Image(systemName: "ellipsis") + .imageScale(.small) + } + .menuStyle(.borderlessButton) } - } - .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 - } + + Button(action: onSelect) { + VStack(alignment: .leading, spacing: 4) { + Text(entry.previewText.isEmpty ? "Untitled entry" : entry.previewText) + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + .lineLimit(3) } + .frame(maxWidth: .infinity, alignment: .leading) } + .buttonStyle(.plain) + .padding(12) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(isSelected ? Color.white.opacity(0.14) : Color.white.opacity(0.04)) + ) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(isSelected ? Color.white.opacity(0.25) : Color.white.opacity(0.08), lineWidth: 0.8) + ) } - .onReceive(NotificationCenter.default.publisher(for: NSWindow.willEnterFullScreenNotification)) { _ in - isFullscreen = true - } - .onReceive(NotificationCenter.default.publisher(for: NSWindow.willExitFullScreenNotification)) { _ in - isFullscreen = false + } +} + +struct JournalEditorView: View { + let text: String + let placeholder: String + let selectedFont: String + let fontSize: CGFloat + let lineSpacing: CGFloat + let colorScheme: ColorScheme + let wordCount: Int + let onTextChange: (String) -> Void + var showsContext: Bool = true + + var body: some View { + VStack(alignment: .leading, spacing: showsContext ? 18 : 0) { + if showsContext { + VStack(alignment: .leading, spacing: 6) { + Text("Today") + .font(.headline) + Text("Focus on the detail in front of you and let everything else happen later.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + ZStack(alignment: .topLeading) { + TextEditor(text: Binding( + get: { text }, + set: { onTextChange($0) } + )) + .font(.custom(selectedFont, size: fontSize)) + .foregroundColor(colorScheme == .dark ? .white : Color(red: 0.20, green: 0.20, blue: 0.20)) + .scrollContentBackground(.hidden) + .lineSpacing(lineSpacing) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.top, showsContext ? 0 : 0) + + if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text(placeholder) + .font(.custom(selectedFont, size: fontSize)) + .foregroundStyle(.secondary.opacity(0.6)) + .padding(.top, 2) + .padding(.leading, 4) + .allowsHitTesting(false) + } + } + + if showsContext { + HStack(spacing: 12) { + Label("\(wordCount) words", systemImage: "character.cursor.ibeam") + .font(.footnote) + .foregroundStyle(.secondary) + Spacer() + Text("Auto-saved") + .font(.footnote) + .foregroundStyle(.tertiary) + } + } } + .padding(editorPadding) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - - 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 + + private var editorPadding: EdgeInsets { + if showsContext { + return EdgeInsets(top: 20, leading: 26, bottom: 26, trailing: 26) } else { - return Color.clear + return EdgeInsets(top: 28, leading: 38, bottom: 32, trailing: 38) } } - - 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 +} + +struct TimelinePage: View { + let entries: [HumanEntry] + let actualData: [TimelinePoint] + let prediction: TimelinePrediction? + let timelineError: String? + let isGeneratingPrediction: Bool + @Binding var predictionMonths: Int + let onGeneratePrediction: () -> Void + let onSelectEntry: (HumanEntry) -> Void + let onOpenSettings: () -> Void + @Binding var selectedPoint: TimelinePoint? + @Binding var isDrawerVisible: Bool + + var body: some View { + ZStack(alignment: .trailing) { + VStack(alignment: .leading, spacing: 24) { + header + + if let timelineError { + errorBanner(for: timelineError) + } + + chartSection + } + .padding(32) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + + if isDrawerVisible, let selectedPoint { + TimelineDetailDrawer( + point: selectedPoint, + entries: entriesFor(point: selectedPoint), + onSelectEntry: { entry in + onSelectEntry(entry) + closeDrawer() + }, + onClose: closeDrawer + ) + .frame(width: 360) + .padding(.trailing, 36) + .padding(.top, 96) + .transition(.move(edge: .trailing).combined(with: .opacity)) } - } 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)") } + .animation(.easeInOut(duration: 0.3), value: isDrawerVisible) } - - 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)") + + private var header: some View { + HStack(alignment: .center, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text("Timeline") + .font(.title3.weight(.semibold)) + Text("Scan your story and where it could go next.") + .font(.footnote) + .foregroundStyle(.secondary) } - } catch { - print("Error loading entry: \(error)") + + Spacer(minLength: 24) + + Picker("Months ahead", selection: $predictionMonths) { + Text("3m").tag(3) + Text("6m").tag(6) + Text("12m").tag(12) + } + .pickerStyle(.segmented) + .frame(width: 180) + + Button(action: onGeneratePrediction) { + HStack(spacing: 8) { + if isGeneratingPrediction { + ProgressView() + .scaleEffect(0.7) + } else { + Image(systemName: "sparkles") + } + Text(isGeneratingPrediction ? "Projecting" : "Project timelines") + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + .buttonStyle(GlassControlStyle()) + .disabled(isGeneratingPrediction) } } - - 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 + + private var chartSection: some View { + Group { + if hasChartData { + TimelineChartView( + actualData: actualData, + prediction: prediction, + selectedPoint: $selectedPoint, + showsInlineSummary: false + ) { point in + selectedPoint = point + withAnimation(.easeInOut(duration: 0.25)) { + isDrawerVisible = true + } + } + .frame(maxWidth: .infinity) + .frame(minHeight: 360) + .frame(maxHeight: .infinity) + } else { + VStack(alignment: .leading, spacing: 10) { + Text("No timeline yet") + .font(.headline) + Text("Write a few entries and generate a forecast to see your story unfold here.") + .font(.subheadline) + .foregroundStyle(.secondary) + Button(action: onOpenSettings) { + Text("Open settings") + } + .buttonStyle(GlassControlStyle()) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } - // 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 errorBanner(for message: String) -> some View { + HStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Color.orange) + Text(message) + .font(.footnote) + .foregroundStyle(.secondary) + Spacer() + Button("Settings", action: onOpenSettings) + .buttonStyle(GlassControlStyle()) } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.orange.opacity(0.12)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.orange.opacity(0.35), lineWidth: 0.8) + ) + ) } - - 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 entriesFor(point: TimelinePoint) -> [TimelineEntryDetail] { + let calendar = Calendar.current + let documentsDirectory = FileManager.default + .urls(for: .documentDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Freewrite") + + return entries + .filter { calendar.isDate($0.rawDate, equalTo: point.date, toGranularity: .month) } + .compactMap { entry in + let url = documentsDirectory.appendingPathComponent(entry.filename) + let content = (try? String(contentsOf: url, encoding: .utf8)) ?? entry.previewText + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) + return TimelineEntryDetail(entry: entry, content: trimmed) + } + .sorted { $0.entry.rawDate > $1.entry.rawDate } } - private func copyPromptToClipboard() { - let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) - let fullText = aiChatPrompt + "\n\n" + trimmedText + private var hasChartData: Bool { + if !actualData.isEmpty { return true } + guard let prediction else { return false } + return !prediction.bestTimeline.isEmpty || !prediction.darkestTimeline.isEmpty + } - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(fullText, forType: .string) - print("Prompt copied to clipboard") + private func closeDrawer() { + withAnimation(.easeInOut(duration: 0.25)) { + isDrawerVisible = false + selectedPoint = nil + } } - - 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) +} + +struct TimelineDetailDrawer: View { + let point: TimelinePoint + let entries: [TimelineEntryDetail] + let onSelectEntry: (HumanEntry) -> Void + let onClose: () -> Void + + var body: some View { + LiquidGlassPanel { + VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 6) { + Text(monthYearString(point.date)) + .font(.title3.weight(.semibold)) + Text(point.scenario.displayTitle) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(point.scenario.accentColor) + } + Spacer() + Button(action: onClose) { + Image(systemName: "xmark") + } + .buttonStyle(GlassControlStyle()) + } + + HStack(spacing: 12) { + Label(String(format: "%.1f", point.happiness), systemImage: "waveform.path.ecg") + .font(.footnote.weight(.semibold)) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .backgroundCapsule() + .foregroundStyle(point.scenario.accentColor) + } + + Text(point.description) + .font(.callout) + .foregroundStyle(.primary) + + Divider() + + if point.scenario == .actual { + Text(entries.isEmpty ? "No entries captured for this month." : "Entries from this month") + .font(.subheadline.weight(.semibold)) + + if entries.isEmpty { + Text("Capture more reflections to build your story here.") + .font(.footnote) + .foregroundStyle(.secondary) } else { - createNewEntry() + ScrollView { + VStack(alignment: .leading, spacing: 12) { + ForEach(entries) { detail in + Button(action: { + onSelectEntry(detail.entry) + }) { + VStack(alignment: .leading, spacing: 6) { + Text(longDateString(detail.entry.rawDate)) + .font(.caption) + .foregroundStyle(.secondary) + Text(detail.content.isEmpty ? "Tap to open entry" : detail.content) + .font(.footnote) + .foregroundStyle(.primary) + .multilineTextAlignment(.leading) + .lineLimit(nil) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.white.opacity(0.05)) + ) + } + .buttonStyle(.plain) + } + } + } + .frame(maxHeight: 260) } + } else { + Text("This forecast is generated by Claude based on your recent writing.") + .font(.footnote) + .foregroundStyle(.secondary) } } - } 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() +} + +struct TimelineEntryDetail: Identifiable { + let entry: HumanEntry + let content: String + + var id: UUID { entry.id } +} + +struct SettingsSheet: View { + @Binding var claudeApiToken: String + @Binding var predictionMonths: Int + @Binding var showApiToken: Bool + @ObservedObject var claudeService: ClaudeAPIService + let onDismiss: () -> Void + + var body: some View { + NavigationStack { + Form { + Section("Claude API") { + HStack { + Group { + if showApiToken { + TextField("Claude API Token", text: $claudeApiToken) + } else { + SecureField("Claude API Token", text: $claudeApiToken) + } + } + Button(action: { showApiToken.toggle() }) { + Image(systemName: showApiToken ? "eye.slash" : "eye") + } + } + + Button { + Task { await claudeService.testConnection(apiToken: claudeApiToken) } + } label: { + if claudeService.isTestingConnection { + ProgressView() + } else { + Text("Test Connection") + } + } + .disabled(claudeApiToken.isEmpty || claudeService.isTestingConnection) + + if let result = claudeService.connectionTestResult { + Text(result) + .font(.footnote) + .foregroundStyle(result.contains("✅") ? Color.green : Color.red) + } + } + + Section("Timeline Horizon") { + Picker("Months ahead", selection: $predictionMonths) { + Text("3 months").tag(3) + Text("6 months").tag(6) + Text("12 months").tag(12) + } + .pickerStyle(.segmented) + } } - .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)") + .formStyle(.grouped) + .navigationTitle("Settings") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close", action: onDismiss) } } - } 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 +} + +struct LiquidGlassPanel: View { + @ViewBuilder var content: () -> Content + + var body: some View { + content() + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 28, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 28, style: .continuous) + .stroke(Color.white.opacity(0.18), lineWidth: 0.7) + ) + .shadow(color: Color.black.opacity(0.25), radius: 40, x: 0, y: 30) ) - - // 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 - } + } +} + +private extension TimelinePoint.TimelineScenario { + var displayTitle: String { + switch self { + case .actual: return "Current Story" + case .best: return "Best Timeline" + case .darkest: return "Darkest Timeline" + } + } + + var accentColor: Color { + switch self { + case .actual: return Color.cyan + case .best: return Color.green + case .darkest: return Color.red } - - // 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 +struct GlassControlStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding(.horizontal, 14) + .padding(.vertical, 8) + .backgroundCapsule(active: configuration.isPressed) + .animation(.easeOut(duration: 0.15), value: configuration.isPressed) + } } -// 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 +struct GlassButtonStyle: ButtonStyle { + let tint: Color + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.callout.weight(.semibold)) + .padding(.vertical, 10) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(tint.opacity(configuration.isPressed ? 0.7 : 0.9)) + ) + .foregroundStyle(.white) } } -// 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 +struct ShakeEffect: GeometryEffect { + var amplitude: CGFloat = 8 + var shakesPerUnit: CGFloat = 3 + var animatableData: CGFloat + + func effectValue(size: CGSize) -> ProjectionTransform { + let translation = amplitude * sin(animatableData * .pi * shakesPerUnit) + return ProjectionTransform(CGAffineTransform(translationX: translation, y: 0)) + } +} + +extension View { + func backgroundCapsule(active: Bool = false) -> some View { + self + .background( + Capsule(style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + Capsule(style: .continuous) + .stroke(Color.white.opacity(active ? 0.35 : 0.18), lineWidth: active ? 1.2 : 0.8) + ) + ) } } #Preview { ContentView() -} \ No newline at end of file +} + +func getLineHeight(font: NSFont) -> CGFloat { + font.ascender - font.descender + font.leading +} + +fileprivate let monthYearFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "MMM yyyy" + return formatter +}() + +fileprivate let longDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "MMM d, yyyy" + return formatter +}() + +fileprivate func monthYearString(_ date: Date) -> String { + monthYearFormatter.string(from: date) +} + +fileprivate func longDateString(_ date: Date) -> String { + longDateFormatter.string(from: date) +} + diff --git a/freewrite/JournalViewModel.swift b/freewrite/JournalViewModel.swift new file mode 100644 index 0000000..fb2bad8 --- /dev/null +++ b/freewrite/JournalViewModel.swift @@ -0,0 +1,369 @@ +import SwiftUI +import AppKit +import CoreText + +final class JournalViewModel: ObservableObject { + @Published var entries: [HumanEntry] = [] + @Published var selectedEntryID: UUID? + @Published var editorText: String = "" + @Published var placeholderText: String + @Published var timelinePrediction: TimelinePrediction? + @Published var timelineError: String? + + private let placeholderOptions = [ + "Begin writing", + "Pick a thought and go", + "What's on your mind", + "Just start", + "Type your first thought", + "Start with one sentence", + "Let it flow" + ] + + private let fileManager = FileManager.default + private let documentsDirectory: URL + + init() { + let directory = FileManager.default + .urls(for: .documentDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Freewrite") + + if !FileManager.default.fileExists(atPath: directory.path) { + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } catch { + print("Error creating Freewrite directory: \(error)") + } + } + + documentsDirectory = directory + placeholderText = placeholderOptions.randomElement() ?? "Begin writing" + + loadEntries() + } + + var documentsPath: URL { + documentsDirectory + } + + var selectedEntry: HumanEntry? { + guard let id = selectedEntryID else { return nil } + return entries.first(where: { $0.id == id }) + } + + var wordCount: Int { + editorText + .trimmingCharacters(in: .whitespacesAndNewlines) + .split { $0.isWhitespace || $0.isNewline } + .count + } + + var actualTimelineData: [TimelinePoint] { + guard !entries.isEmpty else { return [] } + let calendar = Calendar.current + var monthlyEntries: [Date: [HumanEntry]] = [:] + + for entry in entries { + let entryDate = entry.rawDate + let monthStart = calendar.dateInterval(of: .month, for: entryDate)?.start ?? entryDate + monthlyEntries[monthStart, default: []].append(entry) + } + + return monthlyEntries + .sorted(by: { $0.key < $1.key }) + .map { monthDate, entries in + TimelinePoint( + date: monthDate, + happiness: calculateHappinessScore(for: entries), + description: generateMonthDescription(for: entries), + scenario: .actual + ) + } + } + + func loadEntries() { + do { + let fileURLs = try fileManager.contentsOfDirectory(at: documentsDirectory, includingPropertiesForKeys: nil) + let markdownFiles = fileURLs.filter { $0.pathExtension == "md" } + + let fileDateFormatter = DateFormatter() + fileDateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "MMM d" + + let entriesWithDetails: [(entry: HumanEntry, date: Date, content: String)] = markdownFiles.compactMap { url in + let filename = url.lastPathComponent + + guard let content = try? String(contentsOf: url, encoding: .utf8) else { return nil } + let preview = content + .replacingOccurrences(of: "\n", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + let truncated = preview.isEmpty ? "" : (preview.count > 40 ? String(preview.prefix(40)) + "..." : preview) + + if 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())) { + let dateString = String(filename[dateMatch].dropFirst().dropLast()) + if let rawDate = fileDateFormatter.date(from: dateString) { + let entry = HumanEntry( + id: uuid, + date: displayFormatter.string(from: rawDate), + filename: filename, + rawDate: rawDate, + previewText: truncated, + summary: nil, + summaryGenerated: nil + ) + return (entry, rawDate, content) + } + } + + let attributes = (try? fileManager.attributesOfItem(atPath: url.path)) ?? [:] + let fallbackDate = (attributes[.creationDate] as? Date) + ?? (attributes[.modificationDate] as? Date) + ?? Date() + + let entry = HumanEntry( + id: UUID(), + date: displayFormatter.string(from: fallbackDate), + filename: filename, + rawDate: fallbackDate, + previewText: truncated, + summary: nil, + summaryGenerated: nil + ) + + return (entry, fallbackDate, content) + } + + let sorted = entriesWithDetails.sorted { $0.date > $1.date } + entries = sorted.map { $0.entry } + + let contentLookup = Dictionary(uniqueKeysWithValues: sorted.map { ($0.entry.id, $0.content) }) + + guard !entries.isEmpty else { + createNewEntry() + return + } + + let calendar = Calendar.current + let today = Date() + let todayStart = calendar.startOfDay(for: today) + + let hasEmptyEntryToday = entries.contains { entry in + let entryDayStart = calendar.startOfDay(for: entry.rawDate) + return calendar.isDate(entryDayStart, inSameDayAs: todayStart) && entry.previewText.isEmpty + } + + let hasOnlyWelcomeEntry = entries.count == 1 && sorted.first?.content.contains("Welcome to Freewrite.") == true + + if !hasEmptyEntryToday && !hasOnlyWelcomeEntry { + createNewEntry() + return + } + + if let todaysEntry = entries.first(where: { entry in + let entryDayStart = calendar.startOfDay(for: entry.rawDate) + return calendar.isDate(entryDayStart, inSameDayAs: todayStart) && entry.previewText.isEmpty + }) { + selectEntry(todaysEntry, content: contentLookup[todaysEntry.id]) + } else if let first = entries.first { + selectEntry(first, content: contentLookup[first.id]) + } + + } catch { + print("Error loading entries: \(error)") + createNewEntry() + } + } + + func selectEntry(_ entry: HumanEntry) { + let content = try? String(contentsOf: documentsDirectory.appendingPathComponent(entry.filename), encoding: .utf8) + selectEntry(entry, content: content) + } + + private func selectEntry(_ entry: HumanEntry, content: String?) { + selectedEntryID = entry.id + let trimmed = content?.trimmingCharacters(in: .newlines) ?? "" + editorText = trimmed + } + + func createNewEntry() { + let newEntry = HumanEntry.createNew() + entries.insert(newEntry, at: 0) + selectedEntryID = newEntry.id + + if entries.count == 1, + let defaultMessageURL = Bundle.main.url(forResource: "default", withExtension: "md"), + let defaultMessage = try? String(contentsOf: defaultMessageURL, encoding: .utf8) { + editorText = defaultMessage + } else { + placeholderText = placeholderOptions.randomElement() ?? "Begin writing" + editorText = "" + } + + saveCurrentEntry() + } + + func updateEditorText(_ newValue: String) { + editorText = newValue + saveCurrentEntry() + } + + func saveCurrentEntry() { + guard let entry = selectedEntry else { return } + let fileURL = documentsDirectory.appendingPathComponent(entry.filename) + + do { + try editorText.write(to: fileURL, atomically: true, encoding: .utf8) + refreshPreview(for: entry) + } catch { + print("Error saving entry: \(error)") + } + } + + func deleteEntry(_ entry: HumanEntry) { + let url = documentsDirectory.appendingPathComponent(entry.filename) + do { + try fileManager.removeItem(at: url) + if let index = entries.firstIndex(where: { $0.id == entry.id }) { + entries.remove(at: index) + } + + if selectedEntryID == entry.id { + if let first = entries.first { + selectEntry(first) + } else { + createNewEntry() + } + } + } catch { + print("Error deleting entry: \(error)") + } + } + + func regeneratePlaceholder() { + placeholderText = placeholderOptions.randomElement() ?? "Begin writing" + } + + func contentURL(for entry: HumanEntry) -> URL { + documentsDirectory.appendingPathComponent(entry.filename) + } + + func exportCurrentEntryAsPDF(fontName: String, fontSize: CGFloat, lineHeight: CGFloat) -> Data? { + guard let entry = selectedEntry else { return nil } + return exportEntryAsPDF(entry: entry, fontName: fontName, fontSize: fontSize, lineHeight: lineHeight) + } + + func exportEntryAsPDF(entry: HumanEntry, fontName: String, fontSize: CGFloat, lineHeight: CGFloat) -> Data? { + let contentURL = documentsDirectory.appendingPathComponent(entry.filename) + guard let text = try? String(contentsOf: contentURL, encoding: .utf8) else { return nil } + return createPDF(text: text, fontName: fontName, fontSize: fontSize, lineHeight: lineHeight) + } + + private func createPDF(text: String, fontName: String, fontSize: CGFloat, lineHeight: CGFloat) -> Data? { + let pageWidth: CGFloat = 612.0 + let pageHeight: CGFloat = 792.0 + let margin: CGFloat = 72.0 + let contentRect = CGRect(x: margin, y: margin, width: pageWidth - margin * 2, height: pageHeight - margin * 2) + + let pdfData = NSMutableData() + guard let consumer = CGDataConsumer(data: pdfData as CFMutableData), + let context = CGContext(consumer: consumer, mediaBox: nil, nil) else { + return nil + } + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineSpacing = lineHeight + + let font = NSFont(name: fontName, size: fontSize) ?? .systemFont(ofSize: fontSize) + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: NSColor.black, + .paragraphStyle: paragraphStyle + ] + + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + let attributed = NSAttributedString(string: trimmed, attributes: attributes) + let framesetter = CTFramesetterCreateWithAttributedString(attributed) + + var currentRange = CFRange(location: 0, length: 0) + var pageIndex = 0 + + let path = CGMutablePath() + path.addRect(contentRect) + + while currentRange.location < attributed.length { + context.beginPage(mediaBox: nil) + context.setFillColor(NSColor.white.cgColor) + context.fill(CGRect(x: 0, y: 0, width: pageWidth, height: pageHeight)) + + let frame = CTFramesetterCreateFrame(framesetter, currentRange, path, nil) + CTFrameDraw(frame, context) + let visibleRange = CTFrameGetVisibleStringRange(frame) + currentRange.location += visibleRange.length + context.endPage() + + pageIndex += 1 + if pageIndex > 1000 { break } + } + + context.closePDF() + return pdfData as Data + } + + private func refreshPreview(for entry: HumanEntry) { + let url = documentsDirectory.appendingPathComponent(entry.filename) + guard let content = try? String(contentsOf: url, encoding: .utf8), + let index = entries.firstIndex(where: { $0.id == entry.id }) else { + return + } + + let preview = content + .replacingOccurrences(of: "\n", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + let truncated = preview.isEmpty ? "" : (preview.count > 40 ? String(preview.prefix(40)) + "..." : preview) + entries[index].previewText = truncated + } + + private func calculateHappinessScore(for entries: [HumanEntry]) -> Double { + let positiveWords = ["happy", "good", "great", "amazing", "love", "excited", "wonderful", "fantastic", "awesome", "perfect", "beautiful", "successful", "accomplished", "grateful", "thankful", "blessed", "confident", "optimistic", "hopeful", "peaceful", "joyful", "content", "satisfied", "pleased", "delighted", "thrilled", "ecstatic", "proud", "inspired", "motivated", "energized"] + let negativeWords = ["sad", "bad", "terrible", "awful", "hate", "worried", "horrible", "disgusting", "disappointed", "frustrated", "angry", "annoyed", "stressed", "anxious", "depressed", "upset", "miserable", "lonely", "tired", "exhausted", "overwhelmed", "confused", "lost", "hopeless", "scared", "afraid", "nervous", "uncomfortable", "embarrassed", "ashamed", "guilty", "regretful", "bitter", "resentful"] + + var positiveCount = 0 + var negativeCount = 0 + var totalWords = 0 + + for entry in entries { + let words = entry.previewText.lowercased().split(separator: " ") + totalWords += words.count + for word in words { + if positiveWords.contains(String(word)) { + positiveCount += 1 + } else if negativeWords.contains(String(word)) { + negativeCount += 1 + } + } + } + + let sentiment = Double(positiveCount - negativeCount) + let wordCount = max(Double(totalWords), 1) + let normalizedSentiment = sentiment / wordCount * 100 + return max(1, min(10, 5 + normalizedSentiment)) + } + + private func generateMonthDescription(for entries: [HumanEntry]) -> String { + let wordCount = entries.reduce(0) { count, entry in + count + entry.previewText.split(separator: " ").count + } + + switch entries.count { + case 0: + return "No entries this month" + case 1: + return "1 entry • \(wordCount) words" + default: + return "\(entries.count) entries • \(wordCount) words" + } + } +} diff --git a/freewrite/freewrite.entitlements b/freewrite/freewrite.entitlements index 6d968ed..54f2267 100644 --- a/freewrite/freewrite.entitlements +++ b/freewrite/freewrite.entitlements @@ -6,5 +6,7 @@ com.apple.security.files.user-selected.read-write + com.apple.security.network.client +