Переглянути джерело

Add custom app creation and persistent tile reordering.

Implement a create-app sheet, enable drag-to-reorder in the grid, and persist both custom apps and tile order across app launches.

Made-with: Cursor
huzaifahayat12 4 місяців тому
батько
коміт
c48ff0557e

+ 0 - 9
google_apps/AppTileView.swift

@@ -88,15 +88,6 @@ struct AppTileView: View {
             .animation(.easeOut(duration: 0.12), value: isPressing)
         }
         .buttonStyle(.plain)
-        .simultaneousGesture(
-            DragGesture(minimumDistance: 0)
-                .onChanged { _ in
-                    isPressing = true
-                }
-                .onEnded { _ in
-                    isPressing = false
-                }
-        )
         .onHover { hovering in
             isHovering = hovering
             if !hovering {

+ 187 - 0
google_apps/CreateAppSheetView.swift

@@ -0,0 +1,187 @@
+import SwiftUI
+
+struct CreateAppSheetView: View {
+    struct NewAppInput {
+        let name: String
+        let url: URL
+    }
+
+    @Environment(\.dismiss) private var dismiss
+
+    @State private var name = ""
+    @State private var urlText = ""
+    @State private var urlError: String?
+
+    let onSave: (NewAppInput) -> Void
+
+    var body: some View {
+        ZStack {
+            backgroundLayer
+
+            VStack(spacing: 22) {
+                header
+                formContent
+                footer
+            }
+            .padding(.horizontal, 20)
+            .padding(.vertical, 16)
+        }
+    }
+
+    private var backgroundLayer: some View {
+        ZStack {
+            LinearGradient(
+                colors: [Color.black.opacity(0.95), Color(red: 0.05, green: 0.08, blue: 0.1)],
+                startPoint: .topLeading,
+                endPoint: .bottomTrailing
+            )
+            .ignoresSafeArea()
+
+            Circle()
+                .fill(Color.blue.opacity(0.18))
+                .blur(radius: 90)
+                .frame(width: 360, height: 360)
+                .offset(x: -70, y: -120)
+
+            Circle()
+                .fill(Color.green.opacity(0.16))
+                .blur(radius: 100)
+                .frame(width: 380, height: 380)
+                .offset(x: 210, y: 80)
+        }
+    }
+
+    private var header: some View {
+        HStack {
+            Button(action: { dismiss() }) {
+                Image(systemName: "xmark")
+                    .font(.system(size: 17, weight: .medium))
+                    .foregroundStyle(.white.opacity(0.86))
+            }
+            .buttonStyle(.plain)
+
+            Spacer()
+
+            Button("Save") {
+                saveApp()
+            }
+            .buttonStyle(.plain)
+            .font(.system(size: 17, weight: .semibold))
+            .foregroundStyle(canSave ? Color.white.opacity(0.9) : Color.white.opacity(0.4))
+            .disabled(!canSave)
+        }
+    }
+
+    private var formContent: some View {
+        HStack(alignment: .top, spacing: 18) {
+            RoundedRectangle(cornerRadius: 16, style: .continuous)
+                .fill(Color.white.opacity(0.32))
+                .frame(width: 160, height: 160)
+                .overlay(
+                    Image(systemName: "globe")
+                        .font(.system(size: 38, weight: .regular))
+                        .foregroundStyle(.white.opacity(0.24))
+                )
+
+            VStack(spacing: 10) {
+                inputField(title: "Name", text: $name)
+                inputField(title: "URL", text: $urlText)
+
+                if let urlError {
+                    Text(urlError)
+                        .font(.system(size: 12, weight: .medium))
+                        .foregroundStyle(.red.opacity(0.9))
+                        .frame(maxWidth: .infinity, alignment: .leading)
+                }
+            }
+        }
+    }
+
+    private var footer: some View {
+        HStack {
+            VStack(alignment: .leading, spacing: 5) {
+                Text("Open with")
+                    .font(.system(size: 41 / 2.2, weight: .semibold))
+                    .foregroundStyle(.white.opacity(0.92))
+                Text("This app opens in your in-app browser window.")
+                    .font(.system(size: 13 / 1.35))
+                    .foregroundStyle(.white.opacity(0.65))
+            }
+
+            Spacer()
+
+            HStack(spacing: 8) {
+                RoundedRectangle(cornerRadius: 10, style: .continuous)
+                    .fill(Color.white.opacity(0.14))
+                    .overlay(
+                        Image(systemName: "app.badge")
+                            .font(.system(size: 17, weight: .semibold))
+                            .foregroundStyle(.white.opacity(0.9))
+                    )
+                    .frame(width: 42, height: 42)
+
+                Text("Apps for Google App")
+                    .font(.system(size: 42 / 2.2, weight: .medium))
+
+                Image(systemName: "chevron.down")
+                    .font(.system(size: 11, weight: .semibold))
+                    .offset(y: 1)
+            }
+            .minimumScaleFactor(0.6)
+            .lineLimit(1)
+                .foregroundStyle(.white.opacity(0.88))
+        }
+        .padding(.top, 2)
+    }
+
+    private var canSave: Bool {
+        !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
+        !urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+    }
+
+    private func inputField(title: String, text: Binding<String>) -> some View {
+        TextField(title, text: text)
+            .textFieldStyle(.plain)
+            .font(.system(size: 39 / 2.2, weight: .medium))
+            .foregroundStyle(.white.opacity(0.95))
+            .padding(.horizontal, 22)
+            .padding(.vertical, 16)
+            .background(
+                RoundedRectangle(cornerRadius: 20, style: .continuous)
+                    .fill(Color.white.opacity(0.06))
+            )
+            .overlay(
+                RoundedRectangle(cornerRadius: 20, style: .continuous)
+                    .stroke(Color.white.opacity(0.3), lineWidth: 1.7)
+            )
+    }
+
+    private func saveApp() {
+        let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+        let trimmedURL = urlText.trimmingCharacters(in: .whitespacesAndNewlines)
+
+        guard !trimmedName.isEmpty, !trimmedURL.isEmpty else { return }
+
+        let candidate = normalizedURLString(trimmedURL)
+        guard let url = URL(string: candidate), let scheme = url.scheme, !scheme.isEmpty else {
+            urlError = "Please enter a valid URL."
+            return
+        }
+
+        urlError = nil
+        onSave(NewAppInput(name: trimmedName, url: url))
+        dismiss()
+    }
+
+    private func normalizedURLString(_ value: String) -> String {
+        if value.lowercased().hasPrefix("http://") || value.lowercased().hasPrefix("https://") {
+            return value
+        }
+        return "https://\(value)"
+    }
+}
+
+#Preview {
+    CreateAppSheetView { _ in }
+        .frame(width: 800, height: 390)
+}

+ 47 - 20
google_apps/LauncherApp.swift

@@ -1,35 +1,62 @@
 import Foundation
 
 struct LauncherApp: Identifiable, Hashable {
-    let id = UUID()
+    let id: UUID
     let name: String
     let assetIconName: String
     let fallbackSymbolName: String
     let description: String
     let isCreateNew: Bool
     let webURL: URL?
+
+    init(
+        id: UUID = UUID(),
+        name: String,
+        assetIconName: String,
+        fallbackSymbolName: String,
+        description: String,
+        isCreateNew: Bool,
+        webURL: URL?
+    ) {
+        self.id = id
+        self.name = name
+        self.assetIconName = assetIconName
+        self.fallbackSymbolName = fallbackSymbolName
+        self.description = description
+        self.isCreateNew = isCreateNew
+        self.webURL = webURL
+    }
 }
 
 extension LauncherApp {
+    static let createNewTile = LauncherApp(
+        id: UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!,
+        name: "Create New App",
+        assetIconName: "icon_create_new_app",
+        fallbackSymbolName: "plus",
+        description: "Create and add a custom app shortcut.",
+        isCreateNew: true,
+        webURL: nil
+    )
+
     static let sampleApps: [LauncherApp] = [
-        LauncherApp(name: "Google Shopping", assetIconName: "icon_google_shopping", fallbackSymbolName: "bag.fill", description: "Explore products and offers.", isCreateNew: false, webURL: URL(string: "https://shopping.google.com")),
-        LauncherApp(name: "Gmail", assetIconName: "icon_gmail", fallbackSymbolName: "envelope.fill", description: "Read and send emails quickly.", isCreateNew: false, webURL: URL(string: "https://mail.google.com")),
-        LauncherApp(name: "Google Docs", assetIconName: "icon_google_docs", fallbackSymbolName: "doc.text.fill", description: "Create and edit documents.", isCreateNew: false, webURL: URL(string: "https://docs.google.com")),
-        LauncherApp(name: "Google Drive", assetIconName: "icon_google_drive", fallbackSymbolName: "externaldrive.fill", description: "Store and sync your files.", isCreateNew: false, webURL: URL(string: "https://drive.google.com")),
-        LauncherApp(name: "Google Earth", assetIconName: "icon_google_earth", fallbackSymbolName: "globe.americas.fill", description: "Explore places worldwide.", isCreateNew: false, webURL: URL(string: "https://earth.google.com")),
-        LauncherApp(name: "Google Photos", assetIconName: "icon_google_photos", fallbackSymbolName: "photo.stack.fill", description: "View and organize photos.", isCreateNew: false, webURL: URL(string: "https://photos.google.com")),
-        LauncherApp(name: "Google Maps", assetIconName: "icon_google_maps", fallbackSymbolName: "map.fill", description: "Find routes and places.", isCreateNew: false, webURL: URL(string: "https://maps.google.com")),
-        LauncherApp(name: "Google Translate", assetIconName: "icon_google_translate", fallbackSymbolName: "character.bubble.fill", description: "Translate text and speech.", isCreateNew: false, webURL: URL(string: "https://translate.google.com")),
-        LauncherApp(name: "Google Sheets", assetIconName: "icon_google_sheets", fallbackSymbolName: "tablecells.fill", description: "Work with spreadsheets.", isCreateNew: false, webURL: URL(string: "https://sheets.google.com")),
-        LauncherApp(name: "Google Search", assetIconName: "icon_google_search", fallbackSymbolName: "magnifyingglass.circle.fill", description: "Search the web quickly.", isCreateNew: false, webURL: URL(string: "https://www.google.com")),
-        LauncherApp(name: "YouTube", assetIconName: "icon_youtube", fallbackSymbolName: "play.rectangle.fill", description: "Watch videos and channels.", isCreateNew: false, webURL: URL(string: "https://www.youtube.com")),
-        LauncherApp(name: "Google Calendar", assetIconName: "icon_google_calendar", fallbackSymbolName: "calendar", description: "Manage events and schedules.", isCreateNew: false, webURL: URL(string: "https://calendar.google.com")),
-        LauncherApp(name: "Google Keep", assetIconName: "icon_google_keep", fallbackSymbolName: "lightbulb.fill", description: "Capture notes and reminders.", isCreateNew: false, webURL: URL(string: "https://keep.google.com")),
-        LauncherApp(name: "Google Slides", assetIconName: "icon_google_slides", fallbackSymbolName: "rectangle.on.rectangle.fill", description: "Build and present slides.", isCreateNew: false, webURL: URL(string: "https://slides.google.com")),
-        LauncherApp(name: "Google Forms", assetIconName: "icon_google_forms", fallbackSymbolName: "list.bullet.rectangle.fill", description: "Create forms and surveys.", isCreateNew: false, webURL: URL(string: "https://forms.google.com")),
-        LauncherApp(name: "Google Books", assetIconName: "icon_google_books", fallbackSymbolName: "book.fill", description: "Browse and read books.", isCreateNew: false, webURL: URL(string: "https://books.google.com")),
-        LauncherApp(name: "Google Travel", assetIconName: "icon_google_travel", fallbackSymbolName: "airplane", description: "Plan and organize trips.", isCreateNew: false, webURL: URL(string: "https://travel.google.com")),
-        LauncherApp(name: "Google Meet", assetIconName: "icon_google_meet", fallbackSymbolName: "video.fill", description: "Start and join video meetings.", isCreateNew: false, webURL: URL(string: "https://meet.google.com")),
-        LauncherApp(name: "Create New App", assetIconName: "icon_create_new_app", fallbackSymbolName: "plus", description: "Create and add a custom app shortcut.", isCreateNew: true, webURL: nil),
+        LauncherApp(id: UUID(uuidString: "F0000000-0000-0000-0000-000000000001")!, name: "Google Shopping", assetIconName: "icon_google_shopping", fallbackSymbolName: "bag.fill", description: "Explore products and offers.", isCreateNew: false, webURL: URL(string: "https://shopping.google.com")),
+        LauncherApp(id: UUID(uuidString: "F0000000-0000-0000-0000-000000000002")!, name: "Gmail", assetIconName: "icon_gmail", fallbackSymbolName: "envelope.fill", description: "Read and send emails quickly.", isCreateNew: false, webURL: URL(string: "https://mail.google.com")),
+        LauncherApp(id: UUID(uuidString: "F0000000-0000-0000-0000-000000000003")!, name: "Google Docs", assetIconName: "icon_google_docs", fallbackSymbolName: "doc.text.fill", description: "Create and edit documents.", isCreateNew: false, webURL: URL(string: "https://docs.google.com")),
+        LauncherApp(id: UUID(uuidString: "F0000000-0000-0000-0000-000000000004")!, name: "Google Drive", assetIconName: "icon_google_drive", fallbackSymbolName: "externaldrive.fill", description: "Store and sync your files.", isCreateNew: false, webURL: URL(string: "https://drive.google.com")),
+        LauncherApp(id: UUID(uuidString: "F0000000-0000-0000-0000-000000000005")!, name: "Google Earth", assetIconName: "icon_google_earth", fallbackSymbolName: "globe.americas.fill", description: "Explore places worldwide.", isCreateNew: false, webURL: URL(string: "https://earth.google.com")),
+        LauncherApp(id: UUID(uuidString: "F0000000-0000-0000-0000-000000000006")!, name: "Google Photos", assetIconName: "icon_google_photos", fallbackSymbolName: "photo.stack.fill", description: "View and organize photos.", isCreateNew: false, webURL: URL(string: "https://photos.google.com")),
+        LauncherApp(id: UUID(uuidString: "F0000000-0000-0000-0000-000000000007")!, name: "Google Maps", assetIconName: "icon_google_maps", fallbackSymbolName: "map.fill", description: "Find routes and places.", isCreateNew: false, webURL: URL(string: "https://maps.google.com")),
+        LauncherApp(id: UUID(uuidString: "F0000000-0000-0000-0000-000000000008")!, name: "Google Translate", assetIconName: "icon_google_translate", fallbackSymbolName: "character.bubble.fill", description: "Translate text and speech.", isCreateNew: false, webURL: URL(string: "https://translate.google.com")),
+        LauncherApp(id: UUID(uuidString: "F0000000-0000-0000-0000-000000000009")!, name: "Google Sheets", assetIconName: "icon_google_sheets", fallbackSymbolName: "tablecells.fill", description: "Work with spreadsheets.", isCreateNew: false, webURL: URL(string: "https://sheets.google.com")),
+        LauncherApp(id: UUID(uuidString: "F0000000-0000-0000-0000-000000000010")!, name: "Google Search", assetIconName: "icon_google_search", fallbackSymbolName: "magnifyingglass.circle.fill", description: "Search the web quickly.", isCreateNew: false, webURL: URL(string: "https://www.google.com")),
+        LauncherApp(id: UUID(uuidString: "F0000000-0000-0000-0000-000000000011")!, name: "YouTube", assetIconName: "icon_youtube", fallbackSymbolName: "play.rectangle.fill", description: "Watch videos and channels.", isCreateNew: false, webURL: URL(string: "https://www.youtube.com")),
+        LauncherApp(id: UUID(uuidString: "F0000000-0000-0000-0000-000000000012")!, name: "Google Calendar", assetIconName: "icon_google_calendar", fallbackSymbolName: "calendar", description: "Manage events and schedules.", isCreateNew: false, webURL: URL(string: "https://calendar.google.com")),
+        LauncherApp(id: UUID(uuidString: "F0000000-0000-0000-0000-000000000013")!, name: "Google Keep", assetIconName: "icon_google_keep", fallbackSymbolName: "lightbulb.fill", description: "Capture notes and reminders.", isCreateNew: false, webURL: URL(string: "https://keep.google.com")),
+        LauncherApp(id: UUID(uuidString: "F0000000-0000-0000-0000-000000000014")!, name: "Google Slides", assetIconName: "icon_google_slides", fallbackSymbolName: "rectangle.on.rectangle.fill", description: "Build and present slides.", isCreateNew: false, webURL: URL(string: "https://slides.google.com")),
+        LauncherApp(id: UUID(uuidString: "F0000000-0000-0000-0000-000000000015")!, name: "Google Forms", assetIconName: "icon_google_forms", fallbackSymbolName: "list.bullet.rectangle.fill", description: "Create forms and surveys.", isCreateNew: false, webURL: URL(string: "https://forms.google.com")),
+        LauncherApp(id: UUID(uuidString: "F0000000-0000-0000-0000-000000000016")!, name: "Google Books", assetIconName: "icon_google_books", fallbackSymbolName: "book.fill", description: "Browse and read books.", isCreateNew: false, webURL: URL(string: "https://books.google.com")),
+        LauncherApp(id: UUID(uuidString: "F0000000-0000-0000-0000-000000000017")!, name: "Google Travel", assetIconName: "icon_google_travel", fallbackSymbolName: "airplane", description: "Plan and organize trips.", isCreateNew: false, webURL: URL(string: "https://travel.google.com")),
+        LauncherApp(id: UUID(uuidString: "F0000000-0000-0000-0000-000000000018")!, name: "Google Meet", assetIconName: "icon_google_meet", fallbackSymbolName: "video.fill", description: "Start and join video meetings.", isCreateNew: false, webURL: URL(string: "https://meet.google.com")),
     ]
 }

+ 206 - 3
google_apps/LauncherRootView.swift

@@ -1,5 +1,6 @@
 import SwiftUI
 import AppKit
+import UniformTypeIdentifiers
 
 struct LauncherRootView: View {
     enum LayoutMode {
@@ -10,17 +11,47 @@ struct LauncherRootView: View {
     @State private var query = ""
     @State private var selectedApp: LauncherApp?
     @State private var showingPremiumScreen = false
+    @State private var showingCreateAppSheet = false
+    @State private var customApps: [LauncherApp] = []
+    @State private var appOrder: [UUID] = []
+    @State private var draggedAppID: UUID?
     @State private var layoutMode: LayoutMode = .grid
+    @AppStorage("customLauncherAppsData") private var customAppsData = ""
+    @AppStorage("launcherAppOrderData") private var appOrderData = ""
 
-    private let apps = LauncherApp.sampleApps
+    private var apps: [LauncherApp] {
+        LauncherApp.sampleApps + customApps + [LauncherApp.createNewTile]
+    }
     private var columns: [GridItem] {
         [GridItem(.adaptive(minimum: 120, maximum: 145), spacing: 22)]
     }
 
     private var filteredApps: [LauncherApp] {
         let q = query.trimmingCharacters(in: .whitespacesAndNewlines)
-        guard !q.isEmpty else { return apps }
-        return apps.filter { $0.name.localizedCaseInsensitiveContains(q) }
+        guard !q.isEmpty else { return orderedApps }
+        return orderedApps.filter { $0.name.localizedCaseInsensitiveContains(q) }
+    }
+
+    private var orderedApps: [LauncherApp] {
+        guard !apps.isEmpty else { return [] }
+
+        let defaultIndexByID = Dictionary(
+            uniqueKeysWithValues: apps.enumerated().map { ($0.element.id, $0.offset) }
+        )
+        let rankByID = Dictionary(
+            uniqueKeysWithValues: appOrder.enumerated().map { ($0.element, $0.offset) }
+        )
+
+        return apps.sorted { lhs, rhs in
+            let leftRank = rankByID[lhs.id] ?? Int.max
+            let rightRank = rankByID[rhs.id] ?? Int.max
+
+            if leftRank != rightRank {
+                return leftRank < rightRank
+            }
+
+            return (defaultIndexByID[lhs.id] ?? Int.max) < (defaultIndexByID[rhs.id] ?? Int.max)
+        }
     }
 
     var body: some View {
@@ -81,6 +112,18 @@ struct LauncherRootView: View {
                                     AppTileView(app: app) {
                                         handleAppTap(app)
                                     }
+                                    .onDrag {
+                                        draggedAppID = app.id
+                                        return NSItemProvider(object: app.id.uuidString as NSString)
+                                    }
+                                    .onDrop(
+                                        of: [UTType.text],
+                                        delegate: TileReorderDropDelegate(
+                                            destinationAppID: app.id,
+                                            draggedAppID: $draggedAppID,
+                                            onMove: moveApp
+                                        )
+                                    )
                                 }
                             }
                             .padding(.top, 10)
@@ -101,9 +144,25 @@ struct LauncherRootView: View {
             PremiumFeaturesView()
                 .frame(minWidth: 560, minHeight: 690)
         }
+        .sheet(isPresented: $showingCreateAppSheet) {
+            CreateAppSheetView { input in
+                addCustomApp(name: input.name, url: input.url)
+            }
+            .frame(minWidth: 760, minHeight: 420)
+        }
+        .onAppear {
+            loadCustomAppsFromStorage()
+            loadAppOrderFromStorage()
+            normalizeAppOrderAndPersist()
+        }
     }
 
     private func handleAppTap(_ app: LauncherApp) {
+        if app.isCreateNew {
+            showingCreateAppSheet = true
+            return
+        }
+
         if let webURL = app.webURL {
             InAppBrowserWindowManager.shared.open(url: webURL, title: app.name)
             return
@@ -111,6 +170,150 @@ struct LauncherRootView: View {
 
         selectedApp = app
     }
+
+    private func addCustomApp(name: String, url: URL) {
+        let newApp = LauncherApp(
+            name: name,
+            assetIconName: "",
+            fallbackSymbolName: "globe",
+            description: "Custom app shortcut.",
+            isCreateNew: false,
+            webURL: url
+        )
+        customApps.append(newApp)
+        saveCustomAppsToStorage()
+        normalizeAppOrderAndPersist()
+    }
+
+    private func loadCustomAppsFromStorage() {
+        guard !customAppsData.isEmpty else {
+            customApps = []
+            return
+        }
+
+        guard let data = customAppsData.data(using: .utf8) else {
+            customApps = []
+            return
+        }
+
+        do {
+            let persisted = try JSONDecoder().decode([PersistedCustomApp].self, from: data)
+            customApps = persisted.compactMap { item in
+                guard let parsedURL = URL(string: item.urlString) else { return nil }
+                return LauncherApp(
+                    id: item.id,
+                    name: item.name,
+                    assetIconName: "",
+                    fallbackSymbolName: "globe",
+                    description: "Custom app shortcut.",
+                    isCreateNew: false,
+                    webURL: parsedURL
+                )
+            }
+        } catch {
+            customApps = []
+        }
+    }
+
+    private func saveCustomAppsToStorage() {
+        let persisted = customApps.compactMap { app -> PersistedCustomApp? in
+            guard !app.isCreateNew, let urlString = app.webURL?.absoluteString else { return nil }
+            return PersistedCustomApp(id: app.id, name: app.name, urlString: urlString)
+        }
+
+        do {
+            let encoded = try JSONEncoder().encode(persisted)
+            customAppsData = String(decoding: encoded, as: UTF8.self)
+        } catch {
+            // Keep UI responsive even if persistence fails.
+        }
+    }
+
+    private func moveApp(from sourceID: UUID, to destinationID: UUID) {
+        guard sourceID != destinationID else { return }
+        guard let sourceIndex = appOrder.firstIndex(of: sourceID),
+              let destinationIndex = appOrder.firstIndex(of: destinationID) else {
+            return
+        }
+
+        var updated = appOrder
+        let movedID = updated.remove(at: sourceIndex)
+        let insertionIndex = destinationIndex > sourceIndex ? destinationIndex - 1 : destinationIndex
+        updated.insert(movedID, at: insertionIndex)
+        appOrder = updated
+        saveAppOrderToStorage()
+    }
+
+    private func loadAppOrderFromStorage() {
+        guard !appOrderData.isEmpty else {
+            appOrder = []
+            return
+        }
+
+        guard let data = appOrderData.data(using: .utf8) else {
+            appOrder = []
+            return
+        }
+
+        do {
+            let decoded = try JSONDecoder().decode([String].self, from: data)
+            appOrder = decoded.compactMap { UUID(uuidString: $0) }
+        } catch {
+            appOrder = []
+        }
+    }
+
+    private func normalizeAppOrderAndPersist() {
+        let currentIDs = apps.map(\.id)
+        var seen = Set<UUID>()
+        var normalized: [UUID] = []
+
+        for id in appOrder where currentIDs.contains(id) {
+            if seen.insert(id).inserted {
+                normalized.append(id)
+            }
+        }
+
+        for id in currentIDs where seen.insert(id).inserted {
+            normalized.append(id)
+        }
+
+        appOrder = normalized
+        saveAppOrderToStorage()
+    }
+
+    private func saveAppOrderToStorage() {
+        do {
+            let payload = appOrder.map(\.uuidString)
+            let encoded = try JSONEncoder().encode(payload)
+            appOrderData = String(decoding: encoded, as: UTF8.self)
+        } catch {
+            // Ignore persistence failure and keep local order.
+        }
+    }
+}
+
+private struct PersistedCustomApp: Codable {
+    let id: UUID
+    let name: String
+    let urlString: String
+}
+
+private struct TileReorderDropDelegate: DropDelegate {
+    let destinationAppID: UUID
+    @Binding var draggedAppID: UUID?
+    let onMove: (UUID, UUID) -> Void
+
+    func dropEntered(info: DropInfo) {
+        guard let draggedAppID else { return }
+        guard draggedAppID != destinationAppID else { return }
+        onMove(draggedAppID, destinationAppID)
+    }
+
+    func performDrop(info: DropInfo) -> Bool {
+        draggedAppID = nil
+        return true
+    }
 }
 
 private struct SearchHeader: View {