mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
ENH - merge Picstagram Sky UI updates
Merge the Picstagram Sky UI work and its dependent phone, media, voice, gallery, app store, setup, and UI parity updates into weather-app. Resolve the frontend development port configuration and retain the Sky UI EasyShare implementation. Frontend validation and local resource deployment completed; included configuration, locale, and SQL updates require no separate migration step beyond this merge.
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getDailyHighlights } from '@/utils/appStoreHighlights'
|
||||
|
||||
const candidates = [
|
||||
{ id: 'banking' },
|
||||
{ id: 'feather' },
|
||||
{ id: 'snake' },
|
||||
{ id: 'music' },
|
||||
{ id: 'picstagram' },
|
||||
]
|
||||
|
||||
describe('daily App Store highlights', () => {
|
||||
it('keeps the generated order stable throughout one local day', () => {
|
||||
const morning = getDailyHighlights(candidates, new Date(2026, 7, 14, 8, 15))
|
||||
const evening = getDailyHighlights(
|
||||
candidates,
|
||||
new Date(2026, 7, 14, 22, 45),
|
||||
)
|
||||
|
||||
expect(evening).toEqual(morning)
|
||||
expect(morning).toHaveLength(candidates.length)
|
||||
})
|
||||
|
||||
it('generates a different curation for the next local day', () => {
|
||||
const today = getDailyHighlights(candidates, new Date(2026, 7, 14))
|
||||
const tomorrow = getDailyHighlights(candidates, new Date(2026, 7, 15))
|
||||
|
||||
expect(tomorrow.map((app) => app.id)).not.toEqual(
|
||||
today.map((app) => app.id),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
type HighlightCandidate = {
|
||||
id: string
|
||||
}
|
||||
|
||||
function localDayNumber(date: Date): number {
|
||||
return Math.floor(
|
||||
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000,
|
||||
)
|
||||
}
|
||||
|
||||
function nextRandom(seed: number): number {
|
||||
return (Math.imul(seed, 1_664_525) + 1_013_904_223) >>> 0
|
||||
}
|
||||
|
||||
export function getDailyHighlights<T extends HighlightCandidate>(
|
||||
candidates: readonly T[],
|
||||
date = new Date(),
|
||||
): T[] {
|
||||
const ordered = [...candidates].sort((left, right) =>
|
||||
left.id.localeCompare(right.id),
|
||||
)
|
||||
let seed = localDayNumber(date) >>> 0
|
||||
|
||||
for (let index = ordered.length - 1; index > 0; index -= 1) {
|
||||
seed = nextRandom(seed)
|
||||
const target = seed % (index + 1)
|
||||
const current = ordered[index]
|
||||
ordered[index] = ordered[target]
|
||||
ordered[target] = current
|
||||
}
|
||||
|
||||
if (ordered.length > 1) {
|
||||
const offset = localDayNumber(date) % ordered.length
|
||||
return [...ordered.slice(offset), ...ordered.slice(0, offset)]
|
||||
}
|
||||
|
||||
return ordered
|
||||
}
|
||||
@@ -1,22 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { LaunchablePhoneAppId } from '@/types/apps'
|
||||
import {
|
||||
addHomeAppToFolder,
|
||||
addHomePage,
|
||||
createHomeFolder,
|
||||
createDefaultHomeLayout,
|
||||
createHomeFolder,
|
||||
deleteHomePage,
|
||||
extractHomeFolderApp,
|
||||
getHomeFolder,
|
||||
HOME_GRID_COLUMNS,
|
||||
HOME_GRID_PAGE_SIZE,
|
||||
HOME_GRID_ROWS,
|
||||
homeKeyboardTarget,
|
||||
isHomeFolder,
|
||||
MAX_HOME_GRID_PAGES,
|
||||
moveHomeFolderApp,
|
||||
moveHomeApp,
|
||||
moveHomeAppToGridPage,
|
||||
moveHomeFolderApp,
|
||||
parseHomeLayout,
|
||||
renameHomeFolder,
|
||||
removeHomeApp,
|
||||
renameHomeFolder,
|
||||
restoreHomeApp,
|
||||
type HomeLayout,
|
||||
} from '@/utils/homeLayout'
|
||||
@@ -28,10 +32,36 @@ const defaults = createDefaultHomeLayout(
|
||||
['phone', 'messages', 'clock'],
|
||||
)
|
||||
|
||||
function generatedApp(index: number): LaunchablePhoneAppId {
|
||||
return `generated-app-${index}` as LaunchablePhoneAppId
|
||||
}
|
||||
|
||||
function pageLayout(pageCounts: readonly number[]): HomeLayout {
|
||||
const grid: HomeLayout['grid'] = Array.from(
|
||||
{ length: pageCounts.length * HOME_GRID_PAGE_SIZE },
|
||||
() => null,
|
||||
)
|
||||
let appIndex = 0
|
||||
for (const [pageIndex, count] of pageCounts.entries()) {
|
||||
for (let offset = 0; offset < count; offset += 1) {
|
||||
grid[pageIndex * HOME_GRID_PAGE_SIZE + offset] = generatedApp(appIndex)
|
||||
appIndex += 1
|
||||
}
|
||||
}
|
||||
return {
|
||||
dock: [generatedApp(999), null, null, null],
|
||||
grid,
|
||||
hidden: [],
|
||||
version: 5,
|
||||
}
|
||||
}
|
||||
|
||||
describe('home layout', () => {
|
||||
it('uses fixed grid and dock slots for the registry arrangement', () => {
|
||||
it('uses six-row grid pages and fixed dock slots', () => {
|
||||
const layout = parseHomeLayout(undefined, defaults, [...installed])
|
||||
|
||||
expect(HOME_GRID_PAGE_SIZE).toBe(HOME_GRID_COLUMNS * HOME_GRID_ROWS)
|
||||
expect(HOME_GRID_ROWS).toBe(6)
|
||||
expect(layout.grid).toHaveLength(HOME_GRID_PAGE_SIZE)
|
||||
expect(layout.grid.slice(0, 6)).toEqual([
|
||||
'phone',
|
||||
@@ -62,11 +92,8 @@ describe('home layout', () => {
|
||||
expect(layout.version).toBe(5)
|
||||
})
|
||||
|
||||
it('preserves explicit gaps in versioned layouts', () => {
|
||||
const grid: HomeLayout['grid'] = Array.from(
|
||||
{ length: 20 },
|
||||
() => null,
|
||||
)
|
||||
it('closes gaps within a versioned page without moving apps between pages', () => {
|
||||
const grid: HomeLayout['grid'] = Array.from({ length: 20 }, () => null)
|
||||
grid[0] = 'phone'
|
||||
grid[7] = 'mail'
|
||||
|
||||
@@ -81,88 +108,219 @@ describe('home layout', () => {
|
||||
[...installed],
|
||||
)
|
||||
|
||||
expect(layout.grid[0]).toBe('phone')
|
||||
expect(layout.grid[1]).toBeNull()
|
||||
expect(layout.grid[7]).toBe('mail')
|
||||
expect(layout.grid.slice(0, 3)).toEqual(['phone', 'mail', null])
|
||||
expect(layout.dock).toEqual(['messages', null, 'clock', null])
|
||||
})
|
||||
|
||||
it('keeps valid custom-app tombstones in version 3 layouts', () => {
|
||||
const grid: HomeLayout['grid'] = Array.from(
|
||||
{ length: 20 },
|
||||
() => null,
|
||||
)
|
||||
it('keeps valid custom-app tombstones while migrating version 3 layouts', () => {
|
||||
const grid: HomeLayout['grid'] = Array.from({ length: 20 }, () => null)
|
||||
grid[6] = 'temporarily-missing' as HomeLayout['hidden'][number]
|
||||
|
||||
const layout = parseHomeLayout(
|
||||
{
|
||||
dock: ['phone', null, null, null],
|
||||
grid,
|
||||
hidden: [],
|
||||
version: 3,
|
||||
},
|
||||
{ dock: ['phone'], grid, hidden: [], version: 3 },
|
||||
defaults,
|
||||
[...installed],
|
||||
)
|
||||
|
||||
expect(layout.grid[6]).toBe('temporarily-missing')
|
||||
expect(layout.grid).toContain('temporarily-missing')
|
||||
const pageItemCount = layout.grid
|
||||
.slice(0, HOME_GRID_PAGE_SIZE)
|
||||
.filter((item) => item !== null).length
|
||||
expect(layout.grid.slice(0, pageItemCount)).not.toContain(null)
|
||||
expect(layout.version).toBe(5)
|
||||
})
|
||||
|
||||
it('expands version 3 capacity without compacting persisted gaps', () => {
|
||||
it('preserves page membership while expanding version 3 pages', () => {
|
||||
const grid: HomeLayout['grid'] = Array.from({ length: 40 }, () => null)
|
||||
grid[19] = 'mail'
|
||||
grid[20] = 'notes'
|
||||
grid[0] = 'phone'
|
||||
grid[19] = 'messages'
|
||||
grid[20] = 'mail'
|
||||
grid[39] = 'clock'
|
||||
|
||||
const layout = parseHomeLayout(
|
||||
{
|
||||
dock: ['phone', null, null, null],
|
||||
grid,
|
||||
hidden: [],
|
||||
version: 3,
|
||||
},
|
||||
{ dock: [], 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.grid.slice(0, 3)).toEqual(['phone', 'messages', 'notes'])
|
||||
expect(layout.grid[HOME_GRID_PAGE_SIZE]).toBe('mail')
|
||||
expect(layout.grid[HOME_GRID_PAGE_SIZE + 1]).toBe('clock')
|
||||
expect(layout.version).toBe(5)
|
||||
})
|
||||
|
||||
it('reads version 4 pages directly before migrating the schema', () => {
|
||||
const grid: HomeLayout['grid'] = Array.from(
|
||||
{ length: HOME_GRID_PAGE_SIZE * 2 },
|
||||
() => null,
|
||||
)
|
||||
grid[20] = 'phone'
|
||||
grid[HOME_GRID_PAGE_SIZE] = 'messages'
|
||||
|
||||
const layout = parseHomeLayout(
|
||||
{ dock: [], grid, hidden: [], version: 4 },
|
||||
defaults,
|
||||
[...installed],
|
||||
)
|
||||
|
||||
expect(layout.grid).toHaveLength(HOME_GRID_PAGE_SIZE * 2)
|
||||
expect(layout.grid[0]).toBe('phone')
|
||||
expect(layout.grid[HOME_GRID_PAGE_SIZE]).toBe('messages')
|
||||
expect(layout.version).toBe(5)
|
||||
})
|
||||
|
||||
it('preserves folders and page membership in version 5 layouts', () => {
|
||||
const grid: HomeLayout['grid'] = Array.from(
|
||||
{ length: HOME_GRID_PAGE_SIZE * 2 },
|
||||
() => null,
|
||||
)
|
||||
grid[8] = {
|
||||
apps: ['mail', 'notes'],
|
||||
id: 'folder-work-123456',
|
||||
name: 'Work',
|
||||
type: 'folder',
|
||||
}
|
||||
grid[HOME_GRID_PAGE_SIZE + 7] = 'clock'
|
||||
|
||||
const layout = parseHomeLayout(
|
||||
{ dock: ['phone'], grid, hidden: [], version: 5 },
|
||||
defaults,
|
||||
[...installed],
|
||||
)
|
||||
|
||||
expect(getHomeFolder(layout, 'folder-work-123456')?.apps).toEqual([
|
||||
'mail',
|
||||
'notes',
|
||||
])
|
||||
expect(layout.grid[HOME_GRID_PAGE_SIZE]).toBe('clock')
|
||||
expect(layout.version).toBe(5)
|
||||
})
|
||||
|
||||
it('preserves independently positioned shortcuts for the same app', () => {
|
||||
const grid: HomeLayout['grid'] = Array.from(
|
||||
{ length: 20 },
|
||||
() => null,
|
||||
)
|
||||
const grid: HomeLayout['grid'] = Array.from({ length: 20 }, () => null)
|
||||
grid[0] = 'phone'
|
||||
grid[5] = 'phone'
|
||||
|
||||
const layout = parseHomeLayout(
|
||||
{
|
||||
dock: ['phone', null, null, null],
|
||||
grid,
|
||||
hidden: [],
|
||||
version: 2,
|
||||
},
|
||||
{ dock: ['phone'], grid, hidden: [], version: 2 },
|
||||
defaults,
|
||||
[...installed],
|
||||
)
|
||||
|
||||
expect(layout.grid[0]).toBe('phone')
|
||||
expect(layout.grid[5]).toBe('phone')
|
||||
expect(layout.grid[1]).toBe('phone')
|
||||
expect(layout.dock[0]).toBe('phone')
|
||||
})
|
||||
|
||||
it('moves to an exact empty slot without compacting other apps', () => {
|
||||
it('inserts into an empty area and closes the source gap', () => {
|
||||
const moved = moveHomeApp(defaults, 'grid', 2, 'grid', 12)
|
||||
|
||||
expect(moved.grid[2]).toBeNull()
|
||||
expect(moved.grid[12]).toBe('mail')
|
||||
expect(moved.grid[0]).toBe('phone')
|
||||
expect(moved.grid[4]).toBe('notes')
|
||||
expect(moved.grid.slice(0, 6)).toEqual([
|
||||
'phone',
|
||||
'messages',
|
||||
'clock',
|
||||
'notes',
|
||||
'mail',
|
||||
null,
|
||||
])
|
||||
})
|
||||
|
||||
it('extends the layout when moving an item onto an empty new page', () => {
|
||||
const targetIndex = HOME_GRID_PAGE_SIZE + 20
|
||||
const moved = moveHomeApp(defaults, 'grid', 0, 'grid', targetIndex)
|
||||
|
||||
expect(moved.grid).toHaveLength(HOME_GRID_PAGE_SIZE * 2)
|
||||
expect(moved.grid.slice(0, 5)).toEqual([
|
||||
'messages',
|
||||
'mail',
|
||||
'clock',
|
||||
'notes',
|
||||
null,
|
||||
])
|
||||
expect(moved.grid[HOME_GRID_PAGE_SIZE]).toBe('phone')
|
||||
})
|
||||
|
||||
it.each([20, 21, 23])(
|
||||
'fills a target page containing %s apps without swapping one backwards',
|
||||
(targetCount) => {
|
||||
const layout = pageLayout([2, targetCount])
|
||||
const dragged = layout.grid[0]
|
||||
const originalTarget = layout.grid
|
||||
.slice(HOME_GRID_PAGE_SIZE, HOME_GRID_PAGE_SIZE * 2)
|
||||
.filter((item) => item !== null)
|
||||
const moved = moveHomeAppToGridPage(layout, 'grid', 0, 2, targetCount)
|
||||
|
||||
expect(moved.grid[0]).toBe(layout.grid[1])
|
||||
expect(
|
||||
moved.grid
|
||||
.slice(HOME_GRID_PAGE_SIZE, HOME_GRID_PAGE_SIZE * 2)
|
||||
.filter((item) => item !== null),
|
||||
).toEqual([...originalTarget, dragged])
|
||||
},
|
||||
)
|
||||
|
||||
it('cascades a full target page forward into a newly created page', () => {
|
||||
const layout = pageLayout([2, HOME_GRID_PAGE_SIZE])
|
||||
const dragged = layout.grid[0]
|
||||
const displaced = layout.grid[HOME_GRID_PAGE_SIZE * 2 - 1]
|
||||
const moved = moveHomeAppToGridPage(layout, 'grid', 0, 2, 0)
|
||||
|
||||
expect(moved.grid).toHaveLength(HOME_GRID_PAGE_SIZE * 3)
|
||||
expect(moved.grid.slice(0, 2)).toEqual([layout.grid[1], null])
|
||||
expect(moved.grid[HOME_GRID_PAGE_SIZE]).toBe(dragged)
|
||||
expect(moved.grid[HOME_GRID_PAGE_SIZE * 2]).toBe(displaced)
|
||||
})
|
||||
|
||||
it('uses a later source-page gap for forward overflow', () => {
|
||||
const layout = pageLayout([HOME_GRID_PAGE_SIZE, 2])
|
||||
const draggedIndex = HOME_GRID_PAGE_SIZE
|
||||
const dragged = layout.grid[draggedIndex]
|
||||
const displaced = layout.grid[HOME_GRID_PAGE_SIZE - 1]
|
||||
const remainingSecondPageItem = layout.grid[draggedIndex + 1]
|
||||
const moved = moveHomeAppToGridPage(layout, 'grid', draggedIndex, 1, 4)
|
||||
|
||||
expect(moved.grid).toHaveLength(HOME_GRID_PAGE_SIZE * 2)
|
||||
expect(moved.grid[4]).toBe(dragged)
|
||||
expect(moved.grid[HOME_GRID_PAGE_SIZE]).toBe(displaced)
|
||||
expect(moved.grid[HOME_GRID_PAGE_SIZE + 1]).toBe(remainingSecondPageItem)
|
||||
})
|
||||
|
||||
it('rejects a dock insertion atomically when all pages are full', () => {
|
||||
const layout = pageLayout(
|
||||
Array.from({ length: MAX_HOME_GRID_PAGES }, () => HOME_GRID_PAGE_SIZE),
|
||||
)
|
||||
|
||||
expect(moveHomeAppToGridPage(layout, 'dock', 0, 1, 0)).toBe(layout)
|
||||
})
|
||||
|
||||
it('moves a dock app onto another page and preserves every other item', () => {
|
||||
const layout = pageLayout([3, 2])
|
||||
const dockApp = layout.dock[0]
|
||||
const before = [...layout.grid.filter(Boolean), dockApp].sort()
|
||||
const moved = moveHomeAppToGridPage(layout, 'dock', 0, 2, 1)
|
||||
|
||||
expect(moved.dock[0]).toBeNull()
|
||||
expect(moved.grid[HOME_GRID_PAGE_SIZE + 1]).toBe(dockApp)
|
||||
expect(moved.grid.filter(Boolean).sort()).toEqual(before)
|
||||
})
|
||||
|
||||
it('respects widget-reduced capacities while cascading forward', () => {
|
||||
const layout = pageLayout([HOME_GRID_PAGE_SIZE])
|
||||
const moved = moveHomeAppToGridPage(layout, 'grid', 0, 1, 0, [
|
||||
20,
|
||||
HOME_GRID_PAGE_SIZE,
|
||||
])
|
||||
|
||||
expect(moved.grid.slice(0, 20).filter(Boolean)).toHaveLength(20)
|
||||
expect(
|
||||
moved.grid
|
||||
.slice(HOME_GRID_PAGE_SIZE, HOME_GRID_PAGE_SIZE * 2)
|
||||
.filter(Boolean),
|
||||
).toHaveLength(4)
|
||||
expect(moved.grid.filter(Boolean).sort()).toEqual(
|
||||
layout.grid.filter(Boolean).sort(),
|
||||
)
|
||||
})
|
||||
|
||||
it('provides bounded keyboard reorder targets without wrapping rows', () => {
|
||||
@@ -174,33 +332,40 @@ describe('home layout', () => {
|
||||
expect(homeKeyboardTarget(defaults, 'dock', 1, 'down')).toBeNull()
|
||||
})
|
||||
|
||||
it('swaps occupied grid slots without moving unrelated apps', () => {
|
||||
it('shifts occupied grid slots instead of replacing their items', () => {
|
||||
const reordered = moveHomeApp(defaults, 'grid', 2, 'grid', 0)
|
||||
|
||||
expect(reordered.grid.slice(0, 5)).toEqual([
|
||||
'mail',
|
||||
'messages',
|
||||
'phone',
|
||||
'messages',
|
||||
'clock',
|
||||
'notes',
|
||||
])
|
||||
})
|
||||
|
||||
it('swaps an occupied dock slot with the exact grid source', () => {
|
||||
it('shifts an occupied dock slot toward its gap', () => {
|
||||
const docked = moveHomeApp(defaults, 'grid', 2, 'dock', 2)
|
||||
|
||||
expect(docked.dock).toEqual(['phone', 'messages', 'mail', null])
|
||||
expect(docked.grid[2]).toBe('clock')
|
||||
expect(docked.dock).toEqual(['phone', 'messages', 'mail', 'clock'])
|
||||
expect(docked.grid.slice(0, 5)).toEqual([
|
||||
'phone',
|
||||
'messages',
|
||||
'clock',
|
||||
'notes',
|
||||
null,
|
||||
])
|
||||
})
|
||||
|
||||
it('swaps with an app in a full dock without shifting the dock', () => {
|
||||
it('moves a dock item displaced from a full dock into the source slot', () => {
|
||||
const layout: HomeLayout = {
|
||||
...defaults,
|
||||
dock: ['phone', 'messages', 'clock', 'notes'],
|
||||
}
|
||||
const docked = moveHomeApp(layout, 'grid', 2, 'dock', 1)
|
||||
|
||||
expect(docked.dock).toEqual(['phone', 'mail', 'clock', 'notes'])
|
||||
expect(docked.grid[2]).toBe('messages')
|
||||
expect(docked.dock).toEqual(['phone', 'mail', 'messages', 'clock'])
|
||||
expect(docked.grid[2]).toBe('notes')
|
||||
})
|
||||
|
||||
it('moves shortcuts between the dock and grid independently', () => {
|
||||
@@ -211,21 +376,29 @@ describe('home layout', () => {
|
||||
expect(movedToGrid.grid[5]).toBe('phone')
|
||||
|
||||
const movedToDock = moveHomeApp(movedToGrid, 'grid', 1, 'dock', 3)
|
||||
expect(movedToDock.grid[1]).toBeNull()
|
||||
expect(movedToDock.grid.slice(0, 6)).toEqual([
|
||||
'phone',
|
||||
'mail',
|
||||
'clock',
|
||||
'notes',
|
||||
'phone',
|
||||
null,
|
||||
])
|
||||
expect(movedToDock.dock[1]).toBe('messages')
|
||||
expect(movedToDock.dock[3]).toBe('messages')
|
||||
})
|
||||
|
||||
it('removes shortcuts without closing gaps and restores the first gap', () => {
|
||||
const layout: HomeLayout = moveHomeApp(defaults, 'grid', 0, 'grid', 10)
|
||||
it('removes and restores shortcuts without leaving a page gap', () => {
|
||||
const layout = moveHomeApp(defaults, 'grid', 0, 'grid', 10)
|
||||
const removed = removeHomeApp(layout, 'phone')
|
||||
expect(removed.grid[0]).toBeNull()
|
||||
expect(removed.grid[10]).toBeNull()
|
||||
|
||||
expect(removed.grid).not.toContain('phone')
|
||||
expect(removed.dock[0]).toBeNull()
|
||||
expect(removed.hidden).toContain('phone')
|
||||
|
||||
const restored = restoreHomeApp(removed, 'phone')
|
||||
expect(restored.grid[0]).toBe('phone')
|
||||
expect(restored.grid[4]).toBe('phone')
|
||||
expect(restored.grid.slice(0, 5)).not.toContain(null)
|
||||
expect(restored.hidden).not.toContain('phone')
|
||||
})
|
||||
|
||||
@@ -239,7 +412,7 @@ describe('home layout', () => {
|
||||
expect(addHomePage(layout)).toBe(layout)
|
||||
})
|
||||
|
||||
it('deletes a page and moves its apps into remaining empty slots', () => {
|
||||
it('deletes a page and moves its items into remaining empty slots', () => {
|
||||
let layout = addHomePage(defaults)
|
||||
layout = moveHomeApp(layout, 'grid', 0, 'grid', HOME_GRID_PAGE_SIZE)
|
||||
const deleted = deleteHomePage(layout, 2)
|
||||
@@ -249,7 +422,7 @@ describe('home layout', () => {
|
||||
expect(deleteHomePage(deleted, 1)).toBe(deleted)
|
||||
})
|
||||
|
||||
it('creates, renames, and persists a folder without moving other slots', () => {
|
||||
it('creates, renames, and parses a folder with compact pages', () => {
|
||||
const folderLayout = createHomeFolder(
|
||||
defaults,
|
||||
'grid',
|
||||
@@ -259,21 +432,21 @@ describe('home layout', () => {
|
||||
'folder-work-123456',
|
||||
'Work',
|
||||
)
|
||||
const folder = folderLayout.grid[4]
|
||||
const folder = getHomeFolder(folderLayout, 'folder-work-123456')
|
||||
|
||||
expect(folderLayout.grid[2]).toBeNull()
|
||||
expect(isHomeFolder(folder) && folder.apps).toEqual(['notes', 'mail'])
|
||||
expect(folderLayout.grid[0]).toBe('phone')
|
||||
expect(folder?.apps).toEqual(['notes', 'mail'])
|
||||
expect(folderLayout.grid.slice(0, 4)).toEqual([
|
||||
'phone',
|
||||
'messages',
|
||||
'clock',
|
||||
folder,
|
||||
])
|
||||
|
||||
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({
|
||||
@@ -284,7 +457,7 @@ describe('home layout', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('adds apps to a folder and swaps apps inside its 3x3 pages', () => {
|
||||
it('adds apps to a folder and swaps apps inside it', () => {
|
||||
const folderLayout = createHomeFolder(
|
||||
defaults,
|
||||
'grid',
|
||||
@@ -294,25 +467,20 @@ describe('home layout', () => {
|
||||
'folder-tools-123456',
|
||||
'Tools',
|
||||
)
|
||||
const clockIndex = folderLayout.grid.indexOf('clock')
|
||||
const expanded = addHomeAppToFolder(
|
||||
folderLayout,
|
||||
'grid',
|
||||
3,
|
||||
clockIndex,
|
||||
'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,
|
||||
)
|
||||
const reordered = moveHomeFolderApp(expanded, 'folder-tools-123456', 2, 0)
|
||||
expect(getHomeFolder(reordered, 'folder-tools-123456')?.apps).toEqual([
|
||||
'clock',
|
||||
'mail',
|
||||
@@ -320,7 +488,7 @@ describe('home layout', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('extracts a folder app into an exact gap and dissolves one-app folders', () => {
|
||||
it('extracts a folder app and dissolves one-app folders', () => {
|
||||
const folderLayout = createHomeFolder(
|
||||
defaults,
|
||||
'grid',
|
||||
@@ -338,12 +506,17 @@ describe('home layout', () => {
|
||||
12,
|
||||
)
|
||||
|
||||
expect(extracted.grid[12]).toBe('mail')
|
||||
expect(extracted.grid[4]).toBe('notes')
|
||||
expect(extracted.grid.slice(0, 5)).toEqual([
|
||||
'phone',
|
||||
'messages',
|
||||
'clock',
|
||||
'notes',
|
||||
'mail',
|
||||
])
|
||||
expect(getHomeFolder(extracted, 'folder-social-123456')).toBeNull()
|
||||
})
|
||||
|
||||
it('removes hidden apps from folders and dissolves the folder when needed', () => {
|
||||
it('removes hidden apps from folders and dissolves the folder', () => {
|
||||
const folderLayout = createHomeFolder(
|
||||
defaults,
|
||||
'grid',
|
||||
@@ -355,7 +528,39 @@ describe('home layout', () => {
|
||||
)
|
||||
const removed = removeHomeApp(folderLayout, 'mail')
|
||||
|
||||
expect(removed.grid[4]).toBe('notes')
|
||||
expect(removed.grid.slice(0, 4)).toEqual([
|
||||
'phone',
|
||||
'messages',
|
||||
'clock',
|
||||
'notes',
|
||||
])
|
||||
expect(removed.hidden).toContain('mail')
|
||||
})
|
||||
|
||||
it('moves folders as one item without crossing page boundaries', () => {
|
||||
const folderLayout = createHomeFolder(
|
||||
defaults,
|
||||
'grid',
|
||||
2,
|
||||
'grid',
|
||||
4,
|
||||
'folder-page-123456',
|
||||
'Page two',
|
||||
)
|
||||
const folderIndex = folderLayout.grid.findIndex(isHomeFolder)
|
||||
const moved = moveHomeApp(
|
||||
folderLayout,
|
||||
'grid',
|
||||
folderIndex,
|
||||
'grid',
|
||||
HOME_GRID_PAGE_SIZE + 8,
|
||||
)
|
||||
|
||||
expect(moved.grid.slice(0, 4)).toEqual(['phone', 'messages', 'clock', null])
|
||||
const movedFolder = moved.grid[HOME_GRID_PAGE_SIZE]
|
||||
expect(isHomeFolder(movedFolder)).toBe(true)
|
||||
expect(isHomeFolder(movedFolder) && movedFolder.id).toBe(
|
||||
'folder-page-123456',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,10 +3,12 @@ import type { ReorderDirection } from '@/utils/keyboard'
|
||||
|
||||
export const HOME_DOCK_CAPACITY = 4
|
||||
export const HOME_GRID_COLUMNS = 4
|
||||
export const HOME_GRID_PAGE_SIZE = 24
|
||||
export const HOME_GRID_ROWS = 6
|
||||
export const HOME_GRID_PAGE_SIZE = HOME_GRID_COLUMNS * HOME_GRID_ROWS
|
||||
export const HOME_FOLDER_PAGE_SIZE = 9
|
||||
export const HOME_FOLDER_NAME_MAX_LENGTH = 32
|
||||
export const MAX_HOME_GRID_PAGES = 5
|
||||
export const HOME_LAYOUT_VERSION = 5
|
||||
const LEGACY_HOME_GRID_PAGE_SIZE = 20
|
||||
|
||||
export type HomeArea = 'dock' | 'grid'
|
||||
@@ -25,9 +27,11 @@ export type HomeLayout = {
|
||||
dock: HomeSlot[]
|
||||
grid: HomeSlot[]
|
||||
hidden: LaunchablePhoneAppId[]
|
||||
version: 5
|
||||
version: typeof HOME_LAYOUT_VERSION
|
||||
}
|
||||
|
||||
export type HomeGridPageCapacities = readonly number[]
|
||||
|
||||
export function isHomeFolder(value: unknown): value is HomeFolder {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const folder = value as Partial<HomeFolder>
|
||||
@@ -77,11 +81,6 @@ 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
|
||||
}
|
||||
@@ -91,7 +90,7 @@ function cloneLayout(layout: HomeLayout): HomeLayout {
|
||||
dock: layout.dock.map(cloneItem),
|
||||
grid: layout.grid.map(cloneItem),
|
||||
hidden: [...layout.hidden],
|
||||
version: 5,
|
||||
version: HOME_LAYOUT_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +104,7 @@ function readItem(
|
||||
value: unknown,
|
||||
availableIds: Set<LaunchablePhoneAppId>,
|
||||
folderIds: Set<string>,
|
||||
allowFolders: boolean,
|
||||
): HomeSlot {
|
||||
if (
|
||||
typeof value === 'string' &&
|
||||
@@ -112,8 +112,14 @@ function readItem(
|
||||
) {
|
||||
return value as LaunchablePhoneAppId
|
||||
}
|
||||
if (!isHomeFolder(value) || !isPersistableFolderId(value.id)) return null
|
||||
if (folderIds.has(value.id)) return null
|
||||
if (
|
||||
!allowFolders ||
|
||||
!isHomeFolder(value) ||
|
||||
!isPersistableFolderId(value.id) ||
|
||||
folderIds.has(value.id)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const apps = readAppIds(value.apps, availableIds)
|
||||
if (apps.length === 0) return null
|
||||
@@ -132,12 +138,45 @@ function readSlots(
|
||||
availableIds: Set<LaunchablePhoneAppId>,
|
||||
length: number,
|
||||
folderIds: Set<string>,
|
||||
allowFolders: boolean,
|
||||
): HomeSlot[] {
|
||||
const slots = createSlots(length)
|
||||
if (!Array.isArray(value)) return slots
|
||||
|
||||
for (let index = 0; index < Math.min(value.length, length); index += 1) {
|
||||
slots[index] = readItem(value[index], availableIds, folderIds)
|
||||
slots[index] = readItem(value[index], availableIds, folderIds, allowFolders)
|
||||
}
|
||||
return slots
|
||||
}
|
||||
|
||||
function migrateLegacyGrid(
|
||||
value: unknown,
|
||||
availableIds: Set<LaunchablePhoneAppId>,
|
||||
): HomeSlot[] {
|
||||
if (!Array.isArray(value)) return createSlots(HOME_GRID_PAGE_SIZE)
|
||||
|
||||
const legacyLength = Math.min(
|
||||
value.length,
|
||||
LEGACY_HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES,
|
||||
)
|
||||
const pageCount = Math.max(
|
||||
1,
|
||||
Math.ceil(legacyLength / LEGACY_HOME_GRID_PAGE_SIZE),
|
||||
)
|
||||
const slots = createSlots(pageCount * HOME_GRID_PAGE_SIZE)
|
||||
for (let index = 0; index < legacyLength; index += 1) {
|
||||
const valueId = value[index]
|
||||
if (
|
||||
typeof valueId !== 'string' ||
|
||||
!availableIds.has(valueId as LaunchablePhoneAppId)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
const page = Math.floor(index / LEGACY_HOME_GRID_PAGE_SIZE)
|
||||
const pageIndex = index % LEGACY_HOME_GRID_PAGE_SIZE
|
||||
slots[page * HOME_GRID_PAGE_SIZE + pageIndex] =
|
||||
valueId as LaunchablePhoneAppId
|
||||
}
|
||||
return slots
|
||||
}
|
||||
@@ -153,6 +192,42 @@ function placeInFirstEmptySlot(slots: HomeSlot[], item: HomeItem): void {
|
||||
slots[slots.length - HOME_GRID_PAGE_SIZE] = cloneItem(item)
|
||||
}
|
||||
|
||||
function insertIntoSlot(
|
||||
slots: HomeSlot[],
|
||||
targetIndex: number,
|
||||
item: HomeItem,
|
||||
): HomeSlot {
|
||||
if (slots[targetIndex] === null) {
|
||||
slots[targetIndex] = cloneItem(item)
|
||||
return null
|
||||
}
|
||||
|
||||
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] = cloneItem(item)
|
||||
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] = cloneItem(item)
|
||||
return null
|
||||
}
|
||||
|
||||
const displacedItem = cloneItem(slots.at(-1) ?? null)
|
||||
for (let index = slots.length - 1; index > targetIndex; index -= 1) {
|
||||
slots[index] = slots[index - 1]
|
||||
}
|
||||
slots[targetIndex] = cloneItem(item)
|
||||
return displacedItem
|
||||
}
|
||||
|
||||
function itemContainsApp(item: HomeSlot, appId: LaunchablePhoneAppId): boolean {
|
||||
return isHomeFolder(item) ? item.apps.includes(appId) : item === appId
|
||||
}
|
||||
@@ -180,6 +255,177 @@ export function getHomeFolder(
|
||||
return isHomeFolder(item) ? item : null
|
||||
}
|
||||
|
||||
function readPageItems(slots: HomeSlot[], pageStart: number): HomeItem[] {
|
||||
return slots
|
||||
.slice(pageStart, pageStart + HOME_GRID_PAGE_SIZE)
|
||||
.filter((item): item is HomeItem => item !== null)
|
||||
.map((item) => cloneItem(item) as HomeItem)
|
||||
}
|
||||
|
||||
function writePageItems(
|
||||
slots: HomeSlot[],
|
||||
pageStart: number,
|
||||
items: HomeItem[],
|
||||
): void {
|
||||
for (let offset = 0; offset < HOME_GRID_PAGE_SIZE; offset += 1) {
|
||||
slots[pageStart + offset] = cloneItem(items[offset] ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
function compactGridPages(slots: HomeSlot[]): HomeSlot[] {
|
||||
const compacted = slots.map(cloneItem)
|
||||
for (
|
||||
let pageStart = 0;
|
||||
pageStart < compacted.length;
|
||||
pageStart += HOME_GRID_PAGE_SIZE
|
||||
) {
|
||||
writePageItems(compacted, pageStart, readPageItems(compacted, pageStart))
|
||||
}
|
||||
return compacted
|
||||
}
|
||||
|
||||
type IndexedHomeItem = {
|
||||
item: HomeItem
|
||||
sourceIndex: number
|
||||
}
|
||||
|
||||
function gridPageCapacity(
|
||||
capacities: HomeGridPageCapacities,
|
||||
page: number,
|
||||
): number {
|
||||
const capacity = capacities[page - 1]
|
||||
return Number.isFinite(capacity)
|
||||
? Math.max(0, Math.min(HOME_GRID_PAGE_SIZE, Math.trunc(capacity)))
|
||||
: HOME_GRID_PAGE_SIZE
|
||||
}
|
||||
|
||||
function distributeGridPages(
|
||||
slots: readonly HomeSlot[],
|
||||
capacities: HomeGridPageCapacities,
|
||||
minimumPageCount: number,
|
||||
): IndexedHomeItem[][] | null {
|
||||
const persistedPageCount = Math.max(
|
||||
1,
|
||||
Math.ceil(slots.length / HOME_GRID_PAGE_SIZE),
|
||||
)
|
||||
const pages: IndexedHomeItem[][] = []
|
||||
let incoming: IndexedHomeItem[] = []
|
||||
let pageCount = Math.max(persistedPageCount, minimumPageCount)
|
||||
|
||||
for (let page = 1; page <= pageCount; page += 1) {
|
||||
const pageStart = (page - 1) * HOME_GRID_PAGE_SIZE
|
||||
const persisted = slots
|
||||
.slice(pageStart, pageStart + HOME_GRID_PAGE_SIZE)
|
||||
.flatMap((item, offset) =>
|
||||
item === null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
item: cloneItem(item) as HomeItem,
|
||||
sourceIndex: pageStart + offset,
|
||||
},
|
||||
],
|
||||
)
|
||||
const available = [...incoming, ...persisted]
|
||||
const capacity = gridPageCapacity(capacities, page)
|
||||
pages.push(available.slice(0, capacity))
|
||||
incoming = available.slice(capacity)
|
||||
|
||||
if (
|
||||
page === pageCount &&
|
||||
incoming.length &&
|
||||
pageCount < MAX_HOME_GRID_PAGES
|
||||
) {
|
||||
pageCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
return incoming.length ? null : pages
|
||||
}
|
||||
|
||||
function serializeGridPages(pages: readonly IndexedHomeItem[][]): HomeSlot[] {
|
||||
const slots = createSlots(Math.max(1, pages.length) * HOME_GRID_PAGE_SIZE)
|
||||
for (const [pageIndex, entries] of pages.entries()) {
|
||||
const pageStart = pageIndex * HOME_GRID_PAGE_SIZE
|
||||
for (const [offset, entry] of entries.entries()) {
|
||||
slots[pageStart + offset] = cloneItem(entry.item)
|
||||
}
|
||||
}
|
||||
return slots
|
||||
}
|
||||
|
||||
export function moveHomeAppToGridPage(
|
||||
layout: HomeLayout,
|
||||
from: HomeArea,
|
||||
sourceIndex: number,
|
||||
targetPage: number,
|
||||
targetOffset: number,
|
||||
capacities: HomeGridPageCapacities = [],
|
||||
): HomeLayout {
|
||||
const sourceSlots = layout[from]
|
||||
const sourceItem = sourceSlots[sourceIndex]
|
||||
if (
|
||||
sourceItem === null ||
|
||||
sourceItem === undefined ||
|
||||
sourceIndex < 0 ||
|
||||
sourceIndex >= sourceSlots.length ||
|
||||
!Number.isInteger(targetPage) ||
|
||||
targetPage < 1 ||
|
||||
targetPage > MAX_HOME_GRID_PAGES ||
|
||||
!Number.isFinite(targetOffset) ||
|
||||
gridPageCapacity(capacities, targetPage) === 0
|
||||
) {
|
||||
return layout
|
||||
}
|
||||
|
||||
const pages = distributeGridPages(layout.grid, capacities, targetPage)
|
||||
if (!pages) return layout
|
||||
|
||||
let sourcePage = -1
|
||||
if (from === 'grid') {
|
||||
for (const [pageIndex, entries] of pages.entries()) {
|
||||
const entryIndex = entries.findIndex(
|
||||
(entry) => entry.sourceIndex === sourceIndex,
|
||||
)
|
||||
if (entryIndex !== -1) {
|
||||
sourcePage = pageIndex + 1
|
||||
entries.splice(entryIndex, 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
if (sourcePage === -1) return layout
|
||||
}
|
||||
|
||||
while (pages.length < targetPage) pages.push([])
|
||||
const targetEntries = pages[targetPage - 1]
|
||||
const requestedOffset = Math.max(0, Math.trunc(targetOffset))
|
||||
targetEntries.splice(Math.min(requestedOffset, targetEntries.length), 0, {
|
||||
item: cloneItem(sourceItem) as HomeItem,
|
||||
sourceIndex,
|
||||
})
|
||||
|
||||
for (let page = targetPage; page <= pages.length; page += 1) {
|
||||
const entries = pages[page - 1]
|
||||
const capacity = gridPageCapacity(capacities, page)
|
||||
if (entries.length <= capacity) continue
|
||||
const overflow = entries.splice(capacity)
|
||||
if (page >= MAX_HOME_GRID_PAGES) return layout
|
||||
if (!pages[page]) pages.push([])
|
||||
pages[page].unshift(...overflow)
|
||||
}
|
||||
|
||||
const next = cloneLayout(layout)
|
||||
next.grid = serializeGridPages(pages)
|
||||
if (from === 'dock') next.dock[sourceIndex] = null
|
||||
if (
|
||||
JSON.stringify(next.grid) === JSON.stringify(layout.grid) &&
|
||||
JSON.stringify(next.dock) === JSON.stringify(layout.dock)
|
||||
) {
|
||||
return layout
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export function createDefaultHomeLayout(
|
||||
installedIds: LaunchablePhoneAppId[],
|
||||
defaultGridIds: LaunchablePhoneAppId[],
|
||||
@@ -200,19 +446,23 @@ export function createDefaultHomeLayout(
|
||||
dock[index] = id
|
||||
}
|
||||
|
||||
return { dock, grid, hidden: [], version: 5 }
|
||||
return { dock, grid, hidden: [], version: HOME_LAYOUT_VERSION }
|
||||
}
|
||||
|
||||
export function parseHomeLayout(
|
||||
value: unknown,
|
||||
defaults: HomeLayout,
|
||||
installedIds: LaunchablePhoneAppId[],
|
||||
preservePersistedIds = true,
|
||||
): HomeLayout {
|
||||
if (!value || typeof value !== 'object') return defaults
|
||||
|
||||
const source = value as Partial<Record<keyof HomeLayout, unknown>>
|
||||
const availableIds = new Set(installedIds)
|
||||
if (source.version === 3 || source.version === 4 || source.version === 5) {
|
||||
if (
|
||||
preservePersistedIds &&
|
||||
(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 item of collection) {
|
||||
@@ -227,12 +477,16 @@ export function parseHomeLayout(
|
||||
}
|
||||
const hidden = readAppIds(source.hidden, availableIds)
|
||||
const hiddenIds = new Set(hidden)
|
||||
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)
|
||||
const isLegacyVersionedLayout = source.version === 2 || source.version === 3
|
||||
const persistedGridLength = Array.isArray(source.grid)
|
||||
? isLegacyVersionedLayout
|
||||
? Math.ceil(
|
||||
Math.min(
|
||||
source.grid.length,
|
||||
LEGACY_HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES,
|
||||
) / LEGACY_HOME_GRID_PAGE_SIZE,
|
||||
) * HOME_GRID_PAGE_SIZE
|
||||
: Math.min(source.grid.length, HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES)
|
||||
: 0
|
||||
const gridLength = Math.max(
|
||||
defaults.grid.length,
|
||||
@@ -241,15 +495,35 @@ export function parseHomeLayout(
|
||||
let grid: HomeSlot[]
|
||||
let dock: HomeSlot[]
|
||||
|
||||
if (
|
||||
source.version === 2 ||
|
||||
source.version === 3 ||
|
||||
source.version === 4 ||
|
||||
source.version === 5
|
||||
) {
|
||||
if (isLegacyVersionedLayout) {
|
||||
grid = migrateLegacyGrid(source.grid, availableIds)
|
||||
if (grid.length < gridLength) {
|
||||
grid.push(...createSlots(gridLength - grid.length))
|
||||
}
|
||||
dock = readSlots(
|
||||
source.dock,
|
||||
availableIds,
|
||||
HOME_DOCK_CAPACITY,
|
||||
new Set(),
|
||||
false,
|
||||
)
|
||||
} else if (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)
|
||||
const allowFolders = source.version === 5
|
||||
grid = readSlots(
|
||||
source.grid,
|
||||
availableIds,
|
||||
gridLength,
|
||||
folderIds,
|
||||
allowFolders,
|
||||
)
|
||||
dock = readSlots(
|
||||
source.dock,
|
||||
availableIds,
|
||||
HOME_DOCK_CAPACITY,
|
||||
folderIds,
|
||||
allowFolders,
|
||||
)
|
||||
} else {
|
||||
grid = createSlots(gridLength)
|
||||
for (const id of readAppIds(source.grid, availableIds)) {
|
||||
@@ -271,8 +545,9 @@ export function parseHomeLayout(
|
||||
apps: item.apps.filter((appId) => !hiddenIds.has(appId)),
|
||||
})
|
||||
}
|
||||
grid = grid.map(removeHidden)
|
||||
grid = compactGridPages(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)
|
||||
@@ -288,7 +563,12 @@ export function parseHomeLayout(
|
||||
}
|
||||
}
|
||||
|
||||
return { dock, grid, hidden, version: 5 }
|
||||
return {
|
||||
dock: dock.map(cloneItem),
|
||||
grid: compactGridPages(grid),
|
||||
hidden,
|
||||
version: HOME_LAYOUT_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
export function removeHomeApp(
|
||||
@@ -297,7 +577,7 @@ export function removeHomeApp(
|
||||
): HomeLayout {
|
||||
const removeFromItem = (item: HomeSlot): HomeSlot => {
|
||||
if (item === appId) return null
|
||||
if (!isHomeFolder(item)) return item
|
||||
if (!isHomeFolder(item)) return cloneItem(item)
|
||||
return normalizeFolder({
|
||||
...item,
|
||||
apps: item.apps.filter((folderAppId) => folderAppId !== appId),
|
||||
@@ -305,11 +585,11 @@ export function removeHomeApp(
|
||||
}
|
||||
return {
|
||||
dock: layout.dock.map(removeFromItem),
|
||||
grid: layout.grid.map(removeFromItem),
|
||||
grid: compactGridPages(layout.grid.map(removeFromItem)),
|
||||
hidden: layout.hidden.includes(appId)
|
||||
? [...layout.hidden]
|
||||
: [...layout.hidden, appId],
|
||||
version: 5,
|
||||
version: HOME_LAYOUT_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,9 +607,9 @@ export function restoreHomeApp(
|
||||
placeInFirstEmptySlot(grid, appId)
|
||||
return {
|
||||
dock: layout.dock.map(cloneItem),
|
||||
grid,
|
||||
grid: compactGridPages(grid),
|
||||
hidden: layout.hidden.filter((id) => id !== appId),
|
||||
version: 5,
|
||||
version: HOME_LAYOUT_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,7 +622,7 @@ export function addHomePage(layout: HomeLayout): HomeLayout {
|
||||
dock: layout.dock.map(cloneItem),
|
||||
grid: [...layout.grid.map(cloneItem), ...createSlots(HOME_GRID_PAGE_SIZE)],
|
||||
hidden: [...layout.hidden],
|
||||
version: 5,
|
||||
version: HOME_LAYOUT_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,9 +642,9 @@ export function deleteHomePage(layout: HomeLayout, page: number): HomeLayout {
|
||||
|
||||
return {
|
||||
dock: layout.dock.map(cloneItem),
|
||||
grid,
|
||||
grid: compactGridPages(grid),
|
||||
hidden: [...layout.hidden],
|
||||
version: 5,
|
||||
version: HOME_LAYOUT_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,23 +655,46 @@ export function moveHomeApp(
|
||||
to: HomeArea,
|
||||
targetIndex: number,
|
||||
): HomeLayout {
|
||||
const next = cloneLayout(layout)
|
||||
const source = next[from]
|
||||
const target = next[to]
|
||||
const item = source[sourceIndex]
|
||||
const sourceSlots = layout[from]
|
||||
const item = sourceSlots[sourceIndex]
|
||||
const maximumTargetIndex =
|
||||
to === 'grid'
|
||||
? HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES
|
||||
: HOME_DOCK_CAPACITY
|
||||
if (
|
||||
!item ||
|
||||
item === null ||
|
||||
item === undefined ||
|
||||
sourceIndex < 0 ||
|
||||
sourceIndex >= source.length ||
|
||||
sourceIndex >= sourceSlots.length ||
|
||||
targetIndex < 0 ||
|
||||
targetIndex >= target.length
|
||||
targetIndex >= maximumTargetIndex ||
|
||||
(from === to && sourceIndex === targetIndex)
|
||||
) {
|
||||
return layout
|
||||
}
|
||||
|
||||
if (from === to && sourceIndex === targetIndex) return layout
|
||||
source[sourceIndex] = cloneItem(target[targetIndex])
|
||||
target[targetIndex] = item
|
||||
if (to === 'grid') {
|
||||
return moveHomeAppToGridPage(
|
||||
layout,
|
||||
from,
|
||||
sourceIndex,
|
||||
Math.floor(targetIndex / HOME_GRID_PAGE_SIZE) + 1,
|
||||
targetIndex % HOME_GRID_PAGE_SIZE,
|
||||
)
|
||||
}
|
||||
|
||||
const next = cloneLayout(layout)
|
||||
const source = next[from]
|
||||
const target = next[to]
|
||||
|
||||
if (from === to) {
|
||||
source[sourceIndex] = null
|
||||
insertIntoSlot(source, targetIndex, item)
|
||||
return next
|
||||
}
|
||||
|
||||
source[sourceIndex] = insertIntoSlot(target, targetIndex, item)
|
||||
next.grid = compactGridPages(next.grid)
|
||||
return next
|
||||
}
|
||||
|
||||
@@ -404,8 +707,9 @@ export function createHomeFolder(
|
||||
folderId: string,
|
||||
name: string,
|
||||
): HomeLayout {
|
||||
if (!isPersistableFolderId(folderId)) return layout
|
||||
if (folderLocation(layout, folderId)) return layout
|
||||
if (!isPersistableFolderId(folderId) || folderLocation(layout, folderId)) {
|
||||
return layout
|
||||
}
|
||||
const sourceItem = layout[from][sourceIndex]
|
||||
const targetItem = layout[to][targetIndex]
|
||||
if (
|
||||
@@ -424,6 +728,7 @@ export function createHomeFolder(
|
||||
name: name.trim().slice(0, HOME_FOLDER_NAME_MAX_LENGTH),
|
||||
type: 'folder',
|
||||
}
|
||||
next.grid = compactGridPages(next.grid)
|
||||
return next
|
||||
}
|
||||
|
||||
@@ -443,6 +748,7 @@ export function addHomeAppToFolder(
|
||||
if (!isHomeFolder(folder)) return layout
|
||||
next[from][sourceIndex] = null
|
||||
folder.apps.push(sourceItem)
|
||||
next.grid = compactGridPages(next.grid)
|
||||
return next
|
||||
}
|
||||
|
||||
@@ -522,6 +828,7 @@ export function extractHomeFolderApp(
|
||||
const [appId] = nextFolder.apps.splice(sourceIndex, 1)
|
||||
next[to][targetIndex] = appId
|
||||
next[location.area][location.index] = normalizeFolder(nextFolder)
|
||||
next.grid = compactGridPages(next.grid)
|
||||
return next
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
bottomRightGridPosition,
|
||||
filterMedia,
|
||||
formatMediaSize,
|
||||
formatRecordingDuration,
|
||||
hasNextMediaPage,
|
||||
mediaErrorKey,
|
||||
mergeMedia,
|
||||
orderMedia,
|
||||
orderMediaOldestFirst,
|
||||
} from './media'
|
||||
|
||||
const media = [
|
||||
{ createdAt: 10, id: 1, mediaType: 'photo' as const, url: 'photo' },
|
||||
{ createdAt: 20, id: 2, mediaType: 'video' as const, url: 'video' },
|
||||
{ createdAt: 10, favorite: false, id: 1, mediaType: 'photo' as const, url: 'photo' },
|
||||
{ createdAt: 20, favorite: false, id: 2, mediaType: 'video' as const, url: 'video' },
|
||||
]
|
||||
|
||||
describe('media utilities', () => {
|
||||
@@ -24,8 +27,8 @@ describe('media utilities', () => {
|
||||
it('merges pages without duplicates and keeps newest first', () => {
|
||||
expect(
|
||||
mergeMedia(media, [
|
||||
{ createdAt: 30, id: 1, mediaType: 'photo', url: 'updated' },
|
||||
{ createdAt: 25, id: 3, mediaType: 'photo', url: 'new' },
|
||||
{ createdAt: 30, favorite: true, id: 1, mediaType: 'photo', url: 'updated' },
|
||||
{ createdAt: 25, favorite: false, id: 3, mediaType: 'photo', url: 'new' },
|
||||
]).map((entry) => [entry.id, entry.url]),
|
||||
).toEqual([
|
||||
[1, 'updated'],
|
||||
@@ -34,6 +37,28 @@ describe('media utilities', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('orders the photo grid from oldest to newest', () => {
|
||||
expect(orderMediaOldestFirst(media).map((entry) => entry.id)).toEqual([
|
||||
1, 2,
|
||||
])
|
||||
})
|
||||
|
||||
it('orders the photo grid in the selected direction', () => {
|
||||
expect(orderMedia(media, 'oldest').map((entry) => entry.id)).toEqual([1, 2])
|
||||
expect(orderMedia(media, 'newest').map((entry) => entry.id)).toEqual([2, 1])
|
||||
})
|
||||
|
||||
it('fills the gallery from bottom-right to top-left', () => {
|
||||
expect(bottomRightGridPosition(0, 1)).toEqual({ column: 3, row: 1 })
|
||||
expect(bottomRightGridPosition(0, 3)).toEqual({ column: 3, row: 1 })
|
||||
expect(bottomRightGridPosition(1, 3)).toEqual({ column: 2, row: 1 })
|
||||
expect(bottomRightGridPosition(2, 3)).toEqual({ column: 1, row: 1 })
|
||||
expect(bottomRightGridPosition(0, 4)).toEqual({ column: 3, row: 2 })
|
||||
expect(bottomRightGridPosition(3, 4)).toEqual({ column: 3, row: 1 })
|
||||
expect(bottomRightGridPosition(4, 5)).toEqual({ column: 2, row: 1 })
|
||||
expect(bottomRightGridPosition(10, 11)).toEqual({ column: 2, row: 1 })
|
||||
})
|
||||
|
||||
it('loads another gallery page only after a full 30-item batch', () => {
|
||||
expect(hasNextMediaPage(30)).toBe(true)
|
||||
expect(hasNextMediaPage(29)).toBe(false)
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { GalleryFilter, MediaType, PhoneMedia } from '@/types/media'
|
||||
import type {
|
||||
GalleryFilter,
|
||||
GallerySortOrder,
|
||||
MediaType,
|
||||
PhoneMedia,
|
||||
} from '@/types/media'
|
||||
|
||||
export const MEDIA_PAGE_SIZE = 30
|
||||
|
||||
@@ -26,6 +31,37 @@ export function mergeMedia(
|
||||
)
|
||||
}
|
||||
|
||||
export function orderMediaOldestFirst(media: PhoneMedia[]): PhoneMedia[] {
|
||||
return [...media].sort(
|
||||
(left, right) => left.createdAt - right.createdAt || left.id - right.id,
|
||||
)
|
||||
}
|
||||
|
||||
export function orderMedia(
|
||||
media: PhoneMedia[],
|
||||
sortOrder: GallerySortOrder,
|
||||
): PhoneMedia[] {
|
||||
return sortOrder === 'oldest'
|
||||
? orderMediaOldestFirst(media)
|
||||
: [...media].sort(
|
||||
(left, right) =>
|
||||
right.createdAt - left.createdAt || right.id - left.id,
|
||||
)
|
||||
}
|
||||
|
||||
export function bottomRightGridPosition(
|
||||
itemIndex: number,
|
||||
itemCount: number,
|
||||
columnCount = 3,
|
||||
): { column: number; row: number } {
|
||||
const count = Math.max(1, itemCount)
|
||||
const index = Math.max(0, Math.min(itemIndex, count - 1))
|
||||
return {
|
||||
column: columnCount - (index % columnCount),
|
||||
row: Math.ceil(count / columnCount) - Math.floor(index / columnCount),
|
||||
}
|
||||
}
|
||||
|
||||
export function hasNextMediaPage(pageLength: number): boolean {
|
||||
return pageLength === MEDIA_PAGE_SIZE
|
||||
}
|
||||
|
||||
@@ -1,7 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { LaunchablePhoneAppId } from '@/types/apps'
|
||||
import { DEFAULT_PHONE_PREFERENCES, parsePhonePreferences } from './preferences'
|
||||
import {
|
||||
DEFAULT_PHONE_PREFERENCES,
|
||||
PHONE_SETUP_LAST_STEP,
|
||||
parsePhonePreferences,
|
||||
WALLPAPER_IDS,
|
||||
} from './preferences'
|
||||
describe('preferences', () => {
|
||||
it('starts Setup Assistant for a phone without saved settings', () => {
|
||||
const value = parsePhonePreferences(null)
|
||||
|
||||
expect(value.settings.setupCompleted).toBe(false)
|
||||
expect(value.settings.setupStep).toBe(0)
|
||||
})
|
||||
|
||||
it('migrates existing phones past Setup Assistant', () => {
|
||||
const value = parsePhonePreferences(
|
||||
JSON.stringify({ version: 1, settings: { wallpaper: 'aurora' } }),
|
||||
)
|
||||
|
||||
expect(value.settings.setupCompleted).toBe(true)
|
||||
expect(value.settings.setupStep).toBe(PHONE_SETUP_LAST_STEP)
|
||||
})
|
||||
|
||||
it('restores an interrupted Setup Assistant step', () => {
|
||||
const value = parsePhonePreferences(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
settings: { setupCompleted: false, setupStep: 5 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(value.settings.setupCompleted).toBe(false)
|
||||
expect(value.settings.setupStep).toBe(5)
|
||||
})
|
||||
|
||||
it('falls back for malformed and obsolete records', () => {
|
||||
expect(parsePhonePreferences('{')).toEqual(DEFAULT_PHONE_PREFERENCES)
|
||||
expect(parsePhonePreferences('{"version":2}')).toEqual(
|
||||
@@ -132,4 +165,52 @@ describe('preferences', () => {
|
||||
wifiEnabled: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('provides twelve built-in wallpapers', () => {
|
||||
expect(WALLPAPER_IDS).toHaveLength(12)
|
||||
expect(new Set(WALLPAPER_IDS).size).toBe(12)
|
||||
})
|
||||
|
||||
it('restores custom photo wallpapers and only the latest four history entries', () => {
|
||||
const value = parsePhonePreferences(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
settings: {
|
||||
wallpaper: 'custom',
|
||||
wallpaperImageUrl: 'https://media.example/current.jpg',
|
||||
wallpaperHistory: [
|
||||
{
|
||||
wallpaper: 'custom',
|
||||
imageUrl: 'https://media.example/current.jpg',
|
||||
},
|
||||
{ wallpaper: 'prism', imageUrl: null },
|
||||
{ wallpaper: 'ocean', imageUrl: null },
|
||||
{ wallpaper: 'rose', imageUrl: null },
|
||||
{ wallpaper: 'sand', imageUrl: null },
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(value.settings.wallpaper).toBe('custom')
|
||||
expect(value.settings.wallpaperImageUrl).toBe(
|
||||
'https://media.example/current.jpg',
|
||||
)
|
||||
expect(value.settings.wallpaperHistory).toHaveLength(4)
|
||||
expect(
|
||||
value.settings.wallpaperHistory.map((entry) => entry.wallpaper),
|
||||
).toEqual(['custom', 'prism', 'ocean', 'rose'])
|
||||
})
|
||||
|
||||
it('rejects a custom wallpaper without a photo URL', () => {
|
||||
const value = parsePhonePreferences(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
settings: { wallpaper: 'custom', wallpaperImageUrl: '' },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(value.settings.wallpaper).toBe('midnight')
|
||||
expect(value.settings.wallpaperImageUrl).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,17 +22,36 @@ export const PHONE_FRAME_IDS = [
|
||||
] as const
|
||||
export const RINGTONE_IDS = ['skyline', 'horizon', 'pulse'] as const
|
||||
export const NOTIFICATION_SOUND_IDS = ['chime', 'signal', 'soft'] as const
|
||||
export const WALLPAPER_IDS = ['midnight', 'aurora', 'ember'] as const
|
||||
export const WALLPAPER_IDS = [
|
||||
'midnight',
|
||||
'aurora',
|
||||
'ember',
|
||||
'ocean',
|
||||
'sunrise',
|
||||
'violet',
|
||||
'forest',
|
||||
'cobalt',
|
||||
'rose',
|
||||
'sand',
|
||||
'graphite',
|
||||
'prism',
|
||||
] as const
|
||||
export const PHONE_SCALE_MIN = 75
|
||||
export const PHONE_SCALE_MAX = 150
|
||||
export const PHONE_SCALE_STEP = 5
|
||||
export const PHONE_SETUP_LAST_STEP = 9
|
||||
|
||||
export type AppearanceMode = (typeof APPEARANCE_MODE_IDS)[number]
|
||||
export type GraphicsMode = (typeof GRAPHICS_MODE_IDS)[number]
|
||||
export type PhoneFrameId = (typeof PHONE_FRAME_IDS)[number]
|
||||
export type RingtoneId = (typeof RINGTONE_IDS)[number]
|
||||
export type NotificationSoundId = (typeof NOTIFICATION_SOUND_IDS)[number]
|
||||
export type WallpaperId = (typeof WALLPAPER_IDS)[number]
|
||||
export type BuiltInWallpaperId = (typeof WALLPAPER_IDS)[number]
|
||||
export type WallpaperId = BuiltInWallpaperId | 'custom'
|
||||
export type WallpaperHistoryEntry = {
|
||||
imageUrl: string | null
|
||||
wallpaper: WallpaperId
|
||||
}
|
||||
export type AppNotificationPreferences = {
|
||||
enabled: boolean
|
||||
sounds: boolean
|
||||
@@ -61,8 +80,12 @@ export type PhonePreferencesV1 = {
|
||||
ringtone: RingtoneId
|
||||
ringtoneVolume: number
|
||||
screenBrightness: number
|
||||
setupCompleted: boolean
|
||||
setupStep: number
|
||||
streamerMode: boolean
|
||||
wallpaper: WallpaperId
|
||||
wallpaperHistory: WallpaperHistoryEntry[]
|
||||
wallpaperImageUrl: string | null
|
||||
wifiEnabled: boolean
|
||||
}
|
||||
version: 1
|
||||
@@ -129,8 +152,12 @@ export const DEFAULT_PHONE_PREFERENCES: PhonePreferencesV1 = {
|
||||
ringtone: 'skyline',
|
||||
ringtoneVolume: 80,
|
||||
screenBrightness: 100,
|
||||
setupCompleted: false,
|
||||
setupStep: 0,
|
||||
streamerMode: false,
|
||||
wallpaper: 'midnight',
|
||||
wallpaperHistory: [{ imageUrl: null, wallpaper: 'midnight' }],
|
||||
wallpaperImageUrl: null,
|
||||
wifiEnabled: true,
|
||||
},
|
||||
version: 1,
|
||||
@@ -161,6 +188,39 @@ function readChoice<T extends string>(
|
||||
: fallback
|
||||
}
|
||||
|
||||
function readWallpaperImageUrl(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
const imageUrl = value.trim()
|
||||
return imageUrl.length > 0 && imageUrl.length <= 2_000_000 ? imageUrl : null
|
||||
}
|
||||
|
||||
function readWallpaperHistory(value: unknown): WallpaperHistoryEntry[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
|
||||
const history: WallpaperHistoryEntry[] = []
|
||||
for (const rawEntry of value.slice(0, 4)) {
|
||||
if (!rawEntry || typeof rawEntry !== 'object' || Array.isArray(rawEntry)) {
|
||||
continue
|
||||
}
|
||||
const entry = rawEntry as Partial<WallpaperHistoryEntry>
|
||||
const imageUrl = readWallpaperImageUrl(entry.imageUrl)
|
||||
if (entry.wallpaper === 'custom' && imageUrl) {
|
||||
history.push({ imageUrl, wallpaper: 'custom' })
|
||||
continue
|
||||
}
|
||||
if (
|
||||
typeof entry.wallpaper === 'string' &&
|
||||
WALLPAPER_IDS.includes(entry.wallpaper as BuiltInWallpaperId)
|
||||
) {
|
||||
history.push({
|
||||
imageUrl: null,
|
||||
wallpaper: entry.wallpaper as BuiltInWallpaperId,
|
||||
})
|
||||
}
|
||||
}
|
||||
return history
|
||||
}
|
||||
|
||||
function readNotifications(
|
||||
value: unknown,
|
||||
): Record<LaunchablePhoneAppId, AppNotificationPreferences> {
|
||||
@@ -214,6 +274,13 @@ export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
|
||||
}
|
||||
|
||||
const defaults = DEFAULT_PHONE_PREFERENCES.settings
|
||||
const wallpaperImageUrl = readWallpaperImageUrl(settings.wallpaperImageUrl)
|
||||
const wallpaper =
|
||||
settings.wallpaper === 'custom' && wallpaperImageUrl
|
||||
? 'custom'
|
||||
: readChoice(settings.wallpaper, WALLPAPER_IDS, defaults.wallpaper)
|
||||
const wallpaperHistory = readWallpaperHistory(settings.wallpaperHistory)
|
||||
|
||||
return {
|
||||
settings: {
|
||||
airplaneMode: readBoolean(settings.airplaneMode, defaults.airplaneMode),
|
||||
@@ -280,12 +347,32 @@ export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
|
||||
10,
|
||||
100,
|
||||
),
|
||||
streamerMode: readBoolean(settings.streamerMode, defaults.streamerMode),
|
||||
wallpaper: readChoice(
|
||||
settings.wallpaper,
|
||||
WALLPAPER_IDS,
|
||||
defaults.wallpaper,
|
||||
setupCompleted:
|
||||
typeof settings.setupCompleted === 'boolean'
|
||||
? settings.setupCompleted
|
||||
: true,
|
||||
setupStep: Math.floor(
|
||||
readNumber(
|
||||
settings.setupStep,
|
||||
typeof settings.setupCompleted === 'boolean'
|
||||
? defaults.setupStep
|
||||
: PHONE_SETUP_LAST_STEP,
|
||||
0,
|
||||
PHONE_SETUP_LAST_STEP,
|
||||
),
|
||||
),
|
||||
streamerMode: readBoolean(settings.streamerMode, defaults.streamerMode),
|
||||
wallpaper,
|
||||
wallpaperHistory:
|
||||
wallpaperHistory.length > 0
|
||||
? wallpaperHistory
|
||||
: [
|
||||
{
|
||||
imageUrl: wallpaper === 'custom' ? wallpaperImageUrl : null,
|
||||
wallpaper,
|
||||
},
|
||||
],
|
||||
wallpaperImageUrl: wallpaper === 'custom' ? wallpaperImageUrl : null,
|
||||
wifiEnabled: readBoolean(settings.wifiEnabled, defaults.wifiEnabled),
|
||||
},
|
||||
version: 1,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
maximumRenderedWidgetPage,
|
||||
resolveSpringboardEdgeTurn,
|
||||
resolveSpringboardHomeEdgeTurn,
|
||||
springboardEdgeDirection,
|
||||
springboardPageDragCompensation,
|
||||
springboardSwipeIntent,
|
||||
springboardViewportDeltaToLocal,
|
||||
springboardViewportToLocal,
|
||||
} from '@/utils/springboardDrag'
|
||||
|
||||
describe('springboard widget drag', () => {
|
||||
it('turns pages from the reachable phone edge zones', () => {
|
||||
expect(springboardEdgeDirection(113, 100, 468)).toBe(-1)
|
||||
expect(springboardEdgeDirection(455, 100, 468)).toBe(1)
|
||||
expect(springboardEdgeDirection(284, 100, 468)).toBe(0)
|
||||
})
|
||||
|
||||
it('resolves reachable page destinations without crossing page bounds', () => {
|
||||
expect(resolveSpringboardEdgeTurn(455, 100, 468, 1, 0, 3)).toEqual({
|
||||
destination: 2,
|
||||
direction: 1,
|
||||
})
|
||||
expect(resolveSpringboardEdgeTurn(113, 100, 468, 2, 0, 3)).toEqual({
|
||||
destination: 1,
|
||||
direction: -1,
|
||||
})
|
||||
expect(resolveSpringboardEdgeTurn(113, 100, 468, 0, 0, 3)).toBeNull()
|
||||
expect(resolveSpringboardEdgeTurn(455, 100, 468, 3, 0, 3)).toBeNull()
|
||||
})
|
||||
|
||||
it('previews one new trailing page without crossing the home-page limit', () => {
|
||||
expect(
|
||||
resolveSpringboardHomeEdgeTurn(455, 100, 468, 2, 2, 5, false),
|
||||
).toEqual({ destination: 3, direction: 1, previewsPage: true })
|
||||
expect(
|
||||
resolveSpringboardHomeEdgeTurn(455, 100, 468, 2, 3, 5, true),
|
||||
).toEqual({ destination: 3, direction: 1, previewsPage: false })
|
||||
expect(
|
||||
resolveSpringboardHomeEdgeTurn(455, 100, 468, 5, 5, 5, false),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the original widget page mounted during a drag preview', () => {
|
||||
expect(maximumRenderedWidgetPage([1, 3], [1, 2])).toBe(3)
|
||||
expect(maximumRenderedWidgetPage([], [])).toBe(1)
|
||||
})
|
||||
|
||||
it('distinguishes a page swipe from a tap or vertical gesture', () => {
|
||||
expect(springboardSwipeIntent(5, 2)).toBe('pending')
|
||||
expect(springboardSwipeIntent(30, 8)).toBe('horizontal')
|
||||
expect(springboardSwipeIntent(8, 30)).toBe('vertical')
|
||||
})
|
||||
|
||||
it('keeps a dragged item under the pointer while the track changes pages', () => {
|
||||
expect(springboardPageDragCompensation(3, 2, 368)).toBe(-368)
|
||||
expect(springboardPageDragCompensation(2, 3, 368)).toBe(368)
|
||||
expect(springboardPageDragCompensation(2, 2, 368)).toBe(0)
|
||||
})
|
||||
|
||||
it('converts viewport coordinates into local phone coordinates under zoom', () => {
|
||||
expect(
|
||||
springboardViewportToLocal(169, 238, 100, 100, 253.92, 582.36, 368, 844),
|
||||
).toEqual({ x: 100, y: 200 })
|
||||
expect(
|
||||
springboardViewportToLocal(200, 250, 100, 50, 0, 0, 368, 844),
|
||||
).toEqual({ x: 100, y: 200 })
|
||||
expect(
|
||||
springboardViewportDeltaToLocal(69, 138, 253.92, 582.36, 368, 844),
|
||||
).toEqual({ x: 100, y: 200 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
export type PageTurnDirection = -1 | 0 | 1
|
||||
|
||||
export type SpringboardEdgeTurn = {
|
||||
destination: number
|
||||
direction: Exclude<PageTurnDirection, 0>
|
||||
}
|
||||
|
||||
export type SpringboardHomeEdgeTurn = SpringboardEdgeTurn & {
|
||||
previewsPage: boolean
|
||||
}
|
||||
|
||||
export type SpringboardSwipeIntent = 'horizontal' | 'pending' | 'vertical'
|
||||
|
||||
export type SpringboardLocalPoint = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export function springboardViewportToLocal(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
viewportLeft: number,
|
||||
viewportTop: number,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
layoutWidth: number,
|
||||
layoutHeight: number,
|
||||
): SpringboardLocalPoint {
|
||||
const scaleX = viewportWidth > 0 ? layoutWidth / viewportWidth : 1
|
||||
const scaleY = viewportHeight > 0 ? layoutHeight / viewportHeight : 1
|
||||
return {
|
||||
x: (clientX - viewportLeft) * scaleX,
|
||||
y: (clientY - viewportTop) * scaleY,
|
||||
}
|
||||
}
|
||||
|
||||
export function springboardViewportDeltaToLocal(
|
||||
deltaX: number,
|
||||
deltaY: number,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
layoutWidth: number,
|
||||
layoutHeight: number,
|
||||
): SpringboardLocalPoint {
|
||||
const scaleX = viewportWidth > 0 ? layoutWidth / viewportWidth : 1
|
||||
const scaleY = viewportHeight > 0 ? layoutHeight / viewportHeight : 1
|
||||
return { x: deltaX * scaleX, y: deltaY * scaleY }
|
||||
}
|
||||
|
||||
export function springboardPageDragCompensation(
|
||||
startPage: number,
|
||||
currentPage: number,
|
||||
pageWidth: number,
|
||||
): number {
|
||||
return (currentPage - startPage) * Math.max(0, pageWidth)
|
||||
}
|
||||
|
||||
export function springboardSwipeIntent(
|
||||
deltaX: number,
|
||||
deltaY: number,
|
||||
threshold = 8,
|
||||
): SpringboardSwipeIntent {
|
||||
if (Math.hypot(deltaX, deltaY) <= threshold) return 'pending'
|
||||
return Math.abs(deltaX) > Math.abs(deltaY) ? 'horizontal' : 'vertical'
|
||||
}
|
||||
|
||||
export function springboardEdgeDirection(
|
||||
clientX: number,
|
||||
left: number,
|
||||
right: number,
|
||||
): PageTurnDirection {
|
||||
const width = Math.max(0, right - left)
|
||||
const edgeSize = Math.min(42, width * 0.12)
|
||||
if (clientX <= left + edgeSize) return -1
|
||||
if (clientX >= right - edgeSize) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
export function resolveSpringboardEdgeTurn(
|
||||
clientX: number,
|
||||
left: number,
|
||||
right: number,
|
||||
currentPage: number,
|
||||
minimumPage: number,
|
||||
maximumPage: number,
|
||||
): SpringboardEdgeTurn | null {
|
||||
const direction = springboardEdgeDirection(clientX, left, right)
|
||||
if (direction === 0) return null
|
||||
const destination = currentPage + direction
|
||||
if (destination < minimumPage || destination > maximumPage) return null
|
||||
return { destination, direction }
|
||||
}
|
||||
|
||||
export function resolveSpringboardHomeEdgeTurn(
|
||||
clientX: number,
|
||||
left: number,
|
||||
right: number,
|
||||
currentPage: number,
|
||||
renderedPageCount: number,
|
||||
maximumPageCount: number,
|
||||
previewPageActive: boolean,
|
||||
): SpringboardHomeEdgeTurn | null {
|
||||
const canPreviewPage =
|
||||
!previewPageActive && renderedPageCount < maximumPageCount
|
||||
const maximumPage = Math.min(
|
||||
maximumPageCount,
|
||||
renderedPageCount + (canPreviewPage ? 1 : 0),
|
||||
)
|
||||
const turn = resolveSpringboardEdgeTurn(
|
||||
clientX,
|
||||
left,
|
||||
right,
|
||||
currentPage,
|
||||
1,
|
||||
maximumPage,
|
||||
)
|
||||
return turn
|
||||
? { ...turn, previewsPage: turn.destination > renderedPageCount }
|
||||
: null
|
||||
}
|
||||
|
||||
export function maximumRenderedWidgetPage(
|
||||
persistedPages: readonly number[],
|
||||
previewPages: readonly number[],
|
||||
): number {
|
||||
return Math.max(1, ...persistedPages, ...previewPages)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { LaunchablePhoneAppId } from '@/types/apps'
|
||||
import { HOME_GRID_PAGE_SIZE, type HomeSlot } from '@/utils/homeLayout'
|
||||
import { layoutSpringboardHomePages } from '@/utils/springboardLayout'
|
||||
|
||||
function app(id: string): LaunchablePhoneAppId {
|
||||
return id as LaunchablePhoneAppId
|
||||
}
|
||||
|
||||
describe('springboard app page layout', () => {
|
||||
it('closes visual gaps per page while keeping page membership', () => {
|
||||
const grid = new Array<LaunchablePhoneAppId | null>(
|
||||
HOME_GRID_PAGE_SIZE * 2,
|
||||
).fill(null)
|
||||
grid[20] = app('row-six')
|
||||
grid[24] = app('page-two')
|
||||
|
||||
const pages = layoutSpringboardHomePages(grid, new Map(), 2)
|
||||
|
||||
expect(pages).toHaveLength(2)
|
||||
expect(pages[0]?.cells[0]).toMatchObject({
|
||||
item: 'row-six',
|
||||
sourceIndex: 20,
|
||||
})
|
||||
expect(pages[0]?.cells[1]).toMatchObject({ item: null, sourceIndex: 1 })
|
||||
expect(pages[1]?.cells[0]).toMatchObject({
|
||||
item: 'page-two',
|
||||
sourceIndex: 24,
|
||||
})
|
||||
})
|
||||
|
||||
it('reflows apps forward around widgets without swapping their order', () => {
|
||||
const grid = new Array<LaunchablePhoneAppId | null>(
|
||||
HOME_GRID_PAGE_SIZE,
|
||||
).fill(null)
|
||||
for (let index = 0; index < 8; index += 1) grid[index] = app(`app-${index}`)
|
||||
|
||||
const pages = layoutSpringboardHomePages(
|
||||
grid,
|
||||
new Map([[1, new Set([0, 1, 4, 5])]]),
|
||||
1,
|
||||
)
|
||||
|
||||
expect(
|
||||
pages[0]?.cells.slice(0, 12).map((cell) => cell?.item ?? null),
|
||||
).toEqual([
|
||||
null,
|
||||
null,
|
||||
'app-0',
|
||||
'app-1',
|
||||
null,
|
||||
null,
|
||||
'app-2',
|
||||
'app-3',
|
||||
'app-4',
|
||||
'app-5',
|
||||
'app-6',
|
||||
'app-7',
|
||||
])
|
||||
})
|
||||
|
||||
it('reflows folders as one item and keeps their source index', () => {
|
||||
const grid = new Array<HomeSlot>(HOME_GRID_PAGE_SIZE).fill(null)
|
||||
grid[0] = app('mail')
|
||||
grid[1] = {
|
||||
apps: [app('clock'), app('notes')],
|
||||
id: 'folder-tools-123456',
|
||||
name: 'Tools',
|
||||
type: 'folder',
|
||||
}
|
||||
|
||||
const pages = layoutSpringboardHomePages(
|
||||
grid,
|
||||
new Map([[1, new Set([0, 1])]]),
|
||||
1,
|
||||
)
|
||||
|
||||
expect(pages[0]?.cells[2]).toMatchObject({
|
||||
item: 'mail',
|
||||
sourceIndex: 0,
|
||||
})
|
||||
expect(pages[0]?.cells[3]).toMatchObject({
|
||||
item: {
|
||||
apps: ['clock', 'notes'],
|
||||
id: 'folder-tools-123456',
|
||||
type: 'folder',
|
||||
},
|
||||
sourceIndex: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('maps widget overflow to the visible destination page and offset', () => {
|
||||
const grid = Array.from({ length: HOME_GRID_PAGE_SIZE }, (_, index) =>
|
||||
app(`app-${index}`),
|
||||
)
|
||||
const pages = layoutSpringboardHomePages(
|
||||
grid,
|
||||
new Map([[1, new Set([0, 1, 2, 3])]]),
|
||||
2,
|
||||
)
|
||||
|
||||
expect(pages[1]?.cells[0]).toMatchObject({
|
||||
item: 'app-20',
|
||||
sourceIndex: 20,
|
||||
targetOffset: 0,
|
||||
targetPage: 2,
|
||||
})
|
||||
expect(pages[1]?.cells[1]).toMatchObject({
|
||||
item: 'app-21',
|
||||
sourceIndex: 21,
|
||||
targetOffset: 1,
|
||||
targetPage: 2,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
HOME_GRID_PAGE_SIZE,
|
||||
isHomeFolder,
|
||||
MAX_HOME_GRID_PAGES,
|
||||
type HomeItem,
|
||||
type HomeSlot,
|
||||
} from '@/utils/homeLayout'
|
||||
|
||||
export type SpringboardHomeCell = {
|
||||
item: HomeItem | null
|
||||
renderKey: string
|
||||
sourceIndex: number | null
|
||||
targetOffset: number
|
||||
targetPage: number
|
||||
}
|
||||
|
||||
export type SpringboardHomePage = {
|
||||
cells: Array<SpringboardHomeCell | null>
|
||||
page: number
|
||||
}
|
||||
|
||||
type PendingHomeCell = Pick<
|
||||
SpringboardHomeCell,
|
||||
'item' | 'renderKey' | 'sourceIndex'
|
||||
>
|
||||
|
||||
function homeItemRenderKey(
|
||||
item: HomeItem,
|
||||
appOccurrences: Map<string, number>,
|
||||
): string {
|
||||
if (isHomeFolder(item)) return `grid-folder-${item.id}`
|
||||
const occurrence = appOccurrences.get(item) ?? 0
|
||||
appOccurrences.set(item, occurrence + 1)
|
||||
return `grid-app-${item}-${occurrence}`
|
||||
}
|
||||
|
||||
export function layoutSpringboardHomePages(
|
||||
grid: readonly HomeSlot[],
|
||||
occupiedByPage: ReadonlyMap<number, ReadonlySet<number>>,
|
||||
minimumPageCount: number,
|
||||
): SpringboardHomePage[] {
|
||||
const pageCount = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
MAX_HOME_GRID_PAGES,
|
||||
Math.max(minimumPageCount, Math.ceil(grid.length / HOME_GRID_PAGE_SIZE)),
|
||||
),
|
||||
)
|
||||
const pages: SpringboardHomePage[] = []
|
||||
const pending: PendingHomeCell[] = []
|
||||
const appOccurrences = new Map<string, number>()
|
||||
|
||||
const createPage = (page: number): SpringboardHomePage => {
|
||||
const pageStart = (page - 1) * HOME_GRID_PAGE_SIZE
|
||||
for (let offset = 0; offset < HOME_GRID_PAGE_SIZE; offset += 1) {
|
||||
const sourceIndex = pageStart + offset
|
||||
const item = grid[sourceIndex]
|
||||
if (!item) continue
|
||||
pending.push({
|
||||
item,
|
||||
renderKey: homeItemRenderKey(item, appOccurrences),
|
||||
sourceIndex,
|
||||
})
|
||||
}
|
||||
|
||||
const occupied = occupiedByPage.get(page) ?? new Set<number>()
|
||||
const cells = new Array<SpringboardHomeCell | null>(
|
||||
HOME_GRID_PAGE_SIZE,
|
||||
).fill(null)
|
||||
let targetOffset = 0
|
||||
for (let cell = 0; cell < HOME_GRID_PAGE_SIZE; cell += 1) {
|
||||
if (occupied.has(cell)) continue
|
||||
const pendingCell = pending.shift()
|
||||
cells[cell] = pendingCell
|
||||
? { ...pendingCell, targetOffset, targetPage: page }
|
||||
: {
|
||||
item: null,
|
||||
renderKey: `grid-empty-${pageStart + targetOffset}`,
|
||||
sourceIndex: pageStart + targetOffset,
|
||||
targetOffset,
|
||||
targetPage: page,
|
||||
}
|
||||
targetOffset += 1
|
||||
}
|
||||
return { cells, page }
|
||||
}
|
||||
|
||||
for (let page = 1; page <= pageCount; page += 1) {
|
||||
pages.push(createPage(page))
|
||||
}
|
||||
|
||||
while (pending.length && pages.length < MAX_HOME_GRID_PAGES) {
|
||||
pages.push(createPage(pages.length + 1))
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
addWidget,
|
||||
createDefaultWidgetLayout,
|
||||
deleteWidgetPage,
|
||||
MAX_WIDGET_HOME_PAGES,
|
||||
moveWidget,
|
||||
parseWidgetLayout,
|
||||
removeWidget,
|
||||
@@ -75,6 +76,42 @@ describe('widget layout', () => {
|
||||
expect(widgetOccupiedCells(moved.instances, 2).has(7)).toBe(false)
|
||||
})
|
||||
|
||||
it('moves a widget across pages into the sixth widget row', () => {
|
||||
const layout = createDefaultWidgetLayout()
|
||||
const moved = moveWidget(layout, 'home-clock', 2, 2, 4)
|
||||
|
||||
expect(
|
||||
moved.instances.find((instance) => instance.id === 'home-clock'),
|
||||
).toMatchObject({ column: 2, page: 2, row: 4 })
|
||||
expect([...widgetOccupiedCells(moved.instances, 2)]).toEqual([
|
||||
18, 19, 22, 23,
|
||||
])
|
||||
})
|
||||
|
||||
it('bounds persisted and requested widget pages to the home page limit', () => {
|
||||
const parsed = parseWidgetLayout({
|
||||
instances: [
|
||||
{
|
||||
column: 0,
|
||||
id: 'clock-too-far',
|
||||
kind: 'clock',
|
||||
page: 999,
|
||||
row: 0,
|
||||
settings: {},
|
||||
size: 'small',
|
||||
},
|
||||
],
|
||||
version: 1,
|
||||
})
|
||||
const moved = moveWidget(parsed, 'clock-too-far', 999, 0, 4)
|
||||
|
||||
expect(parsed.instances[0]?.page).toBe(MAX_WIDGET_HOME_PAGES)
|
||||
expect(moved.instances[0]).toMatchObject({
|
||||
page: MAX_WIDGET_HOME_PAGES,
|
||||
row: 4,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the original layout when displaced widgets cannot be reflowed', () => {
|
||||
const layout = createDefaultWidgetLayout()
|
||||
const packed = {
|
||||
|
||||
@@ -6,12 +6,17 @@ import type {
|
||||
WidgetSettings,
|
||||
WidgetSize,
|
||||
} from '@/types/widgets'
|
||||
import {
|
||||
HOME_GRID_COLUMNS,
|
||||
HOME_GRID_ROWS,
|
||||
MAX_HOME_GRID_PAGES,
|
||||
} from '@/utils/homeLayout'
|
||||
import type { ReorderDirection } from '@/utils/keyboard'
|
||||
|
||||
export const WIDGET_GRID_COLUMNS = 4
|
||||
export const WIDGET_HOME_ROWS = 6
|
||||
export const WIDGET_GRID_COLUMNS = HOME_GRID_COLUMNS
|
||||
export const WIDGET_HOME_ROWS = HOME_GRID_ROWS
|
||||
export const WIDGET_PAGE_ROWS = 10
|
||||
const MAX_WIDGET_HOME_PAGES = 5
|
||||
export const MAX_WIDGET_HOME_PAGES = MAX_HOME_GRID_PAGES
|
||||
|
||||
export const WIDGET_SPANS: Record<
|
||||
WidgetSize,
|
||||
@@ -26,6 +31,13 @@ function rowsForPage(page: number): number {
|
||||
return page === 0 ? WIDGET_PAGE_ROWS : WIDGET_HOME_ROWS
|
||||
}
|
||||
|
||||
function normalizePage(page: number): number {
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(Math.floor(Number(page) || 0), MAX_WIDGET_HOME_PAGES),
|
||||
)
|
||||
}
|
||||
|
||||
function overlaps(left: WidgetInstance, right: WidgetInstance): boolean {
|
||||
if (left.page !== right.page) return false
|
||||
const leftSpan = WIDGET_SPANS[left.size]
|
||||
@@ -221,7 +233,7 @@ export function parseWidgetLayout(value: unknown): WidgetLayout {
|
||||
column: Math.floor(Number(candidate.column) || 0),
|
||||
id: candidate.id,
|
||||
kind: candidate.kind as WidgetKind,
|
||||
page: Math.max(0, Math.floor(Number(candidate.page) || 0)),
|
||||
page: normalizePage(Number(candidate.page)),
|
||||
row: Math.floor(Number(candidate.row) || 0),
|
||||
settings: normalizeSettings(candidate.settings),
|
||||
size,
|
||||
@@ -248,7 +260,7 @@ export function addWidget(
|
||||
column: 0,
|
||||
id: `${kind}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
kind,
|
||||
page,
|
||||
page: normalizePage(page),
|
||||
row: 0,
|
||||
settings: {
|
||||
...(kind === 'clock' ? { showDate: true } : {}),
|
||||
@@ -256,7 +268,7 @@ export function addWidget(
|
||||
},
|
||||
size,
|
||||
}
|
||||
const positioned = findPosition(instance, layout.instances, page)
|
||||
const positioned = findPosition(instance, layout.instances, instance.page)
|
||||
if (!positioned) return layout
|
||||
return { instances: [...layout.instances, positioned], version: 1 }
|
||||
}
|
||||
@@ -278,11 +290,12 @@ export function moveWidget(
|
||||
const moving = layout.instances.find((instance) => instance.id === id)
|
||||
if (!moving) return layout
|
||||
const span = WIDGET_SPANS[moving.size]
|
||||
const targetPage = normalizePage(page)
|
||||
const requested: WidgetInstance = {
|
||||
...moving,
|
||||
column: Math.max(0, Math.min(column, WIDGET_GRID_COLUMNS - span.columns)),
|
||||
page,
|
||||
row: Math.max(0, Math.min(row, rowsForPage(page) - span.rows)),
|
||||
page: targetPage,
|
||||
row: Math.max(0, Math.min(row, rowsForPage(targetPage) - span.rows)),
|
||||
}
|
||||
const placed: WidgetInstance[] = [requested]
|
||||
for (const instance of layout.instances) {
|
||||
@@ -309,8 +322,7 @@ export function widgetKeyboardTarget(
|
||||
instance.column +
|
||||
(direction === 'left' ? -1 : direction === 'right' ? 1 : 0),
|
||||
row:
|
||||
instance.row +
|
||||
(direction === 'up' ? -1 : direction === 'down' ? 1 : 0),
|
||||
instance.row + (direction === 'up' ? -1 : direction === 'down' ? 1 : 0),
|
||||
}
|
||||
if (
|
||||
target.column < 0 ||
|
||||
|
||||
Reference in New Issue
Block a user