From 71bfdd4087d0b4d94bd5345cb45bb335617d737c Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Thu, 28 May 2026 15:27:05 +0800
Subject: [PATCH] Confirm single-tag removal on empty grid drop
---
Apptag/ContentView.swift | 112 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 files changed, 110 insertions(+), 2 deletions(-)
diff --git a/Apptag/ContentView.swift b/Apptag/ContentView.swift
index c163918..d6ca151 100644
--- a/Apptag/ContentView.swift
+++ b/Apptag/ContentView.swift
@@ -317,6 +317,12 @@
let removableTags: [String]
}
+private struct PendingTagRemovalDrop: Identifiable {
+ let id = UUID()
+ let app: AppInfo
+ let tagName: String
+}
+
private enum SmartStartNoticeMode {
case autoApplied
case suggestionOnly
@@ -371,6 +377,8 @@
@State private var hoveredBubble: AppBubbleContext? = nil
@State private var editingBubble: AppBubbleContext? = nil
@State private var pendingUncategorizedDrop: PendingUncategorizedDrop? = nil
+ @State private var pendingTagRemovalDrop: PendingTagRemovalDrop? = nil
+ @State private var tagRemovalDropSuppressFuturePrompt = false
@State private var appDragResetToken = 0
@State private var bubbleDraftNote = ""
@FocusState private var bubbleNoteFocused: Bool
@@ -406,13 +414,14 @@
@AppStorage("displayMode") private var displayMode = AppDefaults.displayMode
@AppStorage("hideAppNames") private var hideAppNames = AppDefaults.hideAppNames
@AppStorage("showUncommonAppBubbles") private var showUncommonAppBubbles = AppDefaults.showUncommonAppBubbles
+ @AppStorage("skipTagRemovalDropConfirm") private var skipTagRemovalDropConfirm = false
private let editSidebarWidth: CGFloat = 188
private let editSidebarHorizontalInset: CGFloat = 12
private let floatingControlsTrailingInset: CGFloat = 20
private let floatingControlsReservedWidth: CGFloat = 120
private var appBubbleDisabled: Bool {
- appDragModeActive || pendingUncategorizedDrop != nil
+ appDragModeActive || pendingUncategorizedDrop != nil || pendingTagRemovalDrop != nil
}
private let rightSidebarFloatingClearance: CGFloat = 44
@@ -470,6 +479,7 @@
smartStartNoticeOverlay
editActionFeedbackOverlay
uncategorizedDropConfirmOverlay
+ tagRemovalDropConfirmOverlay
}
quickSearchOverlay
@@ -959,6 +969,9 @@
onDropApp: { path, source, target, copy in
dropApp(path: path, sourceTag: source, targetTag: target, copy: copy)
},
+ onDropOutsideGroup: { path, source, copy in
+ dropAppOutsideGroup(path: path, sourceTag: source, copy: copy)
+ },
onGroupActivate: { groupName in
if isColorlessContainerMode {
toggleColorlessFill(groupName)
@@ -1434,6 +1447,34 @@
}
.ignoresSafeArea()
.allowsHitTesting(pendingUncategorizedDrop != nil)
+ }
+
+ private var tagRemovalDropConfirmOverlay: some View {
+ GeometryReader { proxy in
+ if let pendingDrop = pendingTagRemovalDrop {
+ ZStack {
+ Color.black.opacity(0.14)
+ .ignoresSafeArea()
+
+ TagRemovalDropConfirmBubble(
+ title: tr("drop.removeTagConfirmTitle"),
+ message: tagRemovalConfirmMessage(for: pendingDrop),
+ doNotRemindTitle: tr("drop.removeTagDoNotAskAgain"),
+ doNotRemind: $tagRemovalDropSuppressFuturePrompt,
+ cancelTitle: tr("drop.removeTagConfirmNo"),
+ confirmTitle: tr("drop.removeTagConfirmYes"),
+ onCancel: dismissTagRemovalDropConfirm,
+ onConfirm: confirmPendingTagRemovalDrop
+ )
+ .frame(width: min(560, max(350, proxy.size.width - 120)))
+ .position(x: proxy.size.width / 2, y: proxy.size.height / 2)
+ .transition(.scale(scale: 0.94).combined(with: .opacity))
+ }
+ .zIndex(711)
+ }
+ }
+ .ignoresSafeArea()
+ .allowsHitTesting(pendingTagRemovalDrop != nil)
}
private func buildEditActionFeedback(for selectedApps: [AppInfo], tags: [String]) -> EditActionFeedback {
@@ -1970,6 +2011,25 @@
refreshApps(forceLayoutRefresh: true)
}
+ private func dropAppOutsideGroup(path: String, sourceTag: String, copy: Bool) {
+ resetTransientDragState(keepingPendingTagRemovalDrop: true)
+ guard isRemovableRegularTag(sourceTag) else { return }
+ guard let app = allApps.first(where: { $0.path.path == path }),
+ appHasTag(app, tagName: sourceTag)
+ else { return }
+
+ if skipTagRemovalDropConfirm {
+ removeTagFromDroppedApp(app: app, tagName: sourceTag)
+ return
+ }
+
+ clearAppBubbleState()
+ tagRemovalDropSuppressFuturePrompt = false
+ withAnimation(.spring(response: 0.24, dampingFraction: 0.84)) {
+ pendingTagRemovalDrop = PendingTagRemovalDrop(app: app, tagName: sourceTag)
+ }
+ }
+
private func confirmAndMoveAppToUncategorized(path: String) {
guard let app = allApps.first(where: { $0.path.path == path }) else { return }
let removableTags = removableRegularTags(for: app)
@@ -2019,6 +2079,16 @@
)
}
+ private func tagRemovalConfirmMessage(for pendingDrop: PendingTagRemovalDrop) -> String {
+ formattedFeedbackMessage(
+ forKey: "drop.removeTagConfirmMessage",
+ replacements: [
+ "%appName%": pendingDrop.app.displayName,
+ "%tagName%": displayTagName(pendingDrop.tagName)
+ ]
+ )
+ }
+
private func dismissUncategorizedDropConfirm() {
resetTransientDragState(keepingPendingUncategorizedDrop: true)
withAnimation(.easeOut(duration: 0.18)) {
@@ -2036,6 +2106,38 @@
}
guard !tags.isEmpty else { return }
TagEditor.removeTags(tags, from: [path])
+ showDropRefresh()
+ refreshApps(forceLayoutRefresh: true)
+ }
+
+ private func dismissTagRemovalDropConfirm() {
+ resetTransientDragState(keepingPendingTagRemovalDrop: true)
+ tagRemovalDropSuppressFuturePrompt = false
+ withAnimation(.easeOut(duration: 0.18)) {
+ pendingTagRemovalDrop = nil
+ }
+ }
+
+ private func confirmPendingTagRemovalDrop() {
+ guard let pendingDrop = pendingTagRemovalDrop else { return }
+ let app = pendingDrop.app
+ let tagName = pendingDrop.tagName
+ if tagRemovalDropSuppressFuturePrompt {
+ skipTagRemovalDropConfirm = true
+ }
+ resetTransientDragState(keepingPendingTagRemovalDrop: true)
+ tagRemovalDropSuppressFuturePrompt = false
+ withAnimation(.easeOut(duration: 0.16)) {
+ pendingTagRemovalDrop = nil
+ }
+ removeTagFromDroppedApp(app: app, tagName: tagName)
+ }
+
+ private func removeTagFromDroppedApp(app: AppInfo, tagName: String) {
+ guard isRemovableRegularTag(tagName),
+ appHasTag(app, tagName: tagName)
+ else { return }
+ TagEditor.removeTags([tagName], from: [app.path.path])
showDropRefresh()
refreshApps(forceLayoutRefresh: true)
}
@@ -2114,7 +2216,10 @@
}
}
- private func resetTransientDragState(keepingPendingUncategorizedDrop: Bool = false) {
+ private func resetTransientDragState(
+ keepingPendingUncategorizedDrop: Bool = false,
+ keepingPendingTagRemovalDrop: Bool = false
+ ) {
let hadAppDragState = appDragModeActive
AppDragCoordinator.shared.cancelDrag()
if appDragModeActive {
@@ -2139,6 +2244,9 @@
if !keepingPendingUncategorizedDrop, pendingUncategorizedDrop != nil {
pendingUncategorizedDrop = nil
}
+ if !keepingPendingTagRemovalDrop, pendingTagRemovalDrop != nil {
+ pendingTagRemovalDrop = nil
+ }
}
func refreshApps(forceLayoutRefresh: Bool = false) {
--
Gitblit v1.9.3