5 Commits d559bbf116 ... 799ae24b15

Autor SHA1 Mensaje Fecha
  Sam Jaffe 799ae24b15 feat: implement text filtering hace 2 semanas
  Sam Jaffe db415b3c2d feat: add filtering on status, relocate Move toggle into popover menu hace 2 semanas
  Sam Jaffe a44ef357fe refactor: extract Aggregate/Ordered protocols and use extension to inject them into models hace 2 semanas
  Sam Jaffe 485d99ba26 refactor: extract status picker to component hace 2 semanas
  Sam Jaffe f1a3a2735d refactor: move {View => View/Components}/VisibilityTapper.swift hace 2 semanas

+ 0 - 16
Todos/Model/Aggregate.swift

@@ -1,16 +0,0 @@
-//
-//  Aggregate.swift
-//  Todos
-//
-//  Created by Sam Jaffe on 3/5/26.
-//
-
-import Foundation
-
-protocol Aggregate {
-  associatedtype Element
-
-  func move(fromOffsets: IndexSet, toOffset: Int)
-  func remove(_ item: Element)
-  func reindex()
-}

+ 1 - 19
Todos/Model/Project.swift

@@ -10,9 +10,7 @@ import SwiftData
 import SwiftUI
 
 @Model
-final class Project: Codable, Ordered, Aggregate {
-  typealias Element = Task
-
+final class Project: Codable {
   var sortOrder: Int = 0
   var name: String = "New Project"
   var category: String = ""
@@ -24,22 +22,6 @@ final class Project: Codable, Ordered, Aggregate {
     self.sortOrder = sortOrder
   }
 
-  func move(fromOffsets: IndexSet, toOffset: Int) {
-    tasks.move(fromOffsets: fromOffsets, toOffset: toOffset)
-    reindex()
-  }
-
-  func remove(_ item: Element) {
-    tasks.removeAll(where: { $0.id == item.id })
-    reindex()
-  }
-
-  func reindex() {
-    for (index, item) in tasks.enumerated() {
-      item.sortOrder = index
-    }
-  }
-
   func yaml(_ indent: Int = 0) -> String {
     let hdr = String(repeating: "  ", count: indent)
     var rval = hdr + "\(name):\n"

+ 1 - 1
Todos/Model/SubTask.swift

@@ -9,7 +9,7 @@ import Foundation
 import SwiftData
 
 @Model
-final class SubTask: Codable, Ordered {
+final class SubTask: Codable {
   var sortOrder: Int = 0
   var name: String
   var task: Task?

+ 1 - 19
Todos/Model/Task.swift

@@ -10,9 +10,7 @@ import SwiftData
 import SwiftUI
 
 @Model
-final class Task: Codable, Ordered, Aggregate {
-  typealias Element = SubTask
-
+final class Task: Codable {
   var sortOrder: Int = 0
   var name: String
   var project: Project?
@@ -31,22 +29,6 @@ final class Task: Codable, Ordered, Aggregate {
     self.sortOrder = parent?.tasks.count ?? 0
   }
 
-  func move(fromOffsets: IndexSet, toOffset: Int) {
-    subtasks.move(fromOffsets: fromOffsets, toOffset: toOffset)
-    reindex()
-  }
-
-  func remove(_ item: Element) {
-    subtasks.removeAll(where: { $0.id == item.id })
-    reindex()
-  }
-
-  func reindex() {
-    for (index, item) in subtasks.enumerated() {
-      item.sortOrder = index
-    }
-  }
-
   func yaml(_ indent: Int = 0) -> String {
     let hdr = String(repeating: "  ", count: indent)
     let subhdr = hdr + "  # "

+ 40 - 0
Todos/View/Components/StatusChecklist.swift

@@ -0,0 +1,40 @@
+//
+//  StatusChecklist.swift
+//  Todos
+//
+//  Created by Sam Jaffe on 3/7/26.
+//
+
+import SwiftUI
+
+struct StatusChecklist: View {
+  @Binding var statuses : StatusList
+
+  var body: some View {
+    VStack(alignment: .leading) {
+      Text("Filter by Status")
+        .font(.title3.bold())
+      ForEach(Status.allCases) { unit in
+        Toggle("Show \"\(unit.description)\"", systemImage: unit.label,
+               isOn: bindingFor(unit))
+        .padding(.leading, 15)
+      }
+    }
+  }
+  
+  private func bindingFor(_ index: Status) -> Binding<Bool> {
+    switch (index) {
+    case .todo: return $statuses.todo
+    case .complete: return $statuses.complete
+    case .inProgress: return $statuses.inProgess
+    case .hiatus: return $statuses.hiatus
+    case .waiting: return $statuses.waiting
+    case .unknown: return $statuses.unknown
+    }
+  }
+}
+
+#Preview {
+  @Previewable @State var statuses = StatusList()
+  StatusChecklist(statuses: $statuses)
+}

+ 34 - 0
Todos/View/Components/StatusPicker.swift

@@ -0,0 +1,34 @@
+//
+//  StatusPicker.swift
+//  Todos
+//
+//  Created by Sam Jaffe on 3/7/26.
+//
+
+import SwiftUI
+
+struct StatusPicker: View {
+  @Binding var status : Status
+  var displayIcon : Bool = true
+
+  var body: some View {
+    HStack {
+      if displayIcon {
+        Image(systemName: status.label)
+          .frame(width: 20)
+          .padding(.trailing, -10)
+      }
+      Picker("", selection: $status) {
+        ForEach(Status.allCases) { unit in
+          Text(unit.description).tag(unit)
+        }
+      }
+      .fixedSize(horizontal: true, vertical: false)
+    }
+  }
+}
+
+#Preview {
+  @Previewable @State var status = Status.todo
+  StatusPicker(status: $status, displayIcon: true)
+}

Todos/View/VisibilityTapper.swift → Todos/View/Components/VisibilityTapper.swift


+ 35 - 4
Todos/View/ProjectPanelView.swift

@@ -14,7 +14,11 @@ struct ProjectPanelView: View {
 
   @Bindable var item: Project
   @State private var empty = Category()
+
+  @State private var showDialogue = false
   @State private var move = false
+  @State private var taskFilter = ""
+  @State private var statuses = StatusList()
 
   var body: some View {
     HStack {
@@ -27,8 +31,28 @@ struct ProjectPanelView: View {
       }
       .help("New Task")
       .padding(.trailing, 10)
-      Toggle("Move Tasks", isOn: $move)
-        .padding(.trailing, 10)
+      Button {
+        showDialogue = !showDialogue
+      } label: {
+        Label("", systemImage: "ellipsis.circle")
+          .foregroundStyle(.gray)
+          .font(.title)
+      }
+      .buttonStyle(.borderless)
+      .popover(isPresented: $showDialogue) {
+        List{
+          HStack {
+            Label("", systemImage: "arrow.up.arrow.down")
+            Toggle("Move Tasks", isOn: $move)
+          }
+          HStack {
+            Label("", systemImage: "magnifyingglass")
+            TextField("Filter Tasks", text: $taskFilter)
+          }
+          StatusChecklist(statuses: $statuses)
+        }
+      }
+      Text("")
     }
     HStack(alignment: .top) {
       TextField("Project Notes", text: $item.notes, axis: .vertical)
@@ -44,7 +68,7 @@ struct ProjectPanelView: View {
       }.help("Default category for new Tasks")
     }
     List {
-      ForEach($item.tasks.sorted(by: Task.less), id: \.id) { task in
+      ForEach(selected($item.tasks), id: \.id) { task in
         TaskView(task: task)
           .swipeActions {
             Button("Delete", systemImage: "trash", role: .destructive) {
@@ -52,7 +76,7 @@ struct ProjectPanelView: View {
             }
           }
 
-        ForEach(task.subtasks.sorted(by: SubTask.less), id: \.id) { subtask in
+        ForEach(selected(task.subtasks), id: \.id) { subtask in
           SubTaskView(task: subtask)
             .swipeActions {
               Button("Delete", systemImage: "trash", role: .destructive) {
@@ -67,6 +91,13 @@ struct ProjectPanelView: View {
       .moveDisabled(!move)
     }
   }
+  
+  private func selected<T : Ordered & Filterable>(_ items: Binding<[T]>) -> [Binding<T>] {
+    return items.sorted(by: T.less).filter({
+      statuses.test($0.wrappedValue.status) &&
+        (taskFilter.isEmpty || $0.wrappedValue.containsText(taskFilter))
+    })
+  }
 
   private func addItem() {
     withAnimation {

+ 1 - 9
Todos/View/SubTaskView.swift

@@ -20,15 +20,7 @@ struct SubTaskView: View {
         Label("", systemImage: "chevron.right")
           .padding(.trailing, -10)
 
-        Image(systemName: task.status.label)
-          .frame(width: 20)
-          .padding(.trailing, -10)
-        Picker("", selection: $task.status) {
-          ForEach(Status.allCases) { unit in
-            Text(unit.description).tag(unit)
-          }
-        }
-        .fixedSize(horizontal: true, vertical: false)
+        StatusPicker(status: $task.status)
 
         TextField("Task Name", text: $task.name)
           .focused($isFocused)

+ 7 - 15
Todos/View/TaskView.swift

@@ -22,22 +22,14 @@ struct TaskView: View {
   var body: some View {
     VStack {
       HStack {
-        Image(systemName: task.status.label)
-          .frame(width: 20)
-          .padding(.trailing, -10)
-        Picker("", selection: $task.status) {
-          ForEach(Status.allCases) { unit in
-            Text(unit.description).tag(unit)
-          }
-        }
-        .fixedSize(horizontal: true, vertical: false)
-        .onChange(of: task.status) {
-          if task.status.isStrong {
-            task.subtasks
-              .filter({ !$0.status.isStrong })
-              .forEach({ subtask in subtask.status = task.status })
+        StatusPicker(status: $task.status)
+          .onChange(of: task.status) {
+            if task.status.isStrong {
+              task.subtasks
+                .filter({ !$0.status.isStrong })
+                .forEach({ subtask in subtask.status = task.status })
+            }
           }
-        }
 
         TextField("Task Name", text: $task.name)
           .focused($isFocused)

+ 57 - 0
Todos/ViewModel/Aggregate.swift

@@ -0,0 +1,57 @@
+//
+//  Aggregate.swift
+//  Todos
+//
+//  Created by Sam Jaffe on 3/5/26.
+//
+
+import Foundation
+import SwiftUI
+
+protocol Aggregate {
+  associatedtype Element
+
+  func move(fromOffsets: IndexSet, toOffset: Int)
+  func remove(_ item: Element)
+  func reindex()
+}
+
+extension Project : Aggregate {
+  typealias Element = Task
+
+  func move(fromOffsets: IndexSet, toOffset: Int) {
+    tasks.move(fromOffsets: fromOffsets, toOffset: toOffset)
+    reindex()
+  }
+
+  func remove(_ item: Element) {
+    tasks.removeAll(where: { $0.id == item.id })
+    reindex()
+  }
+
+  func reindex() {
+    for (index, item) in tasks.enumerated() {
+      item.sortOrder = index
+    }
+  }
+}
+
+extension Task : Aggregate {
+  typealias Element = SubTask
+
+  func move(fromOffsets: IndexSet, toOffset: Int) {
+    subtasks.move(fromOffsets: fromOffsets, toOffset: toOffset)
+    reindex()
+  }
+
+  func remove(_ item: Element) {
+    subtasks.removeAll(where: { $0.id == item.id })
+    reindex()
+  }
+
+  func reindex() {
+    for (index, item) in subtasks.enumerated() {
+      item.sortOrder = index
+    }
+  }
+}

+ 26 - 0
Todos/ViewModel/Filterable.swift

@@ -0,0 +1,26 @@
+//
+//  Filterable.swift
+//  Todos
+//
+//  Created by Sam Jaffe on 3/7/26.
+//
+
+import Foundation
+
+protocol Filterable {
+  var status: Status { get }
+  func containsText(_ text: String) -> Bool
+}
+
+extension SubTask : Filterable {
+  func containsText(_ text: String) -> Bool {
+    return name.contains(text) || notes.contains(text)
+  }
+}
+
+extension Task : Filterable {
+  func containsText(_ text: String) -> Bool {
+    return name.contains(text) || notes.contains(text) ||
+      subtasks.contains(where: { $0.containsText(text) })
+  }
+}

+ 4 - 0
Todos/Model/Ordered.swift

@@ -23,3 +23,7 @@ extension Ordered {
     return lhs.sortOrder.wrappedValue < rhs.sortOrder.wrappedValue
   }
 }
+
+extension Project : Ordered {}
+extension Task : Ordered {}
+extension SubTask : Ordered {}

+ 32 - 0
Todos/ViewModel/StatusList.swift

@@ -0,0 +1,32 @@
+//
+//  StatusList.swift
+//  Todos
+//
+//  Created by Sam Jaffe on 3/7/26.
+//
+
+import Foundation
+import SwiftData
+
+@Model // Must be @Model to watch changes to children
+final class StatusList {
+  var todo: Bool = true
+  var complete: Bool = true
+  var inProgess: Bool = true
+  var hiatus: Bool = true
+  var waiting: Bool = true
+  var unknown: Bool = true
+  
+  init() {}
+  
+  func test(_ index: Status) -> Bool {
+    switch (index) {
+    case .todo: return todo
+    case .complete: return complete
+    case .inProgress: return inProgess
+    case .hiatus: return hiatus
+    case .waiting: return waiting
+    case .unknown: return unknown
+    }
+  }
+}