From 369c8f03db2f6d507697ab2296db5e6f81c8cd1e Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sat, 27 Jun 2026 17:15:11 +0800
Subject: [PATCH] Reduce SmartStart default categories to thirteen
---
src/Apptag/ContentView.swift | 399 +++++++++++++++++++++++++++++++++++++++++---------------
1 files changed, 292 insertions(+), 107 deletions(-)
diff --git a/src/Apptag/ContentView.swift b/src/Apptag/ContentView.swift
index 7ab2869..2560bb9 100644
--- a/src/Apptag/ContentView.swift
+++ b/src/Apptag/ContentView.swift
@@ -16,7 +16,10 @@
enum SettingsTabTarget {
static let userInfoKey = "tab"
+ static let theme = "theme"
static let tags = "tags"
+ static let data = "data"
+ static let about = "about"
}
// MARK: - Edit Phase
@@ -326,6 +329,7 @@
var editingBubble: AppBubbleContext? = nil
var pendingUncategorizedDrop: PendingUncategorizedDrop? = nil
var pendingTagRemovalDrop: PendingTagRemovalDrop? = nil
+ var proPrompt: PendingProPrompt? = nil
var uncategorizedDropSuppressFuturePrompt = false
var tagRemovalDropSuppressFuturePrompt = false
var usageTipsCloseReminderVisible = false
@@ -357,11 +361,18 @@
let tagName: String
}
+private struct PendingProPrompt: Identifiable {
+ let id = UUID()
+ let feature: ProFeature
+ let noteQuotaStatus: ProNoteQuotaStatus?
+}
+
struct ContentView: View {
let hideOverlay: () -> Void
private let initialQuickSearchSource: String?
@Environment(\.colorScheme) private var colorScheme
+ @ObservedObject private var proEntitlement = ProEntitlementCenter.shared
@State private var allApps: [AppInfo] = []
@State private var displayGroups: [TagGroup] = []
@State private var tagColors: [String: Int] = [:]
@@ -453,6 +464,7 @@
|| appGridInteraction.pendingUncategorizedDrop != nil
|| appGridInteraction.pendingTagRemovalDrop != nil
|| appGridInteraction.usageTipsCloseReminderVisible
+ || appGridInteraction.proPrompt != nil
}
private var appGridHighlightedGroupName: String? {
appGridInteraction.tagNavigationHoveredGroupName
@@ -472,7 +484,9 @@
}
private var renderedAppGridTheme: AppGridTheme {
- editPhase == .none ? appGridTheme : .defaultLight
+ editPhase == .none
+ ? ProEntitlementPolicy.effectiveTheme(for: appGridTheme)
+ : .defaultLight
}
private var isSideLayout: Bool {
@@ -512,6 +526,65 @@
}
var body: some View {
+ contentStack
+ .onAppear(perform: handleContentAppear)
+ .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidShow), perform: handleOverlayDidShow)
+ .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidHide), perform: handleOverlayDidHide)
+ .onReceive(NotificationCenter.default.publisher(for: .tagLauncherQuickSearchRequested), perform: handleQuickSearchRequested)
+ .onReceive(NotificationCenter.default.publisher(for: .tagLauncherQuickSearchDismissRequested), perform: handleQuickSearchDismissRequested)
+ .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification), perform: handleApplicationDidBecomeActive)
+ .onReceive(NotificationCenter.default.publisher(for: .tagLauncherDataDidChange), perform: handleDataDidChange)
+ .onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange), perform: handleAppLanguageDidChange)
+ .onChange(of: editPhase) { _, newPhase in
+ let active = newPhase != .none
+ dismissAppBubble()
+ NotificationCenter.default.post(
+ name: .tagLauncherEditModeChanged,
+ object: nil,
+ userInfo: ["active": active]
+ )
+ }
+ .onChange(of: bubbleNoteFocused) { _, focused in
+ if appGridInteraction.editingBubble != nil && !focused {
+ commitBubbleNote()
+ }
+ }
+ .onChange(of: draggedTagNames) { _, newOrder in
+ rebuildDisplayGroups(apps: allApps, tagOrder: newOrder)
+ }
+ .onChange(of: defaultGroupName) { _, _ in
+ rebuildDisplayGroups(apps: allApps, tagOrder: draggedTagNames)
+ }
+ .onChange(of: quickSearchQuery) { _, _ in
+ quickSearchErrorMessage = nil
+ refreshQuickSearchResults()
+ }
+ .onChange(of: appGridInteraction.pendingUncategorizedDrop != nil) { _, _ in
+ publishModalInteractionState()
+ }
+ .onChange(of: appGridInteraction.pendingTagRemovalDrop != nil) { _, _ in
+ publishModalInteractionState()
+ }
+ .onChange(of: appGridInteraction.proPrompt != nil) { _, _ in
+ publishModalInteractionState()
+ }
+ .onChange(of: smartStartNotice != nil) { _, _ in
+ publishModalInteractionState()
+ }
+ .onChange(of: proEntitlement.accessState) { _, newState in
+ handleProAccessStateChange(newState)
+ }
+ .onDisappear {
+ NotificationCenter.default.post(
+ name: .tagLauncherModalInteractionChanged,
+ object: nil,
+ userInfo: ["active": false]
+ )
+ }
+ }
+
+ @ViewBuilder
+ private var contentStack: some View {
ZStack {
if shouldRenderAppGridBehindQuickSearch {
appGridBackground
@@ -525,108 +598,6 @@
dropWarningToastOverlay
dropRefreshOverlay
- }
- .onAppear {
- refreshNotchHeight()
- refreshAppsForOverlay()
- if let initialQuickSearchSource, !initialQuickSearchConsumed {
- initialQuickSearchConsumed = true
- if initialQuickSearchSource == QuickSearchOpenSource.globalHidden {
- quickSearchCloseHidesOverlay = true
- quickSearchOnlySession = true
- }
- if !quickSearchVisible {
- quickSearchVisible = true
- quickSearchFocusToken &+= 1
- }
- quickSearchOpenedAt = Date()
- refreshQuickSearchResults()
- refreshAppsForQuickSearch()
- NotificationCenter.default.post(
- name: .tagLauncherQuickSearchVisibilityChanged,
- object: nil,
- userInfo: [
- "active": true,
- "hideOverlayOnClose": quickSearchCloseHidesOverlay
- ]
- )
- }
- }
- .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidShow)) { _ in
- resetTransientDragState()
- refreshNotchHeight()
- refreshAppsForOverlay()
- }
- .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidHide)) { _ in
- resetTransientDragState()
- closeQuickSearch(notify: true, hideOverlayIfNeeded: false)
- quickSearchOnlySession = false
- quickSearchCloseHidesOverlay = false
- }
- .onReceive(NotificationCenter.default.publisher(for: .tagLauncherQuickSearchRequested)) { notification in
- let source = notification.userInfo?["source"] as? String ?? QuickSearchOpenSource.mainOverlay
- openQuickSearch(source: source)
- }
- .onReceive(NotificationCenter.default.publisher(for: .tagLauncherQuickSearchDismissRequested)) { notification in
- let source = notification.userInfo?["source"] as? String
- closeQuickSearch(dismissalSource: source)
- }
- .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
- guard allApps.isEmpty, !refreshInProgress else { return }
- refreshApps()
- }
- .onReceive(NotificationCenter.default.publisher(for: .tagLauncherDataDidChange)) { _ in
- refreshApps(forceLayoutRefresh: true)
- }
- .onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { _ in
- let appsSnapshot = allApps
- DispatchQueue.global(qos: .userInitiated).async {
- _ = AppleDefaultAppCatalog.relocalizeDefaultNotesForCurrentLanguage(apps: appsSnapshot)
- _ = SmartStartService.relocalizeDefaultNotesForCurrentLanguage(apps: appsSnapshot)
- DispatchQueue.main.async {
- refreshApps(forceLayoutRefresh: true)
- }
- }
- }
- .onChange(of: editPhase) { _, newPhase in
- let active = newPhase != .none
- dismissAppBubble()
- NotificationCenter.default.post(
- name: .tagLauncherEditModeChanged,
- object: nil,
- userInfo: ["active": active]
- )
- }
- .onChange(of: bubbleNoteFocused) { _, focused in
- if appGridInteraction.editingBubble != nil && !focused {
- commitBubbleNote()
- }
- }
- .onChange(of: draggedTagNames) { _, newOrder in
- rebuildDisplayGroups(apps: allApps, tagOrder: newOrder)
- }
- .onChange(of: defaultGroupName) { _, _ in
- rebuildDisplayGroups(apps: allApps, tagOrder: draggedTagNames)
- }
- .onChange(of: quickSearchQuery) { _, _ in
- quickSearchErrorMessage = nil
- refreshQuickSearchResults()
- }
- .onChange(of: appGridInteraction.pendingUncategorizedDrop != nil) { _, _ in
- publishModalInteractionState()
- }
- .onChange(of: appGridInteraction.pendingTagRemovalDrop != nil) { _, _ in
- publishModalInteractionState()
- }
- .onChange(of: smartStartNotice != nil) { _, _ in
- publishModalInteractionState()
- }
- .onDisappear {
- NotificationCenter.default.post(
- name: .tagLauncherModalInteractionChanged,
- object: nil,
- userInfo: ["active": false]
- )
}
}
@@ -657,6 +628,7 @@
uncategorizedDropConfirmOverlay
tagRemovalDropConfirmOverlay
usageTipsCloseReminderOverlay
+ proAccessPromptOverlay
}
@ViewBuilder
@@ -674,6 +646,31 @@
)
.transition(.scale(scale: 0.96).combined(with: .opacity))
.allowsHitTesting(false)
+ }
+ }
+
+ @ViewBuilder
+ private var proAccessPromptOverlay: some View {
+ if let prompt = appGridInteraction.proPrompt {
+ ZStack {
+ Color.black.opacity(0.16)
+ .ignoresSafeArea()
+
+ ProUpgradePromptView(
+ title: tr(prompt.feature.titleKey),
+ message: proPromptMessage(for: prompt),
+ benefitText: tr(prompt.feature.benefitKey),
+ operationText: proEntitlement.operationState.statusMessageKey.map(tr),
+ unlockTitle: tr("pro.card.unlock"),
+ restoreTitle: tr("pro.card.restore"),
+ isBusy: proEntitlement.operationState.isBusy,
+ onClose: dismissProPrompt,
+ onUnlock: { proEntitlement.purchasePro() },
+ onRestore: { proEntitlement.restorePurchases() }
+ )
+ }
+ .transition(.opacity)
+ .zIndex(920)
}
}
@@ -709,6 +706,7 @@
|| appGridInteraction.pendingUncategorizedDrop != nil
|| appGridInteraction.pendingTagRemovalDrop != nil
|| appGridInteraction.usageTipsCloseReminderVisible
+ || appGridInteraction.proPrompt != nil
]
)
}
@@ -1291,6 +1289,8 @@
isEditing: editing,
placement: placement,
arrowOffset: metrics.arrowOffset,
+ noteQuotaText: editing ? noteQuotaHintText(for: context.app) : nil,
+ noteQuotaWarning: editing && isNoteQuotaWarning(for: context.app),
draftNote: $appGridInteraction.bubbleDraftNote,
noteFocused: $bubbleNoteFocused,
onCommit: commitBubbleNote,
@@ -2088,10 +2088,18 @@
private func commitBubbleNote() {
guard let context = appGridInteraction.editingBubble else { return }
+ guard appGridInteraction.proPrompt == nil else { return }
let limited = String(appGridInteraction.bubbleDraftNote.prefix(TagDatabase.maxAppNoteLength))
.trimmingCharacters(in: .whitespacesAndNewlines)
- TagEditor.setAppNote(limited, for: context.app.path.path)
+ let decision = TagEditor.setAppNote(limited, for: context.app.path.path)
appGridInteraction.bubbleDraftNote = limited
+ guard case .allow = decision else {
+ if case .blocked(let status) = decision {
+ presentProPrompt(for: .unlimitedNotes, noteQuotaStatus: status)
+ }
+ bubbleNoteFocused = false
+ return
+ }
appGridInteraction.editingBubble = nil
bubbleNoteFocused = false
notifyAppNoteEditing(active: false)
@@ -2135,6 +2143,159 @@
return app.note ?? ""
}
+ private func noteQuotaStatus(for app: AppInfo) -> ProNoteQuotaStatus? {
+ guard !proEntitlement.isUnlocked else { return nil }
+ let decision = ProEntitlementPolicy.noteSaveDecision(
+ note: appGridInteraction.bubbleDraftNote,
+ for: app.path.path
+ )
+ switch decision {
+ case .allow(let status), .blocked(let status):
+ return status
+ }
+ }
+
+ private func noteQuotaHintText(for app: AppInfo) -> String? {
+ guard let status = noteQuotaStatus(for: app), !status.isUnlimited else { return nil }
+ if status.remaining <= 0 {
+ return tr("pro.notes.limitReached")
+ }
+ return tr(
+ "pro.notes.remaining",
+ replacements: [
+ "%remaining%": "\(status.remaining)",
+ "%limit%": "\(status.limit)"
+ ]
+ )
+ }
+
+ private func isNoteQuotaWarning(for app: AppInfo) -> Bool {
+ guard let status = noteQuotaStatus(for: app), !status.isUnlimited else { return false }
+ return status.remaining <= 0
+ }
+
+ private func proPromptMessage(for prompt: PendingProPrompt) -> String {
+ if prompt.feature == .unlimitedNotes,
+ let status = prompt.noteQuotaStatus,
+ !status.isUnlimited,
+ status.remaining <= 0 {
+ return tr("pro.notes.limitReached")
+ }
+ return tr(prompt.feature.promptMessageKey)
+ }
+
+ private func presentProPrompt(for feature: ProFeature, noteQuotaStatus: ProNoteQuotaStatus? = nil) {
+ proEntitlement.clearTransientOperationState()
+ withAnimation(.spring(response: 0.22, dampingFraction: 0.84)) {
+ appGridInteraction.proPrompt = PendingProPrompt(
+ feature: feature,
+ noteQuotaStatus: noteQuotaStatus
+ )
+ }
+ }
+
+ private func dismissProPrompt() {
+ proEntitlement.clearTransientOperationState()
+ withAnimation(.easeOut(duration: 0.18)) {
+ appGridInteraction.proPrompt = nil
+ }
+ }
+
+ private func handleProAccessStateChange(_ newState: ProAccessState) {
+ guard newState.isUnlocked else { return }
+
+ if let preview = proEntitlement.themePreviewState {
+ appGridThemeID = preview.theme.rawValue
+ proEntitlement.stopThemePreview()
+ }
+
+ let hadPrompt = appGridInteraction.proPrompt != nil
+ if hadPrompt {
+ dismissProPrompt()
+ }
+
+ if appGridInteraction.editingBubble != nil {
+ DispatchQueue.main.async {
+ bubbleNoteFocused = true
+ }
+ }
+ }
+
+ private func handleContentAppear() {
+ refreshNotchHeight()
+ refreshAppsForOverlay()
+ guard let initialQuickSearchSource, !initialQuickSearchConsumed else { return }
+
+ initialQuickSearchConsumed = true
+ if initialQuickSearchSource == QuickSearchOpenSource.globalHidden {
+ quickSearchCloseHidesOverlay = true
+ quickSearchOnlySession = true
+ }
+ if !quickSearchVisible {
+ quickSearchVisible = true
+ quickSearchFocusToken &+= 1
+ }
+ quickSearchOpenedAt = Date()
+ refreshQuickSearchResults()
+ refreshAppsForQuickSearch()
+ NotificationCenter.default.post(
+ name: .tagLauncherQuickSearchVisibilityChanged,
+ object: nil,
+ userInfo: [
+ "active": true,
+ "hideOverlayOnClose": quickSearchCloseHidesOverlay
+ ]
+ )
+ }
+
+ private func handleOverlayDidShow(_ notification: Notification) {
+ _ = notification
+ resetTransientDragState()
+ refreshNotchHeight()
+ refreshAppsForOverlay()
+ }
+
+ private func handleOverlayDidHide(_ notification: Notification) {
+ _ = notification
+ resetTransientDragState()
+ closeQuickSearch(notify: true, hideOverlayIfNeeded: false)
+ quickSearchOnlySession = false
+ quickSearchCloseHidesOverlay = false
+ }
+
+ private func handleQuickSearchRequested(_ notification: Notification) {
+ let source = notification.userInfo?["source"] as? String ?? QuickSearchOpenSource.mainOverlay
+ openQuickSearch(source: source)
+ }
+
+ private func handleQuickSearchDismissRequested(_ notification: Notification) {
+ let source = notification.userInfo?["source"] as? String
+ closeQuickSearch(dismissalSource: source)
+ }
+
+ private func handleApplicationDidBecomeActive(_ notification: Notification) {
+ _ = notification
+ guard allApps.isEmpty, !refreshInProgress else { return }
+ refreshApps()
+ }
+
+ private func handleDataDidChange(_ notification: Notification) {
+ _ = notification
+ refreshApps(forceLayoutRefresh: true)
+ }
+
+ private func handleAppLanguageDidChange(_ notification: Notification) {
+ _ = notification
+ let appsSnapshot = allApps
+ DispatchQueue.global(qos: .userInitiated).async {
+ _ = AppleDefaultAppCatalog.relocalizeDefaultNotesForCurrentLanguage(apps: appsSnapshot)
+ _ = SmartStartService.relocalizeDefaultNotesForCurrentLanguage(apps: appsSnapshot)
+ DispatchQueue.main.async {
+ refreshApps(forceLayoutRefresh: true)
+ }
+ }
+ }
+
private func bubblePlacement(for frame: CGRect, rootFrame: CGRect) -> BubblePlacement {
frame.minY < 170 ? .below : .above
}
@@ -2170,7 +2331,8 @@
private func estimatedBubbleHeight(for app: AppInfo, width: CGFloat, isEditing: Bool) -> CGFloat {
if isEditing {
- return 120
+ let hint = noteQuotaHintText(for: app)
+ return hint == nil ? 124 : 148
}
let note = currentNote(for: app).trimmingCharacters(in: .whitespacesAndNewlines)
guard !note.isEmpty else { return 68 }
@@ -2395,6 +2557,10 @@
containerAppOrder[key] = paths
rebuildDisplayGroups(apps: allApps, tagOrder: draggedTagNames)
+ guard proEntitlement.isUnlocked else {
+ showDropWarning(message: tr("pro.sorting.previewToast"))
+ return
+ }
TagEditor.reorderApps(inContainer: key, orderedPaths: paths)
refreshApps(forceLayoutRefresh: true)
}
@@ -2615,9 +2781,9 @@
return !protectedNames.contains(tag) && tagColors[tag] != nil
}
- private func showDropWarning() {
+ private func showDropWarning(message: String = tr("drop.systemDefaultWarning")) {
withAnimation(.spring(response: 0.24, dampingFraction: 0.82)) {
- appGridInteraction.dropWarningToast = tr("drop.systemDefaultWarning")
+ appGridInteraction.dropWarningToast = message
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.6) {
withAnimation(.easeOut(duration: 0.18)) {
@@ -2788,6 +2954,15 @@
closeQuickSearch(hideOverlayIfNeeded: true)
hideOverlay()
}
+ if isCurrentApp(app) {
+ if closeQuickSearchOnSuccess && !closeQuickSearchAndOverlayBeforeOpening {
+ closeQuickSearch(hideOverlayIfNeeded: closeOverlayOnSuccess)
+ }
+ if closeOverlayOnSuccess && !closeQuickSearchAndOverlayBeforeOpening {
+ hideOverlay()
+ }
+ return
+ }
let configuration = NSWorkspace.OpenConfiguration()
NSWorkspace.shared.openApplication(at: app.path, configuration: configuration) { _, error in
DispatchQueue.main.async {
@@ -2809,6 +2984,16 @@
}
}
+ private func isCurrentApp(_ app: AppInfo) -> Bool {
+ if app.bundleIdentifier?.caseInsensitiveCompare(AppIdentity.bundleIdentifier) == .orderedSame {
+ return true
+ }
+
+ let currentURL = Bundle.main.bundleURL.standardizedFileURL.resolvingSymlinksInPath()
+ let appURL = app.path.standardizedFileURL.resolvingSymlinksInPath()
+ return currentURL == appURL
+ }
+
func openApp(_ app: AppInfo) {
hideOverlay()
launchApp(app, closeOverlayOnSuccess: false)
--
Gitblit v1.9.3