mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
Merge branch 'dev' into weather-app
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
export function copyText(value: string): boolean {
|
||||
const activeElement =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: undefined
|
||||
const textarea = document.createElement('textarea')
|
||||
|
||||
textarea.value = value
|
||||
textarea.readOnly = true
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.left = '-9999px'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
textarea.setSelectionRange(0, value.length)
|
||||
|
||||
try {
|
||||
return document.execCommand('copy')
|
||||
} finally {
|
||||
textarea.remove()
|
||||
activeElement?.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
createDefaultHomeLayout,
|
||||
HOME_GRID_PAGE_SIZE,
|
||||
moveHomeApp,
|
||||
parseHomeLayout,
|
||||
removeHomeApp,
|
||||
restoreHomeApp,
|
||||
type HomeLayout,
|
||||
} from '@/utils/homeLayout'
|
||||
|
||||
const installed = ['phone', 'messages', 'mail', 'clock', 'notes'] as const
|
||||
const defaults = createDefaultHomeLayout(
|
||||
[...installed],
|
||||
['phone', 'messages', 'mail', 'clock', 'notes'],
|
||||
['phone', 'messages', 'clock'],
|
||||
)
|
||||
|
||||
describe('home layout', () => {
|
||||
it('uses fixed grid and dock slots for the registry arrangement', () => {
|
||||
const layout = parseHomeLayout(undefined, defaults, [...installed])
|
||||
|
||||
expect(layout.grid).toHaveLength(HOME_GRID_PAGE_SIZE)
|
||||
expect(layout.grid.slice(0, 6)).toEqual([
|
||||
'phone',
|
||||
'messages',
|
||||
'mail',
|
||||
'clock',
|
||||
'notes',
|
||||
null,
|
||||
])
|
||||
expect(layout.dock).toEqual(['phone', 'messages', 'clock', null])
|
||||
expect(layout.version).toBe(2)
|
||||
})
|
||||
|
||||
it('migrates compact persisted arrays and appends newly installed apps', () => {
|
||||
const layout = parseHomeLayout(
|
||||
{
|
||||
dock: ['messages', 'invalid', 'messages'],
|
||||
grid: ['mail'],
|
||||
hidden: ['phone'],
|
||||
},
|
||||
defaults,
|
||||
[...installed],
|
||||
)
|
||||
|
||||
expect(layout.dock).toEqual(['messages', null, null, null])
|
||||
expect(layout.grid.slice(0, 4)).toEqual(['mail', 'clock', 'notes', null])
|
||||
expect(layout.hidden).toEqual(['phone'])
|
||||
expect(layout.version).toBe(2)
|
||||
})
|
||||
|
||||
it('preserves explicit gaps in versioned layouts', () => {
|
||||
const grid: HomeLayout['grid'] = Array.from(
|
||||
{ length: HOME_GRID_PAGE_SIZE },
|
||||
() => null,
|
||||
)
|
||||
grid[0] = 'phone'
|
||||
grid[7] = 'mail'
|
||||
|
||||
const layout = parseHomeLayout(
|
||||
{
|
||||
dock: ['messages', null, 'clock', null],
|
||||
grid,
|
||||
hidden: ['notes'],
|
||||
version: 2,
|
||||
},
|
||||
defaults,
|
||||
[...installed],
|
||||
)
|
||||
|
||||
expect(layout.grid[0]).toBe('phone')
|
||||
expect(layout.grid[1]).toBeNull()
|
||||
expect(layout.grid[7]).toBe('mail')
|
||||
expect(layout.dock).toEqual(['messages', null, 'clock', null])
|
||||
})
|
||||
|
||||
it('preserves independently positioned shortcuts for the same app', () => {
|
||||
const grid: HomeLayout['grid'] = Array.from(
|
||||
{ length: HOME_GRID_PAGE_SIZE },
|
||||
() => null,
|
||||
)
|
||||
grid[0] = 'phone'
|
||||
grid[5] = 'phone'
|
||||
|
||||
const layout = parseHomeLayout(
|
||||
{
|
||||
dock: ['phone', null, null, null],
|
||||
grid,
|
||||
hidden: [],
|
||||
version: 2,
|
||||
},
|
||||
defaults,
|
||||
[...installed],
|
||||
)
|
||||
|
||||
expect(layout.grid[0]).toBe('phone')
|
||||
expect(layout.grid[5]).toBe('phone')
|
||||
expect(layout.dock[0]).toBe('phone')
|
||||
})
|
||||
|
||||
it('moves to an exact empty slot without compacting other apps', () => {
|
||||
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')
|
||||
})
|
||||
|
||||
it('shifts occupied grid slots instead of replacing their apps', () => {
|
||||
const reordered = moveHomeApp(defaults, 'grid', 2, 'grid', 0)
|
||||
expect(reordered.grid.slice(0, 5)).toEqual([
|
||||
'mail',
|
||||
'phone',
|
||||
'messages',
|
||||
'clock',
|
||||
'notes',
|
||||
])
|
||||
})
|
||||
|
||||
it('shifts an occupied dock slot toward its gap', () => {
|
||||
const docked = moveHomeApp(defaults, 'grid', 2, 'dock', 2)
|
||||
|
||||
expect(docked.dock).toEqual(['phone', 'messages', 'mail', 'clock'])
|
||||
expect(docked.grid[2]).toBeNull()
|
||||
})
|
||||
|
||||
it('moves an app 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', 'messages', 'clock'])
|
||||
expect(docked.grid[2]).toBe('notes')
|
||||
})
|
||||
|
||||
it('moves shortcuts between the dock and grid independently', () => {
|
||||
const movedToGrid = moveHomeApp(defaults, 'dock', 0, 'grid', 5)
|
||||
|
||||
expect(movedToGrid.dock[0]).toBeNull()
|
||||
expect(movedToGrid.grid[0]).toBe('phone')
|
||||
expect(movedToGrid.grid[5]).toBe('phone')
|
||||
|
||||
const movedToDock = moveHomeApp(movedToGrid, 'grid', 1, 'dock', 3)
|
||||
expect(movedToDock.grid[1]).toBeNull()
|
||||
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)
|
||||
const removed = removeHomeApp(layout, 'phone')
|
||||
expect(removed.grid[0]).toBeNull()
|
||||
expect(removed.grid[10]).toBeNull()
|
||||
expect(removed.dock[0]).toBeNull()
|
||||
expect(removed.hidden).toContain('phone')
|
||||
|
||||
const restored = restoreHomeApp(removed, 'phone')
|
||||
expect(restored.grid[0]).toBe('phone')
|
||||
expect(restored.hidden).not.toContain('phone')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,259 @@
|
||||
import type { LaunchablePhoneAppId } from '@/types/apps'
|
||||
|
||||
export const HOME_DOCK_CAPACITY = 4
|
||||
export const HOME_GRID_PAGE_SIZE = 20
|
||||
const MAX_HOME_GRID_PAGES = 5
|
||||
|
||||
export type HomeArea = 'dock' | 'grid'
|
||||
export type HomeSlot = LaunchablePhoneAppId | null
|
||||
|
||||
export type HomeLayout = {
|
||||
dock: HomeSlot[]
|
||||
grid: HomeSlot[]
|
||||
hidden: LaunchablePhoneAppId[]
|
||||
version: 2
|
||||
}
|
||||
|
||||
function readAppIds(
|
||||
value: unknown,
|
||||
availableIds: Set<LaunchablePhoneAppId>,
|
||||
): LaunchablePhoneAppId[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
|
||||
const ids: LaunchablePhoneAppId[] = []
|
||||
for (const valueId of value) {
|
||||
if (
|
||||
typeof valueId === 'string' &&
|
||||
availableIds.has(valueId as LaunchablePhoneAppId) &&
|
||||
!ids.includes(valueId as LaunchablePhoneAppId)
|
||||
) {
|
||||
ids.push(valueId as LaunchablePhoneAppId)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
function createSlots(length: number): HomeSlot[] {
|
||||
return Array.from({ length }, () => null)
|
||||
}
|
||||
|
||||
function getGridCapacity(itemCount: number): number {
|
||||
return Math.max(
|
||||
HOME_GRID_PAGE_SIZE,
|
||||
Math.ceil(itemCount / HOME_GRID_PAGE_SIZE) * HOME_GRID_PAGE_SIZE,
|
||||
)
|
||||
}
|
||||
|
||||
function readSlots(
|
||||
value: unknown,
|
||||
availableIds: Set<LaunchablePhoneAppId>,
|
||||
length: number,
|
||||
): HomeSlot[] {
|
||||
const slots = createSlots(length)
|
||||
if (!Array.isArray(value)) return slots
|
||||
|
||||
for (let index = 0; index < Math.min(value.length, length); index += 1) {
|
||||
const valueId = value[index]
|
||||
if (
|
||||
typeof valueId === 'string' &&
|
||||
availableIds.has(valueId as LaunchablePhoneAppId)
|
||||
) {
|
||||
slots[index] = valueId as LaunchablePhoneAppId
|
||||
}
|
||||
}
|
||||
return slots
|
||||
}
|
||||
|
||||
function placeInFirstEmptySlot(
|
||||
slots: HomeSlot[],
|
||||
appId: LaunchablePhoneAppId,
|
||||
): void {
|
||||
const emptyIndex = slots.indexOf(null)
|
||||
if (emptyIndex !== -1) {
|
||||
slots[emptyIndex] = appId
|
||||
return
|
||||
}
|
||||
|
||||
slots.push(...createSlots(HOME_GRID_PAGE_SIZE))
|
||||
slots[slots.length - HOME_GRID_PAGE_SIZE] = appId
|
||||
}
|
||||
|
||||
function insertIntoSlot(
|
||||
slots: HomeSlot[],
|
||||
targetIndex: number,
|
||||
appId: LaunchablePhoneAppId,
|
||||
): HomeSlot {
|
||||
if (slots[targetIndex] === null) {
|
||||
slots[targetIndex] = appId
|
||||
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] = appId
|
||||
return null
|
||||
}
|
||||
|
||||
const emptyBefore = slots.lastIndexOf(null, targetIndex - 1)
|
||||
if (emptyBefore !== -1) {
|
||||
for (let index = emptyBefore; index < targetIndex; index += 1) {
|
||||
slots[index] = slots[index + 1]
|
||||
}
|
||||
slots[targetIndex] = appId
|
||||
return null
|
||||
}
|
||||
|
||||
const displacedApp = slots.at(-1) ?? null
|
||||
for (let index = slots.length - 1; index > targetIndex; index -= 1) {
|
||||
slots[index] = slots[index - 1]
|
||||
}
|
||||
slots[targetIndex] = appId
|
||||
return displacedApp
|
||||
}
|
||||
|
||||
export function createDefaultHomeLayout(
|
||||
installedIds: LaunchablePhoneAppId[],
|
||||
defaultGridIds: LaunchablePhoneAppId[],
|
||||
defaultDockIds: LaunchablePhoneAppId[],
|
||||
): HomeLayout {
|
||||
const installed = new Set(installedIds)
|
||||
const gridIds = defaultGridIds.filter((id) => installed.has(id))
|
||||
const grid = createSlots(getGridCapacity(gridIds.length))
|
||||
for (let index = 0; index < gridIds.length; index += 1) {
|
||||
grid[index] = gridIds[index]
|
||||
}
|
||||
|
||||
const dock = createSlots(HOME_DOCK_CAPACITY)
|
||||
for (const [index, id] of defaultDockIds
|
||||
.filter((id) => installed.has(id))
|
||||
.slice(0, HOME_DOCK_CAPACITY)
|
||||
.entries()) {
|
||||
dock[index] = id
|
||||
}
|
||||
|
||||
return { dock, grid, hidden: [], version: 2 }
|
||||
}
|
||||
|
||||
export function parseHomeLayout(
|
||||
value: unknown,
|
||||
defaults: HomeLayout,
|
||||
installedIds: LaunchablePhoneAppId[],
|
||||
): HomeLayout {
|
||||
if (!value || typeof value !== 'object') return defaults
|
||||
|
||||
const source = value as Partial<Record<keyof HomeLayout, unknown>>
|
||||
const availableIds = new Set(installedIds)
|
||||
const hidden = readAppIds(source.hidden, availableIds)
|
||||
const hiddenIds = new Set(hidden)
|
||||
const persistedGridLength = Array.isArray(source.grid)
|
||||
? Math.min(source.grid.length, HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES)
|
||||
: 0
|
||||
const gridLength = Math.max(
|
||||
defaults.grid.length,
|
||||
getGridCapacity(persistedGridLength),
|
||||
)
|
||||
let grid: HomeSlot[]
|
||||
let dock: HomeSlot[]
|
||||
|
||||
if (source.version === 2) {
|
||||
grid = readSlots(source.grid, availableIds, gridLength)
|
||||
dock = readSlots(source.dock, availableIds, HOME_DOCK_CAPACITY)
|
||||
} else {
|
||||
grid = createSlots(gridLength)
|
||||
for (const id of readAppIds(source.grid, availableIds)) {
|
||||
placeInFirstEmptySlot(grid, id)
|
||||
}
|
||||
dock = createSlots(HOME_DOCK_CAPACITY)
|
||||
for (const [index, id] of readAppIds(source.dock, availableIds)
|
||||
.slice(0, HOME_DOCK_CAPACITY)
|
||||
.entries()) {
|
||||
dock[index] = id
|
||||
}
|
||||
}
|
||||
|
||||
grid = grid.map((id) => (id && !hiddenIds.has(id) ? id : null))
|
||||
dock = dock.map((id) => (id && !hiddenIds.has(id) ? id : null))
|
||||
const placedIds = new Set(
|
||||
[...grid, ...dock, ...hidden].filter(
|
||||
(id): id is LaunchablePhoneAppId => id !== null,
|
||||
),
|
||||
)
|
||||
|
||||
for (const id of defaults.grid) {
|
||||
if (id && !placedIds.has(id)) {
|
||||
placeInFirstEmptySlot(grid, id)
|
||||
placedIds.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
return { dock, grid, hidden, version: 2 }
|
||||
}
|
||||
|
||||
export function removeHomeApp(
|
||||
layout: HomeLayout,
|
||||
appId: LaunchablePhoneAppId,
|
||||
): HomeLayout {
|
||||
return {
|
||||
dock: layout.dock.map((id) => (id === appId ? null : id)),
|
||||
grid: layout.grid.map((id) => (id === appId ? null : id)),
|
||||
hidden: layout.hidden.includes(appId)
|
||||
? [...layout.hidden]
|
||||
: [...layout.hidden, appId],
|
||||
version: 2,
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreHomeApp(
|
||||
layout: HomeLayout,
|
||||
appId: LaunchablePhoneAppId,
|
||||
): HomeLayout {
|
||||
if (layout.grid.includes(appId) || layout.dock.includes(appId)) return layout
|
||||
const grid = [...layout.grid]
|
||||
placeInFirstEmptySlot(grid, appId)
|
||||
return {
|
||||
dock: [...layout.dock],
|
||||
grid,
|
||||
hidden: layout.hidden.filter((id) => id !== appId),
|
||||
version: 2,
|
||||
}
|
||||
}
|
||||
|
||||
export function moveHomeApp(
|
||||
layout: HomeLayout,
|
||||
from: HomeArea,
|
||||
sourceIndex: number,
|
||||
to: HomeArea,
|
||||
targetIndex: number,
|
||||
): HomeLayout {
|
||||
const next: HomeLayout = {
|
||||
dock: [...layout.dock],
|
||||
grid: [...layout.grid],
|
||||
hidden: [...layout.hidden],
|
||||
version: 2,
|
||||
}
|
||||
const source = next[from]
|
||||
const target = next[to]
|
||||
const appId = source[sourceIndex]
|
||||
if (
|
||||
!appId ||
|
||||
sourceIndex < 0 ||
|
||||
sourceIndex >= source.length ||
|
||||
targetIndex < 0 ||
|
||||
targetIndex >= target.length
|
||||
) {
|
||||
return layout
|
||||
}
|
||||
|
||||
if (from === to) {
|
||||
if (sourceIndex === targetIndex) return layout
|
||||
source[sourceIndex] = null
|
||||
insertIntoSlot(source, targetIndex, appId)
|
||||
return next
|
||||
}
|
||||
|
||||
source[sourceIndex] = insertIntoSlot(target, targetIndex, appId)
|
||||
return next
|
||||
}
|
||||
@@ -1,9 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { clampPage } from './pages'
|
||||
import { clampPage, paginateItems } from './pages'
|
||||
describe('page clamping', () => {
|
||||
it('keeps pages in range', () => {
|
||||
expect(clampPage(-4)).toBe(0)
|
||||
expect(clampPage(1)).toBe(1)
|
||||
expect(clampPage(9)).toBe(2)
|
||||
})
|
||||
|
||||
it('splits overflowing app grids into additional pages', () => {
|
||||
expect(
|
||||
paginateItems(
|
||||
Array.from({ length: 21 }, (_, index) => index),
|
||||
20,
|
||||
),
|
||||
).toEqual([
|
||||
Array.from({ length: 20 }, (_, index) => index),
|
||||
[20],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
export const SPRINGBOARD_PAGE_COUNT = 3
|
||||
|
||||
export function paginateItems<T>(items: readonly T[], pageSize: number): T[][] {
|
||||
if (!Number.isInteger(pageSize) || pageSize <= 0) return []
|
||||
const pages: T[][] = []
|
||||
for (let index = 0; index < items.length; index += pageSize) {
|
||||
pages.push(items.slice(index, index + pageSize))
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
export function clampPage(
|
||||
page: number,
|
||||
pageCount = SPRINGBOARD_PAGE_COUNT,
|
||||
|
||||
@@ -50,6 +50,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
|
||||
> = {
|
||||
phone: { enabled: true, sounds: true },
|
||||
messages: { enabled: true, sounds: true },
|
||||
darkchat: { enabled: true, sounds: true },
|
||||
'app-store': { enabled: true, sounds: true },
|
||||
calculator: { enabled: true, sounds: true },
|
||||
snake: { enabled: true, sounds: true },
|
||||
|
||||
Reference in New Issue
Block a user