Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions Sources/VoidBar/Model/NotchViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"
}
}

Expand All @@ -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")
}
}

Expand All @@ -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 {
Expand Down Expand Up @@ -99,6 +101,7 @@ final class NotchViewModel: ObservableObject {
let teleprompter: TeleprompterStore
let monitor: SystemMonitorStore
let weather: WeatherStore
let tickTick: TickTickStore

private var cancellables = Set<AnyCancellable>()

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
108 changes: 108 additions & 0 deletions Sources/VoidBar/Services/TickTickStore.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
8 changes: 8 additions & 0 deletions Sources/VoidBar/UI/NotchContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}

Expand Down Expand Up @@ -183,6 +189,8 @@ struct NotchContentView: View {
MonitorPane(monitor: vm.monitor)
case .weather:
WeatherPane(weatherStore: vm.weather)
case .tasks:
TasksPane(store: vm.tickTick)
}
}
}
Expand Down
99 changes: 99 additions & 0 deletions Sources/VoidBar/UI/TasksPane.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading