diff --git a/Sources/VoidBar/Model/NotchViewModel.swift b/Sources/VoidBar/Model/NotchViewModel.swift index ec3b916..332514d 100644 --- a/Sources/VoidBar/Model/NotchViewModel.swift +++ b/Sources/VoidBar/Model/NotchViewModel.swift @@ -4,7 +4,7 @@ import Combine @MainActor final class NotchViewModel: ObservableObject { enum Tab: String, CaseIterable, Identifiable { - case media, shelf, clipboard, snippets, calendar, timer, translate, notes, teleprompter, monitor, weather + case media, shelf, clipboard, snippets, calendar, timer, translate, notes, teleprompter, monitor, weather, tasks var id: String { rawValue } var symbol: String { @@ -20,6 +20,7 @@ final class NotchViewModel: ObservableObject { case .teleprompter: return "text.line.first.and.arrowtriangle.forward" case .monitor: return "cpu" case .weather: return "cloud.sun" + case .tasks: return "checkmark.circle" } } @@ -36,6 +37,7 @@ final class NotchViewModel: ObservableObject { case .teleprompter: return localized("Teleprompter") case .monitor: return localized("Monitor") case .weather: return localized("Weather") + case .tasks: return localized("Tasks") } } @@ -48,7 +50,7 @@ final class NotchViewModel: ObservableObject { /// body has — so growth continues in a second column on the right, /// which the scratch notes open. static let leftRail: [Tab] = [.media, .shelf, .clipboard, .snippets, .calendar, .timer, .translate] - static let rightRail: [Tab] = [.notes, .teleprompter, .monitor, .weather] + static let rightRail: [Tab] = [.notes, .tasks, .teleprompter, .monitor, .weather] } @Published var isOpen = false { @@ -99,6 +101,7 @@ final class NotchViewModel: ObservableObject { let teleprompter: TeleprompterStore let monitor: SystemMonitorStore let weather: WeatherStore + let tickTick: TickTickStore private var cancellables = Set() @@ -115,6 +118,7 @@ final class NotchViewModel: ObservableObject { self.teleprompter = TeleprompterStore() self.monitor = SystemMonitorStore() self.weather = WeatherStore() + self.tickTick = TickTickStore() // The panel header reads through to the stores — counters, the source // name, the equalizer. Nested ObservableObjects do not propagate on @@ -154,7 +158,8 @@ final class NotchViewModel: ObservableObject { shelf.objectWillChange, clipboard.objectWillChange, calendar.objectWillChange, - monitor.objectWillChange + monitor.objectWillChange, + tickTick.objectWillChange ] { child .sink { [weak self] _ in diff --git a/Sources/VoidBar/Services/TickTickStore.swift b/Sources/VoidBar/Services/TickTickStore.swift new file mode 100644 index 0000000..9c9afd2 --- /dev/null +++ b/Sources/VoidBar/Services/TickTickStore.swift @@ -0,0 +1,108 @@ +import Foundation +import Combine +import SwiftUI + +struct TickTickTask: Identifiable, Equatable { + let id: String + let title: String + let isCompleted: Bool + let dueDate: Date? + let priority: Int // 0, 1, 3, 5 +} + +@MainActor +final class TickTickStore: ObservableObject { + @Published var tasks: [TickTickTask] = [] + @Published var isLoading: Bool = false + @Published var errorMessage: String? = nil + + // The user's iCal subscription URL from TickTick + @AppStorage("tickTickCalendarURL") var calendarURLString: String = "" + + private var timer: Timer? + + init() { + // Initial fetch + if !calendarURLString.isEmpty { + Task { await fetchTasks() } + } + + // Refresh every 15 minutes + timer = Timer.scheduledTimer(withTimeInterval: 15 * 60, repeats: true) { [weak self] _ in + Task { @MainActor in + await self?.fetchTasks() + } + } + } + + deinit { + timer?.invalidate() + } + + func fetchTasks() async { + guard let url = URL(string: calendarURLString), !calendarURLString.isEmpty else { + errorMessage = "Please set your TickTick calendar URL in Settings." + return + } + + isLoading = true + errorMessage = nil + + do { + let (data, _) = try await URLSession.shared.data(from: url) + guard let icalString = String(data: data, encoding: .utf8) else { + throw URLError(.cannotDecodeRawData) + } + + let parsedTasks = parseICal(icalString) + self.tasks = parsedTasks.sorted { ($0.dueDate ?? Date.distantFuture) < ($1.dueDate ?? Date.distantFuture) } + } catch { + self.errorMessage = "Failed to load tasks: \(error.localizedDescription)" + } + + isLoading = false + } + + private func parseICal(_ ical: String) -> [TickTickTask] { + var tasks: [TickTickTask] = [] + let lines = ical.components(separatedBy: .newlines) + + var currentId = "" + var currentTitle = "" + var isCompleted = false + var priority = 0 + var inEvent = false + var dueDate: Date? = nil + + // Very basic iCal parser + for rawLine in lines { + let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + + if line == "BEGIN:VTODO" || line == "BEGIN:VEVENT" { + inEvent = true + currentId = UUID().uuidString // fallback + currentTitle = "Untitled Task" + isCompleted = false + priority = 0 + dueDate = nil + } else if line == "END:VTODO" || line == "END:VEVENT" { + if inEvent { + tasks.append(TickTickTask(id: currentId, title: currentTitle, isCompleted: isCompleted, dueDate: dueDate, priority: priority)) + } + inEvent = false + } else if inEvent { + if line.hasPrefix("UID:") { + currentId = String(line.dropFirst(4)) + } else if line.hasPrefix("SUMMARY:") { + currentTitle = String(line.dropFirst(8)) + } else if line.hasPrefix("STATUS:COMPLETED") { + isCompleted = true + } else if line.hasPrefix("PRIORITY:") { + priority = Int(line.dropFirst(9)) ?? 0 + } + } + } + + return tasks + } +} diff --git a/Sources/VoidBar/UI/NotchContentView.swift b/Sources/VoidBar/UI/NotchContentView.swift index a0fba36..fcbf70e 100644 --- a/Sources/VoidBar/UI/NotchContentView.swift +++ b/Sources/VoidBar/UI/NotchContentView.swift @@ -114,6 +114,12 @@ struct NotchContentView: View { EmptyView() case .weather: EmptyView() + case .tasks: + if !vm.tickTick.tasks.isEmpty { + counter(vm.tickTick.tasks.filter { !$0.isCompleted }.count) + } else { + EmptyView() + } } } @@ -183,6 +189,8 @@ struct NotchContentView: View { MonitorPane(monitor: vm.monitor) case .weather: WeatherPane(weatherStore: vm.weather) + case .tasks: + TasksPane(store: vm.tickTick) } } } diff --git a/Sources/VoidBar/UI/TasksPane.swift b/Sources/VoidBar/UI/TasksPane.swift new file mode 100644 index 0000000..0965cdf --- /dev/null +++ b/Sources/VoidBar/UI/TasksPane.swift @@ -0,0 +1,99 @@ +import SwiftUI + +struct TasksPane: View { + @ObservedObject var store: TickTickStore + + var body: some View { + VStack(spacing: 0) { + header + + if store.isLoading && store.tasks.isEmpty { + Spacer() + ProgressView() + Spacer() + } else if let error = store.errorMessage { + Spacer() + Text(error) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(Theme.tertiary) + .multilineTextAlignment(.center) + .padding() + Spacer() + } else if store.tasks.isEmpty { + Spacer() + VStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 24)) + .foregroundColor(Theme.tertiary) + Text("All done for today!") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(Theme.tertiary) + } + Spacer() + } else { + ScrollView { + VStack(alignment: .leading, spacing: 10) { + ForEach(store.tasks) { task in + taskRow(task) + } + } + .padding(14) + } + } + } + } + + private var header: some View { + HStack { + Text("TickTick") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(Theme.secondary) + Spacer() + Button { + Task { + await store.fetchTasks() + } + } label: { + Image(systemName: "arrow.clockwise") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(Theme.tertiary) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 14) + .padding(.top, 14) + .padding(.bottom, 6) + } + + private func taskRow(_ task: TickTickTask) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle") + .font(.system(size: 14)) + .foregroundColor(task.isCompleted ? Theme.secondary : Theme.tertiary) + .padding(.top, 2) + + VStack(alignment: .leading, spacing: 4) { + Text(task.title) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(task.isCompleted ? Theme.tertiary : .white) + .strikethrough(task.isCompleted) + + if let date = task.dueDate { + Text(date, style: .date) + .font(.system(size: 11)) + .foregroundColor(priorityColor(task.priority)) + } + } + Spacer() + } + } + + private func priorityColor(_ priority: Int) -> Color { + switch priority { + case 5: return .red + case 3: return .orange + case 1: return .blue + default: return Theme.tertiary + } + } +}