From bc4b439eb68d74ff151a3e80beafcf1fc6b1c07c Mon Sep 17 00:00:00 2001 From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:15:25 +0200 Subject: [PATCH] ADD - support home screen app folders --- frontend/src/assets/main.css | 59 +- frontend/src/components/HomeFolderIcon.vue | 239 ++++++++ frontend/src/components/HomeFolderOverlay.vue | 558 ++++++++++++++++++ frontend/src/stores/app-store.test.ts | 39 +- frontend/src/stores/app-store.ts | 92 ++- frontend/src/stores/phone.ts | 7 + frontend/src/ui/overlays.css | 33 ++ frontend/src/ui/overlays/SkySheet.vue | 50 +- frontend/src/utils/homeLayout.test.ts | 168 +++++- frontend/src/utils/homeLayout.ts | 418 +++++++++---- frontend/src/utils/widgetLayout.test.ts | 2 +- frontend/src/utils/widgetLayout.ts | 2 +- frontend/src/views/SpringboardView.vue | 460 +++++++++++++-- frontend/testserver/index.cjs | 42 +- sky_phone/config/locales/en.lua | 1 + 15 files changed, 1976 insertions(+), 194 deletions(-) create mode 100644 frontend/src/components/HomeFolderIcon.vue create mode 100644 frontend/src/components/HomeFolderOverlay.vue diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 8863b60..7c389cd 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -2263,13 +2263,36 @@ button { display: flex; touch-action: pan-y; transform: translateX(calc(var(--springboard-offset) + var(--drag-offset))); - transition: transform var(--springboard-page-duration) - var(--springboard-page-easing); + transition: + transform var(--springboard-page-duration) var(--springboard-page-easing), + filter 0.22s ease; will-change: transform; } .springboard--dragging .springboard-track { transition: none; } +.springboard--folder-open .springboard-track, +.springboard--folder-open .app-dock, +.springboard--folder-open .page-indicator, +.springboard--folder-open .springboard-edit-add, +.springboard--folder-open .springboard-edit-done { + filter: blur(6px) brightness(0.72) saturate(0.88); + pointer-events: none; +} + +.home-folder-enter-active, +.home-folder-leave-active { + transition: + opacity 0.2s ease, + transform 0.24s cubic-bezier(0.22, 0.75, 0.18, 1); + transform-origin: 50% 42%; +} + +.home-folder-enter-from, +.home-folder-leave-to { + opacity: 0; + transform: scale(0.9); +} .springboard-page { position: relative; flex: none; @@ -6558,7 +6581,7 @@ button { display: none; } .app-grid { - grid-template-rows: repeat(5, var(--springboard-grid-row)); + grid-template-rows: repeat(6, var(--springboard-grid-row)); gap: var(--springboard-grid-row-gap) var(--springboard-grid-column-gap); } .app-grid-slot { @@ -6600,8 +6623,36 @@ button { } .app-reflow-move { z-index: 1; - transition: transform 0.58s cubic-bezier(0.2, 0.82, 0.2, 1); + transition: transform 0.34s cubic-bezier(0.22, 0.75, 0.18, 1); will-change: transform; + backface-visibility: hidden; +} +.app-icon-item--drop-settling { + z-index: 2; + animation: home-app-drop-settle 0.26s cubic-bezier(0.22, 0.75, 0.18, 1); + will-change: transform; + backface-visibility: hidden; +} +.home-item--folder-hover:not(.app-icon-item--dragging) { + z-index: 3; + animation: home-folder-hover 0.58s ease-in-out infinite alternate; +} +@keyframes home-folder-hover { + to { + transform: scale(1.1); + } +} +@keyframes home-app-drop-settle { + from { + transform: translate3d( + var(--home-app-drop-x, 0), + var(--home-app-drop-y, 0), + 0 + ); + } + to { + transform: translate3d(0, 0, 0); + } } .springboard-page-add { diff --git a/frontend/src/components/HomeFolderIcon.vue b/frontend/src/components/HomeFolderIcon.vue new file mode 100644 index 0000000..ddcfc35 --- /dev/null +++ b/frontend/src/components/HomeFolderIcon.vue @@ -0,0 +1,239 @@ + + + + + diff --git a/frontend/src/components/HomeFolderOverlay.vue b/frontend/src/components/HomeFolderOverlay.vue new file mode 100644 index 0000000..68d9e5e --- /dev/null +++ b/frontend/src/components/HomeFolderOverlay.vue @@ -0,0 +1,558 @@ + + + + + diff --git a/frontend/src/stores/app-store.test.ts b/frontend/src/stores/app-store.test.ts index a57a421..90efc2f 100644 --- a/frontend/src/stores/app-store.test.ts +++ b/frontend/src/stores/app-store.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { NON_REMOVABLE_PHONE_APP_IDS } from '@/config/apps' import { useAppStoreStore } from '@/stores/app-store' -import { removeHomeApp } from '@/utils/homeLayout' +import { getHomeFolder, removeHomeApp } from '@/utils/homeLayout' const mocks = vi.hoisted(() => ({ phone: { @@ -160,6 +160,43 @@ describe('app store', () => { }) }) + it('persists the complete folder lifecycle through store actions', () => { + const apps = useAppStoreStore() + apps.hydrate(null) + mocks.phone.saveDeviceNamespace.mockClear() + + const notesIndex = apps.homeLayout.grid.indexOf('notes') + const clockIndex = apps.homeLayout.grid.indexOf('clock') + const mailIndex = apps.homeLayout.grid.indexOf('mail') + const folderId = apps.createHomeFolder( + 'grid', + notesIndex, + 'grid', + clockIndex, + 'Utilities', + ) + + expect(folderId).toBeTruthy() + expect(getHomeFolder(apps.homeLayout, folderId!)?.apps).toEqual([ + 'clock', + 'notes', + ]) + expect(apps.addHomeAppToFolder('grid', mailIndex, folderId!)).toBe(true) + apps.moveHomeFolderApp(folderId!, 2, 0) + apps.renameHomeFolder(folderId!, 'Work') + expect(getHomeFolder(apps.homeLayout, folderId!)).toMatchObject({ + apps: ['mail', 'notes', 'clock'], + name: 'Work', + }) + + const emptyIndex = apps.homeLayout.grid.indexOf(null) + expect( + apps.extractHomeFolderApp(folderId!, 0, 'grid', emptyIndex), + ).toBe(true) + expect(apps.homeLayout.grid[emptyIndex]).toBe('mail') + expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(5) + }) + it('does not commit an installation to a different phone', () => { vi.useFakeTimers() const apps = useAppStoreStore() diff --git a/frontend/src/stores/app-store.ts b/frontend/src/stores/app-store.ts index 3c37870..0e0c761 100644 --- a/frontend/src/stores/app-store.ts +++ b/frontend/src/stores/app-store.ts @@ -12,11 +12,16 @@ import { import { usePhoneStore } from '@/stores/phone' import type { LaunchablePhoneAppId } from '@/types/apps' import { + addHomeAppToFolder, addHomePage, + createHomeFolder, createDefaultHomeLayout, deleteHomePage, + extractHomeFolderApp, + moveHomeFolderApp, moveHomeApp, parseHomeLayout, + renameHomeFolder, removeHomeApp, restoreHomeApp, type HomeArea, @@ -192,7 +197,10 @@ export const useAppStoreStore = defineStore('app-store', { (id): id is LaunchablePhoneAppId => typeof id === 'string' && (isPhoneAppId(id) || - (layoutVersion === 3 && isValidExternalPhoneAppId(id))), + ((layoutVersion === 3 || + layoutVersion === 4 || + layoutVersion === 5) && + isValidExternalPhoneAppId(id))), ) : [] const installedIds = [ @@ -218,7 +226,10 @@ export const useAppStoreStore = defineStore('app-store', { for (const [appId, count] of Object.entries(data.launchCounts)) { if ( (isPhoneAppId(appId) || - (layoutVersion === 3 && isValidExternalPhoneAppId(appId))) && + ((layoutVersion === 3 || + layoutVersion === 4 || + layoutVersion === 5) && + isValidExternalPhoneAppId(appId))) && typeof count === 'number' && Number.isFinite(count) && count > 0 @@ -279,6 +290,83 @@ export const useAppStoreStore = defineStore('app-store', { ) this.persist() }, + createHomeFolder( + from: HomeArea, + sourceIndex: number, + to: HomeArea, + targetIndex: number, + name: string, + ): string | null { + const folderId = `folder-${globalThis.crypto.randomUUID()}` + const next = createHomeFolder( + this.homeLayout, + from, + sourceIndex, + to, + targetIndex, + folderId, + name, + ) + if (next === this.homeLayout) return null + this.homeLayout = next + this.persist() + return folderId + }, + addHomeAppToFolder( + from: HomeArea, + sourceIndex: number, + folderId: string, + ): boolean { + const next = addHomeAppToFolder( + this.homeLayout, + from, + sourceIndex, + folderId, + ) + if (next === this.homeLayout) return false + this.homeLayout = next + this.persist() + return true + }, + renameHomeFolder(folderId: string, name: string): void { + const next = renameHomeFolder(this.homeLayout, folderId, name) + if (next === this.homeLayout) return + this.homeLayout = next + this.persist() + }, + moveHomeFolderApp( + folderId: string, + sourceIndex: number, + targetIndex: number, + ): void { + const next = moveHomeFolderApp( + this.homeLayout, + folderId, + sourceIndex, + targetIndex, + ) + if (next === this.homeLayout) return + this.homeLayout = next + this.persist() + }, + extractHomeFolderApp( + folderId: string, + sourceIndex: number, + to: HomeArea, + targetIndex: number, + ): boolean { + const next = extractHomeFolderApp( + this.homeLayout, + folderId, + sourceIndex, + to, + targetIndex, + ) + if (next === this.homeLayout) return false + this.homeLayout = next + this.persist() + return true + }, removeHomeApp(appId: LaunchablePhoneAppId): void { if (isProtectedHomeApp(appId)) return diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 4f22fba..c4a9b34 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -3765,6 +3765,13 @@ const defaultLocales: LocaleTree = { removedFromHome: 'Removed from Home Screen', page: 'Page', pages: 'Home screen pages', + folders: { + defaultName: 'Folder', + close: 'Close folder', + rename: 'Rename Folder', + name: 'Folder name', + pages: 'Folder pages', + }, groups: { suggestions: 'Suggestions', recentlyAdded: 'Recently Added', diff --git a/frontend/src/ui/overlays.css b/frontend/src/ui/overlays.css index 07add69..72ea447 100644 --- a/frontend/src/ui/overlays.css +++ b/frontend/src/ui/overlays.css @@ -16,6 +16,30 @@ background: rgba(0, 0, 0, 0.48); } +.sky-sheet-rise-enter-active .sky-overlay-backdrop, +.sky-sheet-rise-leave-active .sky-overlay-backdrop { + transition: opacity 220ms ease; +} + +.sky-sheet-rise-enter-active .sky-sheet__panel, +.sky-sheet-rise-leave-active .sky-sheet__panel { + transition: + opacity 220ms ease, + transform 320ms cubic-bezier(0.22, 1, 0.36, 1); + will-change: opacity, transform; +} + +.sky-sheet-rise-enter-from .sky-overlay-backdrop, +.sky-sheet-rise-leave-to .sky-overlay-backdrop { + opacity: 0; +} + +.sky-sheet-rise-enter-from .sky-sheet__panel, +.sky-sheet-rise-leave-to .sky-sheet__panel { + opacity: 0; + transform: translateY(100%); +} + .sky-sheet__panel, .sky-action-sheet__panel { position: absolute; @@ -264,3 +288,12 @@ .sky-messagebar textarea:disabled { opacity: 0.45; } + +@media (prefers-reduced-motion: reduce) { + .sky-sheet-rise-enter-active .sky-overlay-backdrop, + .sky-sheet-rise-leave-active .sky-overlay-backdrop, + .sky-sheet-rise-enter-active .sky-sheet__panel, + .sky-sheet-rise-leave-active .sky-sheet__panel { + transition-duration: 0.01ms !important; + } +} diff --git a/frontend/src/ui/overlays/SkySheet.vue b/frontend/src/ui/overlays/SkySheet.vue index 4bbbb1b..0af0455 100644 --- a/frontend/src/ui/overlays/SkySheet.vue +++ b/frontend/src/ui/overlays/SkySheet.vue @@ -54,27 +54,33 @@ useOverlayFocusTrap({ diff --git a/frontend/src/utils/homeLayout.test.ts b/frontend/src/utils/homeLayout.test.ts index e0767f8..3ecf3ab 100644 --- a/frontend/src/utils/homeLayout.test.ts +++ b/frontend/src/utils/homeLayout.test.ts @@ -1,14 +1,21 @@ import { describe, expect, it } from 'vitest' import { + addHomeAppToFolder, addHomePage, + createHomeFolder, createDefaultHomeLayout, deleteHomePage, + extractHomeFolderApp, + getHomeFolder, HOME_GRID_PAGE_SIZE, homeKeyboardTarget, + isHomeFolder, MAX_HOME_GRID_PAGES, + moveHomeFolderApp, moveHomeApp, parseHomeLayout, + renameHomeFolder, removeHomeApp, restoreHomeApp, type HomeLayout, @@ -35,7 +42,7 @@ describe('home layout', () => { null, ]) expect(layout.dock).toEqual(['phone', 'messages', 'clock', null]) - expect(layout.version).toBe(3) + expect(layout.version).toBe(5) }) it('migrates compact persisted arrays and appends newly installed apps', () => { @@ -52,12 +59,12 @@ describe('home layout', () => { expect(layout.dock).toEqual(['messages', null, null, null]) expect(layout.grid.slice(0, 4)).toEqual(['mail', 'clock', 'notes', null]) expect(layout.hidden).toEqual(['phone']) - expect(layout.version).toBe(3) + expect(layout.version).toBe(5) }) it('preserves explicit gaps in versioned layouts', () => { const grid: HomeLayout['grid'] = Array.from( - { length: HOME_GRID_PAGE_SIZE }, + { length: 20 }, () => null, ) grid[0] = 'phone' @@ -82,7 +89,7 @@ describe('home layout', () => { it('keeps valid custom-app tombstones in version 3 layouts', () => { const grid: HomeLayout['grid'] = Array.from( - { length: HOME_GRID_PAGE_SIZE }, + { length: 20 }, () => null, ) grid[6] = 'temporarily-missing' as HomeLayout['hidden'][number] @@ -99,12 +106,35 @@ describe('home layout', () => { ) expect(layout.grid[6]).toBe('temporarily-missing') - expect(layout.version).toBe(3) + expect(layout.version).toBe(5) + }) + + it('expands version 3 capacity without compacting persisted gaps', () => { + const grid: HomeLayout['grid'] = Array.from({ length: 40 }, () => null) + grid[19] = 'mail' + grid[20] = 'notes' + + const layout = parseHomeLayout( + { + dock: ['phone', null, null, null], + grid, + hidden: [], + version: 3, + }, + defaults, + [...installed], + ) + + expect(layout.grid).toHaveLength(HOME_GRID_PAGE_SIZE * 2) + expect(layout.grid[19]).toBe('mail') + expect(layout.grid[20]).toBe('notes') + expect(layout.grid.slice(21, 24)).toEqual([null, null, null]) + expect(layout.version).toBe(5) }) it('preserves independently positioned shortcuts for the same app', () => { const grid: HomeLayout['grid'] = Array.from( - { length: HOME_GRID_PAGE_SIZE }, + { length: 20 }, () => null, ) grid[0] = 'phone' @@ -144,33 +174,33 @@ describe('home layout', () => { expect(homeKeyboardTarget(defaults, 'dock', 1, 'down')).toBeNull() }) - it('shifts occupied grid slots instead of replacing their apps', () => { + it('swaps occupied grid slots without moving unrelated apps', () => { const reordered = moveHomeApp(defaults, 'grid', 2, 'grid', 0) expect(reordered.grid.slice(0, 5)).toEqual([ 'mail', - 'phone', 'messages', + 'phone', 'clock', 'notes', ]) }) - it('shifts an occupied dock slot toward its gap', () => { + it('swaps an occupied dock slot with the exact grid source', () => { const docked = moveHomeApp(defaults, 'grid', 2, 'dock', 2) - expect(docked.dock).toEqual(['phone', 'messages', 'mail', 'clock']) - expect(docked.grid[2]).toBeNull() + expect(docked.dock).toEqual(['phone', 'messages', 'mail', null]) + expect(docked.grid[2]).toBe('clock') }) - it('moves an app displaced from a full dock into the source slot', () => { + it('swaps with an app in a full dock without shifting the dock', () => { const layout: HomeLayout = { ...defaults, dock: ['phone', 'messages', 'clock', 'notes'], } const docked = moveHomeApp(layout, 'grid', 2, 'dock', 1) - expect(docked.dock).toEqual(['phone', 'mail', 'messages', 'clock']) - expect(docked.grid[2]).toBe('notes') + expect(docked.dock).toEqual(['phone', 'mail', 'clock', 'notes']) + expect(docked.grid[2]).toBe('messages') }) it('moves shortcuts between the dock and grid independently', () => { @@ -218,4 +248,114 @@ describe('home layout', () => { expect(deleted.grid).toContain('phone') expect(deleteHomePage(deleted, 1)).toBe(deleted) }) + + it('creates, renames, and persists a folder without moving other slots', () => { + const folderLayout = createHomeFolder( + defaults, + 'grid', + 2, + 'grid', + 4, + 'folder-work-123456', + 'Work', + ) + const folder = folderLayout.grid[4] + + expect(folderLayout.grid[2]).toBeNull() + expect(isHomeFolder(folder) && folder.apps).toEqual(['notes', 'mail']) + expect(folderLayout.grid[0]).toBe('phone') + + const renamed = renameHomeFolder( + folderLayout, + 'folder-work-123456', + ' Dienstprogramme ', + ) + expect(getHomeFolder(renamed, 'folder-work-123456')?.name).toBe( + 'Dienstprogramme', + ) + + const parsed = parseHomeLayout(renamed, defaults, [...installed]) + expect(parsed.version).toBe(5) + expect(getHomeFolder(parsed, 'folder-work-123456')).toEqual({ + apps: ['notes', 'mail'], + id: 'folder-work-123456', + name: 'Dienstprogramme', + type: 'folder', + }) + }) + + it('adds apps to a folder and swaps apps inside its 3x3 pages', () => { + const folderLayout = createHomeFolder( + defaults, + 'grid', + 2, + 'grid', + 4, + 'folder-tools-123456', + 'Tools', + ) + const expanded = addHomeAppToFolder( + folderLayout, + 'grid', + 3, + 'folder-tools-123456', + ) + expect(expanded.grid[3]).toBeNull() + expect(getHomeFolder(expanded, 'folder-tools-123456')?.apps).toEqual([ + 'notes', + 'mail', + 'clock', + ]) + + const reordered = moveHomeFolderApp( + expanded, + 'folder-tools-123456', + 2, + 0, + ) + expect(getHomeFolder(reordered, 'folder-tools-123456')?.apps).toEqual([ + 'clock', + 'mail', + 'notes', + ]) + }) + + it('extracts a folder app into an exact gap and dissolves one-app folders', () => { + const folderLayout = createHomeFolder( + defaults, + 'grid', + 2, + 'grid', + 4, + 'folder-social-123456', + 'Social', + ) + const extracted = extractHomeFolderApp( + folderLayout, + 'folder-social-123456', + 1, + 'grid', + 12, + ) + + expect(extracted.grid[12]).toBe('mail') + expect(extracted.grid[4]).toBe('notes') + expect(getHomeFolder(extracted, 'folder-social-123456')).toBeNull() + }) + + it('removes hidden apps from folders and dissolves the folder when needed', () => { + const folderLayout = createHomeFolder( + defaults, + 'grid', + 2, + 'grid', + 4, + 'folder-mixed-123456', + 'Mixed', + ) + const removed = removeHomeApp(folderLayout, 'mail') + + expect(removed.grid[4]).toBe('notes') + expect(removed.hidden).toContain('mail') + }) }) diff --git a/frontend/src/utils/homeLayout.ts b/frontend/src/utils/homeLayout.ts index 6f01235..261db2d 100644 --- a/frontend/src/utils/homeLayout.ts +++ b/frontend/src/utils/homeLayout.ts @@ -3,23 +3,50 @@ import type { ReorderDirection } from '@/utils/keyboard' export const HOME_DOCK_CAPACITY = 4 export const HOME_GRID_COLUMNS = 4 -export const HOME_GRID_PAGE_SIZE = 20 +export const HOME_GRID_PAGE_SIZE = 24 +export const HOME_FOLDER_PAGE_SIZE = 9 +export const HOME_FOLDER_NAME_MAX_LENGTH = 32 export const MAX_HOME_GRID_PAGES = 5 +const LEGACY_HOME_GRID_PAGE_SIZE = 20 export type HomeArea = 'dock' | 'grid' -export type HomeSlot = LaunchablePhoneAppId | null + +export type HomeFolder = { + apps: LaunchablePhoneAppId[] + id: string + name: string + type: 'folder' +} + +export type HomeItem = HomeFolder | LaunchablePhoneAppId +export type HomeSlot = HomeItem | null export type HomeLayout = { dock: HomeSlot[] grid: HomeSlot[] hidden: LaunchablePhoneAppId[] - version: 3 + version: 5 +} + +export function isHomeFolder(value: unknown): value is HomeFolder { + if (!value || typeof value !== 'object') return false + const folder = value as Partial + return ( + folder.type === 'folder' && + typeof folder.id === 'string' && + typeof folder.name === 'string' && + Array.isArray(folder.apps) + ) } function isPersistableAppId(value: unknown): value is LaunchablePhoneAppId { return typeof value === 'string' && /^[a-z0-9][a-z0-9._-]{1,63}$/.test(value) } +function isPersistableFolderId(value: unknown): value is string { + return typeof value === 'string' && /^folder-[a-z0-9-]{6,80}$/.test(value) +} + function readAppIds( value: unknown, availableIds: Set, @@ -50,74 +77,107 @@ function getGridCapacity(itemCount: number): number { ) } +function migrateLegacyGrid(value: unknown): unknown[] { + if (!Array.isArray(value)) return [] + return value.slice(0, LEGACY_HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES) +} + +function cloneItem(item: HomeSlot): HomeSlot { + return isHomeFolder(item) ? { ...item, apps: [...item.apps] } : item +} + +function cloneLayout(layout: HomeLayout): HomeLayout { + return { + dock: layout.dock.map(cloneItem), + grid: layout.grid.map(cloneItem), + hidden: [...layout.hidden], + version: 5, + } +} + +function normalizeFolder(folder: HomeFolder): HomeSlot { + if (folder.apps.length === 0) return null + if (folder.apps.length === 1) return folder.apps[0] + return folder +} + +function readItem( + value: unknown, + availableIds: Set, + folderIds: Set, +): HomeSlot { + if ( + typeof value === 'string' && + availableIds.has(value as LaunchablePhoneAppId) + ) { + return value as LaunchablePhoneAppId + } + if (!isHomeFolder(value) || !isPersistableFolderId(value.id)) return null + if (folderIds.has(value.id)) return null + + const apps = readAppIds(value.apps, availableIds) + if (apps.length === 0) return null + if (apps.length === 1) return apps[0] + folderIds.add(value.id) + return { + apps, + id: value.id, + name: value.name.trim().slice(0, HOME_FOLDER_NAME_MAX_LENGTH), + type: 'folder', + } +} + function readSlots( value: unknown, availableIds: Set, length: number, + folderIds: Set, ): HomeSlot[] { const slots = createSlots(length) if (!Array.isArray(value)) return slots for (let index = 0; index < Math.min(value.length, length); index += 1) { - const valueId = value[index] - if ( - typeof valueId === 'string' && - availableIds.has(valueId as LaunchablePhoneAppId) - ) { - slots[index] = valueId as LaunchablePhoneAppId - } + slots[index] = readItem(value[index], availableIds, folderIds) } return slots } -function placeInFirstEmptySlot( - slots: HomeSlot[], - appId: LaunchablePhoneAppId, -): void { +function placeInFirstEmptySlot(slots: HomeSlot[], item: HomeItem): void { const emptyIndex = slots.indexOf(null) if (emptyIndex !== -1) { - slots[emptyIndex] = appId + slots[emptyIndex] = cloneItem(item) return } slots.push(...createSlots(HOME_GRID_PAGE_SIZE)) - slots[slots.length - HOME_GRID_PAGE_SIZE] = appId + slots[slots.length - HOME_GRID_PAGE_SIZE] = cloneItem(item) } -function insertIntoSlot( - slots: HomeSlot[], - targetIndex: number, - appId: LaunchablePhoneAppId, -): HomeSlot { - if (slots[targetIndex] === null) { - slots[targetIndex] = appId - return null - } +function itemContainsApp(item: HomeSlot, appId: LaunchablePhoneAppId): boolean { + return isHomeFolder(item) ? item.apps.includes(appId) : item === appId +} - const emptyAfter = slots.indexOf(null, targetIndex + 1) - if (emptyAfter !== -1) { - for (let index = emptyAfter; index > targetIndex; index -= 1) { - slots[index] = slots[index - 1] - } - slots[targetIndex] = appId - return null +function folderLocation( + layout: HomeLayout, + folderId: string, +): { area: HomeArea; index: number } | null { + for (const area of ['grid', 'dock'] as const) { + const index = layout[area].findIndex( + (item) => isHomeFolder(item) && item.id === folderId, + ) + if (index !== -1) return { area, index } } + return null +} - const emptyBefore = slots.lastIndexOf(null, targetIndex - 1) - if (emptyBefore !== -1) { - for (let index = emptyBefore; index < targetIndex; index += 1) { - slots[index] = slots[index + 1] - } - slots[targetIndex] = appId - return null - } - - const displacedApp = slots.at(-1) ?? null - for (let index = slots.length - 1; index > targetIndex; index -= 1) { - slots[index] = slots[index - 1] - } - slots[targetIndex] = appId - return displacedApp +export function getHomeFolder( + layout: HomeLayout, + folderId: string, +): HomeFolder | null { + const location = folderLocation(layout, folderId) + if (!location) return null + const item = layout[location.area][location.index] + return isHomeFolder(item) ? item : null } export function createDefaultHomeLayout( @@ -140,7 +200,7 @@ export function createDefaultHomeLayout( dock[index] = id } - return { dock, grid, hidden: [], version: 3 } + return { dock, grid, hidden: [], version: 5 } } export function parseHomeLayout( @@ -152,18 +212,27 @@ export function parseHomeLayout( const source = value as Partial> const availableIds = new Set(installedIds) - if (source.version === 3) { + if (source.version === 3 || source.version === 4 || source.version === 5) { for (const collection of [source.dock, source.grid, source.hidden]) { if (!Array.isArray(collection)) continue - for (const appId of collection) { - if (isPersistableAppId(appId)) availableIds.add(appId) + for (const item of collection) { + if (isPersistableAppId(item)) availableIds.add(item) + if (source.version === 5 && isHomeFolder(item)) { + for (const appId of item.apps) { + if (isPersistableAppId(appId)) availableIds.add(appId) + } + } } } } const hidden = readAppIds(source.hidden, availableIds) const hiddenIds = new Set(hidden) - const persistedGridLength = Array.isArray(source.grid) - ? Math.min(source.grid.length, HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES) + const persistedGrid = + source.version === 2 || source.version === 3 + ? migrateLegacyGrid(source.grid) + : source.grid + const persistedGridLength = Array.isArray(persistedGrid) + ? Math.min(persistedGrid.length, HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES) : 0 const gridLength = Math.max( defaults.grid.length, @@ -172,9 +241,15 @@ export function parseHomeLayout( let grid: HomeSlot[] let dock: HomeSlot[] - if (source.version === 2 || source.version === 3) { - grid = readSlots(source.grid, availableIds, gridLength) - dock = readSlots(source.dock, availableIds, HOME_DOCK_CAPACITY) + if ( + source.version === 2 || + source.version === 3 || + source.version === 4 || + source.version === 5 + ) { + const folderIds = new Set() + grid = readSlots(persistedGrid, availableIds, gridLength, folderIds) + dock = readSlots(source.dock, availableIds, HOME_DOCK_CAPACITY, folderIds) } else { grid = createSlots(gridLength) for (const id of readAppIds(source.grid, availableIds)) { @@ -188,35 +263,53 @@ export function parseHomeLayout( } } - grid = grid.map((id) => (id && !hiddenIds.has(id) ? id : null)) - dock = dock.map((id) => (id && !hiddenIds.has(id) ? id : null)) - const placedIds = new Set( - [...grid, ...dock, ...hidden].filter( - (id): id is LaunchablePhoneAppId => id !== null, - ), - ) - - for (const id of defaults.grid) { - if (id && !placedIds.has(id)) { - placeInFirstEmptySlot(grid, id) - placedIds.add(id) + const removeHidden = (item: HomeSlot): HomeSlot => { + if (typeof item === 'string') return hiddenIds.has(item) ? null : item + if (!isHomeFolder(item)) return null + return normalizeFolder({ + ...item, + apps: item.apps.filter((appId) => !hiddenIds.has(appId)), + }) + } + grid = grid.map(removeHidden) + dock = dock.map(removeHidden) + const placedIds = new Set(hidden) + for (const item of [...grid, ...dock]) { + if (typeof item === 'string') placedIds.add(item) + if (isHomeFolder(item)) { + for (const appId of item.apps) placedIds.add(appId) } } - return { dock, grid, hidden, version: 3 } + for (const item of defaults.grid) { + if (typeof item === 'string' && !placedIds.has(item)) { + placeInFirstEmptySlot(grid, item) + placedIds.add(item) + } + } + + return { dock, grid, hidden, version: 5 } } export function removeHomeApp( layout: HomeLayout, appId: LaunchablePhoneAppId, ): HomeLayout { + const removeFromItem = (item: HomeSlot): HomeSlot => { + if (item === appId) return null + if (!isHomeFolder(item)) return item + return normalizeFolder({ + ...item, + apps: item.apps.filter((folderAppId) => folderAppId !== appId), + }) + } return { - dock: layout.dock.map((id) => (id === appId ? null : id)), - grid: layout.grid.map((id) => (id === appId ? null : id)), + dock: layout.dock.map(removeFromItem), + grid: layout.grid.map(removeFromItem), hidden: layout.hidden.includes(appId) ? [...layout.hidden] : [...layout.hidden, appId], - version: 3, + version: 5, } } @@ -224,14 +317,19 @@ export function restoreHomeApp( layout: HomeLayout, appId: LaunchablePhoneAppId, ): HomeLayout { - if (layout.grid.includes(appId) || layout.dock.includes(appId)) return layout - const grid = [...layout.grid] + if ( + layout.grid.some((item) => itemContainsApp(item, appId)) || + layout.dock.some((item) => itemContainsApp(item, appId)) + ) { + return layout + } + const grid = layout.grid.map(cloneItem) placeInFirstEmptySlot(grid, appId) return { - dock: [...layout.dock], + dock: layout.dock.map(cloneItem), grid, hidden: layout.hidden.filter((id) => id !== appId), - version: 3, + version: 5, } } @@ -241,10 +339,10 @@ export function addHomePage(layout: HomeLayout): HomeLayout { } return { - dock: [...layout.dock], - grid: [...layout.grid, ...createSlots(HOME_GRID_PAGE_SIZE)], + dock: layout.dock.map(cloneItem), + grid: [...layout.grid.map(cloneItem), ...createSlots(HOME_GRID_PAGE_SIZE)], hidden: [...layout.hidden], - version: 3, + version: 5, } } @@ -253,20 +351,20 @@ export function deleteHomePage(layout: HomeLayout, page: number): HomeLayout { if (pageCount <= 1 || page < 1 || page > pageCount) return layout const pageStart = (page - 1) * HOME_GRID_PAGE_SIZE - const grid = [...layout.grid] - const removedApps = grid + const grid = layout.grid.map(cloneItem) + const removedItems = grid .splice(pageStart, HOME_GRID_PAGE_SIZE) - .filter((appId): appId is LaunchablePhoneAppId => appId !== null) - if (removedApps.length > grid.filter((appId) => appId === null).length) { + .filter((item): item is HomeItem => item !== null) + if (removedItems.length > grid.filter((item) => item === null).length) { return layout } - for (const appId of removedApps) placeInFirstEmptySlot(grid, appId) + for (const item of removedItems) placeInFirstEmptySlot(grid, item) return { - dock: [...layout.dock], + dock: layout.dock.map(cloneItem), grid, hidden: [...layout.hidden], - version: 3, + version: 5, } } @@ -277,17 +375,12 @@ export function moveHomeApp( to: HomeArea, targetIndex: number, ): HomeLayout { - const next: HomeLayout = { - dock: [...layout.dock], - grid: [...layout.grid], - hidden: [...layout.hidden], - version: 3, - } + const next = cloneLayout(layout) const source = next[from] const target = next[to] - const appId = source[sourceIndex] + const item = source[sourceIndex] if ( - !appId || + !item || sourceIndex < 0 || sourceIndex >= source.length || targetIndex < 0 || @@ -296,14 +389,139 @@ export function moveHomeApp( return layout } - if (from === to) { - if (sourceIndex === targetIndex) return layout - source[sourceIndex] = null - insertIntoSlot(source, targetIndex, appId) - return next + if (from === to && sourceIndex === targetIndex) return layout + source[sourceIndex] = cloneItem(target[targetIndex]) + target[targetIndex] = item + return next +} + +export function createHomeFolder( + layout: HomeLayout, + from: HomeArea, + sourceIndex: number, + to: HomeArea, + targetIndex: number, + folderId: string, + name: string, +): HomeLayout { + if (!isPersistableFolderId(folderId)) return layout + if (folderLocation(layout, folderId)) return layout + const sourceItem = layout[from][sourceIndex] + const targetItem = layout[to][targetIndex] + if ( + typeof sourceItem !== 'string' || + typeof targetItem !== 'string' || + (from === to && sourceIndex === targetIndex) + ) { + return layout } - source[sourceIndex] = insertIntoSlot(target, targetIndex, appId) + const next = cloneLayout(layout) + next[from][sourceIndex] = null + next[to][targetIndex] = { + apps: [targetItem, sourceItem], + id: folderId, + name: name.trim().slice(0, HOME_FOLDER_NAME_MAX_LENGTH), + type: 'folder', + } + return next +} + +export function addHomeAppToFolder( + layout: HomeLayout, + from: HomeArea, + sourceIndex: number, + folderId: string, +): HomeLayout { + const sourceItem = layout[from][sourceIndex] + const location = folderLocation(layout, folderId) + if (typeof sourceItem !== 'string' || !location) return layout + if (location.area === from && location.index === sourceIndex) return layout + + const next = cloneLayout(layout) + const folder = next[location.area][location.index] + if (!isHomeFolder(folder)) return layout + next[from][sourceIndex] = null + folder.apps.push(sourceItem) + return next +} + +export function renameHomeFolder( + layout: HomeLayout, + folderId: string, + name: string, +): HomeLayout { + const location = folderLocation(layout, folderId) + if (!location) return layout + const next = cloneLayout(layout) + const folder = next[location.area][location.index] + if (!isHomeFolder(folder)) return layout + const nextName = name.trim().slice(0, HOME_FOLDER_NAME_MAX_LENGTH) + if (folder.name === nextName) return layout + folder.name = nextName + return next +} + +export function moveHomeFolderApp( + layout: HomeLayout, + folderId: string, + sourceIndex: number, + targetIndex: number, +): HomeLayout { + const location = folderLocation(layout, folderId) + if (!location) return layout + const folder = layout[location.area][location.index] + if ( + !isHomeFolder(folder) || + sourceIndex < 0 || + sourceIndex >= folder.apps.length || + targetIndex < 0 || + targetIndex >= folder.apps.length || + sourceIndex === targetIndex + ) { + return layout + } + + const next = cloneLayout(layout) + const nextFolder = next[location.area][location.index] + if (!isHomeFolder(nextFolder)) return layout + const sourceApp = nextFolder.apps[sourceIndex] + nextFolder.apps[sourceIndex] = nextFolder.apps[targetIndex] + nextFolder.apps[targetIndex] = sourceApp + return next +} + +export function extractHomeFolderApp( + layout: HomeLayout, + folderId: string, + sourceIndex: number, + to: HomeArea, + targetIndex: number, +): HomeLayout { + const location = folderLocation(layout, folderId) + const target = layout[to][targetIndex] + if ( + !location || + target !== null || + (location.area === to && location.index === targetIndex) + ) { + return layout + } + const folder = layout[location.area][location.index] + if ( + !isHomeFolder(folder) || + sourceIndex < 0 || + sourceIndex >= folder.apps.length + ) { + return layout + } + + const next = cloneLayout(layout) + const nextFolder = next[location.area][location.index] + if (!isHomeFolder(nextFolder)) return layout + const [appId] = nextFolder.apps.splice(sourceIndex, 1) + next[to][targetIndex] = appId + next[location.area][location.index] = normalizeFolder(nextFolder) return next } diff --git a/frontend/src/utils/widgetLayout.test.ts b/frontend/src/utils/widgetLayout.test.ts index 38f80a3..1b7cd2e 100644 --- a/frontend/src/utils/widgetLayout.test.ts +++ b/frontend/src/utils/widgetLayout.test.ts @@ -30,7 +30,7 @@ describe('widget layout', () => { instance.id.startsWith('wallet-'), ) - expect(added).toMatchObject({ column: 0, page: 2, row: 0, size: 'small' }) + expect(added).toMatchObject({ column: 0, page: 1, row: 4, size: 'small' }) }) it('moves a widget and reflows a displaced neighbor', () => { diff --git a/frontend/src/utils/widgetLayout.ts b/frontend/src/utils/widgetLayout.ts index 5b6dc54..6869be5 100644 --- a/frontend/src/utils/widgetLayout.ts +++ b/frontend/src/utils/widgetLayout.ts @@ -9,7 +9,7 @@ import type { import type { ReorderDirection } from '@/utils/keyboard' export const WIDGET_GRID_COLUMNS = 4 -export const WIDGET_HOME_ROWS = 5 +export const WIDGET_HOME_ROWS = 6 export const WIDGET_PAGE_ROWS = 10 const MAX_WIDGET_HOME_PAGES = 5 diff --git a/frontend/src/views/SpringboardView.vue b/frontend/src/views/SpringboardView.vue index 03b7cb0..be9eb61 100644 --- a/frontend/src/views/SpringboardView.vue +++ b/frontend/src/views/SpringboardView.vue @@ -4,6 +4,8 @@ import { kButton, kGlass, kList, kListItem, kSheet } from 'konsta/vue' import { computed, nextTick, ref, watch } from 'vue' import AppIcon from '@/components/AppIcon.vue' +import HomeFolderIcon from '@/components/HomeFolderIcon.vue' +import HomeFolderOverlay from '@/components/HomeFolderOverlay.vue' import SpringboardWidgetGrid from '@/components/SpringboardWidgetGrid.vue' import WidgetConfigSheet from '@/components/WidgetConfigSheet.vue' import WidgetPickerSheet from '@/components/WidgetPickerSheet.vue' @@ -16,10 +18,14 @@ import type { LaunchablePhoneAppId } from '@/types/apps' import type { WidgetKind, WidgetSettings, WidgetSize } from '@/types/widgets' import { deleteHomePage as previewHomePageDelete, + getHomeFolder, HOME_GRID_PAGE_SIZE, homeKeyboardTarget, + isHomeFolder, MAX_HOME_GRID_PAGES, type HomeArea, + type HomeFolder, + type HomeItem, } from '@/utils/homeLayout' import type { ReorderDirection } from '@/utils/keyboard' import { @@ -37,6 +43,7 @@ const APP_LIBRARY_CATEGORIES: PhoneAppCategory[] = [ 'utilities', 'shopping', ] +const HOME_FOLDER_HOVER_DURATION = 900 const phone = usePhoneStore() const appStore = useAppStoreStore() const widgets = useWidgetsStore() @@ -63,6 +70,9 @@ const draggingHomeApp = ref<{ area: HomeArea index: number } | null>(null) +const openedFolderId = ref(null) +const renameFolderOnOpenId = ref(null) +const folderHoverTarget = ref('') let pointerStart = 0 let pointerStartY = 0 let pointerStartedAt = 0 @@ -75,6 +85,7 @@ let lastWidgetPointer: { clientX: number; clientY: number } | null = null let edgePageTimer: number | undefined let edgePageDirection = 0 let edgePageLocked = false +let folderHoverTimer: number | undefined const installedApps = computed(() => PHONE_APPS.filter((app) => appStore.isInstalled(app.id)), @@ -87,17 +98,25 @@ const hiddenApps = computed(() => appStore.homeLayout.hidden.includes(app.id), ), ) -const gridEntries = computed(() => - appStore.homeLayout.grid - .map((id, sourceIndex) => ({ - app: id ? (installedAppsById.value.get(id) ?? null) : null, +const gridSlots = computed(() => { + const appOccurrences = new Map() + return appStore.homeLayout.grid.map((item, sourceIndex) => { + const appId = typeof item === 'string' ? item : null + const folder = isHomeFolder(item) ? item : null + const occurrence = appId ? (appOccurrences.get(appId) ?? 0) : 0 + if (appId) appOccurrences.set(appId, occurrence + 1) + return { + app: appId ? (installedAppsById.value.get(appId) ?? null) : null, + folder, + renderKey: folder + ? `grid-folder-${folder.id}` + : appId + ? `grid-app-${appId}-${occurrence}` + : `grid-empty-${sourceIndex}`, sourceIndex, - })) - .filter( - (entry): entry is { app: PhoneAppDefinition; sourceIndex: number } => - entry.app !== null, - ), -) + } + }) +}) const previewWidgetLayout = computed(() => { const preview = widgetDragPreview.value return preview @@ -111,9 +130,14 @@ const previewWidgetLayout = computed(() => { : widgets.layout }) const appPages = computed(() => { - const entries = [...gridEntries.value] + const slots = [...gridSlots.value] const pages: Array<{ - cells: Array<{ app: PhoneAppDefinition; sourceIndex: number } | null> + cells: Array<{ + app: PhoneAppDefinition | null + folder: HomeFolder | null + renderKey: string + sourceIndex: number | null + }> page: number }> = [] const maxWidgetPage = Math.max( @@ -126,8 +150,8 @@ const appPages = computed(() => { ) const lastHomePage = Math.max(maxWidgetPage, persistedHomePages) let page = 1 - let entryIndex = 0 - while (entryIndex < entries.length || page <= lastHomePage) { + let slotIndex = 0 + while (slotIndex < slots.length || page <= lastHomePage) { const occupied = widgetOccupiedCells( previewWidgetLayout.value.instances, page, @@ -137,21 +161,40 @@ const appPages = computed(() => { occupied.add(cell) } } - const cells = Array.from<{ - app: PhoneAppDefinition - sourceIndex: number - } | null>({ length: HOME_GRID_PAGE_SIZE }).fill(null) + const cells: Array<{ + app: PhoneAppDefinition | null + folder: HomeFolder | null + renderKey: string + sourceIndex: number | null + }> = Array(HOME_GRID_PAGE_SIZE) + .fill(null) + .map((_, cell) => ({ + app: null, + folder: null, + renderKey: `grid-occupied-${page}-${cell}`, + sourceIndex: null, + })) for (let cell = 0; cell < HOME_GRID_PAGE_SIZE; cell += 1) { - if (occupied.has(cell) || entryIndex >= entries.length) continue - cells[cell] = entries[entryIndex] - entryIndex += 1 + if (occupied.has(cell) || slotIndex >= slots.length) continue + cells[cell] = slots[slotIndex] + slotIndex += 1 } pages.push({ cells, page }) page += 1 } return pages.length ? pages - : [{ cells: Array(HOME_GRID_PAGE_SIZE).fill(null), page: 1 }] + : [ + { + cells: Array.from({ length: HOME_GRID_PAGE_SIZE }, (_, sourceIndex) => ({ + app: null, + folder: null, + renderKey: `grid-empty-${sourceIndex}`, + sourceIndex, + })), + page: 1, + }, + ] }) const pageCount = computed(() => appPages.value.length + 2) const libraryPage = computed(() => pageCount.value - 1) @@ -183,9 +226,27 @@ const canDeleteCurrentPage = computed(() => { ) }) const dockSlots = computed(() => - appStore.homeLayout.dock.map((id) => - id ? (installedAppsById.value.get(id) ?? null) : null, - ), + appStore.homeLayout.dock.map((item, index) => ({ + app: + typeof item === 'string' + ? (installedAppsById.value.get(item) ?? null) + : null, + folder: isHomeFolder(item) ? item : null, + index, + })), +) +const openedFolder = computed(() => + openedFolderId.value + ? getHomeFolder(appStore.homeLayout, openedFolderId.value) + : null, +) +const openedFolderApps = computed(() => + openedFolder.value + ? openedFolder.value.apps.flatMap((appId, index) => { + const app = installedAppsById.value.get(appId) + return app ? [{ app, index }] : [] + }) + : [], ) const filteredApps = computed(() => { const query = searchQuery.value.trim().toLocaleLowerCase(phone.lang) @@ -567,27 +628,151 @@ async function saveWidgetConfig( if (configured) changePage(configured.page) } -function targetHomeIndex(page: number, cell: number): number { - return Math.min( - Math.max(0, appStore.homeLayout.grid.length - 1), - (page - 1) * HOME_GRID_PAGE_SIZE + cell, - ) -} - function startHomeDrag(area: HomeArea, index: number): void { draggingHomeApp.value = { area, index } } +function clearFolderHover(): void { + if (folderHoverTimer !== undefined) window.clearTimeout(folderHoverTimer) + folderHoverTimer = undefined + folderHoverTarget.value = '' +} + +function queueFolderHover(event: PointerEvent): void { + const dragged = draggingHomeApp.value + if (!dragged) return + const sourceItem = appStore.homeLayout[dragged.area][dragged.index] + if (typeof sourceItem !== 'string') { + clearFolderHover() + return + } + + const targetElement = document + .elementsFromPoint(event.clientX, event.clientY) + .map((element) => element.closest('[data-home-index]')) + .find( + (element) => + element && !element.closest('.app-icon-item--dragging'), + ) + const area = targetElement?.dataset.homeArea as HomeArea | undefined + const targetIndex = Number(targetElement?.dataset.homeIndex) + if ( + (area !== 'grid' && area !== 'dock') || + !Number.isInteger(targetIndex) || + (area === dragged.area && targetIndex === dragged.index) + ) { + clearFolderHover() + return + } + const targetItem = appStore.homeLayout[area][targetIndex] + if (typeof targetItem !== 'string' && !isHomeFolder(targetItem)) { + clearFolderHover() + return + } + + const targetKey = `${area}:${targetIndex}` + if (folderHoverTarget.value === targetKey) return + clearFolderHover() + folderHoverTarget.value = targetKey + folderHoverTimer = window.setTimeout(() => { + const currentDrag = draggingHomeApp.value + if (!currentDrag || folderHoverTarget.value !== targetKey) return + const currentSource = appStore.homeLayout[currentDrag.area][currentDrag.index] + const currentTarget = appStore.homeLayout[area][targetIndex] + if (typeof currentSource !== 'string') return + + let folderId: string | null = null + if (typeof currentTarget === 'string') { + folderId = appStore.createHomeFolder( + currentDrag.area, + currentDrag.index, + area, + targetIndex, + phone.t('Home.folders.defaultName'), + ) + if (folderId) renameFolderOnOpenId.value = folderId + } else if (isHomeFolder(currentTarget)) { + folderId = appStore.addHomeAppToFolder( + currentDrag.area, + currentDrag.index, + currentTarget.id, + ) + ? currentTarget.id + : null + } + if (folderId) { + draggingHomeApp.value = null + openedFolderId.value = folderId + } + clearFolderHover() + }, HOME_FOLDER_HOVER_DURATION) +} + function moveHomeDrag(event: PointerEvent): void { if (draggingHomeApp.value?.area === 'grid') { queueEdgePageTurn(event, 'app') } + queueFolderHover(event) +} + +async function animateHomeItemDrop( + item: HomeItem, + area: HomeArea, + index: number, + from: DOMRect, +): Promise { + await nextTick() + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return + + const appElement = Array.from( + document.querySelectorAll('[data-home-item-key]'), + ).find( + (element) => + element.dataset.homeItemKey === + (isHomeFolder(item) ? item.id : `app-${item}`) && + element.dataset.homeArea === area && + Number(element.dataset.homeIndex) === index, + ) + if (!appElement) return + + const target = appElement.getBoundingClientRect() + const offsetX = from.left - target.left + const offsetY = from.top - target.top + if (Math.hypot(offsetX, offsetY) < 1) return + + appElement.style.setProperty('--home-app-drop-x', `${offsetX}px`) + appElement.style.setProperty('--home-app-drop-y', `${offsetY}px`) + let cleanedUp = false + const cleanUpAnimation = (event: AnimationEvent): void => { + if ( + event.target !== appElement || + event.animationName !== 'home-app-drop-settle' + ) { + return + } + if (cleanedUp) return + cleanedUp = true + appElement.classList.remove('app-icon-item--drop-settling') + appElement.style.removeProperty('--home-app-drop-x') + appElement.style.removeProperty('--home-app-drop-y') + appElement.removeEventListener('animationend', cleanUpAnimation) + appElement.removeEventListener('animationcancel', cleanUpAnimation) + } + appElement.addEventListener('animationend', cleanUpAnimation) + appElement.addEventListener('animationcancel', cleanUpAnimation) + appElement.classList.add('app-icon-item--drop-settling') } function finishHomeDrag(event: PointerEvent): void { clearEdgePageTurn() + clearFolderHover() const dragged = draggingHomeApp.value if (!dragged) return + const draggedItem = appStore.homeLayout[dragged.area][dragged.index] + const draggedElement = (event.currentTarget as HTMLElement | null)?.closest( + '.app-icon-item', + ) + const dropOrigin = draggedElement?.getBoundingClientRect() const target = document .elementsFromPoint(event.clientX, event.clientY) .find( @@ -619,15 +804,23 @@ function finishHomeDrag(event: PointerEvent): void { } const area = (targetItem?.dataset.homeArea ?? targetArea?.dataset.homeArea) as HomeArea | undefined + let dropArea = dragged.area + let dropIndex = dragged.index if ((area === 'grid' || area === 'dock') && targetItem) { const targetIndex = Number.parseInt(targetItem.dataset.homeIndex ?? '', 10) appStore.moveHomeApp(dragged.area, dragged.index, area, targetIndex) + dropArea = area + dropIndex = targetIndex } draggingHomeApp.value = null + if (draggedItem && dropOrigin) { + void animateHomeItemDrop(draggedItem, dropArea, dropIndex, dropOrigin) + } } function stopHomeDrag(): void { clearEdgePageTurn() + clearFolderHover() draggingHomeApp.value = null } @@ -636,6 +829,11 @@ function reorderHomeApp( sourceIndex: number, direction: ReorderDirection, ): void { + const item = appStore.homeLayout[area][sourceIndex] + const appElement = document.querySelector( + `[data-home-area="${area}"][data-home-index="${sourceIndex}"]`, + ) + const moveOrigin = appElement?.getBoundingClientRect() const targetIndex = homeKeyboardTarget( appStore.homeLayout, area, @@ -644,6 +842,9 @@ function reorderHomeApp( ) if (targetIndex === null) return appStore.moveHomeApp(area, sourceIndex, area, targetIndex) + if (item && moveOrigin) { + void animateHomeItemDrop(item, area, targetIndex, moveOrigin) + } } async function addHomePage(): Promise { @@ -676,6 +877,90 @@ function restoreHomeApp(appId: LaunchablePhoneAppId): void { appStore.restoreHomeApp(appId) } +function folderPreviewApps(folder: HomeFolder): PhoneAppDefinition[] { + const apps: PhoneAppDefinition[] = [] + for (const appId of folder.apps) { + const app = installedAppsById.value.get(appId) + if (app) apps.push(app) + } + return apps +} + +function openFolder(folderId: string): void { + pageTransitioning.value = false + openedFolderId.value = folderId +} + +function closeFolder(): void { + openedFolderId.value = null + renameFolderOnOpenId.value = null +} + +function renameOpenedFolder(name: string): void { + if (!openedFolderId.value) return + appStore.renameHomeFolder(openedFolderId.value, name) +} + +function moveOpenedFolderApp(sourceIndex: number, targetIndex: number): void { + if (!openedFolderId.value) return + appStore.moveHomeFolderApp(openedFolderId.value, sourceIndex, targetIndex) +} + +function extractOpenedFolderApp( + sourceIndex: number, + event: PointerEvent, +): void { + const folder = openedFolder.value + if (!folder) return + const appId = folder.apps[sourceIndex] + const sourceElement = (event.currentTarget as HTMLElement | null)?.closest( + '.app-icon-item', + ) + const sourceBounds = sourceElement?.getBoundingClientRect() + const candidates = Array.from( + document.querySelectorAll('[data-home-index][data-home-area]'), + ).filter((element) => { + const area = element.dataset.homeArea as HomeArea + const index = Number(element.dataset.homeIndex) + return ( + (area === 'grid' || area === 'dock') && + Number.isInteger(index) && + appStore.homeLayout[area][index] === null + ) + }) + const target = candidates.reduce((closest, candidate) => { + if (!closest) return candidate + const bounds = candidate.getBoundingClientRect() + const closestBounds = closest.getBoundingClientRect() + const distance = Math.hypot( + event.clientX - (bounds.left + bounds.width / 2), + event.clientY - (bounds.top + bounds.height / 2), + ) + const closestDistance = Math.hypot( + event.clientX - (closestBounds.left + closestBounds.width / 2), + event.clientY - (closestBounds.top + closestBounds.height / 2), + ) + return distance < closestDistance ? candidate : closest + }, null) + if (!target) return + + const area = target.dataset.homeArea as HomeArea + const targetIndex = Number(target.dataset.homeIndex) + if ( + !appStore.extractHomeFolderApp( + folder.id, + sourceIndex, + area, + targetIndex, + ) + ) { + return + } + if (sourceBounds) { + void animateHomeItemDrop(appId, area, targetIndex, sourceBounds) + } +} + function clearSearch(): void { searchQuery.value = '' searchFocused.value = false @@ -690,6 +975,9 @@ function openAllApps(): void { watch(isEditablePage, (visible) => { if (!visible) editMode.value = false }) +watch(openedFolder, (folder) => { + if (!folder) closeFolder() +})