diff --git a/CalenTask/App/AppRouter.swift b/CalenTask/App/AppRouter.swift index 570a8c4..acc9ed3 100644 --- a/CalenTask/App/AppRouter.swift +++ b/CalenTask/App/AppRouter.swift @@ -98,6 +98,9 @@ final class AppRouter { /// Un solo ingresso alle Impostazioni su iOS (D70); macOS usa la scena Settings. var isSettingsOpen = false + /// #22 — ultima eliminazione, mostrata nel toast "Eliminata · Annulla". + var deletionNotice: DeletionNotice? + func go(_ section: AppSection) { destination = .section(section) } @@ -126,4 +129,16 @@ final class AppRouter { func inspect(taskID: UUID) { taskForInspector = taskID } + + func announceDeletion(taskID: UUID, title: String) { + deletionNotice = DeletionNotice(taskID: taskID, title: title) + } +} + +/// #22 — cosa è appena finito nel cestino. `id` cambia a ogni eliminazione, +/// così il timer del toast riparte anche se si elimina la stessa task. +struct DeletionNotice: Identifiable, Equatable { + let id = UUID() + let taskID: UUID + let title: String } diff --git a/CalenTask/App/AppShellView.swift b/CalenTask/App/AppShellView.swift index 16bfbbc..8d3dce5 100644 --- a/CalenTask/App/AppShellView.swift +++ b/CalenTask/App/AppShellView.swift @@ -73,6 +73,15 @@ struct AppShellView: View { captureSheet } #endif + // #22 — "Eliminata · Annulla" dopo ogni eliminazione dall'interfaccia. + .overlay(alignment: .bottom) { + if let notice = router.deletionNotice { + DeletionToast(notice: notice) + .padding(.bottom, deletionToastBottomPadding) + .id(notice.id) + } + } + .animation(.dsQuick, value: router.deletionNotice) // La finestra intera, non la split view: la sua larghezza non cambia // quando l'inspector entra o esce. .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width in @@ -245,6 +254,15 @@ struct AppShellView: View { ) } + /// Sopra la tab bar e il pulsante "+" su iPhone; vicino al bordo altrove. + private var deletionToastBottomPadding: CGFloat { + #if os(iOS) + horizontalSizeClass == .regular ? DS.l : 128 + #else + DS.l + #endif + } + #if os(macOS) /// F18 — pannello flottante: backdrop cliccabile + carta centrata in alto. private var captureOverlay: some View { diff --git a/CalenTask/App/DeletionToast.swift b/CalenTask/App/DeletionToast.swift new file mode 100644 index 0000000..f385a94 --- /dev/null +++ b/CalenTask/App/DeletionToast.swift @@ -0,0 +1,78 @@ +import SwiftUI +import SwiftData + +extension TodoTask { + /// #22 — eliminazione dall'interfaccia (menu, swipe, dettaglio): una fase + /// si porta dietro il sottoalbero, e compare il toast "Eliminata · Annulla". + @MainActor + func deleteWithUndo() { + if isPhase { + softDeleteSubtree() + } else { + softDelete() + } + AppRouter.shared.announceDeletion(taskID: id, title: title) + } +} + +/// #22 — "Eliminata · Annulla": per qualche secondo dopo un'eliminazione si +/// può rimettere la task (col suo sottoalbero) dove stava. Poi resta il Cestino. +struct DeletionToast: View { + @Environment(AppRouter.self) private var router + @Environment(\.modelContext) private var modelContext + let notice: DeletionNotice + + static let visibleFor: Duration = .seconds(6) + + var body: some View { + HStack(spacing: DS.m) { + Image(systemName: "trash") + .foregroundStyle(.secondary) + Text("Eliminata: \(notice.title)") + .font(.dsMeta) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: DS.s) + Button("Annulla") { undo() } + .font(.dsMeta.weight(.semibold)) + .buttonStyle(.borderless) + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.dsCaption) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel("Chiudi") + } + .padding(.horizontal, DS.l) + .padding(.vertical, DS.m) + .frame(maxWidth: 480) + .background(.regularMaterial, in: Capsule()) + .overlay { Capsule().strokeBorder(DSColor.hairline) } + .shadow(color: .black.opacity(0.12), radius: 12, y: 4) + .padding(.horizontal, DS.l) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .accessibilityElement(children: .contain) + .task(id: notice.id) { + do { + try await Task.sleep(for: Self.visibleFor) + } catch { + return // sostituito da un'altra eliminazione o chiuso a mano + } + if router.deletionNotice?.id == notice.id { dismiss() } + } + } + + private func undo() { + withAnimation(.dsQuick) { + TrashService.restore(taskID: notice.taskID, in: modelContext) + router.deletionNotice = nil + } + } + + private func dismiss() { + withAnimation(.dsQuick) { router.deletionNotice = nil } + } +} diff --git a/CalenTask/DesignSystem/Components/TaskContextMenu.swift b/CalenTask/DesignSystem/Components/TaskContextMenu.swift index eac9097..7b43c79 100644 --- a/CalenTask/DesignSystem/Components/TaskContextMenu.swift +++ b/CalenTask/DesignSystem/Components/TaskContextMenu.swift @@ -237,13 +237,7 @@ private struct TaskMenuItems: View { Divider() Button(role: .destructive) { - withAnimation { - if task.isPhase { - task.softDeleteSubtree() - } else { - task.softDelete() - } - } + withAnimation { task.deleteWithUndo() } } label: { Label("Elimina", systemImage: "trash") } diff --git a/CalenTask/Features/Inbox/TriageRow.swift b/CalenTask/Features/Inbox/TriageRow.swift index 865f2ba..0b82a6f 100644 --- a/CalenTask/Features/Inbox/TriageRow.swift +++ b/CalenTask/Features/Inbox/TriageRow.swift @@ -22,7 +22,7 @@ struct TriageRow: View { } .swipeActions(edge: .trailing, allowsFullSwipe: false) { Button(role: .destructive) { - withAnimation { task.softDelete() } + withAnimation { task.deleteWithUndo() } } label: { Label("Elimina", systemImage: "trash") } diff --git a/CalenTask/Features/Projects/PhaseSectionView.swift b/CalenTask/Features/Projects/PhaseSectionView.swift index b17f19a..d21627a 100644 --- a/CalenTask/Features/Projects/PhaseSectionView.swift +++ b/CalenTask/Features/Projects/PhaseSectionView.swift @@ -77,7 +77,7 @@ struct PhaseSectionView: View { } Divider() Button("Elimina fase", role: .destructive) { - withAnimation { phase.softDeleteSubtree() } + withAnimation { phase.deleteWithUndo() } } } label: { Image(systemName: "ellipsis.circle") diff --git a/CalenTask/Features/Projects/TaskDetailView.swift b/CalenTask/Features/Projects/TaskDetailView.swift index 30f7966..dbd32da 100644 --- a/CalenTask/Features/Projects/TaskDetailView.swift +++ b/CalenTask/Features/Projects/TaskDetailView.swift @@ -237,7 +237,7 @@ struct TaskDetailView: View { .foregroundStyle(subtask.isDone ? .secondary : .primary) Spacer() Button(role: .destructive) { - withAnimation { subtask.softDelete() } + withAnimation { subtask.deleteWithUndo() } } label: { Image(systemName: "trash") .font(.dsCaption) @@ -285,7 +285,7 @@ struct TaskDetailView: View { Section { Button("Elimina attività", role: .destructive) { - task.softDelete() + task.deleteWithUndo() dismiss() } } diff --git a/CalenTask/Services/TrashService.swift b/CalenTask/Services/TrashService.swift index 9507c4e..e47067a 100644 --- a/CalenTask/Services/TrashService.swift +++ b/CalenTask/Services/TrashService.swift @@ -48,6 +48,20 @@ enum TrashService { task.touch() } + /// Ripristino per ID (toast "Eliminata · Annulla"): ritorna false se la + /// task non c'è più o non è nel cestino. + @MainActor + @discardableResult + static func restore(taskID: UUID, in context: ModelContext) -> Bool { + let predicate = #Predicate { $0.id == taskID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + guard let task = context.fetchOrLog(descriptor).first, + task.deletedAt != nil else { return false } + restore(task) + return true + } + /// Elimina definitivamente tutto il cestino. Ritorna quante task ha rimosso. @MainActor @discardableResult diff --git a/CalenTaskTests/TrashServiceTests.swift b/CalenTaskTests/TrashServiceTests.swift index a351f97..74b9b17 100644 --- a/CalenTaskTests/TrashServiceTests.swift +++ b/CalenTaskTests/TrashServiceTests.swift @@ -61,4 +61,19 @@ struct TrashServiceTests { #expect(TrashService.emptyTrash(in: context) == 1) #expect(try context.fetch(FetchDescriptor()).map(\.title) == ["Viva"]) } + + /// Toast "Eliminata · Annulla": ripristino per ID, solo se è nel cestino. + @Test func restoreByIDOnlyTouchesTrashedTask() throws { + let context = try makeContext() + let author = UUID(), ws = UUID() + let task = TodoTask(workspaceID: ws, title: "Annullabile", createdByID: author) + context.insert(task) + try context.save() + + #expect(TrashService.restore(taskID: task.id, in: context) == false) + task.softDelete() + #expect(TrashService.restore(taskID: task.id, in: context)) + #expect(task.deletedAt == nil) + #expect(TrashService.restore(taskID: UUID(), in: context) == false) + } }