refactor: unify filter chips and sections across list and global views
This commit is contained in:
@@ -2,28 +2,4 @@ import Foundation
|
|||||||
|
|
||||||
enum TodoFilter: Hashable {
|
enum TodoFilter: Hashable {
|
||||||
case open, urgent, due
|
case open, urgent, due
|
||||||
|
|
||||||
var title: String {
|
|
||||||
switch self {
|
|
||||||
case .open: "Offene Aufgaben"
|
|
||||||
case .urgent: "Dringende Aufgaben"
|
|
||||||
case .due: "Fällige Aufgaben"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var emptyMessage: String {
|
|
||||||
switch self {
|
|
||||||
case .open: "Keine offenen Aufgaben"
|
|
||||||
case .urgent: "Keine dringenden Aufgaben"
|
|
||||||
case .due: "Keine fälligen Aufgaben"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var emptyIcon: String {
|
|
||||||
switch self {
|
|
||||||
case .open: "checkmark.circle"
|
|
||||||
case .urgent: "exclamationmark.triangle"
|
|
||||||
case .due: "calendar.badge.checkmark"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,29 +19,21 @@ class ListStore {
|
|||||||
var openCount: Int { allOpenItems.count }
|
var openCount: Int { allOpenItems.count }
|
||||||
|
|
||||||
var urgentItems: [TodoItem] {
|
var urgentItems: [TodoItem] {
|
||||||
let threeDaysFromNow = Calendar.current.date(byAdding: .day, value: 3, to: Date())!
|
allOpenItems.filter { isUrgent($0) }
|
||||||
return allOpenItems.filter { item in
|
|
||||||
item.priority == .high || (item.deadline != nil && item.deadline! <= threeDaysFromNow)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var urgentCount: Int { urgentItems.count }
|
var urgentCount: Int { urgentItems.count }
|
||||||
|
|
||||||
var dueItems: [TodoItem] {
|
var dueItems: [TodoItem] {
|
||||||
let startOfTomorrow = Calendar.current.startOfDay(for: Calendar.current.date(byAdding: .day, value: 1, to: Date())!)
|
allOpenItems.filter { isDue($0) }
|
||||||
return allOpenItems.filter { item in
|
|
||||||
item.deadline != nil && item.deadline! < startOfTomorrow
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var dueCount: Int { dueItems.count }
|
var dueCount: Int { dueItems.count }
|
||||||
|
|
||||||
func items(for filter: TodoFilter) -> [TodoItem] {
|
var allCompletedItems: [TodoItem] {
|
||||||
switch filter {
|
lists.flatMap { $0.items }
|
||||||
case .open: allOpenItems
|
.filter { $0.isCompleted }
|
||||||
case .urgent: urgentItems
|
.sorted { ($0.modifiedAt ?? $0.createdAt) > ($1.modifiedAt ?? $1.createdAt) }
|
||||||
case .due: dueItems
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init(modelContext: ModelContext) {
|
init(modelContext: ModelContext) {
|
||||||
@@ -128,21 +120,14 @@ class ListStore {
|
|||||||
deleteItem(item.id, from: listID)
|
deleteItem(item.id, from: listID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func filteredOpenItems(for list: TodoList, urgent: Bool, due: Bool, sort: ListDetailSort) -> [TodoItem] {
|
func filteredOpenItems(for list: TodoList? = nil, urgent: Bool, due: Bool, sort: ListDetailSort) -> [TodoItem] {
|
||||||
var items = list.openItems
|
var items = list?.openItems ?? allOpenItems
|
||||||
|
|
||||||
if urgent || due {
|
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
|
items = items.filter { item in
|
||||||
let isUrgent = item.priority == .high || (item.deadline != nil && item.deadline! <= threeDaysFromNow)
|
if urgent && due { return isUrgent(item) && isDue(item) }
|
||||||
let isDue = item.deadline != nil && item.deadline! < startOfTomorrow
|
if urgent { return isUrgent(item) }
|
||||||
|
return isDue(item)
|
||||||
if urgent && due { return isUrgent && isDue }
|
|
||||||
if urgent { return isUrgent }
|
|
||||||
return isDue
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +188,16 @@ class ListStore {
|
|||||||
lists = inbox + rest
|
lists = inbox + rest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func isUrgent(_ item: TodoItem) -> Bool {
|
||||||
|
let threeDaysFromNow = Calendar.current.date(byAdding: .day, value: 3, to: Date())!
|
||||||
|
return item.priority == .high || (item.deadline != nil && item.deadline! <= threeDaysFromNow)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isDue(_ item: TodoItem) -> Bool {
|
||||||
|
let startOfTomorrow = Calendar.current.startOfDay(for: Calendar.current.date(byAdding: .day, value: 1, to: Date())!)
|
||||||
|
return item.deadline != nil && item.deadline! < startOfTomorrow
|
||||||
|
}
|
||||||
|
|
||||||
private func save() {
|
private func save() {
|
||||||
try? modelContext.save()
|
try? modelContext.save()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,18 +26,11 @@ struct ListDetailView: View {
|
|||||||
|
|
||||||
List {
|
List {
|
||||||
Section {
|
Section {
|
||||||
HStack(spacing: 12) {
|
FilterChipBar(
|
||||||
chipButton("Dringend", icon: "exclamationmark.triangle", isActive: filterUrgent, color: .orange) {
|
filterUrgent: $filterUrgent,
|
||||||
withAnimation { filterUrgent.toggle() }
|
filterDue: $filterDue,
|
||||||
}
|
selectedSort: $selectedSort
|
||||||
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 {
|
Section {
|
||||||
@@ -108,46 +101,6 @@ struct ListDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// MARK: - Empty State
|
||||||
|
|
||||||
private var emptyState: some View {
|
private var emptyState: some View {
|
||||||
|
|||||||
@@ -5,19 +5,41 @@ struct TodoListScreen: View {
|
|||||||
let filter: TodoFilter
|
let filter: TodoFilter
|
||||||
@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 filterUrgent: Bool
|
||||||
|
@State private var filterDue: Bool
|
||||||
|
@State private var selectedSort: ListDetailSort = .oldest
|
||||||
|
|
||||||
private var items: [TodoItem] {
|
init(filter: TodoFilter) {
|
||||||
store.items(for: filter)
|
self.filter = filter
|
||||||
|
_filterUrgent = State(initialValue: filter == .urgent)
|
||||||
|
_filterDue = State(initialValue: filter == .due)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if items.isEmpty {
|
let filteredItems = store.filteredOpenItems(
|
||||||
ContentUnavailableView(filter.emptyMessage, systemImage: filter.emptyIcon)
|
urgent: filterUrgent, due: filterDue, sort: selectedSort
|
||||||
|
)
|
||||||
|
|
||||||
|
if store.allOpenItems.isEmpty && store.allCompletedItems.isEmpty {
|
||||||
|
ContentUnavailableView("Keine Einträge", systemImage: "tray")
|
||||||
} else {
|
} else {
|
||||||
List {
|
List {
|
||||||
|
Section {
|
||||||
|
FilterChipBar(
|
||||||
|
filterUrgent: $filterUrgent,
|
||||||
|
filterDue: $filterDue,
|
||||||
|
selectedSort: $selectedSort
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
if filteredItems.isEmpty {
|
||||||
|
emptyState
|
||||||
|
} else {
|
||||||
TodoListView(
|
TodoListView(
|
||||||
items: items,
|
items: filteredItems,
|
||||||
showListName: true,
|
showListName: true,
|
||||||
onToggle: { item in
|
onToggle: { item in
|
||||||
store.toggleItemCompleted(item)
|
store.toggleItemCompleted(item)
|
||||||
@@ -34,8 +56,45 @@ struct TodoListScreen: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
.navigationTitle(filter.title)
|
)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Button {
|
||||||
|
withAnimation {
|
||||||
|
showCompleted.toggle()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("Erledigt (\(store.allCompletedItems.count))")
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: showCompleted ? "chevron.down" : "chevron.right")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Aufgaben")
|
||||||
.sheet(item: $editorItem) { item in
|
.sheet(item: $editorItem) { item in
|
||||||
if let listID = item.list?.id {
|
if let listID = item.list?.id {
|
||||||
TodoEditorView(listID: listID, item: item)
|
TodoEditorView(listID: listID, item: item)
|
||||||
@@ -47,4 +106,42 @@ struct TodoListScreen: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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