diff --git a/IrisCompanion/iris/ContentView.swift b/IrisCompanion/iris/ContentView.swift index a89accf..21f29e5 100644 --- a/IrisCompanion/iris/ContentView.swift +++ b/IrisCompanion/iris/ContentView.swift @@ -16,6 +16,8 @@ struct ContentView: View { .tabItem { Label("BLE", systemImage: "dot.radiowaves.left.and.right") } OrchestratorView() .tabItem { Label("Orchestrator", systemImage: "bolt.horizontal.circle") } + TodosView() + .tabItem { Label("Todos", systemImage: "checklist") } } .onAppear { orchestrator.start() } } diff --git a/IrisCompanion/iris/Models/TodoItem.swift b/IrisCompanion/iris/Models/TodoItem.swift new file mode 100644 index 0000000..42cdd9a --- /dev/null +++ b/IrisCompanion/iris/Models/TodoItem.swift @@ -0,0 +1,31 @@ +// +// TodoItem.swift +// iris +// +// Created by Codex. +// + +import Foundation + +struct TodoItem: Identifiable, Codable, Equatable { + let id: UUID + var title: String + var isCompleted: Bool + var createdAt: Date + var updatedAt: Date + var completedAt: Date? + + init(id: UUID = UUID(), + title: String, + isCompleted: Bool = false, + createdAt: Date = Date(), + updatedAt: Date = Date(), + completedAt: Date? = nil) { + self.id = id + self.title = title + self.isCompleted = isCompleted + self.createdAt = createdAt + self.updatedAt = updatedAt + self.completedAt = completedAt + } +} diff --git a/IrisCompanion/iris/Models/TodoStore.swift b/IrisCompanion/iris/Models/TodoStore.swift new file mode 100644 index 0000000..83f4e91 --- /dev/null +++ b/IrisCompanion/iris/Models/TodoStore.swift @@ -0,0 +1,116 @@ +// +// TodoStore.swift +// iris +// +// Created by Codex. +// + +import Foundation + +final class TodoStore { + private struct Persisted: Codable { + var schema: Int? + var items: [TodoItem] + } + + private let queue = DispatchQueue(label: "iris.todostore.queue") + private let fileURL: URL + private var items: [TodoItem] + + init(filename: String = "todos_v1.json") { + self.fileURL = Self.defaultFileURL(filename: filename) + let persisted = Self.load(from: fileURL) + self.items = persisted?.items ?? [] + } + + func snapshot() -> [TodoItem] { + queue.sync { items } + } + + @discardableResult + func add(title: String, now: Date = Date()) -> TodoItem { + queue.sync { + let item = TodoItem(title: title, createdAt: now, updatedAt: now) + items.append(item) + save() + return item + } + } + + @discardableResult + func updateTitle(id: UUID, title: String, now: Date = Date()) -> TodoItem? { + queue.sync { + guard let index = items.firstIndex(where: { $0.id == id }) else { return nil } + var item = items[index] + item.title = title + item.updatedAt = now + items[index] = item + save() + return item + } + } + + @discardableResult + func setCompleted(id: UUID, completed: Bool, now: Date = Date()) -> TodoItem? { + queue.sync { + guard let index = items.firstIndex(where: { $0.id == id }) else { return nil } + var item = items[index] + item.isCompleted = completed + item.updatedAt = now + item.completedAt = completed ? now : nil + items[index] = item + save() + return item + } + } + + func delete(id: UUID) { + queue.sync { + items.removeAll { $0.id == id } + save() + } + } + + private func save() { + let persisted = Persisted(schema: 1, items: items) + Self.save(persisted, to: fileURL) + } + + private static func defaultFileURL(filename: String) -> URL { + let fm = FileManager.default + let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? fm.temporaryDirectory + let bundle = Bundle.main.bundleIdentifier ?? "iris" + return base + .appendingPathComponent(bundle, isDirectory: true) + .appendingPathComponent(filename, isDirectory: false) + } + + private static func load(from url: URL) -> Persisted? { + guard let data = try? Data(contentsOf: url) else { return nil } + return try? makeDecoder().decode(Persisted.self, from: data) + } + + private static func save(_ persisted: Persisted, to url: URL) { + do { + let fm = FileManager.default + try fm.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let data = try makeEncoder().encode(persisted) + try data.write(to: url, options: [.atomic]) + } catch { + // Best-effort persistence. + } + } + + private static func makeEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes] + encoder.dateEncodingStrategy = .iso8601 + return encoder + } + + private static func makeDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } +} diff --git a/IrisCompanion/iris/ViewModels/TodosViewModel.swift b/IrisCompanion/iris/ViewModels/TodosViewModel.swift new file mode 100644 index 0000000..0b25d0a --- /dev/null +++ b/IrisCompanion/iris/ViewModels/TodosViewModel.swift @@ -0,0 +1,73 @@ +// +// TodosViewModel.swift +// iris +// +// Created by Codex. +// + +import Foundation + +@MainActor +final class TodosViewModel: ObservableObject { + @Published private(set) var todos: [TodoItem] = [] + @Published var newTitle: String = "" + + private let store: TodoStore + + init(store: TodoStore = TodoStore()) { + self.store = store + refresh() + } + + func refresh() { + todos = sortedItems(store.snapshot()) + } + + func addTodo() { + let trimmed = normalizedTitle(newTitle) + guard !trimmed.isEmpty else { return } + store.add(title: trimmed) + newTitle = "" + refresh() + } + + func deleteTodo(id: UUID) { + store.delete(id: id) + refresh() + } + + func toggleCompleted(id: UUID) { + guard let current = todos.first(where: { $0.id == id }) else { return } + store.setCompleted(id: id, completed: !current.isCompleted) + refresh() + } + + func updateTitle(id: UUID, title: String) { + let trimmed = normalizedTitle(title) + guard !trimmed.isEmpty else { return } + store.updateTitle(id: id, title: trimmed) + refresh() + } + + private func normalizedTitle(_ raw: String) -> String { + raw.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func sortedItems(_ items: [TodoItem]) -> [TodoItem] { + items.sorted { lhs, rhs in + if lhs.isCompleted != rhs.isCompleted { + return !lhs.isCompleted + } + if lhs.isCompleted { + let lhsDate = lhs.completedAt ?? lhs.updatedAt + let rhsDate = rhs.completedAt ?? rhs.updatedAt + if lhsDate != rhsDate { + return lhsDate > rhsDate + } + } else if lhs.createdAt != rhs.createdAt { + return lhs.createdAt > rhs.createdAt + } + return lhs.id.uuidString < rhs.id.uuidString + } + } +} diff --git a/IrisCompanion/iris/Views/TodosView.swift b/IrisCompanion/iris/Views/TodosView.swift new file mode 100644 index 0000000..283cc38 --- /dev/null +++ b/IrisCompanion/iris/Views/TodosView.swift @@ -0,0 +1,195 @@ +// +// TodosView.swift +// iris +// +// Created by Codex. +// + +import SwiftUI + +struct TodosView: View { + @StateObject private var model = TodosViewModel() + @State private var isDoneExpanded = false + @State private var lastDoneCount = 0 + + private var openTodos: [TodoItem] { + model.todos.filter { !$0.isCompleted } + } + + private var doneTodos: [TodoItem] { + model.todos.filter { $0.isCompleted } + } + + var body: some View { + NavigationStack { + List { + Section("Add") { + HStack(spacing: 12) { + TextField("Add a todo", text: $model.newTitle) + .textInputAutocapitalization(.sentences) + .disableAutocorrection(false) + .submitLabel(.done) + .onSubmit { model.addTodo() } + + Button { + model.addTodo() + } label: { + Image(systemName: "plus.circle.fill") + .font(.title3) + } + .buttonStyle(.plain) + .disabled(model.newTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .accessibilityLabel("Add todo") + } + } + + Section("Todos") { + if openTodos.isEmpty { + Text("No open todos") + .foregroundStyle(.secondary) + } else { + ForEach(openTodos) { item in + TodoRow( + item: item, + onToggle: { model.toggleCompleted(id: item.id) }, + onDelete: { model.deleteTodo(id: item.id) }, + onUpdateTitle: { model.updateTitle(id: item.id, title: $0) } + ) + } + } + } + + Section { + if isDoneExpanded { + if doneTodos.isEmpty { + Text("No completed todos") + .foregroundStyle(.secondary) + } else { + ForEach(doneTodos) { item in + TodoRow( + item: item, + onToggle: { model.toggleCompleted(id: item.id) }, + onDelete: { model.deleteTodo(id: item.id) }, + onUpdateTitle: { model.updateTitle(id: item.id, title: $0) } + ) + } + } + } + } header: { + Button { + withAnimation(.easeInOut(duration: 0.15)) { + isDoneExpanded.toggle() + } + } label: { + HStack(spacing: 8) { + Text("Done") + Spacer() + if doneTodos.count > 0 { + Text("\(doneTodos.count)") + .foregroundStyle(.secondary) + } + Image(systemName: isDoneExpanded ? "chevron.down" : "chevron.right") + .foregroundStyle(.secondary) + .font(.caption) + } + } + .buttonStyle(.plain) + } + } + .navigationTitle("Todos") + .onAppear { + lastDoneCount = doneTodos.count + if lastDoneCount == 0 { + isDoneExpanded = false + } + } + .onChange(of: doneTodos.count) { newCount in + if lastDoneCount == 0, newCount > 0, !isDoneExpanded { + withAnimation(.easeInOut(duration: 0.15)) { + isDoneExpanded = true + } + } else if newCount == 0, isDoneExpanded { + withAnimation(.easeInOut(duration: 0.15)) { + isDoneExpanded = false + } + } + lastDoneCount = newCount + } + } + } +} + +private struct TodoRow: View { + let item: TodoItem + let onToggle: () -> Void + let onDelete: () -> Void + let onUpdateTitle: (String) -> Void + + @State private var draftTitle: String + @FocusState private var isFocused: Bool + + init(item: TodoItem, + onToggle: @escaping () -> Void, + onDelete: @escaping () -> Void, + onUpdateTitle: @escaping (String) -> Void) { + self.item = item + self.onToggle = onToggle + self.onDelete = onDelete + self.onUpdateTitle = onUpdateTitle + _draftTitle = State(initialValue: item.title) + } + + var body: some View { + HStack(spacing: 12) { + Button(action: onToggle) { + Image(systemName: item.isCompleted ? "checkmark.circle.fill" : "circle") + .foregroundStyle(item.isCompleted ? .secondary : .primary) + } + .buttonStyle(.plain) + .accessibilityLabel(item.isCompleted ? "Mark incomplete" : "Mark complete") + + TextField("Todo", text: $draftTitle) + .focused($isFocused) + .submitLabel(.done) + .onSubmit { commitTitleIfNeeded() } + .onChange(of: isFocused) { focused in + if !focused { + commitTitleIfNeeded() + } + } + .onChange(of: item.title) { newValue in + if !isFocused { + draftTitle = newValue + } + } + .foregroundStyle(item.isCompleted ? .secondary : .primary) + .opacity(item.isCompleted ? 0.7 : 1.0) + } + .onDisappear { + if isFocused { + commitTitleIfNeeded() + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive, action: onDelete) { + Label("Delete", systemImage: "trash") + } + } + } + + private func commitTitleIfNeeded() { + let trimmed = draftTitle.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + draftTitle = item.title + return + } + guard trimmed != item.title else { return } + onUpdateTitle(trimmed) + } +} + +struct TodosView_Previews: PreviewProvider { + static var previews: some View { + TodosView() + } +}