mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-02 19:28:56 +00:00
ADD - support home screen app folders
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
+318
-100
@@ -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<HomeFolder>
|
||||
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<LaunchablePhoneAppId>,
|
||||
@@ -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<LaunchablePhoneAppId>,
|
||||
folderIds: Set<string>,
|
||||
): 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<LaunchablePhoneAppId>,
|
||||
length: number,
|
||||
folderIds: Set<string>,
|
||||
): 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<Record<keyof HomeLayout, unknown>>
|
||||
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<string>()
|
||||
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<LaunchablePhoneAppId>(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
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user