feat: add visible filter chips and sort menu to list detail view

This commit is contained in:
2026-02-12 23:18:50 +01:00
parent 3d9dd8cd8f
commit eeee1af570
3 changed files with 185 additions and 14 deletions

View File

@@ -0,0 +1,23 @@
import Foundation
enum ListDetailSort: CaseIterable {
case oldest, newest, dueDate, priority
var label: String {
switch self {
case .oldest: "Älteste zuerst"
case .newest: "Neueste zuerst"
case .dueDate: "Deadline zuerst"
case .priority: "Priorität zuerst"
}
}
var shortLabel: String {
switch self {
case .oldest: "Älteste"
case .newest: "Neueste"
case .dueDate: "Deadline"
case .priority: "Priorität"
}
}
}

View File

@@ -128,6 +128,50 @@ class ListStore {
deleteItem(item.id, from: listID) deleteItem(item.id, from: listID)
} }
func filteredOpenItems(for list: TodoList, urgent: Bool, due: Bool, sort: ListDetailSort) -> [TodoItem] {
var items = list.openItems
if urgent || due {
let now = Date()
let threeDaysFromNow = Calendar.current.date(byAdding: .day, value: 3, to: now)!
let startOfTomorrow = Calendar.current.startOfDay(for: Calendar.current.date(byAdding: .day, value: 1, to: now)!)
items = items.filter { item in
let isUrgent = item.priority == .high || (item.deadline != nil && item.deadline! <= threeDaysFromNow)
let isDue = item.deadline != nil && item.deadline! < startOfTomorrow
if urgent && due { return isUrgent && isDue }
if urgent { return isUrgent }
return isDue
}
}
switch sort {
case .oldest:
items.sort { $0.createdAt < $1.createdAt }
case .newest:
items.sort { $0.createdAt > $1.createdAt }
case .dueDate:
items.sort { lhs, rhs in
switch (lhs.deadline, rhs.deadline) {
case let (l?, r?): l < r
case (_?, nil): true
case (nil, _?): false
case (nil, nil): lhs.createdAt < rhs.createdAt
}
}
case .priority:
items.sort { lhs, rhs in
let lp = lhs.priority?.rawValue ?? -1
let rp = rhs.priority?.rawValue ?? -1
if lp != rp { return lp > rp }
return lhs.createdAt < rhs.createdAt
}
}
return items
}
func moveItem(_ itemID: UUID, from sourceListID: UUID, to targetListID: UUID) { func moveItem(_ itemID: UUID, from sourceListID: UUID, to targetListID: UUID) {
guard sourceListID != targetListID, guard sourceListID != targetListID,
let sourceList = lists.first(where: { $0.id == sourceListID }), let sourceList = lists.first(where: { $0.id == sourceListID }),

View File

@@ -6,6 +6,9 @@ struct ListDetailView: View {
@State private var editorItem: TodoItem? @State private var editorItem: TodoItem?
@State private var moveItem: TodoItem? @State private var moveItem: TodoItem?
@State private var showCompleted = true @State private var showCompleted = true
@State private var filterUrgent = false
@State private var filterDue = false
@State private var selectedSort: ListDetailSort = .oldest
private var todoList: TodoList? { private var todoList: TodoList? {
store.lists.first { $0.id == listID } store.lists.first { $0.id == listID }
@@ -17,10 +20,32 @@ struct ListDetailView: View {
if todoList.items.isEmpty { if todoList.items.isEmpty {
ContentUnavailableView("Keine Einträge", systemImage: "tray") ContentUnavailableView("Keine Einträge", systemImage: "tray")
} else { } else {
let filteredItems = store.filteredOpenItems(
for: todoList, urgent: filterUrgent, due: filterDue, sort: selectedSort
)
List { List {
Section { Section {
HStack(spacing: 12) {
chipButton("Dringend", icon: "exclamationmark.triangle", isActive: filterUrgent, color: .orange) {
withAnimation { filterUrgent.toggle() }
}
chipButton("Fällig", icon: "calendar.badge.clock", isActive: filterDue, color: .red) {
withAnimation { filterDue.toggle() }
}
sortMenu
}
.frame(maxWidth: .infinity)
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.listRowBackground(Color.clear)
}
Section {
if filteredItems.isEmpty {
emptyState
} else {
TodoListView( TodoListView(
items: todoList.openItems, items: filteredItems,
onToggle: { item in onToggle: { item in
store.toggleItemCompleted(item.id, in: listID) store.toggleItemCompleted(item.id, in: listID)
}, },
@@ -35,6 +60,7 @@ struct ListDetailView: View {
} }
) )
} }
}
if !todoList.completedItems.isEmpty { if !todoList.completedItems.isEmpty {
Section { Section {
@@ -81,4 +107,82 @@ struct ListDetailView: View {
MoveToListView(itemID: item.id, currentListID: listID) MoveToListView(itemID: item.id, currentListID: listID)
} }
} }
// MARK: - Filter Chip
@ViewBuilder
private func chipButton(_ label: String, icon: String, isActive: Bool, color: Color, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: icon)
.font(.subheadline.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(isActive ? color.opacity(0.15) : Color(.systemGray6))
.foregroundStyle(isActive ? color : .secondary)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
// MARK: - Sort Menu
private var sortMenu: some View {
Menu {
Picker("Sortierung", selection: $selectedSort) {
ForEach(ListDetailSort.allCases, id: \.self) { sort in
Text(sort.label).tag(sort)
}
}
} label: {
HStack(spacing: 4) {
Image(systemName: "arrow.up.arrow.down")
ZStack(alignment: .leading) {
ForEach(ListDetailSort.allCases, id: \.self) { sort in
Text(sort.shortLabel)
.opacity(sort == selectedSort ? 1 : 0)
}
}
}
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
// MARK: - Empty State
private var emptyState: some View {
VStack(spacing: 8) {
Image(systemName: emptyIcon)
.font(.title2)
.foregroundStyle(.secondary)
Text(emptyMessage)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
.listRowBackground(Color.clear)
}
private var emptyMessage: String {
if filterUrgent && filterDue {
"Keine dringenden oder fälligen Aufgaben"
} else if filterUrgent {
"Keine dringenden Aufgaben"
} else if filterDue {
"Keine fälligen Aufgaben"
} else {
"Keine offenen Aufgaben"
}
}
private var emptyIcon: String {
if filterUrgent {
"exclamationmark.triangle"
} else if filterDue {
"calendar.badge.checkmark"
} else {
"checkmark.circle"
}
}
} }