feat: add visible filter chips and sort menu to list detail view
This commit is contained in:
23
MindDump/Models/ListDetailConfig.swift
Normal file
23
MindDump/Models/ListDetailConfig.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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,23 +20,46 @@ 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 {
|
||||||
TodoListView(
|
HStack(spacing: 12) {
|
||||||
items: todoList.openItems,
|
chipButton("Dringend", icon: "exclamationmark.triangle", isActive: filterUrgent, color: .orange) {
|
||||||
onToggle: { item in
|
withAnimation { filterUrgent.toggle() }
|
||||||
store.toggleItemCompleted(item.id, in: listID)
|
|
||||||
},
|
|
||||||
onTap: { item in
|
|
||||||
editorItem = item
|
|
||||||
},
|
|
||||||
onDelete: { item in
|
|
||||||
store.deleteItem(item.id, from: listID)
|
|
||||||
},
|
|
||||||
onMove: { item in
|
|
||||||
moveItem = item
|
|
||||||
}
|
}
|
||||||
)
|
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(
|
||||||
|
items: filteredItems,
|
||||||
|
onToggle: { item in
|
||||||
|
store.toggleItemCompleted(item.id, in: listID)
|
||||||
|
},
|
||||||
|
onTap: { item in
|
||||||
|
editorItem = item
|
||||||
|
},
|
||||||
|
onDelete: { item in
|
||||||
|
store.deleteItem(item.id, from: listID)
|
||||||
|
},
|
||||||
|
onMove: { item in
|
||||||
|
moveItem = item
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !todoList.completedItems.isEmpty {
|
if !todoList.completedItems.isEmpty {
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user