196 lines
6.8 KiB
Swift
196 lines
6.8 KiB
Swift
|
|
//
|
||
|
|
// TodosView.swift
|
||
|
|
// iris
|
||
|
|
//
|
||
|
|
// Created by Codex.
|
||
|
|
//
|
||
|
|
|
||
|
|
import SwiftUI
|
||
|
|
|
||
|
|
struct TodosView: View {
|
||
|
|
@StateObject private var model = TodosViewModel()
|
||
|
|
@State private var isDoneExpanded = false
|
||
|
|
@State private var lastDoneCount = 0
|
||
|
|
|
||
|
|
private var openTodos: [TodoItem] {
|
||
|
|
model.todos.filter { !$0.isCompleted }
|
||
|
|
}
|
||
|
|
|
||
|
|
private var doneTodos: [TodoItem] {
|
||
|
|
model.todos.filter { $0.isCompleted }
|
||
|
|
}
|
||
|
|
|
||
|
|
var body: some View {
|
||
|
|
NavigationStack {
|
||
|
|
List {
|
||
|
|
Section("Add") {
|
||
|
|
HStack(spacing: 12) {
|
||
|
|
TextField("Add a todo", text: $model.newTitle)
|
||
|
|
.textInputAutocapitalization(.sentences)
|
||
|
|
.disableAutocorrection(false)
|
||
|
|
.submitLabel(.done)
|
||
|
|
.onSubmit { model.addTodo() }
|
||
|
|
|
||
|
|
Button {
|
||
|
|
model.addTodo()
|
||
|
|
} label: {
|
||
|
|
Image(systemName: "plus.circle.fill")
|
||
|
|
.font(.title3)
|
||
|
|
}
|
||
|
|
.buttonStyle(.plain)
|
||
|
|
.disabled(model.newTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||
|
|
.accessibilityLabel("Add todo")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Section("Todos") {
|
||
|
|
if openTodos.isEmpty {
|
||
|
|
Text("No open todos")
|
||
|
|
.foregroundStyle(.secondary)
|
||
|
|
} else {
|
||
|
|
ForEach(openTodos) { item in
|
||
|
|
TodoRow(
|
||
|
|
item: item,
|
||
|
|
onToggle: { model.toggleCompleted(id: item.id) },
|
||
|
|
onDelete: { model.deleteTodo(id: item.id) },
|
||
|
|
onUpdateTitle: { model.updateTitle(id: item.id, title: $0) }
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Section {
|
||
|
|
if isDoneExpanded {
|
||
|
|
if doneTodos.isEmpty {
|
||
|
|
Text("No completed todos")
|
||
|
|
.foregroundStyle(.secondary)
|
||
|
|
} else {
|
||
|
|
ForEach(doneTodos) { item in
|
||
|
|
TodoRow(
|
||
|
|
item: item,
|
||
|
|
onToggle: { model.toggleCompleted(id: item.id) },
|
||
|
|
onDelete: { model.deleteTodo(id: item.id) },
|
||
|
|
onUpdateTitle: { model.updateTitle(id: item.id, title: $0) }
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} header: {
|
||
|
|
Button {
|
||
|
|
withAnimation(.easeInOut(duration: 0.15)) {
|
||
|
|
isDoneExpanded.toggle()
|
||
|
|
}
|
||
|
|
} label: {
|
||
|
|
HStack(spacing: 8) {
|
||
|
|
Text("Done")
|
||
|
|
Spacer()
|
||
|
|
if doneTodos.count > 0 {
|
||
|
|
Text("\(doneTodos.count)")
|
||
|
|
.foregroundStyle(.secondary)
|
||
|
|
}
|
||
|
|
Image(systemName: isDoneExpanded ? "chevron.down" : "chevron.right")
|
||
|
|
.foregroundStyle(.secondary)
|
||
|
|
.font(.caption)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
.buttonStyle(.plain)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
.navigationTitle("Todos")
|
||
|
|
.onAppear {
|
||
|
|
lastDoneCount = doneTodos.count
|
||
|
|
if lastDoneCount == 0 {
|
||
|
|
isDoneExpanded = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
.onChange(of: doneTodos.count) { newCount in
|
||
|
|
if lastDoneCount == 0, newCount > 0, !isDoneExpanded {
|
||
|
|
withAnimation(.easeInOut(duration: 0.15)) {
|
||
|
|
isDoneExpanded = true
|
||
|
|
}
|
||
|
|
} else if newCount == 0, isDoneExpanded {
|
||
|
|
withAnimation(.easeInOut(duration: 0.15)) {
|
||
|
|
isDoneExpanded = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
lastDoneCount = newCount
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private struct TodoRow: View {
|
||
|
|
let item: TodoItem
|
||
|
|
let onToggle: () -> Void
|
||
|
|
let onDelete: () -> Void
|
||
|
|
let onUpdateTitle: (String) -> Void
|
||
|
|
|
||
|
|
@State private var draftTitle: String
|
||
|
|
@FocusState private var isFocused: Bool
|
||
|
|
|
||
|
|
init(item: TodoItem,
|
||
|
|
onToggle: @escaping () -> Void,
|
||
|
|
onDelete: @escaping () -> Void,
|
||
|
|
onUpdateTitle: @escaping (String) -> Void) {
|
||
|
|
self.item = item
|
||
|
|
self.onToggle = onToggle
|
||
|
|
self.onDelete = onDelete
|
||
|
|
self.onUpdateTitle = onUpdateTitle
|
||
|
|
_draftTitle = State(initialValue: item.title)
|
||
|
|
}
|
||
|
|
|
||
|
|
var body: some View {
|
||
|
|
HStack(spacing: 12) {
|
||
|
|
Button(action: onToggle) {
|
||
|
|
Image(systemName: item.isCompleted ? "checkmark.circle.fill" : "circle")
|
||
|
|
.foregroundStyle(item.isCompleted ? .secondary : .primary)
|
||
|
|
}
|
||
|
|
.buttonStyle(.plain)
|
||
|
|
.accessibilityLabel(item.isCompleted ? "Mark incomplete" : "Mark complete")
|
||
|
|
|
||
|
|
TextField("Todo", text: $draftTitle)
|
||
|
|
.focused($isFocused)
|
||
|
|
.submitLabel(.done)
|
||
|
|
.onSubmit { commitTitleIfNeeded() }
|
||
|
|
.onChange(of: isFocused) { focused in
|
||
|
|
if !focused {
|
||
|
|
commitTitleIfNeeded()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
.onChange(of: item.title) { newValue in
|
||
|
|
if !isFocused {
|
||
|
|
draftTitle = newValue
|
||
|
|
}
|
||
|
|
}
|
||
|
|
.foregroundStyle(item.isCompleted ? .secondary : .primary)
|
||
|
|
.opacity(item.isCompleted ? 0.7 : 1.0)
|
||
|
|
}
|
||
|
|
.onDisappear {
|
||
|
|
if isFocused {
|
||
|
|
commitTitleIfNeeded()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||
|
|
Button(role: .destructive, action: onDelete) {
|
||
|
|
Label("Delete", systemImage: "trash")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private func commitTitleIfNeeded() {
|
||
|
|
let trimmed = draftTitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
|
|
guard !trimmed.isEmpty else {
|
||
|
|
draftTitle = item.title
|
||
|
|
return
|
||
|
|
}
|
||
|
|
guard trimmed != item.title else { return }
|
||
|
|
onUpdateTitle(trimmed)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
struct TodosView_Previews: PreviewProvider {
|
||
|
|
static var previews: some View {
|
||
|
|
TodosView()
|
||
|
|
}
|
||
|
|
}
|