diff --git a/SessionFlow.xcodeproj/project.pbxproj b/SessionFlow.xcodeproj/project.pbxproj index c18b5fc..2b2144a 100644 --- a/SessionFlow.xcodeproj/project.pbxproj +++ b/SessionFlow.xcodeproj/project.pbxproj @@ -72,6 +72,8 @@ E5E5F6A7B8C9D0E1F2A3B4C5 /* SharedAwarenessComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5A6B7C8D9E0F1A2B3C4D5E6 /* SharedAwarenessComponents.swift */; }; A1B1C1D1E1F10001A2B2C2D1 /* ShortcutService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C1D1E1F10002A2B2C2D1 /* ShortcutService.swift */; }; A1B1C1D1E1F10003A2B2C2D1 /* SessionFlowIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C1D1E1F10004A2B2C2D1 /* SessionFlowIntents.swift */; }; + EC01A2B3C4D5E6F7A8B9C0D1 /* RecentEventsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC01A2B3C4D5E6F7A8B9C0D2 /* RecentEventsStore.swift */; }; + EC02A2B3C4D5E6F7A8B9C0D1 /* EventCreationPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC02A2B3C4D5E6F7A8B9C0D2 /* EventCreationPopover.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -142,6 +144,8 @@ A1B1C1D1E1F10002A2B2C2D1 /* ShortcutService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutService.swift; sourceTree = ""; }; A1B1C1D1E1F10004A2B2C2D1 /* SessionFlowIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionFlowIntents.swift; sourceTree = ""; }; FF01CHANGELOG001 /* CHANGELOG.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; name = CHANGELOG.md; path = CHANGELOG.md; sourceTree = SOURCE_ROOT; }; + EC01A2B3C4D5E6F7A8B9C0D2 /* RecentEventsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentEventsStore.swift; sourceTree = ""; }; + EC02A2B3C4D5E6F7A8B9C0D2 /* EventCreationPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventCreationPopover.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -237,6 +241,7 @@ E3F4A5B6C7D8E9F0A1B2C3D4 /* MiniPlayerWindowController.swift */, E4F5A6B7C8D9E0F1A2B3C4D5 /* MiniPlayerView.swift */, F5A6B7C8D9E0F1A2B3C4D5E6 /* SharedAwarenessComponents.swift */, + EC02A2B3C4D5E6F7A8B9C0D2 /* EventCreationPopover.swift */, ); path = Views; sourceTree = ""; @@ -256,6 +261,7 @@ E1F2A3B4C5D6E7F8A9B0C1D2 /* DockProgressController.swift */, E2F3A4B5C6D7E8F9A0B1C2D3 /* MenuBarController.swift */, A1B1C1D1E1F10002A2B2C2D1 /* ShortcutService.swift */, + EC01A2B3C4D5E6F7A8B9C0D2 /* RecentEventsStore.swift */, ); path = Services; sourceTree = ""; @@ -425,6 +431,8 @@ E5E5F6A7B8C9D0E1F2A3B4C5 /* SharedAwarenessComponents.swift in Sources */, A1B1C1D1E1F10001A2B2C2D1 /* ShortcutService.swift in Sources */, A1B1C1D1E1F10003A2B2C2D1 /* SessionFlowIntents.swift in Sources */, + EC01A2B3C4D5E6F7A8B9C0D1 /* RecentEventsStore.swift in Sources */, + EC02A2B3C4D5E6F7A8B9C0D1 /* EventCreationPopover.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -563,7 +571,7 @@ CODE_SIGN_ENTITLEMENTS = SessionFlow/SessionFlow.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 734; + CURRENT_PROJECT_VERSION = 754; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ""; ENABLE_APP_SANDBOX = NO; @@ -600,7 +608,7 @@ CODE_SIGN_ENTITLEMENTS = SessionFlow/SessionFlow.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 734; + CURRENT_PROJECT_VERSION = 754; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ""; ENABLE_APP_SANDBOX = NO; diff --git a/SessionFlow/Services/CalendarService.swift b/SessionFlow/Services/CalendarService.swift index 925fc13..0242d65 100644 --- a/SessionFlow/Services/CalendarService.swift +++ b/SessionFlow/Services/CalendarService.swift @@ -477,6 +477,13 @@ class CalendarService: ObservableObject { } } + /// Returns the current duration in minutes for an event, or nil if the event no longer exists. + func eventDurationMinutes(identifier: String) -> Int? { + guard let event = eventStore.event(withIdentifier: identifier) else { return nil } + guard let start = event.startDate, let end = event.endDate else { return nil } + return Int(end.timeIntervalSince(start) / 60) + } + func createSessions(_ sessions: [ScheduledSession]) -> (success: Int, failed: Int, eventIds: [String]) { var successCount = 0 var failCount = 0 diff --git a/SessionFlow/Services/EventUndoManager.swift b/SessionFlow/Services/EventUndoManager.swift index cc7dfa4..0fdf665 100644 --- a/SessionFlow/Services/EventUndoManager.swift +++ b/SessionFlow/Services/EventUndoManager.swift @@ -29,6 +29,7 @@ class EventUndoManager: ObservableObject { case time(EventTimeChange) case delete(EventDeleteSnapshot) case schedule(ScheduleSnapshot) + case create(EventDeleteSnapshot) // undo = delete, redo = restore } struct EventTimeChange: Equatable { @@ -102,6 +103,15 @@ class EventUndoManager: ObservableObject { updateState() } + func recordCreate(_ snapshot: EventDeleteSnapshot) { + undoStack.append(.create(snapshot)) + if undoStack.count > maxStackSize { + undoStack.removeFirst() + } + redoStack.removeAll() + updateState() + } + func recordSchedule(_ snapshot: ScheduleSnapshot) { undoStack.append(.schedule(snapshot)) if undoStack.count > maxStackSize { @@ -121,6 +131,8 @@ class EventUndoManager: ObservableObject { break // Caller will push redo after restore (needs new eventId) case .schedule: redoStack.append(change) + case .create: + break // Caller will push redo after delete (needs new eventId after re-create) } updateState() switch change { @@ -149,6 +161,8 @@ class EventUndoManager: ObservableObject { return .delete(snap) case .schedule(let snap): return .schedule(snap) + case .create(let snap): + return .create(snap) } } @@ -167,6 +181,12 @@ class EventUndoManager: ObservableObject { updateState() } + /// Call after undoing a create (deleting event). Pushes redo entry so re-creation is possible. + func pushRedoForUndoneCreate(_ snapshot: EventDeleteSnapshot) { + redoStack.append(.create(snapshot)) + updateState() + } + /// After undoing a schedule, caller provides new event IDs from re-creating sessions. func pushRedoForScheduleUndo(_ snapshot: ScheduleSnapshot) { // Replace the redo entry with updated event IDs @@ -191,6 +211,18 @@ class EventUndoManager: ObservableObject { return .delete(snap) case .schedule(let snap): return .schedule(snap) + case .create(let snap): + return .create(snap) + } + } + + /// After redoing a create, update the undo stack's top `.create` entry with the new event ID. + func updateTopUndoCreateId(_ snapshot: EventDeleteSnapshot) { + if let idx = undoStack.lastIndex(where: { + if case .create = $0 { return true } + return false + }) { + undoStack[idx] = .create(snapshot) } } diff --git a/SessionFlow/Services/RecentEventsStore.swift b/SessionFlow/Services/RecentEventsStore.swift new file mode 100644 index 0000000..e4ec2c8 --- /dev/null +++ b/SessionFlow/Services/RecentEventsStore.swift @@ -0,0 +1,124 @@ +import Foundation +import SwiftUI + +/// Persists recently created event templates for quick re-creation on the timeline. +class RecentEventsStore: ObservableObject { + private static let defaultsKey = "SessionFlow.RecentEventTemplates" + private static let maxEntries = 20 + + struct EventTemplate: Codable, Identifiable, Equatable { + let id: UUID + let title: String + let durationMinutes: Int // fallback if original event is gone + let calendarName: String + let calendarIdentifier: String? + let eventId: String? // reference to the original calendar event + let lastUsed: Date + + init(title: String, durationMinutes: Int, calendarName: String, calendarIdentifier: String?, eventId: String? = nil, lastUsed: Date = Date()) { + self.id = UUID() + self.title = title + self.durationMinutes = durationMinutes + self.calendarName = calendarName + self.calendarIdentifier = calendarIdentifier + self.eventId = eventId + self.lastUsed = lastUsed + } + } + + @Published private(set) var templates: [EventTemplate] = [] + + init() { + load() + } + + /// Records a created event. Updates existing template if title matches, otherwise adds new. + func record(title: String, durationMinutes: Int, calendarName: String, calendarIdentifier: String?, eventId: String? = nil) { + let trimmed = title.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return } + + // Remove existing entry with same title (case-insensitive) + templates.removeAll { $0.title.localizedCaseInsensitiveCompare(trimmed) == .orderedSame } + + let template = EventTemplate( + title: trimmed, + durationMinutes: durationMinutes, + calendarName: calendarName, + calendarIdentifier: calendarIdentifier, + eventId: eventId + ) + templates.insert(template, at: 0) + + // Trim to max + if templates.count > Self.maxEntries { + templates = Array(templates.prefix(Self.maxEntries)) + } + save() + } + + /// Returns templates matching a query, sorted by relevance (prefix match first, then contains). + func search(_ query: String) -> [EventTemplate] { + let q = query.trimmingCharacters(in: .whitespaces) + guard !q.isEmpty else { return templates } + + let lowered = q.lowercased() + + // Partition: prefix matches first, then substring matches + var prefixMatches: [EventTemplate] = [] + var containsMatches: [EventTemplate] = [] + + for t in templates { + let titleLower = t.title.lowercased() + if titleLower.hasPrefix(lowered) { + prefixMatches.append(t) + } else if titleLower.contains(lowered) { + containsMatches.append(t) + } + } + + // Also do fuzzy: match if all query chars appear in order + var fuzzyMatches: [EventTemplate] = [] + let queryChars = Array(lowered) + for t in templates { + if prefixMatches.contains(where: { $0.id == t.id }) || containsMatches.contains(where: { $0.id == t.id }) { + continue + } + let titleLower = t.title.lowercased() + var qi = 0 + for ch in titleLower { + if qi < queryChars.count && ch == queryChars[qi] { + qi += 1 + } + } + if qi == queryChars.count { + fuzzyMatches.append(t) + } + } + + return prefixMatches + containsMatches + fuzzyMatches + } + + /// Returns the live duration for a template by looking up its original event. + /// Falls back to the stored durationMinutes if the event no longer exists. + func resolveDuration(for template: EventTemplate, using calendarService: CalendarService) -> Int { + if let eventId = template.eventId, + let liveDuration = calendarService.eventDurationMinutes(identifier: eventId) { + return liveDuration + } + return template.durationMinutes + } + + private func load() { + guard let data = UserDefaults.standard.data(forKey: Self.defaultsKey), + let decoded = try? JSONDecoder().decode([EventTemplate].self, from: data) else { + return + } + templates = decoded + } + + private func save() { + if let data = try? JSONEncoder().encode(templates) { + UserDefaults.standard.set(data, forKey: Self.defaultsKey) + } + } +} diff --git a/SessionFlow/SessionFlowApp.swift b/SessionFlow/SessionFlowApp.swift index 81c3d58..26e458b 100644 --- a/SessionFlow/SessionFlowApp.swift +++ b/SessionFlow/SessionFlowApp.swift @@ -33,6 +33,7 @@ struct SessionFlowApp: App { @StateObject private var updateService = UpdateService() @StateObject private var sessionAwarenessService = SessionAwarenessService() @StateObject private var sessionAudioService = SessionAudioService() + @StateObject private var recentEventsStore = RecentEventsStore() @StateObject private var menuBarController = MenuBarController() @StateObject private var miniPlayerController = MiniPlayerWindowController() @State private var didInitializeServices = false @@ -47,6 +48,7 @@ struct SessionFlowApp: App { .environmentObject(sessionAwarenessService) .environmentObject(sessionAwarenessService.timeState) .environmentObject(sessionAudioService) + .environmentObject(recentEventsStore) .frame(minWidth: 1000, minHeight: 700) .focusEffectDisabled() .onAppear { diff --git a/SessionFlow/Views/EventCreationPopover.swift b/SessionFlow/Views/EventCreationPopover.swift new file mode 100644 index 0000000..d2d8179 --- /dev/null +++ b/SessionFlow/Views/EventCreationPopover.swift @@ -0,0 +1,470 @@ +import SwiftUI +import EventKit + +/// Inline popover for creating calendar events directly from the timeline. +/// Features Spotlight-like autocomplete from recently created events. +struct EventCreationPopover: View { + let startTime: Date + let defaultDurationMinutes: Int + let onCommit: (String, Date, Date, CalendarDescriptor) -> Void + let onCancel: () -> Void + + @EnvironmentObject var calendarService: CalendarService + @EnvironmentObject var schedulingEngine: SchedulingEngine + @EnvironmentObject var recentEventsStore: RecentEventsStore + + @State private var eventTitle: String = "" + @State private var durationMinutes: Int + @AppStorage("SessionFlow.EventCreationLastCalendar") private var selectedCalendarName: String = "" + @State private var selectedSuggestionIndex: Int = -1 + @FocusState private var titleFieldFocused: Bool + + init(startTime: Date, defaultDurationMinutes: Int = 30, + onCommit: @escaping (String, Date, Date, CalendarDescriptor) -> Void, + onCancel: @escaping () -> Void) { + self.startTime = startTime + self.defaultDurationMinutes = defaultDurationMinutes + self.onCommit = onCommit + self.onCancel = onCancel + _durationMinutes = State(initialValue: defaultDurationMinutes) + } + + /// Calendar info list sorted: non-session calendars first (alphabetically), then work/side/deep. + /// Only writable calendars are included. + private var sortedCalendars: [CalendarService.CalendarInfo] { + let all = calendarService.calendarInfoList(includeExcluded: true) + .filter { info in + calendarService.availableCalendars + .first { $0.calendarIdentifier == info.identifier }? + .allowsContentModifications == true + } + let sessionIds: Set = { + var ids = Set() + if let id = schedulingEngine.workCalendarIdentifier { ids.insert(id) } + if let id = schedulingEngine.sideCalendarIdentifier { ids.insert(id) } + if let id = schedulingEngine.deepSessionConfig.calendarIdentifier { ids.insert(id) } + return ids + }() + let regular = all.filter { !sessionIds.contains($0.identifier) } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + let session = all.filter { sessionIds.contains($0.identifier) } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + return regular + session + } + + private var selectedCalendarInfo: CalendarService.CalendarInfo? { + sortedCalendars.first { $0.name == selectedCalendarName } + } + + /// Unified suggestion: either a recent event template or a calendar name match. + private enum Suggestion: Identifiable { + case recentEvent(RecentEventsStore.EventTemplate) + case calendar(CalendarService.CalendarInfo) + + var id: String { + switch self { + case .recentEvent(let t): return "event-\(t.id)" + case .calendar(let c): return "cal-\(c.id)" + } + } + } + + private var suggestions: [Suggestion] { + let trimmed = eventTitle.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return [] } + + let eventMatches = recentEventsStore.search(trimmed).prefix(5).map { Suggestion.recentEvent($0) } + + let lowered = trimmed.lowercased() + let calMatches = sortedCalendars + .filter { !$0.isExcluded && $0.name.lowercased().contains(lowered) } + .prefix(3) + .map { Suggestion.calendar($0) } + + return Array(eventMatches) + Array(calMatches) + } + + private var showSuggestions: Bool { + !eventTitle.isEmpty && !suggestions.isEmpty + } + + private var endTime: Date { + startTime.addingTimeInterval(Double(durationMinutes) * 60) + } + + private var timeFormatter: DateFormatter { + let f = DateFormatter() + f.dateStyle = .none + f.timeStyle = .short + return f + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + // Header + HStack { + Image(systemName: "plus.circle.fill") + .font(.system(size: 16)) + .foregroundColor(.blue) + Text("New Event") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.white) + Spacer() + Text(timeFormatter.string(from: startTime)) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white.opacity(0.5)) + } + + Divider().background(Color.white.opacity(0.1)) + + // Title field with autocomplete + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.4)) + TextField("Event name", text: $eventTitle) + .textFieldStyle(.plain) + .font(.system(size: 13)) + .foregroundColor(.white) + .focused($titleFieldFocused) + .onSubmit { commitEvent() } + .onChange(of: eventTitle) { _, newValue in + selectedSuggestionIndex = -1 + let trimmed = newValue.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return } + + // Auto-propose from best recent event match + let bestMatch = recentEventsStore.templates.first(where: { + $0.title.localizedCaseInsensitiveCompare(trimmed) == .orderedSame + }) ?? recentEventsStore.search(trimmed).first + + if let match = bestMatch { + durationMinutes = recentEventsStore.resolveDuration(for: match, using: calendarService) + if let calId = match.calendarIdentifier, + let info = sortedCalendars.first(where: { $0.identifier == calId }) { + selectedCalendarName = info.name + } + } + } + } + .padding(8) + .background(Color.white.opacity(0.08)) + .cornerRadius(6) + + // Suggestions dropdown + if showSuggestions { + VStack(spacing: 0) { + let visible = Array(suggestions.prefix(8)) + ForEach(Array(visible.enumerated()), id: \.element.id) { index, suggestion in + suggestionRow(suggestion, isSelected: index == selectedSuggestionIndex) + .onTapGesture { applySuggestion(suggestion) } + } + } + .background(Color(hex: "1A2332")) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.white.opacity(0.1), lineWidth: 1) + ) + .padding(.top, 2) + } + } + + // Calendar picker — reuses CalendarPickerCompact from SettingsPanel + CalendarPickerCompact( + selectedCalendar: $selectedCalendarName, + calendars: sortedCalendars, + accentColor: .blue, + onSelection: { info in + selectedCalendarName = info.name + } + ) + + // Duration control + VStack(spacing: 6) { + HStack { + Text("Duration") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white.opacity(0.6)) + Spacer() + NumericInputField(value: $durationMinutes, range: 5...480, step: 5, unit: "min") + } + HStack(spacing: 6) { + Spacer() + durationChip(30) + durationChip(45) + durationChip(60) + durationChip(90) + } + } + + // Time summary + HStack { + Text("\(timeFormatter.string(from: startTime)) – \(timeFormatter.string(from: endTime))") + .font(.system(size: 11)) + .foregroundColor(.white.opacity(0.5)) + Spacer() + } + + Divider().background(Color.white.opacity(0.1)) + + // Action buttons + HStack { + Button("Cancel") { onCancel() } + .buttonStyle(.plain) + .foregroundColor(.white.opacity(0.5)) + .font(.system(size: 12)) + .hoverEffect(brightness: 0.3) + + Spacer() + + Button { + commitEvent() + } label: { + Text("Create") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal, 14) + .padding(.vertical, 5) + .background(eventTitle.trimmingCharacters(in: .whitespaces).isEmpty ? Color.blue.opacity(0.3) : Color.blue) + .cornerRadius(6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(eventTitle.trimmingCharacters(in: .whitespaces).isEmpty) + .hoverEffect(brightness: 0.2) + } + } + .padding(14) + .frame(width: 300) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color(hex: "1E293B")) + .shadow(color: .black.opacity(0.4), radius: 16, y: 6) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.white.opacity(0.08), lineWidth: 1) + ) + .onAppear { + // Use persisted calendar if still valid, otherwise fall back to first + let cals = sortedCalendars + if selectedCalendarName.isEmpty || !cals.contains(where: { $0.name == selectedCalendarName }) { + selectedCalendarName = cals.first?.name ?? "" + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + titleFieldFocused = true + } + } + .onKeyPress(.upArrow, phases: .down) { press in + if press.modifiers.contains(.command) { + cycleCalendar(delta: -1) + return .handled + } + navigateSuggestion(delta: -1) + return .handled + } + .onKeyPress(.downArrow, phases: .down) { press in + if press.modifiers.contains(.command) { + cycleCalendar(delta: 1) + return .handled + } + navigateSuggestion(delta: 1) + return .handled + } + .onKeyPress(.escape) { + if showSuggestions && selectedSuggestionIndex >= 0 { + selectedSuggestionIndex = -1 + return .handled + } + onCancel() + return .handled + } + .onKeyPress(.tab) { + // Tab applies the current suggestion + if showSuggestions, selectedSuggestionIndex >= 0, + selectedSuggestionIndex < suggestions.count { + applySuggestion(suggestions[selectedSuggestionIndex]) + return .handled + } + if showSuggestions, let first = suggestions.first { + applySuggestion(first) + return .handled + } + return .ignored + } + } + + private func durationChip(_ minutes: Int) -> some View { + let isSelected = durationMinutes == minutes + return Button { + durationMinutes = minutes + } label: { + Text("\(minutes)") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(isSelected ? .white : .white.opacity(0.5)) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(isSelected ? Color.blue.opacity(0.5) : Color.white.opacity(0.06)) + .cornerRadius(4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .hoverEffect(brightness: 0.2) + } + + @ViewBuilder + private func suggestionRow(_ suggestion: Suggestion, isSelected: Bool) -> some View { + switch suggestion { + case .recentEvent(let template): + let liveDuration = recentEventsStore.resolveDuration(for: template, using: calendarService) + HStack(spacing: 8) { + if let calId = template.calendarIdentifier, + let info = sortedCalendars.first(where: { $0.identifier == calId }) { + Circle() + .fill(info.color) + .frame(width: 6, height: 6) + } else { + Circle() + .fill(Color.gray.opacity(0.4)) + .frame(width: 6, height: 6) + } + highlightedTitle(template.title, query: eventTitle) + .lineLimit(1) + Spacer() + Text("\(liveDuration)m") + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundColor(.white.opacity(0.4)) + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(isSelected ? Color.blue.opacity(0.25) : Color.clear) + .contentShape(Rectangle()) + + case .calendar(let info): + HStack(spacing: 8) { + Image(systemName: "calendar") + .font(.system(size: 10)) + .foregroundColor(info.color) + .frame(width: 6) + Circle() + .fill(info.color) + .frame(width: 6, height: 6) + highlightedTitle(info.name, query: eventTitle) + .lineLimit(1) + Spacer() + Text("calendar") + .font(.system(size: 9, weight: .medium)) + .foregroundColor(.white.opacity(0.3)) + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(isSelected ? Color.blue.opacity(0.25) : Color.clear) + .contentShape(Rectangle()) + } + } + + /// Renders the title with matching characters bolded, Spotlight-style. + private func highlightedTitle(_ title: String, query: String) -> some View { + let lowTitle = title.lowercased() + let lowQuery = query.lowercased() + + // Find range of the query in the title + if let range = lowTitle.range(of: lowQuery) { + let before = String(title[title.startIndex..= count { + selectedSuggestionIndex = count - 1 + } else { + selectedSuggestionIndex = newIndex + } + // Preview the highlighted suggestion's calendar and duration + if selectedSuggestionIndex >= 0, selectedSuggestionIndex < count { + previewSuggestion(suggestions[selectedSuggestionIndex]) + } + } + + /// Updates calendar/duration to preview a suggestion without applying the title. + private func previewSuggestion(_ suggestion: Suggestion) { + switch suggestion { + case .recentEvent(let template): + durationMinutes = recentEventsStore.resolveDuration(for: template, using: calendarService) + if let calId = template.calendarIdentifier, + let info = sortedCalendars.first(where: { $0.identifier == calId }) { + selectedCalendarName = info.name + } + case .calendar(let info): + selectedCalendarName = info.name + } + } + + /// Cycle through calendars with Cmd+Up/Down + private func cycleCalendar(delta: Int) { + let cals = sortedCalendars + guard !cals.isEmpty else { return } + let currentIndex = cals.firstIndex(where: { $0.name == selectedCalendarName }) ?? -1 + var newIndex = currentIndex + delta + if newIndex < 0 { newIndex = cals.count - 1 } + if newIndex >= cals.count { newIndex = 0 } + // Skip excluded calendars + let startIndex = newIndex + while cals[newIndex].isExcluded { + newIndex += delta > 0 ? 1 : -1 + if newIndex < 0 { newIndex = cals.count - 1 } + if newIndex >= cals.count { newIndex = 0 } + if newIndex == startIndex { break } + } + selectedCalendarName = cals[newIndex].name + } + + private func applySuggestion(_ suggestion: Suggestion) { + switch suggestion { + case .recentEvent(let template): + eventTitle = template.title + durationMinutes = recentEventsStore.resolveDuration(for: template, using: calendarService) + if let calId = template.calendarIdentifier, + let info = sortedCalendars.first(where: { $0.identifier == calId }) { + selectedCalendarName = info.name + } + case .calendar(let info): + selectedCalendarName = info.name + eventTitle = "" + } + selectedSuggestionIndex = -1 + } + + private func commitEvent() { + // If a suggestion is selected via keyboard, apply it first + if showSuggestions, selectedSuggestionIndex >= 0, + selectedSuggestionIndex < suggestions.count { + applySuggestion(suggestions[selectedSuggestionIndex]) + return + } + + let title = eventTitle.trimmingCharacters(in: .whitespaces) + guard !title.isEmpty, let info = selectedCalendarInfo else { return } + let end = startTime.addingTimeInterval(Double(durationMinutes) * 60) + let descriptor = CalendarDescriptor(name: info.name, identifier: info.identifier) + onCommit(title, startTime, end, descriptor) + } +} diff --git a/SessionFlow/Views/TimelineView.swift b/SessionFlow/Views/TimelineView.swift index 61548d2..35cd5ac 100644 --- a/SessionFlow/Views/TimelineView.swift +++ b/SessionFlow/Views/TimelineView.swift @@ -41,6 +41,7 @@ struct TimelineView: View { @EnvironmentObject var calendarService: CalendarService @EnvironmentObject var schedulingEngine: SchedulingEngine @EnvironmentObject var sessionAwarenessService: SessionAwarenessService + @EnvironmentObject var recentEventsStore: RecentEventsStore private let hourHeight: CGFloat = 90 // Zoomed in from 60 private let timeColumnWidth: CGFloat = 55 @@ -102,6 +103,10 @@ struct TimelineView: View { @State private var renamingSessionId: UUID? = nil @State private var renameText: String = "" + // Event creation popover + @State private var showingEventCreation: Bool = false + @State private var eventCreationTime: Date? = nil + // Feedback badge @State private var feedbackPopoverEventId: String? = nil @@ -155,6 +160,11 @@ struct TimelineView: View { if showingDetailSheet { detailSheetOverlay } + + // Event creation overlay (outside scroll view so it doesn't clip) + if showingEventCreation, let creationTime = eventCreationTime { + eventCreationFloatingOverlay(startTime: creationTime, geoSize: geo.size) + } } .onAppear { containerWidth = geo.size.width @@ -164,12 +174,19 @@ struct TimelineView: View { } // Use keyDown monitor for Esc (cancel drag) and undo/redo. keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in - // Esc cancels active drag/resize or closes detail sheet + // Esc cancels active drag/resize, closes detail sheet, or closes event creation if event.keyCode == 53 { if dragMode != .none { cancelDrag() return nil } + if showingEventCreation { + withAnimation(.easeOut(duration: 0.15)) { + showingEventCreation = false + eventCreationTime = nil + } + return nil + } if showingDetailSheet { withAnimation(.easeOut(duration: 0.2)) { selectedSession = nil @@ -435,11 +452,14 @@ struct TimelineView: View { currentTimeIndicator(currentTime: effectiveNowTimeForIndicator, width: geometry.size.width) } + // Background tap target for event creation (left half only) + eventCreationBackgroundLayer(containerWidth: geometry.size.width) + // Existing events - left half ForEach(busySlotsWithLayout) { positionedSlot in eventBlock(for: positionedSlot, containerWidth: geometry.size.width) } - + // Projected sessions - right half ForEach(filteredProjectedSessions) { session in projectedSessionBlock(for: session, containerWidth: geometry.size.width) @@ -1845,6 +1865,117 @@ extension TimelineView { } } + // MARK: - Event Creation from Timeline + + /// Transparent left-half layer that captures double-click on empty space. + private func eventCreationBackgroundLayer(containerWidth: CGFloat) -> some View { + let leftHalfWidth = max((containerWidth / 2), 10) + return Color.clear + .frame(width: leftHalfWidth, height: CGFloat(visibleHours.count) * hourHeight + 40) + .contentShape(Rectangle()) + .position(x: leftHalfWidth / 2, y: (CGFloat(visibleHours.count) * hourHeight + 40) / 2) + .onTapGesture(count: 2) { location in + let clickedTime = snapToInterval(dateFromYOffset(location.y)) + beginEventCreation(at: clickedTime) + } + } + + /// Floating overlay for event creation, rendered outside the scroll view so it never clips. + private func eventCreationFloatingOverlay(startTime: Date, geoSize: CGSize) -> some View { + ZStack { + // Dimmed background — click to dismiss + Color.black.opacity(0.3) + .ignoresSafeArea() + .onTapGesture { + withAnimation(.easeOut(duration: 0.15)) { + showingEventCreation = false + eventCreationTime = nil + } + } + + VStack { + Spacer() + .frame(height: geoSize.height * 0.22) + EventCreationPopover( + startTime: startTime, + defaultDurationMinutes: 30, + onCommit: { title, start, end, calendar in + createEventFromTimeline(title: title, startDate: start, endDate: end, calendar: calendar) + }, + onCancel: { + withAnimation(.easeOut(duration: 0.15)) { + showingEventCreation = false + eventCreationTime = nil + } + } + ) + Spacer() + } + .frame(maxWidth: .infinity) + } + .transition(.opacity) + .animation(.easeOut(duration: 0.15), value: showingEventCreation) + } + + private func beginEventCreation(at time: Date) { + guard !eventsLocked else { + onModeToast?("Events locked") + return + } + // Close any open detail sheet + selectedSession = nil + selectedBusySlot = nil + resetEditingState() + + withAnimation(.easeOut(duration: 0.15)) { + eventCreationTime = time + showingEventCreation = true + } + } + + private func createEventFromTimeline(title: String, startDate: Date, endDate: Date, calendar: CalendarDescriptor) { + guard let eventId = calendarService.createEventReturningId( + title: title, + startDate: startDate, + endDate: endDate, + calendar: calendar + ) else { + onModeToast?("Failed to create event") + return + } + + // Record in undo stack + let snapshot = EventDeleteSnapshot( + eventId: eventId, + title: title, + notes: nil, + url: nil, + startDate: startDate, + endDate: endDate, + calendarIdentifier: calendar.identifier, + calendarName: calendar.name + ) + eventUndoManager.recordCreate(snapshot) + + // Record in recents + let durationMinutes = Int(endDate.timeIntervalSince(startDate) / 60) + recentEventsStore.record( + title: title, + durationMinutes: durationMinutes, + calendarName: calendar.name, + calendarIdentifier: calendar.identifier, + eventId: eventId + ) + + // Close popover and refresh + withAnimation(.easeOut(duration: 0.15)) { + showingEventCreation = false + eventCreationTime = nil + } + onModeToast?("Created \"\(title)\"") + Task { await calendarService.fetchEvents(for: selectedDate) } + } + // MARK: - Position Calculations private func calculateYPosition(for date: Date) -> CGFloat { @@ -2545,6 +2676,12 @@ extension TimelineView { schedulingEngine.projectedSessions.append(contentsOf: snap.sessions) schedulingEngine.projectedSessions.sort { $0.startTime < $1.startTime } Task { await calendarService.fetchEvents(for: selectedDate) } + case .create(let snap): + // Undo create = delete the event + if calendarService.deleteEvent(identifier: snap.eventId) { + eventUndoManager.pushRedoForUndoneCreate(snap) + Task { await calendarService.fetchEvents(for: selectedDate) } + } } } @@ -2590,6 +2727,24 @@ extension TimelineView { )) Task { await calendarService.fetchEvents(for: selectedDate) } } + case .create(let snap): + // Redo create = restore the event + if let newId = calendarService.restoreEvent(snap) { + // Update the undo stack entry with the new event ID + let updatedSnap = EventDeleteSnapshot( + eventId: newId, + title: snap.title, + notes: snap.notes, + url: snap.url, + startDate: snap.startDate, + endDate: snap.endDate, + calendarIdentifier: snap.calendarIdentifier, + calendarName: snap.calendarName + ) + // Replace the top of the undo stack with updated ID + eventUndoManager.updateTopUndoCreateId(updatedSnap) + Task { await calendarService.fetchEvents(for: selectedDate) } + } } }