Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CalenTask/App/AppRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}
18 changes: 18 additions & 0 deletions CalenTask/App/AppShellView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
78 changes: 78 additions & 0 deletions CalenTask/App/DeletionToast.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
}
8 changes: 1 addition & 7 deletions CalenTask/DesignSystem/Components/TaskContextMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
2 changes: 1 addition & 1 deletion CalenTask/Features/Inbox/TriageRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
2 changes: 1 addition & 1 deletion CalenTask/Features/Projects/PhaseSectionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ struct PhaseSectionView: View {
}
Divider()
Button("Elimina fase", role: .destructive) {
withAnimation { phase.softDeleteSubtree() }
withAnimation { phase.deleteWithUndo() }
}
} label: {
Image(systemName: "ellipsis.circle")
Expand Down
4 changes: 2 additions & 2 deletions CalenTask/Features/Projects/TaskDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -285,7 +285,7 @@ struct TaskDetailView: View {

Section {
Button("Elimina attività", role: .destructive) {
task.softDelete()
task.deleteWithUndo()
dismiss()
}
}
Expand Down
14 changes: 14 additions & 0 deletions CalenTask/Services/TrashService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<TodoTask> { $0.id == taskID }
var descriptor = FetchDescriptor<TodoTask>(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
Expand Down
15 changes: 15 additions & 0 deletions CalenTaskTests/TrashServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,19 @@ struct TrashServiceTests {
#expect(TrashService.emptyTrash(in: context) == 1)
#expect(try context.fetch(FetchDescriptor<TodoTask>()).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)
}
}
Loading