diff --git a/CalenTask/Features/Settings/SettingsPage.swift b/CalenTask/Features/Settings/SettingsPage.swift index df20345..720483d 100644 --- a/CalenTask/Features/Settings/SettingsPage.swift +++ b/CalenTask/Features/Settings/SettingsPage.swift @@ -11,12 +11,12 @@ import Observation /// Nuovo modulo: `case` in `AppModule` (+ le sue `AppFeature`) e le sue /// opzioni in `ModuleOptions.swift`. Nient'altro da toccare qui. enum SettingsPage: Hashable, Identifiable { - case general, appearance, notifications, sync, developer, account + case general, appearance, notifications, sync, trash, developer, account case modules case module(AppModule) /// Le pagine generali nella barra laterale (l'account ha la sua testata). - static let generalPages: [SettingsPage] = [.general, .appearance, .notifications, .sync, .developer] + static let generalPages: [SettingsPage] = [.general, .appearance, .notifications, .sync, .trash, .developer] var id: String { switch self { @@ -24,6 +24,7 @@ enum SettingsPage: Hashable, Identifiable { case .appearance: "appearance" case .notifications: "notifications" case .sync: "sync" + case .trash: "trash" case .developer: "developer" case .account: "account" case .modules: "modules" @@ -37,6 +38,7 @@ enum SettingsPage: Hashable, Identifiable { case .appearance: "Aspetto" case .notifications: "Notifiche" case .sync: "Sincronizzazione" + case .trash: "Cestino" case .developer: "Sviluppatore" case .account: "Account e spazi" case .modules: "Panoramica moduli" @@ -50,6 +52,7 @@ enum SettingsPage: Hashable, Identifiable { case .appearance: "Tema e contenuto della barra laterale." case .notifications: "Permesso di sistema e riepilogo del mattino." case .sync: "iCloud tra i tuoi dispositivi e calendari di sistema." + case .trash: "Attività eliminate: ripristinale o svuota. Dopo 30 giorni spariscono da sole." case .developer: "Aggiornamenti da GitHub, pre-release e diagnostica." case .account: "Il tuo profilo e gli spazi di lavoro." case .modules: "Accendi solo ciò che ti serve: spegnere un modulo nasconde i suoi strumenti ma conserva dati e preferenze." @@ -63,6 +66,7 @@ enum SettingsPage: Hashable, Identifiable { case .appearance: "paintbrush" case .notifications: "bell.badge" case .sync: "arrow.triangle.2.circlepath.icloud" + case .trash: "trash" case .developer: "hammer" case .account: "person.crop.circle" case .modules: "square.grid.2x2" @@ -76,6 +80,7 @@ enum SettingsPage: Hashable, Identifiable { case .appearance: Color(hex: "#5B5BD6") case .notifications: Color(hex: "#E54666") case .sync: Color(hex: "#0091FF") + case .trash: Color(hex: "#8B8D98") case .developer: Color(hex: "#F76B15") case .account: Color(hex: "#30A46C") case .modules: Color(hex: "#8E4EC6") diff --git a/CalenTask/Features/Settings/SettingsView.swift b/CalenTask/Features/Settings/SettingsView.swift index 5756625..431b4b5 100644 --- a/CalenTask/Features/Settings/SettingsView.swift +++ b/CalenTask/Features/Settings/SettingsView.swift @@ -174,6 +174,7 @@ struct SettingsPageView: View { case .appearance: AppearanceSettingsView() case .notifications: NotificationsSettingsView() case .sync: SyncSettingsView() + case .trash: TrashSettingsView() case .developer: DeveloperSettingsView() case .account: AccountSettingsView() case .modules: ModulesOverviewView() diff --git a/CalenTask/Features/Settings/TrashSettingsView.swift b/CalenTask/Features/Settings/TrashSettingsView.swift new file mode 100644 index 0000000..e030f02 --- /dev/null +++ b/CalenTask/Features/Settings/TrashSettingsView.swift @@ -0,0 +1,59 @@ +import Foundation +import SwiftData +import SwiftUI + +/// Impostazioni › Cestino (#22): le attività eliminate si possono ripristinare +/// (con le sotto-attività) o cancellare per sempre; dopo 30 giorni le elimina +/// `TrashService.purgeExpired`. +struct TrashSettingsView: View { + @Environment(\.modelContext) private var modelContext + @Query( + filter: #Predicate { $0.deletedAt != nil }, + sort: \TodoTask.deletedAt, order: .reverse + ) private var deleted: [TodoTask] + @State private var confirmEmpty = false + + var body: some View { + Form { + Section { SettingsPageHeader(page: .trash) } + + if deleted.isEmpty { + Section { + Label("Il cestino è vuoto.", systemImage: "trash") + .foregroundStyle(.secondary) + } + } else { + Section { + ForEach(deleted) { task in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(task.title.isEmpty ? "Senza titolo" : task.title) + if let when = task.deletedAt { + Text("Eliminata \(when.formatted(.relative(presentation: .named)))") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + Button("Ripristina") { task.restoreSubtree() } + .buttonStyle(.borderless) + } + } + } + Section { + Button("Svuota cestino", role: .destructive) { confirmEmpty = true } + } + } + } + .formStyle(.grouped) + .navigationTitle("Cestino") + .confirmationDialog( + "Eliminare per sempre \(deleted.count) attività?", + isPresented: $confirmEmpty, titleVisibility: .visible + ) { + Button("Elimina per sempre", role: .destructive) { + TrashService.deleteForever(deleted, in: modelContext) + } + } + } +} diff --git a/CalenTask/Models/TodoTask+Mutations.swift b/CalenTask/Models/TodoTask+Mutations.swift index b088e63..69a7364 100644 --- a/CalenTask/Models/TodoTask+Mutations.swift +++ b/CalenTask/Models/TodoTask+Mutations.swift @@ -32,6 +32,17 @@ extension TodoTask { touch() } + /// Ripristina dal cestino la task e le sotto-attività eliminate con lei. + @MainActor + func restoreSubtree() { + guard deletedAt != nil else { return } + for child in subtasks where child.deletedAt != nil { + child.restoreSubtree() + } + deletedAt = nil + touch() + } + /// Soft-deletes the whole subtree. Deleting a phase takes its children /// with it (coherent with the unified tree — see Progettazione/04). @MainActor diff --git a/CalenTask/Services/TrashService.swift b/CalenTask/Services/TrashService.swift index a6bdda1..d50b9a9 100644 --- a/CalenTask/Services/TrashService.swift +++ b/CalenTask/Services/TrashService.swift @@ -27,4 +27,12 @@ enum TrashService { context.saveOrLog() return purgeable.count } + + /// Elimina subito e per sempre le task indicate (svuota cestino). + @MainActor + static func deleteForever(_ tasks: [TodoTask], in context: ModelContext) { + guard !tasks.isEmpty else { return } + tasks.forEach { context.delete($0) } + context.saveOrLog() + } } diff --git a/CalenTaskTests/TrashServiceTests.swift b/CalenTaskTests/TrashServiceTests.swift index 3f3a536..7b34822 100644 --- a/CalenTaskTests/TrashServiceTests.swift +++ b/CalenTaskTests/TrashServiceTests.swift @@ -35,4 +35,19 @@ struct TrashServiceTests { let titles = try context.fetch(FetchDescriptor()).map(\.title).sorted() #expect(titles == ["Recente", "Viva"]) } + + @Test func restoreSubtreeBringsBackChildrenToo() throws { + let context = try makeContext() + let author = UUID(), ws = UUID() + let parent = TodoTask(workspaceID: ws, title: "Padre", createdByID: author) + let child = TodoTask(workspaceID: ws, title: "Figlio", createdByID: author) + context.insert(parent) + context.insert(child) + child.parentTask = parent + parent.softDeleteSubtree() + #expect(parent.deletedAt != nil && child.deletedAt != nil) + + parent.restoreSubtree() + #expect(parent.deletedAt == nil && child.deletedAt == nil) + } }