Skip to content

Commit 8ef688a

Browse files
committed
Add batch selection, onboarding, and wheel prefs
Introduce batch-selection and batch-drag UX in the CA grid (checkbox UI, batch count badge, selection toggles, ordered batch drags, companion-index hiding/restoring, compaction during batch drags and cascade insertion). Add onboarding gating and controls to AppStore (shouldShowOnboarding, onboarding version key, complete/force/evaluate onboarding helpers and detection for existing users). Wire a reverse-wheel-paging preference through AppStore -> CAGridView and expose new UserDefaults key. Add a DevelopmentBackgroundOverride enum for dev screenshot overrides and tweak defaults (PerformanceMode default -> .lean). Refactor compaction helpers (compactedItemsWithinPages) and add moveSelectedAppsAcrossPagesWithCascade to handle ordered multi-app moves. Update representable and LaunchpadView to support new features and menu titles. Misc: layout/view layering updates, UI refresh helpers, and various state syncing to keep imported appearance/input settings in sync.
1 parent 9e16b8d commit 8ef688a

10 files changed

+2533
-674
lines changed

LaunchNext/AppStore.swift

Lines changed: 167 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,25 @@ final class AppStore: ObservableObject {
142142
}
143143
}
144144

145+
enum DevelopmentBackgroundOverride: String, CaseIterable, Identifiable {
146+
case none
147+
case solidWhite
148+
case solidBlack
149+
150+
var id: String { rawValue }
151+
152+
var color: Color? {
153+
switch self {
154+
case .none:
155+
return nil
156+
case .solidWhite:
157+
return .white
158+
case .solidBlack:
159+
return .black
160+
}
161+
}
162+
}
163+
145164
enum SidebarIconPreset: String, CaseIterable, Identifiable {
146165
case large
147166
case medium
@@ -203,6 +222,7 @@ final class AppStore: ObservableObject {
203222
static let activePressEffectKey = "enableActivePressEffect"
204223
static let activePressScaleKey = "activePressScale"
205224
static let followScrollPagingKey = "followScrollPagingEnabled"
225+
static let reverseWheelPagingKey = "reverseWheelPagingDirection"
206226
static let useCAGridRendererKey = "useCAGridRenderer"
207227
static let backgroundStyleKey = "launchpadBackgroundStyle"
208228
static let backgroundMaskEnabledKey = "launchpadBackgroundMaskEnabled"
@@ -222,6 +242,8 @@ final class AppStore: ObservableObject {
222242
private static let voiceFeedbackEnabledKey = "voiceFeedbackEnabled"
223243
static let folderDropZoneScaleKey = "folderDropZoneScale"
224244
static let pageIndicatorTopPaddingKey = "pageIndicatorTopPadding"
245+
static let onboardingVersionKey = "onboardingVersionShown"
246+
static let currentOnboardingVersion = 1
225247
// private static let aiFeatureEnabledKey = "aiFeatureEnabled"
226248
// private static let aiOverlayHotKeyKey = "aiOverlayHotKeyConfiguration"
227249

@@ -437,6 +459,9 @@ final class AppStore: ObservableObject {
437459
}
438460
}
439461

462+
// Development-only override to capture flat screenshots quickly.
463+
@Published var developmentBackgroundOverride: DevelopmentBackgroundOverride = .none
464+
440465
@Published var backgroundMaskEnabled: Bool = AppStore.loadBackgroundMaskEnabled() {
441466
didSet {
442467
UserDefaults.standard.set(backgroundMaskEnabled, forKey: Self.backgroundMaskEnabledKey)
@@ -493,7 +518,26 @@ final class AppStore: ObservableObject {
493518
useLocalizedThirdPartyTitles = UserDefaults.standard.object(forKey: "useLocalizedThirdPartyTitles") as? Bool ?? true
494519
enableAnimations = UserDefaults.standard.object(forKey: "enableAnimations") as? Bool ?? true
495520
scrollSensitivity = UserDefaults.standard.object(forKey: "scrollSensitivity") as? Double ?? scrollSensitivity
496-
useCAGridRenderer = UserDefaults.standard.object(forKey: Self.useCAGridRendererKey) as? Bool ?? false
521+
reverseWheelPagingDirection = UserDefaults.standard.object(forKey: Self.reverseWheelPagingKey) as? Bool ?? false
522+
useCAGridRenderer = UserDefaults.standard.object(forKey: Self.useCAGridRendererKey) as? Bool ?? useCAGridRenderer
523+
524+
// Keep imported appearance/input settings in sync without requiring relaunch.
525+
iconScale = UserDefaults.standard.object(forKey: "iconScale") as? Double ?? iconScale
526+
iconLabelFontSize = UserDefaults.standard.object(forKey: "iconLabelFontSize") as? Double ?? iconLabelFontSize
527+
if let rawFontWeight = UserDefaults.standard.string(forKey: Self.iconLabelFontWeightKey),
528+
let fontWeight = IconLabelFontWeightOption(rawValue: rawFontWeight) {
529+
iconLabelFontWeight = fontWeight
530+
}
531+
532+
pageIndicatorOffset = UserDefaults.standard.object(forKey: "pageIndicatorOffset") as? Double ?? pageIndicatorOffset
533+
let importedTopPadding = UserDefaults.standard.object(forKey: Self.pageIndicatorTopPaddingKey) as? Double ?? pageIndicatorTopPadding
534+
pageIndicatorTopPadding = Self.clampPageIndicatorTopPadding(importedTopPadding)
535+
if let importedPerDisplayEnabled = UserDefaults.standard.object(forKey: Self.pageIndicatorPerDisplayEnabledKey) as? Bool {
536+
pageIndicatorPerDisplayEnabled = importedPerDisplayEnabled
537+
}
538+
pageIndicatorOverrides = Self.loadPageIndicatorOverrides()
539+
540+
globalHotKey = Self.loadHotKeyConfiguration()
497541

498542
// Apply hidden filtering immediately
499543
pruneHiddenAppsFromAppList()
@@ -505,6 +549,7 @@ final class AppStore: ObservableObject {
505549
}
506550
@Published var isSetting = false
507551
@Published var isInitialLoading = true
552+
@Published var shouldShowOnboarding: Bool = false
508553
@Published var currentPage = 0 {
509554
didSet {
510555
if currentPage < 0 { currentPage = 0; return }
@@ -788,6 +833,13 @@ final class AppStore: ObservableObject {
788833
didSet { UserDefaults.standard.set(followScrollPagingEnabled, forKey: Self.followScrollPagingKey) }
789834
}
790835

836+
@Published var reverseWheelPagingDirection: Bool = {
837+
if UserDefaults.standard.object(forKey: AppStore.reverseWheelPagingKey) == nil { return false }
838+
return UserDefaults.standard.bool(forKey: AppStore.reverseWheelPagingKey)
839+
}() {
840+
didSet { UserDefaults.standard.set(reverseWheelPagingDirection, forKey: Self.reverseWheelPagingKey) }
841+
}
842+
791843
@Published var useCAGridRenderer: Bool = {
792844
if UserDefaults.standard.object(forKey: AppStore.useCAGridRendererKey) == nil { return true }
793845
let enabled = UserDefaults.standard.bool(forKey: AppStore.useCAGridRendererKey)
@@ -1562,7 +1614,7 @@ final class AppStore: ObservableObject {
15621614
self.isFullscreenMode = UserDefaults.standard.bool(forKey: "isFullscreenMode")
15631615
}
15641616
if UserDefaults.standard.object(forKey: PerformanceMode.userDefaultsKey) == nil {
1565-
PerformanceMode.persist(.full)
1617+
PerformanceMode.persist(.lean)
15661618
}
15671619
let defaults = UserDefaults.standard
15681620

@@ -1629,6 +1681,9 @@ final class AppStore: ObservableObject {
16291681
if UserDefaults.standard.object(forKey: AppStore.followScrollPagingKey) == nil {
16301682
UserDefaults.standard.set(false, forKey: AppStore.followScrollPagingKey)
16311683
}
1684+
if UserDefaults.standard.object(forKey: AppStore.reverseWheelPagingKey) == nil {
1685+
UserDefaults.standard.set(false, forKey: AppStore.reverseWheelPagingKey)
1686+
}
16321687
if defaults.object(forKey: Self.gameControllerMenuToggleKey) == nil {
16331688
defaults.set(true, forKey: Self.gameControllerMenuToggleKey)
16341689
}
@@ -1783,6 +1838,7 @@ final class AppStore: ObservableObject {
17831838

17841839
func configure(modelContext: ModelContext) {
17851840
self.modelContext = modelContext
1841+
evaluateOnboardingGate()
17861842

17871843
// 立即尝试加载持久化数据(如果已有数据)——不要过早设置标记,等待加载完成时设置
17881844
if !hasAppliedOrderFromStore {
@@ -1814,13 +1870,62 @@ final class AppStore: ObservableObject {
18141870
.store(in: &cancellables)
18151871
}
18161872

1873+
func completeOnboarding() {
1874+
UserDefaults.standard.set(Self.currentOnboardingVersion, forKey: Self.onboardingVersionKey)
1875+
shouldShowOnboarding = false
1876+
}
1877+
1878+
func forceShowOnboarding() {
1879+
guard isFullscreenMode else { return }
1880+
1881+
if isSetting {
1882+
isSetting = false
1883+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { [weak self] in
1884+
guard let self else { return }
1885+
self.shouldShowOnboarding = false
1886+
self.shouldShowOnboarding = true
1887+
}
1888+
return
1889+
}
1890+
1891+
shouldShowOnboarding = false
1892+
DispatchQueue.main.async { [weak self] in
1893+
self?.shouldShowOnboarding = true
1894+
}
1895+
}
1896+
1897+
private func evaluateOnboardingGate() {
1898+
let shownVersion = UserDefaults.standard.object(forKey: Self.onboardingVersionKey) as? Int ?? 0
1899+
guard shownVersion < Self.currentOnboardingVersion else {
1900+
shouldShowOnboarding = false
1901+
return
1902+
}
1903+
1904+
if isExistingUserForOnboarding() {
1905+
UserDefaults.standard.set(Self.currentOnboardingVersion, forKey: Self.onboardingVersionKey)
1906+
shouldShowOnboarding = false
1907+
return
1908+
}
1909+
1910+
shouldShowOnboarding = true
1911+
}
1912+
1913+
private func isExistingUserForOnboarding() -> Bool {
1914+
if !hiddenAppPaths.isEmpty { return true }
1915+
if !customTitles.isEmpty { return true }
1916+
if hasPersistedOrderData() { return true }
1917+
return false
1918+
}
1919+
18171920
// MARK: - Order Persistence
18181921
func applyOrderAndFolders() {
18191922
self.loadAllOrder()
18201923
}
18211924

18221925
// MARK: - Initial scan (once)
18231926
func performInitialScanIfNeeded() {
1927+
guard !hasPerformedInitialScan else { return }
1928+
18241929
// 先尝试加载持久化数据,避免被扫描覆盖(不提前设置标记)
18251930
if !hasAppliedOrderFromStore {
18261931
loadAllOrder()
@@ -2994,13 +3099,18 @@ final class AppStore: ObservableObject {
29943099
/// 单页内自动补位:将每页的 .empty 槽位移动到该页尾部,保持非空项的相对顺序
29953100
func compactItemsWithinPages() {
29963101
guard !items.isEmpty else { return }
3102+
items = filteredItemsRemovingHidden(from: compactedItemsWithinPages(items))
3103+
}
3104+
3105+
private func compactedItemsWithinPages(_ source: [LaunchpadItem]) -> [LaunchpadItem] {
3106+
guard !source.isEmpty else { return source }
29973107
let itemsPerPage = self.itemsPerPage // 使用计算属性
29983108
var result: [LaunchpadItem] = []
2999-
result.reserveCapacity(items.count)
3109+
result.reserveCapacity(source.count)
30003110
var index = 0
3001-
while index < items.count {
3002-
let end = min(index + itemsPerPage, items.count)
3003-
let pageSlice = Array(items[index..<end])
3111+
while index < source.count {
3112+
let end = min(index + itemsPerPage, source.count)
3113+
let pageSlice = Array(source[index..<end])
30043114
var nonEmpty: [LaunchpadItem] = []
30053115
var emptyTokens: [String] = []
30063116
nonEmpty.reserveCapacity(pageSlice.count)
@@ -3025,10 +3135,60 @@ final class AppStore: ObservableObject {
30253135

30263136
index = end
30273137
}
3028-
items = filteredItemsRemovingHidden(from: result)
3138+
return result
30293139
}
30303140

30313141
// MARK: - 跨页拖拽:级联插入(满页则将最后一个推入下一页)
3142+
func moveSelectedAppsAcrossPagesWithCascade(appPathsOrdered: [String], to targetIndex: Int) {
3143+
guard !appPathsOrdered.isEmpty else { return }
3144+
3145+
var seenPaths = Set<String>()
3146+
let normalizedOrderedPaths: [String] = appPathsOrdered.compactMap { raw in
3147+
let normalized = standardizedFilePath(raw)
3148+
guard seenPaths.insert(normalized).inserted else { return nil }
3149+
return normalized
3150+
}
3151+
guard !normalizedOrderedPaths.isEmpty else { return }
3152+
let movingPathSet = Set(normalizedOrderedPaths)
3153+
3154+
var movingItemsByPath: [String: LaunchpadItem] = [:]
3155+
for item in items {
3156+
guard case .app(let app) = item else { continue }
3157+
let path = standardizedFilePath(app.url.path)
3158+
if movingPathSet.contains(path), movingItemsByPath[path] == nil {
3159+
movingItemsByPath[path] = .app(app)
3160+
}
3161+
}
3162+
3163+
let orderedMovingItems = normalizedOrderedPaths.compactMap { movingItemsByPath[$0] }
3164+
guard !orderedMovingItems.isEmpty else { return }
3165+
3166+
var result = items
3167+
let sourceIndexes = result.indices.filter { index in
3168+
guard case .app(let app) = result[index] else { return false }
3169+
return movingPathSet.contains(standardizedFilePath(app.url.path))
3170+
}
3171+
guard !sourceIndexes.isEmpty else { return }
3172+
3173+
for index in sourceIndexes {
3174+
result[index] = .empty(UUID().uuidString)
3175+
}
3176+
3177+
result = compactedItemsWithinPages(result)
3178+
var insertionIndex = max(0, min(targetIndex, result.count))
3179+
3180+
for movingItem in orderedMovingItems {
3181+
result = cascadeInsert(into: result, item: movingItem, at: insertionIndex)
3182+
insertionIndex += 1
3183+
}
3184+
3185+
items = filteredItemsRemovingHidden(from: result)
3186+
compactItemsWithinPages()
3187+
removeEmptyPages()
3188+
triggerGridRefresh()
3189+
saveAllOrder()
3190+
}
3191+
30323192
func moveItemAcrossPagesWithCascade(item: LaunchpadItem, to targetIndex: Int) {
30333193
guard items.indices.contains(targetIndex) || targetIndex == items.count else {
30343194
return

0 commit comments

Comments
 (0)