refactor: extract TodoListBodyView to eliminate duplication between ListDetailView and FilteredTodoListView

This commit is contained in:
2026-03-06 18:34:28 +01:00
parent 9747b2ea67
commit 36859a4f45
3 changed files with 178 additions and 260 deletions

View File

@@ -1,147 +1,25 @@
import SwiftUI import SwiftUI
// Displays todos filtered globally (across all lists) by a TodoFilter.
// Delegates the shared list structure to TodoListBodyView and only configures the filter-specific data source.
struct FilteredTodoListView: View { struct FilteredTodoListView: View {
@Environment(ListStore.self) private var store @Environment(ListStore.self) private var store
let filter: TodoFilter let filter: TodoFilter
@State private var editorItem: TodoItem?
@State private var moveItem: TodoItem?
@State private var showCompleted = true
@State private var filterUrgent: Bool
@State private var filterDue: Bool
@State private var selectedSort: ListDetailSort = .oldest
init(filter: TodoFilter) {
self.filter = filter
_filterUrgent = State(initialValue: filter == .urgent)
_filterDue = State(initialValue: filter == .due)
}
var body: some View { var body: some View {
Group { TodoListBodyView(
let filteredItems = store.filteredOpenItems(
urgent: filterUrgent, due: filterDue, sort: selectedSort
)
if store.allOpenItems.isEmpty && store.allCompletedItems.isEmpty {
ContentUnavailableView("Keine Einträge", systemImage: "tray")
} else {
List {
Section {
FilterChipBar(
filterUrgent: $filterUrgent,
filterDue: $filterDue,
selectedSort: $selectedSort
)
}
Section {
if filteredItems.isEmpty {
emptyState
} else {
TodoListView(
items: filteredItems,
showListName: true, showListName: true,
onToggle: { item in isEmpty: store.allOpenItems.isEmpty && store.allCompletedItems.isEmpty,
store.toggleItemCompleted(item) initialFilterUrgent: filter == .urgent,
initialFilterDue: filter == .due,
openItemsProvider: { urgent, due, sort in
store.filteredOpenItems(urgent: urgent, due: due, sort: sort)
}, },
onTap: { item in completedItems: store.allCompletedItems,
editorItem = item onToggle: { store.toggleItemCompleted($0) },
}, onDelete: { store.deleteItem($0) }
onDelete: { item in
store.deleteItem(item)
},
onMove: { item in
moveItem = item
}
) )
}
}
if !store.allCompletedItems.isEmpty {
Section {
if showCompleted {
TodoListView(
items: store.allCompletedItems,
showListName: true,
onToggle: { item in
store.toggleItemCompleted(item)
},
onTap: { item in
editorItem = item
},
onDelete: { item in
store.deleteItem(item)
},
onMove: { item in
moveItem = item
}
)
}
} header: {
Button {
withAnimation {
showCompleted.toggle()
}
} label: {
HStack {
Text("Erledigt (\(store.allCompletedItems.count))")
Spacer()
Image(systemName: showCompleted ? "chevron.down" : "chevron.right")
}
}
}
}
}
}
}
.navigationTitle("Aufgaben") .navigationTitle("Aufgaben")
.sheet(item: $editorItem) { item in
if let listID = item.list?.id {
TodoEditorView(listID: listID, item: item)
}
}
.sheet(item: $moveItem) { item in
if let listID = item.list?.id {
MoveToListView(itemID: item.id, currentListID: listID)
}
}
}
// 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"
}
} }
} }

View File

@@ -1,14 +1,11 @@
import SwiftUI import SwiftUI
// Displays the entries of a single TodoList. Delegates the shared list structure
// to TodoListBodyView and only configures the list-specific data source.
struct ListDetailView: View { struct ListDetailView: View {
@Environment(ListStore.self) private var store @Environment(ListStore.self) private var store
let listID: UUID let listID: UUID
@State private var editorItem: TodoItem?
@State private var moveItem: TodoItem?
@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,125 +14,18 @@ struct ListDetailView: View {
var body: some View { var body: some View {
Group { Group {
if let todoList { if let todoList {
if todoList.items.isEmpty { TodoListBodyView(
ContentUnavailableView("Keine Einträge", systemImage: "tray") showListName: false,
} else { isEmpty: todoList.items.isEmpty,
let filteredItems = store.filteredOpenItems( openItemsProvider: { urgent, due, sort in
for: todoList, urgent: filterUrgent, due: filterDue, sort: selectedSort store.filteredOpenItems(for: todoList, urgent: urgent, due: due, sort: sort)
},
completedItems: todoList.completedItems,
onToggle: { store.toggleItemCompleted($0) },
onDelete: { store.deleteItem($0) }
) )
List {
Section {
FilterChipBar(
filterUrgent: $filterUrgent,
filterDue: $filterDue,
selectedSort: $selectedSort
)
}
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 {
Section {
if showCompleted {
TodoListView(
items: todoList.completedItems,
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
}
)
}
} header: {
Button {
withAnimation {
showCompleted.toggle()
}
} label: {
HStack {
Text("Erledigt (\(todoList.completedItems.count))")
Spacer()
Image(systemName: showCompleted ? "chevron.down" : "chevron.right")
}
}
}
}
}
}
} }
} }
.navigationTitle(todoList?.name ?? "") .navigationTitle(todoList?.name ?? "")
.sheet(item: $editorItem) { item in
TodoEditorView(listID: listID, item: item)
}
.sheet(item: $moveItem) { item in
MoveToListView(itemID: item.id, currentListID: listID)
}
}
// 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"
}
} }
} }

View File

@@ -0,0 +1,150 @@
import SwiftUI
// Shared list body used by both ListDetailView (single list)
// and FilteredTodoListView (global filter) to eliminate code duplication.
struct TodoListBodyView: View {
let showListName: Bool
let isEmpty: Bool
let openItemsProvider: (Bool, Bool, ListDetailSort) -> [TodoItem]
let completedItems: [TodoItem]
let onToggle: (TodoItem) -> Void
let onDelete: (TodoItem) -> Void
@State private var editorItem: TodoItem?
@State private var moveItem: TodoItem?
@State private var showCompleted = true
@State private var filterUrgent: Bool
@State private var filterDue: Bool
@State private var selectedSort: ListDetailSort = .oldest
init(
showListName: Bool,
isEmpty: Bool,
initialFilterUrgent: Bool = false,
initialFilterDue: Bool = false,
openItemsProvider: @escaping (Bool, Bool, ListDetailSort) -> [TodoItem],
completedItems: [TodoItem],
onToggle: @escaping (TodoItem) -> Void,
onDelete: @escaping (TodoItem) -> Void
) {
self.showListName = showListName
self.isEmpty = isEmpty
self.openItemsProvider = openItemsProvider
self.completedItems = completedItems
self.onToggle = onToggle
self.onDelete = onDelete
_filterUrgent = State(initialValue: initialFilterUrgent)
_filterDue = State(initialValue: initialFilterDue)
}
var body: some View {
Group {
if isEmpty {
ContentUnavailableView("Keine Einträge", systemImage: "tray")
} else {
let filteredItems = openItemsProvider(filterUrgent, filterDue, selectedSort)
List {
Section {
FilterChipBar(
filterUrgent: $filterUrgent,
filterDue: $filterDue,
selectedSort: $selectedSort
)
}
Section {
if filteredItems.isEmpty {
emptyState
} else {
TodoListView(
items: filteredItems,
showListName: showListName,
onToggle: onToggle,
onTap: { item in editorItem = item },
onDelete: onDelete,
onMove: { item in moveItem = item }
)
}
}
if !completedItems.isEmpty {
Section {
if showCompleted {
TodoListView(
items: completedItems,
showListName: showListName,
onToggle: onToggle,
onTap: { item in editorItem = item },
onDelete: onDelete,
onMove: { item in moveItem = item }
)
}
} header: {
Button {
withAnimation {
showCompleted.toggle()
}
} label: {
HStack {
Text("Erledigt (\(completedItems.count))")
Spacer()
Image(systemName: showCompleted ? "chevron.down" : "chevron.right")
}
}
}
}
}
}
}
.sheet(item: $editorItem) { item in
if let listID = item.list?.id {
TodoEditorView(listID: listID, item: item)
}
}
.sheet(item: $moveItem) { item in
if let listID = item.list?.id {
MoveToListView(itemID: item.id, currentListID: listID)
}
}
}
// 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"
}
}
}