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:
Type
2026-08-14 17:12:31 +02:00
195 changed files with 29814 additions and 6854 deletions
@@ -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),
)
})
})
+38
View File
@@ -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
}
+296 -91
View File
@@ -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',
)
})
})
+356 -49
View File
@@ -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
}
+29 -4
View File
@@ -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)
+37 -1
View File
@@ -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
}
+82 -1
View File
@@ -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()
})
})
+94 -7
View File
@@ -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 })
})
})
+127
View File
@@ -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,
})
})
})
+97
View File
@@ -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
}
+37
View File
@@ -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 = {
+22 -10
View File
@@ -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 ||