From 5009ead53fe1594133a3898046586559d276c2e6 Mon Sep 17 00:00:00 2001 From: rk-helper <62377740+rk-helper@users.noreply.github.com> Date: Fri, 25 Apr 2025 10:33:20 +0400 Subject: [PATCH 1/5] Centered texting --- freewrite.xcodeproj/project.pbxproj | 8 +++--- freewrite/ContentView.swift | 38 +++++++++++++++++++---------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/freewrite.xcodeproj/project.pbxproj b/freewrite.xcodeproj/project.pbxproj index f89a773..e2600be 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 = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -419,7 +419,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 18.1; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 13.5; + MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = app.humansongs.freewrite; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -444,7 +444,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"freewrite/Preview Content\""; - DEVELOPMENT_TEAM = 2UDAY4J48G; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -461,7 +461,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 18.1; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 13.5; + MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = app.humansongs.freewrite; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/freewrite/ContentView.swift b/freewrite/ContentView.swift index cc4113a..b9b927f 100644 --- a/freewrite/ContentView.swift +++ b/freewrite/ContentView.swift @@ -381,6 +381,8 @@ struct ContentView: View { return colorScheme == .light ? Color.primary : Color.white } + @State private var viewHeight: CGFloat = 0 + var body: some View { let buttonBackground = colorScheme == .light ? Color.white : Color.black let navHeight: CGFloat = 68 @@ -393,17 +395,18 @@ struct ContentView: View { 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 + + 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)) @@ -411,6 +414,8 @@ struct ContentView: View { .scrollIndicators(.never) .lineSpacing(lineHeight) .frame(maxWidth: 650) + + .id("\(selectedFont)-\(fontSize)-\(colorScheme)") .padding(.bottom, bottomNavOpacity > 0 ? navHeight : 0) .ignoresSafeArea() @@ -425,13 +430,20 @@ struct ContentView: View { Text(placeholderText) .font(.custom(selectedFont, size: fontSize)) .foregroundColor(colorScheme == .light ? .gray.opacity(0.5) : .gray.opacity(0.6)) - // .padding(.top, 8) - // .padding(.leading, 8) + // .padding(.top, 8) + // .padding(.leading, 8) .allowsHitTesting(false) .offset(x: 5, y: placeholderOffset) } }, alignment: .topLeading ) + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.height + } action: { height in + viewHeight = height + } + .contentMargins(.bottom, viewHeight / 2) + VStack { Spacer() @@ -1305,4 +1317,4 @@ extension NSView { #Preview { ContentView() -} \ No newline at end of file +} From 57ba4af5be325347048eb884364cb63ba3606025 Mon Sep 17 00:00:00 2001 From: thorfinn Date: Fri, 25 Apr 2025 05:47:09 -0700 Subject: [PATCH 2/5] changes --- freewrite.xcodeproj/project.pbxproj | 8 ++++---- freewrite/ContentView.swift | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/freewrite.xcodeproj/project.pbxproj b/freewrite.xcodeproj/project.pbxproj index e2600be..f89a773 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 = ""; + DEVELOPMENT_TEAM = 2UDAY4J48G; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -419,7 +419,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 18.1; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 13.5; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = app.humansongs.freewrite; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -444,7 +444,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"freewrite/Preview Content\""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = 2UDAY4J48G; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -461,7 +461,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 18.1; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 13.5; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = app.humansongs.freewrite; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/freewrite/ContentView.swift b/freewrite/ContentView.swift index b9b927f..2616d91 100644 --- a/freewrite/ContentView.swift +++ b/freewrite/ContentView.swift @@ -442,7 +442,7 @@ struct ContentView: View { } action: { height in viewHeight = height } - .contentMargins(.bottom, viewHeight / 2) + .contentMargins(.bottom, viewHeight / 4) VStack { From 601365c8529bd448ce72af3f4fb30077a52cce3a Mon Sep 17 00:00:00 2001 From: rk-helper <62377740+rk-helper@users.noreply.github.com> Date: Fri, 25 Apr 2025 17:57:16 +0400 Subject: [PATCH 3/5] initial project file --- freewrite.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/freewrite.xcodeproj/project.pbxproj b/freewrite.xcodeproj/project.pbxproj index e2600be..b4bede5 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 = ""; + DEVELOPMENT_TEAM = 2UDAY4J48G; 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 = ""; + DEVELOPMENT_TEAM = 2UDAY4J48G; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; From 999974835c1a34275e0cb2c7c4313f4114b6bb53 Mon Sep 17 00:00:00 2001 From: Michael Bayron Date: Thu, 10 Jul 2025 15:49:37 +0800 Subject: [PATCH 4/5] added timeline --- CLAUDE.md | 41 + freewrite.xcodeproj/project.pbxproj | 4 +- freewrite/ContentView.swift | 1409 ++++++++++++++++++++++++++- freewrite/freewrite.entitlements | 2 + 4 files changed, 1452 insertions(+), 4 deletions(-) create mode 100644 CLAUDE.md 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 1ed74db..83f979a 100644 --- a/freewrite/ContentView.swift +++ b/freewrite/ContentView.swift @@ -10,12 +10,15 @@ import SwiftUI import AppKit import UniformTypeIdentifiers import PDFKit +import NaturalLanguage struct HumanEntry: Identifiable { let id: UUID let date: String let filename: String var previewText: String + var summary: String? + var summaryGenerated: Date? static func createNew() -> HumanEntry { let id = UUID() @@ -32,11 +35,1015 @@ struct HumanEntry: Identifiable { id: id, date: displayDate, filename: "[\(id)]-[\(dateString)].md", - previewText: "" + previewText: "", + summary: nil, + summaryGenerated: nil ) } } +class SummaryService: ObservableObject { + func generateSummary(for text: String) -> String { + let cleanText = text.replacingOccurrences(of: "\n", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + + if cleanText.isEmpty { + return "Empty entry" + } + + if cleanText.count < 50 { + return cleanText + } + + // Extract first few meaningful sentences for summary + let sentences = cleanText.components(separatedBy: CharacterSet(charactersIn: ".!?")) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty && $0.count > 3 } + + if sentences.isEmpty { + return String(cleanText.prefix(100)) + "..." + } + + // Take first 1-2 sentences up to 150 characters + var summary = "" + for sentence in sentences.prefix(2) { + if summary.count + sentence.count < 150 { + summary += sentence + ". " + } else { + break + } + } + + return summary.isEmpty ? String(cleanText.prefix(100)) + "..." : summary.trimmingCharacters(in: .whitespaces) + } +} + +// Timeline Prediction Data Models +struct TimelinePoint: Identifiable, Codable { + let id = UUID() + let date: Date + let happiness: Double + let description: String + let scenario: TimelineScenario + + enum TimelineScenario: String, Codable { + case actual = "actual" + case best = "best" + case darkest = "darkest" + } +} + +struct TimelinePrediction: Codable { + let bestTimeline: [TimelinePoint] + let darkestTimeline: [TimelinePoint] + let analysisDate: Date + let monthsAhead: Int +} + +// Claude API Service +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" + + func generateTimelinePrediction(entries: [HumanEntry], apiToken: String, monthsAhead: Int) async throws -> TimelinePrediction { + guard !apiToken.isEmpty else { + throw APIError.missingToken + } + + await MainActor.run { + isLoading = true + error = nil + } + + do { + // Prepare entries data for analysis + let entriesData = try await prepareEntriesData(entries: entries) + + // Validate we have enough data + guard !entriesData.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw APIError.networkError("No journal entries found to analyze") + } + + // Create the prompt for Claude + let prompt = createTimelinePredictionPrompt(entriesData: entriesData, monthsAhead: monthsAhead) + + // Make API call to Claude + let response = try await makeClaudeAPICall(prompt: prompt, apiToken: apiToken) + + // Parse the response into timeline prediction + let prediction = try parseTimelinePrediction(response: response, monthsAhead: monthsAhead) + + await MainActor.run { + isLoading = false + } + + return prediction + + } catch let urlError as URLError { + let networkError = APIError.networkError(urlError.localizedDescription) + await MainActor.run { + isLoading = false + self.error = networkError.localizedDescription + } + throw networkError + } catch let apiError as APIError { + await MainActor.run { + isLoading = false + self.error = apiError.localizedDescription + } + throw apiError + } catch { + let genericError = APIError.networkError(error.localizedDescription) + await MainActor.run { + isLoading = false + self.error = genericError.localizedDescription + } + throw genericError + } + } + + private func prepareEntriesData(entries: [HumanEntry]) async throws -> String { + var entriesText = "" + + for entry in entries.prefix(20) { // Limit to recent 20 entries + let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("Freewrite") + let fileURL = documentsDirectory.appendingPathComponent(entry.filename) + + do { + let content = try String(contentsOf: fileURL, encoding: .utf8) + entriesText += "Date: \(entry.date)\n" + entriesText += "Entry: \(content.trimmingCharacters(in: .whitespacesAndNewlines))\n\n" + } catch { + continue + } + } + + return entriesText + } + + private func createTimelinePredictionPrompt(entriesData: String, monthsAhead: Int) -> String { + return """ + You are a skilled life coach and pattern analyst. Based on the following journal entries, I need you to create two timeline predictions for the next \(monthsAhead) months: + + 1. **Best Timeline**: What would likely happen if things go really well + 2. **Darkest Timeline**: What might happen if things go poorly + + For each timeline, provide monthly predictions with: + - A happiness score (1-10, where 10 is most happy) + - A brief story description of what happens that month + + Please respond in this exact JSON format: + { + "bestTimeline": [ + { + "month": 1, + "happiness": 8.5, + "description": "Description of what happens in month 1" + } + ], + "darkestTimeline": [ + { + "month": 1, + "happiness": 4.2, + "description": "Description of what happens in month 1" + } + ] + } + + Journal Entries: + \(entriesData) + + Base your predictions on patterns, concerns, hopes, and themes you see in the entries. Make the stories feel personal and specific to this person's life situation. + """ + } + + private func makeClaudeAPICall(prompt: String, apiToken: String) async throws -> String { + // Validate API token format + guard !apiToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw APIError.missingToken + } + + let cleanToken = apiToken.trimmingCharacters(in: .whitespacesAndNewlines) + + 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(cleanToken, forHTTPHeaderField: "x-api-key") + request.timeoutInterval = 60.0 + + let requestBody = ClaudeAPIRequest( + model: "claude-sonnet-4-20250514", + max_tokens: 4000, + messages: [ + ClaudeMessage(role: "user", content: prompt) + ] + ) + + do { + request.httpBody = try JSONEncoder().encode(requestBody) + } catch { + throw APIError.encodingError(error.localizedDescription) + } + + print("Making API request to: \(url)") + print("Request headers: \(request.allHTTPHeaderFields ?? [:])") + print("Request body model: \(requestBody.model)") + print("Request body max_tokens: \(requestBody.max_tokens)") + + // Create custom URL session with better configuration + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 60.0 + config.timeoutIntervalForResource = 120.0 + config.waitsForConnectivity = true + + let session = URLSession(configuration: config) + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + + print("HTTP Status Code: \(httpResponse.statusCode)") + + if httpResponse.statusCode != 200 { + let errorBody = String(data: data, encoding: .utf8) ?? "Unknown error" + print("Error response body: \(errorBody)") + throw APIError.httpError(httpResponse.statusCode, errorBody) + } + + do { + let apiResponse = try JSONDecoder().decode(ClaudeAPIResponse.self, from: data) + guard let content = apiResponse.content.first?.text else { + throw APIError.invalidResponse + } + return content + } catch { + let responseString = String(data: data, encoding: .utf8) ?? "Unable to decode response" + print("Decoding error: \(error)") + print("Response body: \(responseString)") + throw APIError.decodingError(error.localizedDescription) + } + } + + private func parseTimelinePrediction(response: String, monthsAhead: Int) throws -> TimelinePrediction { + // Extract JSON from response (Claude sometimes adds explanatory text) + let jsonStart = response.firstIndex(of: "{") ?? response.startIndex + let jsonEnd = response.lastIndex(of: "}") ?? response.endIndex + let jsonString = String(response[jsonStart...jsonEnd]) + + let data = jsonString.data(using: .utf8)! + let parsedResponse = try JSONDecoder().decode(ClaudeTimelineResponse.self, from: data) + + let calendar = Calendar.current + let today = Date() + + let bestTimeline = parsedResponse.bestTimeline.map { item in + let futureDate = calendar.date(byAdding: .month, value: item.month, to: today)! + return TimelinePoint( + date: futureDate, + happiness: item.happiness, + description: item.description, + scenario: .best + ) + } + + let darkestTimeline = parsedResponse.darkestTimeline.map { item in + let futureDate = calendar.date(byAdding: .month, value: item.month, to: today)! + return TimelinePoint( + date: futureDate, + happiness: item.happiness, + description: item.description, + scenario: .darkest + ) + } + + return TimelinePrediction( + bestTimeline: bestTimeline, + darkestTimeline: darkestTimeline, + analysisDate: today, + monthsAhead: monthsAhead + ) + } + + 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. Please add it in Settings." + 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. Please check your token in Settings." + } else if code == 429 { + return "Rate limit exceeded. Please try again later." + } else { + return "HTTP error \(code): \(message)" + } + case .encodingError(let message): + return "Request encoding error: \(message)" + case .decodingError(let message): + return "Response decoding error: \(message)" + case .networkError(let message): + return "Network error: \(message). Check your internet connection." + } + } + } + + func testConnection(apiToken: String) async { + await MainActor.run { + isTestingConnection = true + connectionTestResult = nil + } + + do { + let testPrompt = "Hello, please respond with just the word 'success' to test the API connection." + let response = try await makeClaudeAPICall(prompt: testPrompt, apiToken: apiToken) + + await MainActor.run { + isTestingConnection = false + connectionTestResult = "✅ Connection successful! API token is valid." + } + } catch { + await MainActor.run { + isTestingConnection = false + connectionTestResult = "❌ Connection failed: \(error.localizedDescription)" + } + } + } +} + +// Claude API Request/Response Models +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 TimelineDot: View { + let isFirst: Bool + let isLast: Bool + + var body: some View { + VStack(spacing: 0) { + if !isFirst { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .frame(width: 2, height: 20) + } + + Circle() + .fill(Color.blue) + .frame(width: 12, height: 12) + + if !isLast { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .frame(width: 2, height: 20) + } + } + } +} + +struct TimelineEntryView: View { + let entry: HumanEntry + let summary: String + let isFirst: Bool + let isLast: Bool + let onTap: () -> Void + + @State private var isHovered = false + + var body: some View { + HStack(alignment: .top, spacing: 16) { + TimelineDot(isFirst: isFirst, isLast: isLast) + + VStack(alignment: .leading, spacing: 8) { + Text(entry.date) + .font(.caption) + .foregroundColor(.secondary) + + Text(summary) + .font(.body) + .foregroundColor(isHovered ? .primary : .secondary) + .lineLimit(3) + .multilineTextAlignment(.leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, 8) + .padding(.horizontal, 16) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(isHovered ? Color.gray.opacity(0.1) : Color.clear) + ) + .onHover { hovering in + isHovered = hovering + } + .onTapGesture { + onTap() + } + } +} + +struct TimelineView: View { + let entries: [HumanEntry] + let onEntrySelected: (HumanEntry) -> Void + let onClose: () -> Void + + @StateObject private var summaryService = SummaryService() + @State private var summaries: [UUID: String] = [:] + + var body: some View { + VStack(spacing: 0) { + + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(entries.enumerated()), id: \.element.id) { index, entry in + TimelineEntryView( + entry: entry, + summary: summaries[entry.id] ?? "Generating summary...", + isFirst: index == 0, + isLast: index == entries.count - 1, + onTap: { + onEntrySelected(entry) + onClose() + } + ) + .onAppear { + generateSummaryIfNeeded(for: entry) + } + } + } + .padding(.top) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(NSColor.windowBackgroundColor)) + } + + private func generateSummaryIfNeeded(for entry: HumanEntry) { + guard summaries[entry.id] == nil else { return } + + Task { + let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("Freewrite") + let fileURL = documentsDirectory.appendingPathComponent(entry.filename) + + do { + let content = try String(contentsOf: fileURL, encoding: .utf8) + let summary = summaryService.generateSummary(for: content) + + await MainActor.run { + summaries[entry.id] = summary + } + } catch { + await MainActor.run { + summaries[entry.id] = entry.previewText.isEmpty ? "Unable to load content" : entry.previewText + } + } + } + } +} + +struct TimelineChartView: View { + let entries: [HumanEntry] + let prediction: TimelinePrediction? + let onEntrySelected: (HumanEntry) -> Void + + @State private var selectedPoint: TimelinePoint? + @State private var hoveredDate: Date? + @StateObject private var summaryService = SummaryService() + @State private var actualTimelineData: [TimelinePoint] = [] + + var body: some View { + VStack(spacing: 16) { + // Chart Header + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Future Timeline Projection") + .font(.title2) + .fontWeight(.semibold) + Text("Historical happiness data with best and darkest possible futures") + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + + // Legend + HStack(spacing: 20) { + HStack(spacing: 8) { + Circle() + .fill(Color.blue) + .frame(width: 12, height: 12) + Text("Actual Journey") + .font(.caption) + .foregroundColor(.blue) + } + + HStack(spacing: 8) { + Circle() + .fill(Color.green) + .frame(width: 12, height: 12) + Text("Best Timeline") + .font(.caption) + .foregroundColor(.green) + } + + HStack(spacing: 8) { + Circle() + .fill(Color.red) + .frame(width: 12, height: 12) + Text("Darkest Timeline") + .font(.caption) + .foregroundColor(.red) + } + } + } + + // Custom Chart Implementation + CustomChartView( + actualData: actualTimelineData, + prediction: prediction, + onPointSelected: { point in + selectedPoint = point + } + ) + .frame(height: 300) + .onAppear { + generateActualTimelineData() + } + + // Selected point details + if let selectedPoint = selectedPoint { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(getTimelineTitle(for: selectedPoint.scenario)) + .font(.headline) + .foregroundColor(getTimelineColor(for: selectedPoint.scenario)) + + Spacer() + + Text(formatDate(selectedPoint.date)) + .font(.subheadline) + .foregroundColor(.secondary) + } + + Text("Happiness: \(selectedPoint.happiness, specifier: "%.1f")/10") + .font(.subheadline) + .foregroundColor(.secondary) + + Text(selectedPoint.description) + .font(.body) + .fixedSize(horizontal: false, vertical: true) + } + .padding() + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + } + } + .padding() + } + + private func getTimelineTitle(for scenario: TimelinePoint.TimelineScenario) -> String { + switch scenario { + case .actual: + return "Current State" + case .best: + return "Best Timeline" + case .darkest: + return "Darkest Timeline" + } + } + + private func getTimelineColor(for scenario: TimelinePoint.TimelineScenario) -> Color { + switch scenario { + case .actual: + return .blue + case .best: + return .green + case .darkest: + return .red + } + } + + private func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "MMM yyyy" + return formatter.string(from: date) + } + + private func generateActualTimelineData() { + // Generate actual timeline data from entries + let calendar = Calendar.current + + // Group entries by month + var monthlyEntries: [Date: [HumanEntry]] = [:] + + for entry in entries { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "MMM d" + + if let entryDate = dateFormatter.date(from: entry.date) { + // Set current year + var components = calendar.dateComponents([.month, .day], from: entryDate) + components.year = calendar.component(.year, from: Date()) + + if let dateWithYear = calendar.date(from: components) { + let monthStart = calendar.dateInterval(of: .month, for: dateWithYear)?.start ?? dateWithYear + + if monthlyEntries[monthStart] == nil { + monthlyEntries[monthStart] = [] + } + monthlyEntries[monthStart]?.append(entry) + } + } + } + + // Calculate happiness scores for each month + var points: [TimelinePoint] = [] + + for (monthDate, monthEntries) in monthlyEntries.sorted(by: { $0.key < $1.key }) { + let happiness = calculateHappinessScore(for: monthEntries) + let description = generateMonthDescription(for: monthEntries) + + let point = TimelinePoint( + date: monthDate, + happiness: happiness, + description: description, + scenario: .actual + ) + points.append(point) + } + + actualTimelineData = points + } + + private func calculateHappinessScore(for entries: [HumanEntry]) -> Double { + // Simple happiness calculation based on text sentiment + // In a real app, you might use NaturalLanguage framework or more sophisticated analysis + + let totalWords = entries.reduce(0) { count, entry in + count + entry.previewText.split(separator: " ").count + } + + 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 + + for entry in entries { + let words = entry.previewText.lowercased().split(separator: " ") + for word in words { + if positiveWords.contains(String(word)) { + positiveCount += 1 + } else if negativeWords.contains(String(word)) { + negativeCount += 1 + } + } + } + + // Calculate happiness score (1-10) + let sentiment = Double(positiveCount - negativeCount) + let wordCount = max(Double(totalWords), 1) + let normalizedSentiment = sentiment / wordCount * 100 + + // Map to 1-10 scale with 5 as neutral + let happiness = max(1, min(10, 5 + normalizedSentiment)) + + return happiness + } + + private func generateMonthDescription(for entries: [HumanEntry]) -> String { + let wordCount = entries.reduce(0) { count, entry in + count + entry.previewText.split(separator: " ").count + } + + let entryCount = entries.count + + if entryCount == 0 { + return "No entries this month" + } else if entryCount == 1 { + return "1 entry with \(wordCount) words" + } else { + return "\(entryCount) entries with \(wordCount) words total" + } + } +} + +struct CustomChartView: View { + let actualData: [TimelinePoint] + let prediction: TimelinePrediction? + let onPointSelected: (TimelinePoint) -> Void + + @State private var hoveredDate: Date? + + var body: some View { + GeometryReader { geometry in + let width = geometry.size.width + let height = geometry.size.height + let padding: CGFloat = 40 + let chartWidth = width - padding * 2 + let chartHeight = height - padding * 2 + + ZStack { + // Chart background + RoundedRectangle(cornerRadius: 8) + .fill(Color.gray.opacity(0.1)) + + // Grid lines + Path { path in + // Horizontal grid lines + for i in 0...10 { + let y = padding + (chartHeight * CGFloat(10 - i) / 10) + path.move(to: CGPoint(x: padding, y: y)) + path.addLine(to: CGPoint(x: padding + chartWidth, y: y)) + } + + // Vertical grid lines (months) + let allPoints = getAllPoints() + if !allPoints.isEmpty { + let monthCount = allPoints.count > 1 ? allPoints.count - 1 : 1 + for i in 0...monthCount { + let x = padding + (chartWidth * CGFloat(i) / CGFloat(monthCount)) + path.move(to: CGPoint(x: x, y: padding)) + path.addLine(to: CGPoint(x: x, y: padding + chartHeight)) + } + } + } + .stroke(Color.gray.opacity(0.3), lineWidth: 0.5) + + // Plot actual timeline + if !actualData.isEmpty { + drawTimeline(points: actualData, color: .blue, isDashed: false, width: chartWidth, height: chartHeight, padding: padding) + } + + // Plot prediction timelines + if let prediction = prediction { + drawTimeline(points: prediction.bestTimeline, color: .green, isDashed: true, width: chartWidth, height: chartHeight, padding: padding) + drawTimeline(points: prediction.darkestTimeline, color: .red, isDashed: true, width: chartWidth, height: chartHeight, padding: padding) + } + + // Y-axis labels + VStack { + ForEach(0..<11) { i in + HStack { + Text("\(10 - i)") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + } + if i < 10 { + Spacer() + } + } + } + .frame(width: 20, height: chartHeight) + .offset(x: -width/2 + 20, y: 0) + + // X-axis labels + HStack { + ForEach(getAllPoints().indices, id: \.self) { index in + Text(formatDateForAxis(getAllPoints()[index].date)) + .font(.caption) + .foregroundColor(.secondary) + if index < getAllPoints().count - 1 { + Spacer() + } + } + } + .frame(width: chartWidth, height: 20) + .offset(x: 0, y: height/2 - 30) + + // Invisible hover zones for each month + ForEach(getUniqueDates(), id: \.self) { date in + let allPoints = getAllPoints() + let pointIndex = allPoints.firstIndex { Calendar.current.isDate($0.date, inSameDayAs: date) } ?? 0 + let xPosition = padding + (chartWidth * CGFloat(pointIndex) / CGFloat(max(allPoints.count - 1, 1))) + let zoneWidth = chartWidth / CGFloat(max(allPoints.count, 1)) + + Rectangle() + .fill(Color.clear) + .frame(width: zoneWidth, height: chartHeight) + .position(x: xPosition, y: padding + chartHeight/2) + .onHover { isHovering in + hoveredDate = isHovering ? date : nil + } + } + + // Tooltip overlay + if let hoveredDate = hoveredDate { + tooltipView(for: hoveredDate, width: width, height: height, padding: padding, chartWidth: chartWidth, chartHeight: chartHeight) + } + } + } + } + + private func tooltipView(for date: Date, width: CGFloat, height: CGFloat, padding: CGFloat, chartWidth: CGFloat, chartHeight: CGFloat) -> some View { + let allPoints = getAllPoints() + let pointsAtDate = getPointsAtDate(date: date) + + // Get the x position for this date - use same logic as chart points + let pointIndex = allPoints.firstIndex { Calendar.current.isDate($0.date, inSameDayAs: date) } ?? 0 + let pointX = padding + (chartWidth * CGFloat(pointIndex) / CGFloat(max(allPoints.count - 1, 1))) + + return VStack(alignment: .leading, spacing: 12) { + Text(formatDateForTooltip(date)) + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.primary) + + // Show tooltips for each scenario at this date + ForEach(pointsAtDate.sorted { $0.scenario.rawValue < $1.scenario.rawValue }) { point in + VStack(alignment: .leading, spacing: 4) { + HStack { + Circle() + .fill(point.scenario == .best ? .green : point.scenario == .darkest ? .red : .blue) + .frame(width: 8, height: 8) + + Text(point.scenario.rawValue.capitalized + " Timeline") + .font(.caption2) + .fontWeight(.medium) + .foregroundColor(point.scenario == .best ? .green : point.scenario == .darkest ? .red : .blue) + } + + Text("Happiness: \(String(format: "%.1f", point.happiness))/10") + .font(.caption2) + .foregroundColor(.secondary) + + if !point.description.isEmpty { + Text(point.description) + .font(.caption2) + .foregroundColor(.primary) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(6) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(Color.gray.opacity(0.1)) + ) + } + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(NSColor.controlBackgroundColor)) + .shadow(color: .black.opacity(0.15), radius: 6, x: 0, y: 3) + ) + .frame(maxWidth: 280) + .position( + x: min(max(pointX, 140), width - 140), + y: max(height * 0.2, 60) + ) + } + + private func getPointsAtDate(date: Date) -> [TimelinePoint] { + var pointsAtDate: [TimelinePoint] = [] + + // Check actual data + pointsAtDate.append(contentsOf: actualData.filter { Calendar.current.isDate($0.date, inSameDayAs: date) }) + + // Check prediction data + if let prediction = prediction { + pointsAtDate.append(contentsOf: prediction.bestTimeline.filter { Calendar.current.isDate($0.date, inSameDayAs: date) }) + pointsAtDate.append(contentsOf: prediction.darkestTimeline.filter { Calendar.current.isDate($0.date, inSameDayAs: date) }) + } + + return pointsAtDate + } + + private func formatDateForTooltip(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "MMM yyyy" + return formatter.string(from: date) + } + + private func getUniqueDates() -> [Date] { + var allDates: [Date] = [] + + // Collect dates from actual data + allDates.append(contentsOf: actualData.map { $0.date }) + + // Collect dates from prediction data + if let prediction = prediction { + allDates.append(contentsOf: prediction.bestTimeline.map { $0.date }) + allDates.append(contentsOf: prediction.darkestTimeline.map { $0.date }) + } + + // Remove duplicates and sort + let uniqueDates = Array(Set(allDates)).sorted() + return uniqueDates + } + + + private func getAllPoints() -> [TimelinePoint] { + var allPoints = actualData + + if let prediction = prediction { + allPoints.append(contentsOf: prediction.bestTimeline) + allPoints.append(contentsOf: prediction.darkestTimeline) + } + + return allPoints.sorted { $0.date < $1.date } + } + + private func drawTimeline(points: [TimelinePoint], color: Color, isDashed: Bool, width: CGFloat, height: CGFloat, padding: CGFloat) -> some View { + let sortedPoints = points.sorted { $0.date < $1.date } + let allPoints = getAllPoints() + + return ZStack { + // Draw line + if sortedPoints.count > 1 { + Path { path in + for (index, point) in sortedPoints.enumerated() { + let x = padding + (width * CGFloat(getPointIndex(point: point, in: allPoints)) / CGFloat(max(allPoints.count - 1, 1))) + let y = padding + (height * CGFloat(10 - point.happiness) / 10) + + if index == 0 { + path.move(to: CGPoint(x: x, y: y)) + } else { + path.addLine(to: CGPoint(x: x, y: y)) + } + } + } + .stroke(color, style: StrokeStyle(lineWidth: isDashed ? 2 : 3, lineCap: .round, dash: isDashed ? [5, 5] : [])) + } + + // Draw points + ForEach(sortedPoints) { point in + Circle() + .fill(color) + .frame(width: 8, height: 8) + .position( + x: padding + (width * CGFloat(getPointIndex(point: point, in: allPoints)) / CGFloat(max(allPoints.count - 1, 1))), + y: padding + (height * CGFloat(10 - point.happiness) / 10) + ) + .onTapGesture { + onPointSelected(point) + } + } + } + } + + private func getPointIndex(point: TimelinePoint, in allPoints: [TimelinePoint]) -> Int { + return allPoints.firstIndex { $0.date == point.date } ?? 0 + } + + private func formatDateForAxis(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "MMM" + return formatter.string(from: date) + } +} + struct HeartEmoji: Identifiable { let id = UUID() var position: CGPoint @@ -85,6 +1092,22 @@ struct ContentView: View { @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 + @State private var currentView: AppView = .writing // Add state for current view + @State private var isHoveringTimeline = false // Add state for timeline button hover + @State private var showingTimeline = false // Keep for backwards compatibility + @State private var isHoveringSettings = false // Add state for settings button hover + @AppStorage("claudeApiToken") private var claudeApiToken: String = "" + @AppStorage("predictionMonths") private var predictionMonths: Int = 6 + @StateObject private var claudeService = ClaudeAPIService() + @State private var timelinePrediction: TimelinePrediction? + @State private var isGeneratingPrediction = false + @State private var showApiToken = false + + enum AppView { + case writing + case timeline + case settings + } let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() let entryHeight: CGFloat = 40 @@ -249,7 +1272,9 @@ struct ContentView: View { id: uuid, date: displayDate, filename: filename, - previewText: truncated + previewText: truncated, + summary: nil, + summaryGenerated: nil ), date: fileDate, content: content // Store the full content to check for welcome message @@ -390,6 +1415,18 @@ struct ContentView: View { let textColor = colorScheme == .light ? Color.gray : Color.gray.opacity(0.8) let textHoverColor = colorScheme == .light ? Color.black : Color.white + Group { + if currentView == .timeline { + timelinePageView + } else if currentView == .settings { + settingsPageView + } else { + writingPageView(buttonBackground: buttonBackground, navHeight: navHeight, textColor: textColor, textHoverColor: textHoverColor) + } + } + } + + private func writingPageView(buttonBackground: Color, navHeight: CGFloat, textColor: Color, textHoverColor: Color) -> some View { HStack(spacing: 0) { // Main content ZStack { @@ -838,6 +1875,48 @@ struct ContentView: View { } } + Text("•") + .foregroundColor(.gray) + + // Timeline button + Button(action: { + currentView = .timeline + }) { + Image(systemName: "timeline.selection") + .foregroundColor(isHoveringTimeline ? textHoverColor : textColor) + } + .buttonStyle(.plain) + .onHover { hovering in + isHoveringTimeline = hovering + isHoveringBottomNav = hovering + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + + Text("•") + .foregroundColor(.gray) + + // Settings button + Button(action: { + currentView = .settings + }) { + Image(systemName: "gear") + .foregroundColor(isHoveringSettings ? textHoverColor : textColor) + } + .buttonStyle(.plain) + .onHover { hovering in + isHoveringSettings = hovering + isHoveringBottomNav = hovering + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + Text("•") .foregroundColor(.gray) @@ -1067,6 +2146,332 @@ struct ContentView: View { } } + private var timelinePageView: some View { + VStack(spacing: 0) { + // Navigation header + HStack { + Button(action: { + currentView = .writing + }) { + HStack(spacing: 8) { + Image(systemName: "chevron.left") + Text("Back to Writing") + } + .foregroundColor(.primary) + } + .buttonStyle(.plain) + + Spacer() + + Text("Timeline") + .font(.title2) + .fontWeight(.medium) + + Spacer() + + // Empty space for visual balance + HStack(spacing: 8) { + Text("Back to Writing") + .opacity(0) + Image(systemName: "chevron.left") + .opacity(0) + } + } + .padding() + .background(Color(NSColor.windowBackgroundColor)) + + // Timeline content + ScrollView { + VStack(spacing: 24) { + // Generate Predictions Button + HStack { + Button(action: { + generateTimelinePredictions() + }) { + HStack { + if isGeneratingPrediction { + ProgressView() + .scaleEffect(0.8) + .progressViewStyle(CircularProgressViewStyle()) + } else { + Image(systemName: "wand.and.stars") + } + Text(isGeneratingPrediction ? "Generating Predictions..." : "Generate Timeline Predictions") + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(claudeApiToken.isEmpty ? Color.gray.opacity(0.3) : Color.blue) + .foregroundColor(.white) + .cornerRadius(8) + } + .disabled(claudeApiToken.isEmpty || isGeneratingPrediction) + .buttonStyle(.plain) + + Spacer() + + if claudeApiToken.isEmpty { + Button(action: { + currentView = .settings + }) { + HStack { + Image(systemName: "gear") + Text("Add API Token") + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.orange) + .foregroundColor(.white) + .cornerRadius(8) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + + // Error message + if let error = claudeService.error { + Text(error) + .foregroundColor(.red) + .padding() + .background(Color.red.opacity(0.1)) + .cornerRadius(8) + .padding(.horizontal) + } + + // Timeline Chart + TimelineChartView( + entries: entries, + prediction: timelinePrediction, + onEntrySelected: { entry in + selectedEntryId = entry.id + loadEntry(entry: entry) + currentView = .writing + } + ) + + // Original Timeline View (for entry selection) + VStack(alignment: .leading, spacing: 16) { + Text("Journal Entries") + .font(.headline) + .fontWeight(.semibold) + .padding(.horizontal) + + TimelineView( + entries: entries, + onEntrySelected: { entry in + selectedEntryId = entry.id + loadEntry(entry: entry) + currentView = .writing + }, + onClose: { + currentView = .writing + } + ) + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(NSColor.windowBackgroundColor)) + .preferredColorScheme(colorScheme) + } + + private var settingsPageView: some View { + VStack(spacing: 0) { + // Navigation header + HStack { + Button(action: { + currentView = .writing + }) { + HStack(spacing: 8) { + Image(systemName: "chevron.left") + Text("Back to Writing") + } + .foregroundColor(.primary) + } + .buttonStyle(.plain) + + Spacer() + + Text("Settings") + .font(.title2) + .fontWeight(.medium) + + Spacer() + + // Empty space for visual balance + HStack(spacing: 8) { + Text("Back to Writing") + .opacity(0) + Image(systemName: "chevron.left") + .opacity(0) + } + } + .padding() + .background(Color(NSColor.windowBackgroundColor)) + + // Settings content + ScrollView { + VStack(alignment: .leading, spacing: 24) { + // Claude AI API Token Section + VStack(alignment: .leading, spacing: 16) { + Text("Claude AI Integration") + .font(.headline) + .fontWeight(.semibold) + + VStack(alignment: .leading, spacing: 8) { + Text("API Token") + .font(.subheadline) + .fontWeight(.medium) + + Text("Enter your Claude AI API token to enable timeline predictions and advanced analysis.") + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + + HStack { + Group { + if showApiToken { + TextField("Enter Claude AI API Token", text: $claudeApiToken) + } else { + SecureField("Enter Claude AI API Token", text: $claudeApiToken) + } + } + .textFieldStyle(.roundedBorder) + + Button(action: { + showApiToken.toggle() + }) { + Image(systemName: showApiToken ? "eye.slash" : "eye") + .foregroundColor(.gray) + } + .buttonStyle(.plain) + .help(showApiToken ? "Hide API token" : "Show API token") + } + .frame(maxWidth: 400) + + HStack { + Button(action: { + Task { + await claudeService.testConnection(apiToken: claudeApiToken) + } + }) { + HStack { + if claudeService.isTestingConnection { + ProgressView() + .scaleEffect(0.8) + .progressViewStyle(CircularProgressViewStyle()) + } else { + Image(systemName: "antenna.radiowaves.left.and.right") + } + Text(claudeService.isTestingConnection ? "Testing..." : "Test Connection") + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(claudeApiToken.isEmpty ? Color.gray.opacity(0.3) : Color.blue) + .foregroundColor(.white) + .cornerRadius(6) + } + .disabled(claudeApiToken.isEmpty || claudeService.isTestingConnection) + .buttonStyle(.plain) + + Spacer() + } + + if let result = claudeService.connectionTestResult { + Text(result) + .font(.caption) + .foregroundColor(result.contains("✅") ? .green : .red) + .padding(.top, 4) + } + } + + VStack(alignment: .leading, spacing: 8) { + Text("How to get your API token:") + .font(.caption) + .fontWeight(.medium) + + Text("1. Go to console.anthropic.com\n2. Create an account or sign in\n3. Navigate to API Keys\n4. Create a new API key\n5. Copy and paste it above") + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + VStack(alignment: .leading, spacing: 8) { + Text("Troubleshooting:") + .font(.caption) + .fontWeight(.medium) + + Text("• Ensure you have an active internet connection\n• Check that your firewall isn't blocking api.anthropic.com\n• Verify your API token is correct and has sufficient credits\n• Try the 'Test Connection' button above") + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + Divider() + + // Timeline Prediction Settings + VStack(alignment: .leading, spacing: 16) { + Text("Timeline Predictions") + .font(.headline) + .fontWeight(.semibold) + + VStack(alignment: .leading, spacing: 8) { + Text("Prediction Range") + .font(.subheadline) + .fontWeight(.medium) + + Text("How many months ahead would you like to predict?") + .font(.caption) + .foregroundColor(.secondary) + + Picker("Prediction Range", selection: $predictionMonths) { + Text("3 months").tag(3) + Text("6 months").tag(6) + Text("12 months").tag(12) + } + .pickerStyle(.segmented) + .frame(maxWidth: 300) + } + } + + Spacer() + } + .padding() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(NSColor.windowBackgroundColor)) + .preferredColorScheme(colorScheme) + } + + private func generateTimelinePredictions() { + guard !claudeApiToken.isEmpty else { return } + + isGeneratingPrediction = true + + Task { + do { + let prediction = try await claudeService.generateTimelinePrediction( + entries: entries, + apiToken: claudeApiToken, + monthsAhead: predictionMonths + ) + + await MainActor.run { + timelinePrediction = prediction + isGeneratingPrediction = false + } + } catch { + await MainActor.run { + isGeneratingPrediction = false + print("Error generating predictions: \(error)") + } + } + } + } + private func backgroundColor(for entry: HumanEntry) -> Color { if entry.id == selectedEntryId { return Color.gray.opacity(0.1) // More subtle selection highlight 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 + From 11457049b3187154a4854a065dcd79a0ab64be6d Mon Sep 17 00:00:00 2001 From: Michael Bayron Date: Wed, 17 Sep 2025 12:07:16 +0800 Subject: [PATCH 5/5] Updated UI --- freewrite/ContentView.swift | 3944 +++++++++++------------------- freewrite/JournalViewModel.swift | 369 +++ 2 files changed, 1809 insertions(+), 2504 deletions(-) create mode 100644 freewrite/JournalViewModel.swift diff --git a/freewrite/ContentView.swift b/freewrite/ContentView.swift index 83f979a..4477186 100644 --- a/freewrite/ContentView.swift +++ b/freewrite/ContentView.swift @@ -1,40 +1,32 @@ -// Swift 5.0 -// -// ContentView.swift -// freewrite -// -// Created by thorfinn on 2/14/25. -// - import SwiftUI import AppKit import UniformTypeIdentifiers -import PDFKit -import NaturalLanguage 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", + date: displayFormatter.string(from: now), + filename: filename, + rawDate: now, previewText: "", summary: nil, summaryGenerated: nil @@ -42,54 +34,17 @@ struct HumanEntry: Identifiable { } } -class SummaryService: ObservableObject { - func generateSummary(for text: String) -> String { - let cleanText = text.replacingOccurrences(of: "\n", with: " ") - .trimmingCharacters(in: .whitespacesAndNewlines) - - if cleanText.isEmpty { - return "Empty entry" - } - - if cleanText.count < 50 { - return cleanText - } - - // Extract first few meaningful sentences for summary - let sentences = cleanText.components(separatedBy: CharacterSet(charactersIn: ".!?")) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty && $0.count > 3 } - - if sentences.isEmpty { - return String(cleanText.prefix(100)) + "..." - } - - // Take first 1-2 sentences up to 150 characters - var summary = "" - for sentence in sentences.prefix(2) { - if summary.count + sentence.count < 150 { - summary += sentence + ". " - } else { - break - } - } - - return summary.isEmpty ? String(cleanText.prefix(100)) + "..." : summary.trimmingCharacters(in: .whitespaces) - } -} - -// Timeline Prediction Data Models struct TimelinePoint: Identifiable, Codable { let id = UUID() let date: Date let happiness: Double let description: String let scenario: TimelineScenario - + enum TimelineScenario: String, Codable { - case actual = "actual" - case best = "best" - case darkest = "darkest" + case actual + case best + case darkest } } @@ -100,233 +55,210 @@ struct TimelinePrediction: Codable { let monthsAhead: Int } -// Claude API Service 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.isEmpty else { + guard !apiToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw APIError.missingToken } - + await MainActor.run { isLoading = true error = nil } - + do { - // Prepare entries data for analysis - let entriesData = try await prepareEntriesData(entries: entries) - - // Validate we have enough data + let entriesData = try await prepareEntriesSummary(entries: entries) guard !entriesData.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - throw APIError.networkError("No journal entries found to analyze") + throw APIError.networkError("No journal entries to analyse.") } - - // Create the prompt for Claude - let prompt = createTimelinePredictionPrompt(entriesData: entriesData, monthsAhead: monthsAhead) - - // Make API call to Claude - let response = try await makeClaudeAPICall(prompt: prompt, apiToken: apiToken) - - // Parse the response into timeline prediction - let prediction = try parseTimelinePrediction(response: response, monthsAhead: monthsAhead) - + + 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 urlError as URLError { - let networkError = APIError.networkError(urlError.localizedDescription) - await MainActor.run { - isLoading = false - self.error = networkError.localizedDescription - } - throw networkError - } catch let apiError as APIError { + } 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 { - let genericError = APIError.networkError(error.localizedDescription) await MainActor.run { - isLoading = false - self.error = genericError.localizedDescription + isTestingConnection = false + connectionTestResult = "❌ Connection failed: \(error.localizedDescription)" } - throw genericError } } - - private func prepareEntriesData(entries: [HumanEntry]) async throws -> String { - var entriesText = "" - - for entry in entries.prefix(20) { // Limit to recent 20 entries - let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("Freewrite") - let fileURL = documentsDirectory.appendingPathComponent(entry.filename) - - do { - let content = try String(contentsOf: fileURL, encoding: .utf8) - entriesText += "Date: \(entry.date)\n" - entriesText += "Entry: \(content.trimmingCharacters(in: .whitespacesAndNewlines))\n\n" - } catch { - continue + + 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 entriesText + return combined } - - private func createTimelinePredictionPrompt(entriesData: String, monthsAhead: Int) -> String { - return """ - You are a skilled life coach and pattern analyst. Based on the following journal entries, I need you to create two timeline predictions for the next \(monthsAhead) months: - 1. **Best Timeline**: What would likely happen if things go really well - 2. **Darkest Timeline**: What might happen if things go poorly + 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: - For each timeline, provide monthly predictions with: - - A happiness score (1-10, where 10 is most happy) - - A brief story description of what happens that month + 1. Best timeline: realistic but optimistic outcomes each month + 2. Darkest timeline: potential challenges to watch for - Please respond in this exact JSON format: + 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": "Description of what happens in month 1" - } - ], - "darkestTimeline": [ - { - "month": 1, - "happiness": 4.2, - "description": "Description of what happens in month 1" - } - ] + "bestTimeline": [{"month": 1, "happiness": 8.5, "description": "..."}], + "darkestTimeline": [{"month": 1, "happiness": 4.2, "description": "..."}] } - Journal Entries: + Journal entries: \(entriesData) - - Base your predictions on patterns, concerns, hopes, and themes you see in the entries. Make the stories feel personal and specific to this person's life situation. """ } - - private func makeClaudeAPICall(prompt: String, apiToken: String) async throws -> String { - // Validate API token format - guard !apiToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - throw APIError.missingToken - } - - let cleanToken = apiToken.trimmingCharacters(in: .whitespacesAndNewlines) - + + 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(cleanToken, forHTTPHeaderField: "x-api-key") - request.timeoutInterval = 60.0 - - let requestBody = ClaudeAPIRequest( + request.setValue(apiToken, forHTTPHeaderField: "x-api-key") + + let body = ClaudeAPIRequest( model: "claude-sonnet-4-20250514", max_tokens: 4000, - messages: [ - ClaudeMessage(role: "user", content: prompt) - ] + messages: [ClaudeMessage(role: "user", content: prompt)] ) - + do { - request.httpBody = try JSONEncoder().encode(requestBody) + request.httpBody = try JSONEncoder().encode(body) } catch { throw APIError.encodingError(error.localizedDescription) } - - print("Making API request to: \(url)") - print("Request headers: \(request.allHTTPHeaderFields ?? [:])") - print("Request body model: \(requestBody.model)") - print("Request body max_tokens: \(requestBody.max_tokens)") - - // Create custom URL session with better configuration - let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 60.0 - config.timeoutIntervalForResource = 120.0 - config.waitsForConnectivity = true - - let session = URLSession(configuration: config) - + + let session = URLSession(configuration: .default) let (data, response) = try await session.data(for: request) - + guard let httpResponse = response as? HTTPURLResponse else { throw APIError.invalidResponse } - - print("HTTP Status Code: \(httpResponse.statusCode)") - - if httpResponse.statusCode != 200 { - let errorBody = String(data: data, encoding: .utf8) ?? "Unknown error" - print("Error response body: \(errorBody)") - throw APIError.httpError(httpResponse.statusCode, errorBody) + + guard httpResponse.statusCode == 200 else { + let message = String(data: data, encoding: .utf8) ?? "Unknown error" + throw APIError.httpError(httpResponse.statusCode, message) } - + do { - let apiResponse = try JSONDecoder().decode(ClaudeAPIResponse.self, from: data) - guard let content = apiResponse.content.first?.text else { + let decoded = try JSONDecoder().decode(ClaudeAPIResponse.self, from: data) + guard let text = decoded.content.first?.text else { throw APIError.invalidResponse } - return content + return text } catch { - let responseString = String(data: data, encoding: .utf8) ?? "Unable to decode response" - print("Decoding error: \(error)") - print("Response body: \(responseString)") - throw APIError.decodingError(error.localizedDescription) + let raw = String(data: data, encoding: .utf8) ?? "" + throw APIError.decodingError("\(error.localizedDescription)\nRaw: \(raw)") } } - - private func parseTimelinePrediction(response: String, monthsAhead: Int) throws -> TimelinePrediction { - // Extract JSON from response (Claude sometimes adds explanatory text) - let jsonStart = response.firstIndex(of: "{") ?? response.startIndex - let jsonEnd = response.lastIndex(of: "}") ?? response.endIndex + + 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 = jsonString.data(using: .utf8)! - let parsedResponse = try JSONDecoder().decode(ClaudeTimelineResponse.self, from: data) - + let data = Data(jsonString.utf8) + let parsed = try JSONDecoder().decode(ClaudeTimelineResponse.self, from: data) + let calendar = Calendar.current let today = Date() - - let bestTimeline = parsedResponse.bestTimeline.map { item in - let futureDate = calendar.date(byAdding: .month, value: item.month, to: today)! - return TimelinePoint( - date: futureDate, - happiness: item.happiness, - description: item.description, - scenario: .best - ) + + 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 = parsedResponse.darkestTimeline.map { item in - let futureDate = calendar.date(byAdding: .month, value: item.month, to: today)! - return TimelinePoint( - date: futureDate, - happiness: item.happiness, - description: item.description, - scenario: .darkest - ) + + 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, @@ -334,66 +266,8 @@ class ClaudeAPIService: ObservableObject { monthsAhead: monthsAhead ) } - - 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. Please add it in Settings." - 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. Please check your token in Settings." - } else if code == 429 { - return "Rate limit exceeded. Please try again later." - } else { - return "HTTP error \(code): \(message)" - } - case .encodingError(let message): - return "Request encoding error: \(message)" - case .decodingError(let message): - return "Response decoding error: \(message)" - case .networkError(let message): - return "Network error: \(message). Check your internet connection." - } - } - } - - func testConnection(apiToken: String) async { - await MainActor.run { - isTestingConnection = true - connectionTestResult = nil - } - - do { - let testPrompt = "Hello, please respond with just the word 'success' to test the API connection." - let response = try await makeClaudeAPICall(prompt: testPrompt, apiToken: apiToken) - - await MainActor.run { - isTestingConnection = false - connectionTestResult = "✅ Connection successful! API token is valid." - } - } catch { - await MainActor.run { - isTestingConnection = false - connectionTestResult = "❌ Connection failed: \(error.localizedDescription)" - } - } - } } -// Claude API Request/Response Models struct ClaudeAPIRequest: Codable { let model: String let max_tokens: Int @@ -425,2395 +299,1457 @@ struct ClaudeTimelineItem: Codable { let description: String } -struct TimelineDot: View { - let isFirst: Bool - let isLast: Bool - +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(spacing: 0) { - if !isFirst { - Rectangle() - .fill(Color.gray.opacity(0.3)) - .frame(width: 2, height: 20) - } - - Circle() - .fill(Color.blue) - .frame(width: 12, height: 12) - - if !isLast { - Rectangle() - .fill(Color.gray.opacity(0.3)) - .frame(width: 2, height: 20) + 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) } - } - } -} -struct TimelineEntryView: View { - let entry: HumanEntry - let summary: String - let isFirst: Bool - let isLast: Bool - let onTap: () -> Void - - @State private var isHovered = false - - var body: some View { - HStack(alignment: .top, spacing: 16) { - TimelineDot(isFirst: isFirst, isLast: isLast) - - VStack(alignment: .leading, spacing: 8) { - Text(entry.date) - .font(.caption) - .foregroundColor(.secondary) - - Text(summary) - .font(.body) - .foregroundColor(isHovered ? .primary : .secondary) - .lineLimit(3) - .multilineTextAlignment(.leading) + CustomChartView(actualData: actualData, prediction: prediction, selectedPoint: $selectedPoint) { point in + selectedPoint = point + onPointSelected(point) } - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(.vertical, 8) - .padding(.horizontal, 16) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(isHovered ? Color.gray.opacity(0.1) : Color.clear) - ) - .onHover { hovering in - isHovered = hovering - } - .onTapGesture { - onTap() + .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 TimelineView: View { - let entries: [HumanEntry] - let onEntrySelected: (HumanEntry) -> Void - let onClose: () -> Void - - @StateObject private var summaryService = SummaryService() - @State private var summaries: [UUID: String] = [:] - +struct CustomChartView: View { + let actualData: [TimelinePoint] + let prediction: TimelinePrediction? + @Binding var selectedPoint: TimelinePoint? + let onPointSelected: (TimelinePoint) -> Void + var body: some View { - VStack(spacing: 0) { - - ScrollView { - LazyVStack(spacing: 0) { - ForEach(Array(entries.enumerated()), id: \.element.id) { index, entry in - TimelineEntryView( - entry: entry, - summary: summaries[entry.id] ?? "Generating summary...", - isFirst: index == 0, - isLast: index == entries.count - 1, - onTap: { - onEntrySelected(entry) - onClose() - } - ) - .onAppear { - generateSummaryIfNeeded(for: entry) - } - } - } - .padding(.top) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(NSColor.windowBackgroundColor)) - } - - private func generateSummaryIfNeeded(for entry: HumanEntry) { - guard summaries[entry.id] == nil else { return } - - Task { - let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("Freewrite") - let fileURL = documentsDirectory.appendingPathComponent(entry.filename) - - do { - let content = try String(contentsOf: fileURL, encoding: .utf8) - let summary = summaryService.generateSummary(for: content) - - await MainActor.run { - summaries[entry.id] = summary + 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) } - } catch { - await MainActor.run { - summaries[entry.id] = entry.previewText.isEmpty ? "Unable to load content" : entry.previewText + + 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) + ) } } -} -struct TimelineChartView: View { - let entries: [HumanEntry] - let prediction: TimelinePrediction? - let onEntrySelected: (HumanEntry) -> Void - - @State private var selectedPoint: TimelinePoint? - @State private var hoveredDate: Date? - @StateObject private var summaryService = SummaryService() - @State private var actualTimelineData: [TimelinePoint] = [] - - var body: some View { - VStack(spacing: 16) { - // Chart Header - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Future Timeline Projection") - .font(.title2) - .fontWeight(.semibold) - Text("Historical happiness data with best and darkest possible futures") - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() - - // Legend - HStack(spacing: 20) { - HStack(spacing: 8) { - Circle() - .fill(Color.blue) - .frame(width: 12, height: 12) - Text("Actual Journey") - .font(.caption) - .foregroundColor(.blue) - } - - HStack(spacing: 8) { - Circle() - .fill(Color.green) - .frame(width: 12, height: 12) - Text("Best Timeline") - .font(.caption) - .foregroundColor(.green) - } - - HStack(spacing: 8) { + 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() - .fill(Color.red) - .frame(width: 12, height: 12) - Text("Darkest Timeline") - .font(.caption) - .foregroundColor(.red) + .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) } - } } - - // Custom Chart Implementation - CustomChartView( - actualData: actualTimelineData, - prediction: prediction, - onPointSelected: { point in - selectedPoint = point - } - ) - .frame(height: 300) - .onAppear { - generateActualTimelineData() + + 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) } - - // Selected point details - if let selectedPoint = selectedPoint { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text(getTimelineTitle(for: selectedPoint.scenario)) - .font(.headline) - .foregroundColor(getTimelineColor(for: selectedPoint.scenario)) - - Spacer() - - Text(formatDate(selectedPoint.date)) - .font(.subheadline) - .foregroundColor(.secondary) - } - - Text("Happiness: \(selectedPoint.happiness, specifier: "%.1f")/10") - .font(.subheadline) - .foregroundColor(.secondary) - - Text(selectedPoint.description) - .font(.body) - .fixedSize(horizontal: false, vertical: true) - } - .padding() - .background(Color.gray.opacity(0.1)) - .cornerRadius(8) + } + } + + 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) } } - .padding() + + context.stroke(path, with: .color(color.opacity(0.9)), style: StrokeStyle(lineWidth: dashed ? 2 : 3, lineCap: .round, dash: dashed ? [6, 6] : [])) } - - private func getTimelineTitle(for scenario: TimelinePoint.TimelineScenario) -> String { - switch scenario { - case .actual: - return "Current State" - case .best: - return "Best Timeline" - case .darkest: - return "Darkest Timeline" + + 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 getTimelineColor(for scenario: TimelinePoint.TimelineScenario) -> Color { - switch scenario { - case .actual: - return .blue - case .best: - return .green - case .darkest: - return .red + + 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 formatDate(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "MMM yyyy" - return formatter.string(from: date) + + private func pointIndex(_ point: TimelinePoint, in all: [TimelinePoint]) -> Int { + all.firstIndex { $0.date == point.date && $0.scenario == point.scenario } ?? 0 } - - private func generateActualTimelineData() { - // Generate actual timeline data from entries - let calendar = Calendar.current - - // Group entries by month - var monthlyEntries: [Date: [HumanEntry]] = [:] - - for entry in entries { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "MMM d" - - if let entryDate = dateFormatter.date(from: entry.date) { - // Set current year - var components = calendar.dateComponents([.month, .day], from: entryDate) - components.year = calendar.component(.year, from: Date()) - - if let dateWithYear = calendar.date(from: components) { - let monthStart = calendar.dateInterval(of: .month, for: dateWithYear)?.start ?? dateWithYear - - if monthlyEntries[monthStart] == nil { - monthlyEntries[monthStart] = [] + +} + +struct ContentView: View { + @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 fontSize: CGFloat = 18 + @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 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. + Keep it casual, dont say yo, help me make new connections i don't see, comfort, validate, challenge, all of it. dont be afraid to say a lot. format with markdown headings if needed. + Use vivid metaphors and powerful imagery to help me see what I'm really building. Organize your thoughts with meaningful headings that create a narrative journey through my ideas. + Don't just validate my thoughts - reframe them in a way that shows me what I'm really seeking beneath the surface. Go beyond the product concepts to the emotional core of what I'm trying to solve. + Be willing to be profound and philosophical without sounding like you're giving therapy. I want someone who can see the patterns I can't see myself and articulate them in a way that feels like an epiphany. + Start with "hey, thanks for showing me this. my thoughts:" and then use markdown headings to structure your response. + + Here's my journal entry: + """ + + 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) + } } - monthlyEntries[monthStart]?.append(entry) } } + .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 + } } - - // Calculate happiness scores for each month - var points: [TimelinePoint] = [] - - for (monthDate, monthEntries) in monthlyEntries.sorted(by: { $0.key < $1.key }) { - let happiness = calculateHappinessScore(for: monthEntries) - let description = generateMonthDescription(for: monthEntries) - - let point = TimelinePoint( - date: monthDate, - happiness: happiness, - description: description, - scenario: .actual + .sheet(isPresented: $isSettingsPresented) { + SettingsSheet( + claudeApiToken: $claudeApiToken, + predictionMonths: $predictionMonths, + showApiToken: $showApiToken, + claudeService: claudeService, + onDismiss: { isSettingsPresented = false } ) - points.append(point) + .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 + } + } } - - actualTimelineData = points } - - private func calculateHappinessScore(for entries: [HumanEntry]) -> Double { - // Simple happiness calculation based on text sentiment - // In a real app, you might use NaturalLanguage framework or more sophisticated analysis - - let totalWords = entries.reduce(0) { count, entry in - count + entry.previewText.split(separator: " ").count + + private var currentColorScheme: ColorScheme { + colorSchemeString == "dark" ? .dark : .light + } + + 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) + } + + 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) + ) } - - 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 - - for entry in entries { - let words = entry.previewText.lowercased().split(separator: " ") - for word in words { - if positiveWords.contains(String(word)) { - positiveCount += 1 - } else if negativeWords.contains(String(word)) { - negativeCount += 1 + .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 + } } - - // Calculate happiness score (1-10) - let sentiment = Double(positiveCount - negativeCount) - let wordCount = max(Double(totalWords), 1) - let normalizedSentiment = sentiment / wordCount * 100 - - // Map to 1-10 scale with 5 as neutral - let happiness = max(1, min(10, 5 + normalizedSentiment)) - - return happiness } - - private func generateMonthDescription(for entries: [HumanEntry]) -> String { - let wordCount = entries.reduce(0) { count, entry in - count + entry.previewText.split(separator: " ").count + + 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) + } + .buttonStyle(.plain) + } + + Spacer(minLength: 0) } - - let entryCount = entries.count - - if entryCount == 0 { - return "No entries this month" - } else if entryCount == 1 { - return "1 entry with \(wordCount) words" - } else { - return "\(entryCount) entries with \(wordCount) words total" + } + + 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) } -} -struct CustomChartView: View { - let actualData: [TimelinePoint] - let prediction: TimelinePrediction? - let onPointSelected: (TimelinePoint) -> Void - - @State private var hoveredDate: Date? - - var body: some View { - GeometryReader { geometry in - let width = geometry.size.width - let height = geometry.size.height - let padding: CGFloat = 40 - let chartWidth = width - padding * 2 - let chartHeight = height - padding * 2 - - ZStack { - // Chart background - RoundedRectangle(cornerRadius: 8) - .fill(Color.gray.opacity(0.1)) - - // Grid lines - Path { path in - // Horizontal grid lines - for i in 0...10 { - let y = padding + (chartHeight * CGFloat(10 - i) / 10) - path.move(to: CGPoint(x: padding, y: y)) - path.addLine(to: CGPoint(x: padding + chartWidth, y: y)) - } - - // Vertical grid lines (months) - let allPoints = getAllPoints() - if !allPoints.isEmpty { - let monthCount = allPoints.count > 1 ? allPoints.count - 1 : 1 - for i in 0...monthCount { - let x = padding + (chartWidth * CGFloat(i) / CGFloat(monthCount)) - path.move(to: CGPoint(x: x, y: padding)) - path.addLine(to: CGPoint(x: x, y: padding + chartHeight)) - } - } - } - .stroke(Color.gray.opacity(0.3), lineWidth: 0.5) - - // Plot actual timeline - if !actualData.isEmpty { - drawTimeline(points: actualData, color: .blue, isDashed: false, width: chartWidth, height: chartHeight, padding: padding) + 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") } - - // Plot prediction timelines - if let prediction = prediction { - drawTimeline(points: prediction.bestTimeline, color: .green, isDashed: true, width: chartWidth, height: chartHeight, padding: padding) - drawTimeline(points: prediction.darkestTimeline, color: .red, isDashed: true, width: chartWidth, height: chartHeight, padding: padding) + .buttonStyle(GlassControlStyle()) + .contextMenu { + Button("Reset Timer", role: .destructive, action: resetTimer) } - - // Y-axis labels - VStack { - ForEach(0..<11) { i in - HStack { - Text("\(10 - i)") - .font(.caption) - .foregroundColor(.secondary) - Spacer() - } - if i < 10 { - Spacer() - } + + Label("\(viewModel.wordCount) words", systemImage: "character.cursor.ibeam") + .padding(.horizontal, 12) + .padding(.vertical, 8) + .backgroundCapsule() + } + + 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") } - } - .frame(width: 20, height: chartHeight) - .offset(x: -width/2 + 20, y: 0) - - // X-axis labels - HStack { - ForEach(getAllPoints().indices, id: \.self) { index in - Text(formatDateForAxis(getAllPoints()[index].date)) - .font(.caption) - .foregroundColor(.secondary) - if index < getAllPoints().count - 1 { - Spacer() + Divider() + Button("Randomize") { + if let random = availableFonts.randomElement() { + selectedFont = random } } + } label: { + Label(fontDisplayName, systemImage: "textformat") } - .frame(width: chartWidth, height: 20) - .offset(x: 0, y: height/2 - 30) - - // Invisible hover zones for each month - ForEach(getUniqueDates(), id: \.self) { date in - let allPoints = getAllPoints() - let pointIndex = allPoints.firstIndex { Calendar.current.isDate($0.date, inSameDayAs: date) } ?? 0 - let xPosition = padding + (chartWidth * CGFloat(pointIndex) / CGFloat(max(allPoints.count - 1, 1))) - let zoneWidth = chartWidth / CGFloat(max(allPoints.count, 1)) - - Rectangle() - .fill(Color.clear) - .frame(width: zoneWidth, height: chartHeight) - .position(x: xPosition, y: padding + chartHeight/2) - .onHover { isHovering in - hoveredDate = isHovering ? date : nil + .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") + } } + } + } label: { + Label("\(Int(fontSize)) pt", systemImage: "textformat.size") } - - // Tooltip overlay - if let hoveredDate = hoveredDate { - tooltipView(for: hoveredDate, width: width, height: height, padding: padding, chartWidth: chartWidth, chartHeight: chartHeight) + .menuStyle(.borderlessButton) + .buttonStyle(GlassControlStyle()) + + Button(action: toggleColorScheme) { + Image(systemName: currentColorScheme == .dark ? "sun.max.fill" : "moon.fill") } + .buttonStyle(GlassControlStyle()) } - } - } - - private func tooltipView(for date: Date, width: CGFloat, height: CGFloat, padding: CGFloat, chartWidth: CGFloat, chartHeight: CGFloat) -> some View { - let allPoints = getAllPoints() - let pointsAtDate = getPointsAtDate(date: date) - - // Get the x position for this date - use same logic as chart points - let pointIndex = allPoints.firstIndex { Calendar.current.isDate($0.date, inSameDayAs: date) } ?? 0 - let pointX = padding + (chartWidth * CGFloat(pointIndex) / CGFloat(max(allPoints.count - 1, 1))) - - return VStack(alignment: .leading, spacing: 12) { - Text(formatDateForTooltip(date)) - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.primary) - - // Show tooltips for each scenario at this date - ForEach(pointsAtDate.sorted { $0.scenario.rawValue < $1.scenario.rawValue }) { point in - VStack(alignment: .leading, spacing: 4) { - HStack { - Circle() - .fill(point.scenario == .best ? .green : point.scenario == .darkest ? .red : .blue) - .frame(width: 8, height: 8) - - Text(point.scenario.rawValue.capitalized + " Timeline") - .font(.caption2) - .fontWeight(.medium) - .foregroundColor(point.scenario == .best ? .green : point.scenario == .darkest ? .red : .blue) - } - - Text("Happiness: \(String(format: "%.1f", point.happiness))/10") - .font(.caption2) - .foregroundColor(.secondary) - - if !point.description.isEmpty { - Text(point.description) - .font(.caption2) - .foregroundColor(.primary) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) + + 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() } } - .padding(6) - .background( - RoundedRectangle(cornerRadius: 4) - .fill(Color.gray.opacity(0.1)) - ) + .menuStyle(.borderlessButton) + .buttonStyle(GlassControlStyle()) + + Button(action: { isSettingsPresented = true }) { + Label("Settings", systemImage: "gearshape") + } + .buttonStyle(GlassControlStyle()) + + if activeTab == .editor { + entriesToggleButton + } + + focusToggleButton } } - .padding(10) + .padding(.horizontal, 22) + .padding(.vertical, 14) .background( - RoundedRectangle(cornerRadius: 8) - .fill(Color(NSColor.controlBackgroundColor)) - .shadow(color: .black.opacity(0.15), radius: 6, x: 0, y: 3) - ) - .frame(maxWidth: 280) - .position( - x: min(max(pointX, 140), width - 140), - y: max(height * 0.2, 60) + 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) } - - private func getPointsAtDate(date: Date) -> [TimelinePoint] { - var pointsAtDate: [TimelinePoint] = [] - - // Check actual data - pointsAtDate.append(contentsOf: actualData.filter { Calendar.current.isDate($0.date, inSameDayAs: date) }) - - // Check prediction data - if let prediction = prediction { - pointsAtDate.append(contentsOf: prediction.bestTimeline.filter { Calendar.current.isDate($0.date, inSameDayAs: date) }) - pointsAtDate.append(contentsOf: prediction.darkestTimeline.filter { Calendar.current.isDate($0.date, inSameDayAs: date) }) - } - - return pointsAtDate - } - - private func formatDateForTooltip(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "MMM yyyy" - return formatter.string(from: date) - } - - private func getUniqueDates() -> [Date] { - var allDates: [Date] = [] - - // Collect dates from actual data - allDates.append(contentsOf: actualData.map { $0.date }) - - // Collect dates from prediction data - if let prediction = prediction { - allDates.append(contentsOf: prediction.bestTimeline.map { $0.date }) - allDates.append(contentsOf: prediction.darkestTimeline.map { $0.date }) - } - - // Remove duplicates and sort - let uniqueDates = Array(Set(allDates)).sorted() - return uniqueDates - } - - - private func getAllPoints() -> [TimelinePoint] { - var allPoints = actualData - - if let prediction = prediction { - allPoints.append(contentsOf: prediction.bestTimeline) - allPoints.append(contentsOf: prediction.darkestTimeline) + + 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 } - - return allPoints.sorted { $0.date < $1.date } } - - private func drawTimeline(points: [TimelinePoint], color: Color, isDashed: Bool, width: CGFloat, height: CGFloat, padding: CGFloat) -> some View { - let sortedPoints = points.sorted { $0.date < $1.date } - let allPoints = getAllPoints() - - return ZStack { - // Draw line - if sortedPoints.count > 1 { - Path { path in - for (index, point) in sortedPoints.enumerated() { - let x = padding + (width * CGFloat(getPointIndex(point: point, in: allPoints)) / CGFloat(max(allPoints.count - 1, 1))) - let y = padding + (height * CGFloat(10 - point.happiness) / 10) - - if index == 0 { - path.move(to: CGPoint(x: x, y: y)) - } else { - path.addLine(to: CGPoint(x: x, y: y)) - } - } - } - .stroke(color, style: StrokeStyle(lineWidth: isDashed ? 2 : 3, lineCap: .round, dash: isDashed ? [5, 5] : [])) + + private func toggleTimer() { + if timerIsRunning { + timerIsRunning = false + } else { + if timeRemaining == 0 { + timeRemaining = focusSessionDuration } - - // Draw points - ForEach(sortedPoints) { point in - Circle() - .fill(color) - .frame(width: 8, height: 8) - .position( - x: padding + (width * CGFloat(getPointIndex(point: point, in: allPoints)) / CGFloat(max(allPoints.count - 1, 1))), - y: padding + (height * CGFloat(10 - point.happiness) / 10) - ) - .onTapGesture { - onPointSelected(point) - } + timerShakeTrigger = 0 + withAnimation(.easeInOut(duration: 0.35)) { + activeTab = .editor + isSidebarVisible = false + isTimelineDrawerVisible = false + selectedTimelinePoint = nil + isFocusMode = true } + timerIsRunning = true } } - - private func getPointIndex(point: TimelinePoint, in allPoints: [TimelinePoint]) -> Int { - return allPoints.firstIndex { $0.date == point.date } ?? 0 - } - - private func formatDateForAxis(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "MMM" - return formatter.string(from: date) - } -} - -struct HeartEmoji: Identifiable { - let id = UUID() - var position: CGPoint - var offset: CGFloat = 0 -} -struct ContentView: View { - private let headerString = "\n\n" - @State private var entries: [HumanEntry] = [] - @State private var text: String = "" // Remove initial welcome text since we'll handle it in createNewEntry - - @State private var isFullscreen = false - @State private var selectedFont: String = "Lato-Regular" - @State private var currentRandomFont: String = "" - @State private var timeRemaining: Int = 900 // Changed to 900 seconds (15 minutes) - @State private var timerIsRunning = false - @State private var isHoveringTimer = false - @State private var isHoveringFullscreen = false - @State private var hoveredFont: String? = nil - @State private var isHoveringSize = false - @State private var fontSize: CGFloat = 18 - @State private var blinkCount = 0 - @State private var isBlinking = false - @State private var opacity: Double = 1.0 - @State private var shouldShowGray = true // New state to control color - @State private var lastClickTime: Date? = nil - @State private var bottomNavOpacity: Double = 1.0 - @State private var isHoveringBottomNav = false - @State private var selectedEntryIndex: Int = 0 - @State private var scrollOffset: CGFloat = 0 - @State private var selectedEntryId: UUID? = nil - @State private var hoveredEntryId: UUID? = nil - @State private var isHoveringChat = false // Add this state variable - @State private var showingChatMenu = false - @State private var chatMenuAnchor: CGPoint = .zero - @State private var showingSidebar = false // Add this state variable - @State private var hoveredTrashId: UUID? = nil - @State private var hoveredExportId: UUID? = nil - @State private var placeholderText: String = "" // Add this line - @State private var isHoveringNewEntry = false - @State private var isHoveringClock = false - @State private var isHoveringHistory = false - @State private var isHoveringHistoryText = false - @State private var isHoveringHistoryPath = false - @State private var isHoveringHistoryArrow = false - @State private var colorScheme: ColorScheme = .light // Add state for color scheme - @State private var isHoveringThemeToggle = false // Add state for theme toggle hover - @State private var didCopyPrompt: Bool = false // Add state for copy prompt feedback - @State private var currentView: AppView = .writing // Add state for current view - @State private var isHoveringTimeline = false // Add state for timeline button hover - @State private var showingTimeline = false // Keep for backwards compatibility - @State private var isHoveringSettings = false // Add state for settings button hover - @AppStorage("claudeApiToken") private var claudeApiToken: String = "" - @AppStorage("predictionMonths") private var predictionMonths: Int = 6 - @StateObject private var claudeService = ClaudeAPIService() - @State private var timelinePrediction: TimelinePrediction? - @State private var isGeneratingPrediction = false - @State private var showApiToken = false - - enum AppView { - case writing - case timeline - case settings + private func resetTimer() { + timerIsRunning = false + timeRemaining = focusSessionDuration } - let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() - let entryHeight: CGFloat = 40 - - let availableFonts = NSFontManager.shared.availableFontFamilies - let standardFonts = ["Lato-Regular", "Arial", ".AppleSystemUIFont", "Times New Roman"] - let fontSizes: [CGFloat] = [16, 18, 20, 22, 24, 26] - let placeholderOptions = [ - "\n\nBegin writing", - "\n\nPick a thought and go", - "\n\nStart typing", - "\n\nWhat's on your mind", - "\n\nJust start", - "\n\nType your first thought", - "\n\nStart with one sentence", - "\n\nJust say it" - ] - - // Add file manager and save timer - private let fileManager = FileManager.default - private let saveTimer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect() - - // Add cached documents directory - private let documentsDirectory: URL = { - let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("Freewrite") - - // Create Freewrite directory if it doesn't exist - if !FileManager.default.fileExists(atPath: directory.path) { - do { - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - print("Successfully created Freewrite directory") - } catch { - print("Error creating directory: \(error)") - } - } - - return directory - }() - - // Add shared prompt constant - private let aiChatPrompt = """ - below is my journal entry. wyt? talk through it with me like a friend. don't therpaize me and give me a whole breakdown, don't repeat my thoughts with headings. really take all of this, and tell me back stuff truly as if you're an old homie. - - Keep it casual, dont say yo, help me make new connections i don't see, comfort, validate, challenge, all of it. dont be afraid to say a lot. format with markdown headings if needed. - - do not just go through every single thing i say, and say it back to me. you need to proccess everythikng is say, make connections i don't see it, and deliver it all back to me as a story that makes me feel what you think i wanna feel. thats what the best therapists do. - - ideally, you're style/tone should sound like the user themselves. it's as if the user is hearing their own tone but it should still feel different, because you have different things to say and don't just repeat back they say. - - else, start by saying, "hey, thanks for showing me this. my thoughts:" - - my entry: - """ - - private let claudePrompt = """ - Take a look at my journal entry below. I'd like you to analyze it and respond with deep insight that feels personal, not clinical. - Imagine you're not just a friend, but a mentor who truly gets both my tech background and my psychological patterns. I want you to uncover the deeper meaning and emotional undercurrents behind my scattered thoughts. - Keep it casual, dont say yo, help me make new connections i don't see, comfort, validate, challenge, all of it. dont be afraid to say a lot. format with markdown headings if needed. - Use vivid metaphors and powerful imagery to help me see what I'm really building. Organize your thoughts with meaningful headings that create a narrative journey through my ideas. - Don't just validate my thoughts - reframe them in a way that shows me what I'm really seeking beneath the surface. Go beyond the product concepts to the emotional core of what I'm trying to solve. - Be willing to be profound and philosophical without sounding like you're giving therapy. I want someone who can see the patterns I can't see myself and articulate them in a way that feels like an epiphany. - Start with 'hey, thanks for showing me this. my thoughts:' and then use markdown headings to structure your response. - Here's my journal entry: - """ - - // Initialize with saved theme preference if available - init() { - // Load saved color scheme preference - let savedScheme = UserDefaults.standard.string(forKey: "colorScheme") ?? "light" - _colorScheme = State(initialValue: savedScheme == "dark" ? .dark : .light) - } - - // Modify getDocumentsDirectory to use cached value - private func getDocumentsDirectory() -> URL { - return documentsDirectory - } - - // Add function to save text - private func saveText() { - let documentsDirectory = getDocumentsDirectory() - let fileURL = documentsDirectory.appendingPathComponent("entry.md") - - print("Attempting to save file to: \(fileURL.path)") - - do { - try text.write(to: fileURL, atomically: true, encoding: .utf8) - print("Successfully saved file") - } catch { - print("Error saving file: \(error)") - print("Error details: \(error.localizedDescription)") + private func toggleSidebar() { + withAnimation(.easeInOut(duration: 0.3)) { + isSidebarVisible.toggle() } } - - // 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") + + private func toggleFocusMode() { + withAnimation(.easeInOut(duration: 0.35)) { + if isFocusMode { + isFocusMode = false } else { - print("File does not exist yet") + activeTab = .editor + isSidebarVisible = false + isTimelineDrawerVisible = false + selectedTimelinePoint = nil + isFocusMode = true } - } catch { - print("Error loading file: \(error)") - print("Error details: \(error.localizedDescription)") } } - - // Add function to load existing entries - private func loadExistingEntries() { - let documentsDirectory = getDocumentsDirectory() - print("Looking for entries in: \(documentsDirectory.path)") - - do { - let fileURLs = try fileManager.contentsOfDirectory(at: documentsDirectory, includingPropertiesForKeys: nil) - let mdFiles = fileURLs.filter { $0.pathExtension == "md" } - - print("Found \(mdFiles.count) .md files") - - // Process each file - let entriesWithDates = mdFiles.compactMap { fileURL -> (entry: HumanEntry, date: Date, content: String)? in - let filename = fileURL.lastPathComponent - print("Processing: \(filename)") - - // Extract UUID and date from filename - pattern [uuid]-[yyyy-MM-dd-HH-mm-ss].md - guard let uuidMatch = filename.range(of: "\\[(.*?)\\]", options: .regularExpression), - let dateMatch = filename.range(of: "\\[(\\d{4}-\\d{2}-\\d{2}-\\d{2}-\\d{2}-\\d{2})\\]", options: .regularExpression), - let uuid = UUID(uuidString: String(filename[uuidMatch].dropFirst().dropLast())) else { - print("Failed to extract UUID or date from filename: \(filename)") - return nil - } - - // Parse the date string - let dateString = String(filename[dateMatch].dropFirst().dropLast()) - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" - - guard let fileDate = dateFormatter.date(from: dateString) else { - print("Failed to parse date from filename: \(filename)") - return nil - } - - // Read file contents for preview - do { - let content = try String(contentsOf: fileURL, encoding: .utf8) - let preview = content - .replacingOccurrences(of: "\n", with: " ") - .trimmingCharacters(in: .whitespacesAndNewlines) - let truncated = preview.isEmpty ? "" : (preview.count > 30 ? String(preview.prefix(30)) + "..." : preview) - - // Format display date - dateFormatter.dateFormat = "MMM d" - let displayDate = dateFormatter.string(from: fileDate) - - return ( - entry: HumanEntry( - id: uuid, - date: displayDate, - filename: filename, - previewText: truncated, - summary: nil, - summaryGenerated: nil - ), - date: fileDate, - content: content // Store the full content to check for welcome message - ) - } catch { - print("Error reading file: \(error)") - return nil - } - } - - // Sort and extract entries - entries = entriesWithDates - .sorted { $0.date > $1.date } // Sort by actual date from filename - .map { $0.entry } - - print("Successfully loaded and sorted \(entries.count) entries") - - // Check if we need to create a new entry - let calendar = Calendar.current - let today = Date() - let todayStart = calendar.startOfDay(for: today) - - // Check if there's an empty entry from today - let hasEmptyEntryToday = entries.contains { entry in - // Convert the display date (e.g. "Mar 14") to a Date object - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "MMM d" - if let entryDate = dateFormatter.date(from: entry.date) { - // Set year component to current year since our stored dates don't include year - var components = calendar.dateComponents([.year, .month, .day], from: entryDate) - components.year = calendar.component(.year, from: today) - - // Get start of day for the entry date - if let entryDateWithYear = calendar.date(from: components) { - let entryDayStart = calendar.startOfDay(for: entryDateWithYear) - return calendar.isDate(entryDayStart, inSameDayAs: todayStart) && entry.previewText.isEmpty - } - } - return false + + private func handleTabSelection(_ tab: Tab) { + withAnimation(.easeInOut(duration: 0.25)) { + activeTab = tab + if tab != .editor { + isSidebarVisible = false } - - // Check if we have only one entry and it's the welcome message - let hasOnlyWelcomeEntry = entries.count == 1 && entriesWithDates.first?.content.contains("Welcome to Freewrite.") == true - - if entries.isEmpty { - // First time user - create entry with welcome message - print("First time user, creating welcome entry") - createNewEntry() - } else if !hasEmptyEntryToday && !hasOnlyWelcomeEntry { - // No empty entry for today and not just the welcome entry - create new entry - print("No empty entry for today, creating new entry") - createNewEntry() - } else { - // Select the most recent empty entry from today or the welcome entry - if let todayEntry = entries.first(where: { entry in - // Convert the display date (e.g. "Mar 14") to a Date object - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "MMM d" - if let entryDate = dateFormatter.date(from: entry.date) { - // Set year component to current year since our stored dates don't include year - var components = calendar.dateComponents([.year, .month, .day], from: entryDate) - components.year = calendar.component(.year, from: today) - - // Get start of day for the entry date - if let entryDateWithYear = calendar.date(from: components) { - let entryDayStart = calendar.startOfDay(for: entryDateWithYear) - return calendar.isDate(entryDayStart, inSameDayAs: todayStart) && entry.previewText.isEmpty - } - } - return false - }) { - selectedEntryId = todayEntry.id - loadEntry(entry: todayEntry) - } else if hasOnlyWelcomeEntry { - // If we only have the welcome entry, select it - selectedEntryId = entries[0].id - loadEntry(entry: entries[0]) - } + if tab != .timeline { + isTimelineDrawerVisible = false + selectedTimelinePoint = nil } - - } catch { - print("Error loading directory contents: \(error)") - print("Creating default entry after error") - createNewEntry() } } - - var randomButtonTitle: String { - return currentRandomFont.isEmpty ? "Random" : "Random [\(currentRandomFont)]" + + private func toggleColorScheme() { + colorSchemeString = colorSchemeString == "dark" ? "light" : "dark" } - - var timerButtonTitle: String { - if !timerIsRunning && timeRemaining == 900 { - return "15:00" + + private func toggleFullScreen() { + if let window = NSApplication.shared.windows.first { + window.toggleFullScreen(nil) } - let minutes = timeRemaining / 60 - let seconds = timeRemaining % 60 - return String(format: "%d:%02d", minutes, seconds) } - - var timerColor: Color { - if timerIsRunning { - return isHoveringTimer ? (colorScheme == .light ? .black : .white) : .gray.opacity(0.8) - } else { - return isHoveringTimer ? (colorScheme == .light ? .black : .white) : (colorScheme == .light ? .gray : .gray.opacity(0.8)) - } + + private func revealJournalFolder() { + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: viewModel.documentsPath.path) } - - 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 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) } - - var fontSizeButtonTitle: String { - return "\(Int(fontSize))px" + + 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) } - - var placeholderOffset: CGFloat { - // Instead of using calculated line height, use a simple offset - return fontSize / 2 + + 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) } - - // Add a color utility computed property - var popoverBackgroundColor: Color { - return colorScheme == .light ? Color(NSColor.controlBackgroundColor) : Color(NSColor.darkGray) + + 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)) } - - var popoverTextColor: Color { - return colorScheme == .light ? Color.primary : Color.white + + 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)) } - - @State private var viewHeight: CGFloat = 0 - - 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 - - Group { - if currentView == .timeline { - timelinePageView - } else if currentView == .settings { - settingsPageView - } else { - writingPageView(buttonBackground: buttonBackground, navHeight: navHeight, textColor: textColor, textHoverColor: textHoverColor) - } + + 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) } } - - private func writingPageView(buttonBackground: Color, navHeight: CGFloat, textColor: Color, textHoverColor: Color) -> some View { - 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 - ) - .onGeometryChange(for: CGFloat.self) { proxy in - proxy.size.height - } action: { height in - viewHeight = height - } - .contentMargins(.bottom, viewHeight / 4) - - - 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) - - // Timeline button - Button(action: { - currentView = .timeline - }) { - Image(systemName: "timeline.selection") - .foregroundColor(isHoveringTimeline ? textHoverColor : textColor) - } - .buttonStyle(.plain) - .onHover { hovering in - isHoveringTimeline = hovering - isHoveringBottomNav = hovering - if hovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - - Text("•") - .foregroundColor(.gray) - - // Settings button - Button(action: { - currentView = .settings - }) { - Image(systemName: "gear") - .foregroundColor(isHoveringSettings ? textHoverColor : textColor) - } - .buttonStyle(.plain) - .onHover { hovering in - isHoveringSettings = 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 } - - // Right sidebar - if showingSidebar { - Divider() - - VStack(spacing: 0) { - // Header - Button(action: { - NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: getDocumentsDirectory().path) - }) { - HStack { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 4) { - Text("History") - .font(.system(size: 13)) - .foregroundColor(isHoveringHistory ? textHoverColor : textColor) - Image(systemName: "arrow.up.right") - .font(.system(size: 10)) - .foregroundColor(isHoveringHistory ? textHoverColor : textColor) - } - Text(getDocumentsDirectory().path) - .font(.system(size: 10)) - .foregroundColor(.secondary) - .lineLimit(1) - } - Spacer() - } - } - .buttonStyle(.plain) - .padding(.horizontal, 16) - .padding(.vertical, 12) - .onHover { hovering in - isHoveringHistory = hovering - } - - Divider() - - // Entries List - ScrollView { - LazyVStack(spacing: 0) { - ForEach(entries) { entry in - Button(action: { - if selectedEntryId != entry.id { - // Save current entry before switching - if let currentId = selectedEntryId, - let currentEntry = entries.first(where: { $0.id == currentId }) { - saveEntry(entry: currentEntry) - } - - selectedEntryId = entry.id - loadEntry(entry: entry) - } - }) { - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(entry.previewText) - .font(.system(size: 13)) - .lineLimit(1) - .foregroundColor(.primary) - - Spacer() - - // Export/Trash icons that appear on hover - if hoveredEntryId == entry.id { - HStack(spacing: 8) { - // Export PDF button - Button(action: { - exportEntryAsPDF(entry: entry) - }) { - Image(systemName: "arrow.down.circle") - .font(.system(size: 11)) - .foregroundColor(hoveredExportId == entry.id ? - (colorScheme == .light ? .black : .white) : - (colorScheme == .light ? .gray : .gray.opacity(0.8))) - } - .buttonStyle(.plain) - .help("Export entry as PDF") - .onHover { hovering in - withAnimation(.easeInOut(duration: 0.2)) { - hoveredExportId = hovering ? entry.id : nil - } - if hovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - - // Trash icon - Button(action: { - deleteEntry(entry: entry) - }) { - Image(systemName: "trash") - .font(.system(size: 11)) - .foregroundColor(hoveredTrashId == entry.id ? .red : .gray) - } - .buttonStyle(.plain) - .onHover { hovering in - withAnimation(.easeInOut(duration: 0.2)) { - hoveredTrashId = hovering ? entry.id : nil - } - if hovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - } - } - } - - Text(entry.date) - .font(.system(size: 12)) - .foregroundColor(.secondary) - } - } - .frame(maxWidth: .infinity) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 4) - .fill(backgroundColor(for: entry)) - ) - } - .buttonStyle(PlainButtonStyle()) - .contentShape(Rectangle()) - .onHover { hovering in - withAnimation(.easeInOut(duration: 0.2)) { - hoveredEntryId = hovering ? entry.id : nil - } - } - .onAppear { - NSCursor.pop() // Reset cursor when button appears - } - .help("Click to select this entry") // Add tooltip - - if entry.id != entries.last?.id { - Divider() - } - } - } - } - .scrollIndicators(.never) + + 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 } - .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) + + await MainActor.run { + isGeneratingPrediction = false } } - .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 + } +} + +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(GlassControlStyle()) } - } - } - .onReceive(NotificationCenter.default.publisher(for: NSWindow.willEnterFullScreenNotification)) { _ in - isFullscreen = true - } - .onReceive(NotificationCenter.default.publisher(for: NSWindow.willExitFullScreenNotification)) { _ in - isFullscreen = false - } - } - - private var timelinePageView: some View { - VStack(spacing: 0) { - // Navigation header - HStack { - Button(action: { - currentView = .writing - }) { - HStack(spacing: 8) { - Image(systemName: "chevron.left") - Text("Back to Writing") + + 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) } - .foregroundColor(.primary) - } - .buttonStyle(.plain) - - Spacer() - - Text("Timeline") - .font(.title2) - .fontWeight(.medium) - - Spacer() - - // Empty space for visual balance - HStack(spacing: 8) { - Text("Back to Writing") - .opacity(0) - Image(systemName: "chevron.left") - .opacity(0) - } - } - .padding() - .background(Color(NSColor.windowBackgroundColor)) - - // Timeline content - ScrollView { - VStack(spacing: 24) { - // Generate Predictions Button - HStack { - Button(action: { - generateTimelinePredictions() - }) { - HStack { - if isGeneratingPrediction { - ProgressView() - .scaleEffect(0.8) - .progressViewStyle(CircularProgressViewStyle()) - } else { - Image(systemName: "wand.and.stars") - } - Text(isGeneratingPrediction ? "Generating Predictions..." : "Generate Timeline Predictions") - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(claudeApiToken.isEmpty ? Color.gray.opacity(0.3) : Color.blue) - .foregroundColor(.white) - .cornerRadius(8) - } - .disabled(claudeApiToken.isEmpty || isGeneratingPrediction) - .buttonStyle(.plain) - - Spacer() - - if claudeApiToken.isEmpty { - Button(action: { - currentView = .settings - }) { - HStack { - Image(systemName: "gear") - Text("Add API Token") - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(Color.orange) - .foregroundColor(.white) - .cornerRadius(8) + } else { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(entries) { entry in + SidebarEntryRow( + entry: entry, + isSelected: entry.id == selectedEntryID, + onSelect: { onSelect(entry) }, + onReveal: { onReveal(entry) }, + onExport: { onExport(entry) }, + onDelete: { onDelete(entry) } + ) } - .buttonStyle(.plain) } } - .padding(.horizontal) - - // Error message - if let error = claudeService.error { - Text(error) - .foregroundColor(.red) - .padding() - .background(Color.red.opacity(0.1)) - .cornerRadius(8) - .padding(.horizontal) - } - - // Timeline Chart - TimelineChartView( - entries: entries, - prediction: timelinePrediction, - onEntrySelected: { entry in - selectedEntryId = entry.id - loadEntry(entry: entry) - currentView = .writing - } - ) - - // Original Timeline View (for entry selection) - VStack(alignment: .leading, spacing: 16) { - Text("Journal Entries") - .font(.headline) - .fontWeight(.semibold) - .padding(.horizontal) - - TimelineView( - entries: entries, - onEntrySelected: { entry in - selectedEntryId = entry.id - loadEntry(entry: entry) - currentView = .writing - }, - onClose: { - currentView = .writing - } - ) - } } } } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(NSColor.windowBackgroundColor)) - .preferredColorScheme(colorScheme) } - - private var settingsPageView: some View { - VStack(spacing: 0) { - // Navigation header - HStack { - Button(action: { - currentView = .writing - }) { - HStack(spacing: 8) { - Image(systemName: "chevron.left") - Text("Back to Writing") - } - .foregroundColor(.primary) - } - .buttonStyle(.plain) - - Spacer() - - Text("Settings") - .font(.title2) - .fontWeight(.medium) - +} + +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() - - // Empty space for visual balance - HStack(spacing: 8) { - Text("Back to Writing") - .opacity(0) - Image(systemName: "chevron.left") - .opacity(0) + 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) } - .padding() - .background(Color(NSColor.windowBackgroundColor)) - - // Settings content - ScrollView { - VStack(alignment: .leading, spacing: 24) { - // Claude AI API Token Section - VStack(alignment: .leading, spacing: 16) { - Text("Claude AI Integration") - .font(.headline) - .fontWeight(.semibold) - - VStack(alignment: .leading, spacing: 8) { - Text("API Token") - .font(.subheadline) - .fontWeight(.medium) - - Text("Enter your Claude AI API token to enable timeline predictions and advanced analysis.") - .font(.caption) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - - HStack { - Group { - if showApiToken { - TextField("Enter Claude AI API Token", text: $claudeApiToken) - } else { - SecureField("Enter Claude AI API Token", text: $claudeApiToken) - } - } - .textFieldStyle(.roundedBorder) - - Button(action: { - showApiToken.toggle() - }) { - Image(systemName: showApiToken ? "eye.slash" : "eye") - .foregroundColor(.gray) - } - .buttonStyle(.plain) - .help(showApiToken ? "Hide API token" : "Show API token") - } - .frame(maxWidth: 400) - - HStack { - Button(action: { - Task { - await claudeService.testConnection(apiToken: claudeApiToken) - } - }) { - HStack { - if claudeService.isTestingConnection { - ProgressView() - .scaleEffect(0.8) - .progressViewStyle(CircularProgressViewStyle()) - } else { - Image(systemName: "antenna.radiowaves.left.and.right") - } - Text(claudeService.isTestingConnection ? "Testing..." : "Test Connection") - } - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(claudeApiToken.isEmpty ? Color.gray.opacity(0.3) : Color.blue) - .foregroundColor(.white) - .cornerRadius(6) - } - .disabled(claudeApiToken.isEmpty || claudeService.isTestingConnection) - .buttonStyle(.plain) - - Spacer() - } - - if let result = claudeService.connectionTestResult { - Text(result) - .font(.caption) - .foregroundColor(result.contains("✅") ? .green : .red) - .padding(.top, 4) - } - } - - VStack(alignment: .leading, spacing: 8) { - Text("How to get your API token:") - .font(.caption) - .fontWeight(.medium) - - Text("1. Go to console.anthropic.com\n2. Create an account or sign in\n3. Navigate to API Keys\n4. Create a new API key\n5. Copy and paste it above") - .font(.caption) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - VStack(alignment: .leading, spacing: 8) { - Text("Troubleshooting:") - .font(.caption) - .fontWeight(.medium) - - Text("• Ensure you have an active internet connection\n• Check that your firewall isn't blocking api.anthropic.com\n• Verify your API token is correct and has sufficient credits\n• Try the 'Test Connection' button above") - .font(.caption) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - - Divider() - - // Timeline Prediction Settings - VStack(alignment: .leading, spacing: 16) { - Text("Timeline Predictions") - .font(.headline) - .fontWeight(.semibold) - - VStack(alignment: .leading, spacing: 8) { - Text("Prediction Range") - .font(.subheadline) - .fontWeight(.medium) - - Text("How many months ahead would you like to predict?") - .font(.caption) - .foregroundColor(.secondary) - - Picker("Prediction Range", selection: $predictionMonths) { - Text("3 months").tag(3) - Text("6 months").tag(6) - Text("12 months").tag(12) - } - .pickerStyle(.segmented) - .frame(maxWidth: 300) - } - } - - Spacer() + + Button(action: onSelect) { + VStack(alignment: .leading, spacing: 4) { + Text(entry.previewText.isEmpty ? "Untitled entry" : entry.previewText) + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + .lineLimit(3) } - .padding() + .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) + ) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(NSColor.windowBackgroundColor)) - .preferredColorScheme(colorScheme) } - - private func generateTimelinePredictions() { - guard !claudeApiToken.isEmpty else { return } - - isGeneratingPrediction = true - - Task { - do { - let prediction = try await claudeService.generateTimelinePrediction( - entries: entries, - apiToken: claudeApiToken, - monthsAhead: predictionMonths - ) - - await MainActor.run { - timelinePrediction = prediction - isGeneratingPrediction = 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) } - } catch { - await MainActor.run { - isGeneratingPrediction = false - print("Error generating predictions: \(error)") + } + + 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)") } + .animation(.easeInOut(duration: 0.3), value: isDrawerVisible) } - - private func saveEntry(entry: HumanEntry) { - let documentsDirectory = getDocumentsDirectory() - let fileURL = documentsDirectory.appendingPathComponent(entry.filename) - - do { - try text.write(to: fileURL, atomically: true, encoding: .utf8) - print("Successfully saved entry: \(entry.filename)") - updatePreviewText(for: entry) // Update preview after saving - } catch { - print("Error saving entry: \(error)") + + private 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) + } + + 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 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 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) } - } catch { - print("Error loading entry: \(error)") } } - - private func createNewEntry() { - let newEntry = HumanEntry.createNew() - entries.insert(newEntry, at: 0) // Add to the beginning - selectedEntryId = newEntry.id - - // If this is the first entry (entries was empty before adding this one) - if entries.count == 1 { - // Read welcome message from default.md - if let defaultMessageURL = Bundle.main.url(forResource: "default", withExtension: "md"), - let defaultMessage = try? String(contentsOf: defaultMessageURL, encoding: .utf8) { - text = "\n\n" + defaultMessage + + 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 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) } - // 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) - } + .sorted { $0.entry.rawDate > $1.entry.rawDate } } - - 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 var hasChartData: Bool { + if !actualData.isEmpty { return true } + guard let prediction else { return false } + return !prediction.bestTimeline.isEmpty || !prediction.darkestTimeline.isEmpty } - - 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 closeDrawer() { + withAnimation(.easeInOut(duration: 0.25)) { + isDrawerVisible = false + selectedPoint = nil } } +} - private func copyPromptToClipboard() { - let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) - let fullText = aiChatPrompt + "\n\n" + trimmedText +struct TimelineDetailDrawer: View { + let point: TimelinePoint + let entries: [TimelineEntryDetail] + let onSelectEntry: (HumanEntry) -> Void + let onClose: () -> Void - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(fullText, forType: .string) - print("Prompt copied to clipboard") - } - - 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) + 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() } + +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" + } + } +}