mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 09:13:24 +00:00
Merge branch 'dev' into feature/funk
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
addHomePage,
|
||||
createDefaultHomeLayout,
|
||||
deleteHomePage,
|
||||
HOME_GRID_PAGE_SIZE,
|
||||
MAX_HOME_GRID_PAGES,
|
||||
moveHomeApp,
|
||||
parseHomeLayout,
|
||||
removeHomeApp,
|
||||
@@ -163,4 +166,24 @@ describe('home layout', () => {
|
||||
expect(restored.grid[0]).toBe('phone')
|
||||
expect(restored.hidden).not.toContain('phone')
|
||||
})
|
||||
|
||||
it('adds persistent empty pages up to the home screen limit', () => {
|
||||
let layout = defaults
|
||||
for (let page = 1; page < MAX_HOME_GRID_PAGES; page += 1) {
|
||||
layout = addHomePage(layout)
|
||||
}
|
||||
|
||||
expect(layout.grid).toHaveLength(HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES)
|
||||
expect(addHomePage(layout)).toBe(layout)
|
||||
})
|
||||
|
||||
it('deletes a page and moves its apps into remaining empty slots', () => {
|
||||
let layout = addHomePage(defaults)
|
||||
layout = moveHomeApp(layout, 'grid', 0, 'grid', HOME_GRID_PAGE_SIZE)
|
||||
const deleted = deleteHomePage(layout, 2)
|
||||
|
||||
expect(deleted.grid).toHaveLength(HOME_GRID_PAGE_SIZE)
|
||||
expect(deleted.grid).toContain('phone')
|
||||
expect(deleteHomePage(deleted, 1)).toBe(deleted)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 const MAX_HOME_GRID_PAGES = 5
|
||||
|
||||
export type HomeArea = 'dock' | 'grid'
|
||||
export type HomeSlot = LaunchablePhoneAppId | null
|
||||
@@ -221,6 +221,41 @@ export function restoreHomeApp(
|
||||
}
|
||||
}
|
||||
|
||||
export function addHomePage(layout: HomeLayout): HomeLayout {
|
||||
if (layout.grid.length >= HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES) {
|
||||
return layout
|
||||
}
|
||||
|
||||
return {
|
||||
dock: [...layout.dock],
|
||||
grid: [...layout.grid, ...createSlots(HOME_GRID_PAGE_SIZE)],
|
||||
hidden: [...layout.hidden],
|
||||
version: 2,
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteHomePage(layout: HomeLayout, page: number): HomeLayout {
|
||||
const pageCount = Math.ceil(layout.grid.length / HOME_GRID_PAGE_SIZE)
|
||||
if (pageCount <= 1 || page < 1 || page > pageCount) return layout
|
||||
|
||||
const pageStart = (page - 1) * HOME_GRID_PAGE_SIZE
|
||||
const grid = [...layout.grid]
|
||||
const removedApps = grid
|
||||
.splice(pageStart, HOME_GRID_PAGE_SIZE)
|
||||
.filter((appId): appId is LaunchablePhoneAppId => appId !== null)
|
||||
if (removedApps.length > grid.filter((appId) => appId === null).length) {
|
||||
return layout
|
||||
}
|
||||
for (const appId of removedApps) placeInFirstEmptySlot(grid, appId)
|
||||
|
||||
return {
|
||||
dock: [...layout.dock],
|
||||
grid,
|
||||
hidden: [...layout.hidden],
|
||||
version: 2,
|
||||
}
|
||||
}
|
||||
|
||||
export function moveHomeApp(
|
||||
layout: HomeLayout,
|
||||
from: HomeArea,
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
addWidget,
|
||||
createDefaultWidgetLayout,
|
||||
deleteWidgetPage,
|
||||
moveWidget,
|
||||
parseWidgetLayout,
|
||||
removeWidget,
|
||||
resizeWidget,
|
||||
widgetOccupiedCells,
|
||||
} from '@/utils/widgetLayout'
|
||||
|
||||
describe('widget layout', () => {
|
||||
it('creates a non-overlapping default layout across both surfaces', () => {
|
||||
const layout = createDefaultWidgetLayout()
|
||||
|
||||
expect(layout.instances).toHaveLength(7)
|
||||
expect(widgetOccupiedCells(layout.instances, 0).size).toBe(32)
|
||||
expect(widgetOccupiedCells(layout.instances, 1).size).toBe(16)
|
||||
})
|
||||
|
||||
it('adds a widget to the first free snapped position', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(42)
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5)
|
||||
const layout = createDefaultWidgetLayout()
|
||||
const next = addWidget(layout, 'wallet', 'small', 1)
|
||||
const added = next.instances.find((instance) =>
|
||||
instance.id.startsWith('wallet-'),
|
||||
)
|
||||
|
||||
expect(added).toMatchObject({ column: 0, page: 2, row: 0, size: 'small' })
|
||||
})
|
||||
|
||||
it('moves a widget and reflows a displaced neighbor', () => {
|
||||
const layout = createDefaultWidgetLayout()
|
||||
const next = moveWidget(layout, 'home-clock', 1, 2, 0)
|
||||
const clock = next.instances.find(
|
||||
(instance) => instance.id === 'home-clock',
|
||||
)
|
||||
const weather = next.instances.find(
|
||||
(instance) => instance.id === 'home-weather',
|
||||
)
|
||||
|
||||
expect(clock).toMatchObject({ column: 2, page: 1, row: 0 })
|
||||
expect(weather).toMatchObject({ column: 0, page: 1, row: 0 })
|
||||
expect(next.instances).toHaveLength(layout.instances.length)
|
||||
})
|
||||
|
||||
it('allows a small widget in the center with app cells on both sides', () => {
|
||||
const layout = createDefaultWidgetLayout()
|
||||
const moved = moveWidget(layout, 'home-clock', 2, 1, 1)
|
||||
|
||||
expect(
|
||||
moved.instances.find((instance) => instance.id === 'home-clock'),
|
||||
).toMatchObject({ column: 1, page: 2, row: 1 })
|
||||
expect([...widgetOccupiedCells(moved.instances, 2)]).toEqual([5, 6, 9, 10])
|
||||
expect(widgetOccupiedCells(moved.instances, 2).has(4)).toBe(false)
|
||||
expect(widgetOccupiedCells(moved.instances, 2).has(7)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the original layout when displaced widgets cannot be reflowed', () => {
|
||||
const layout = createDefaultWidgetLayout()
|
||||
const packed = {
|
||||
instances: [
|
||||
layout.instances.find((instance) => instance.id === 'home-clock')!,
|
||||
...Array.from({ length: 5 }, (_, index) => ({
|
||||
column: 0,
|
||||
id: `packed-${index}`,
|
||||
kind: 'date' as const,
|
||||
page: 0,
|
||||
row: index * 2,
|
||||
settings: {},
|
||||
size: 'medium' as const,
|
||||
})),
|
||||
],
|
||||
version: 1 as const,
|
||||
}
|
||||
|
||||
expect(moveWidget(packed, 'home-clock', 0, 0, 0)).toBe(packed)
|
||||
})
|
||||
|
||||
it('resizes and removes widgets without changing unrelated settings', () => {
|
||||
const layout = createDefaultWidgetLayout()
|
||||
const resized = resizeWidget(layout, 'home-clock', 'medium')
|
||||
const removed = removeWidget(resized, 'home-weather')
|
||||
|
||||
expect(
|
||||
resized.instances.find((instance) => instance.id === 'home-clock'),
|
||||
).toMatchObject({ settings: { showDate: true }, size: 'medium' })
|
||||
expect(
|
||||
removed.instances.some((instance) => instance.id === 'home-weather'),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects unsupported and duplicate persisted entries', () => {
|
||||
const parsed = parseWidgetLayout({
|
||||
instances: [
|
||||
{
|
||||
column: 0,
|
||||
id: 'clock-a',
|
||||
kind: 'clock',
|
||||
page: 1,
|
||||
row: 0,
|
||||
settings: { showDate: true },
|
||||
size: 'large',
|
||||
},
|
||||
{
|
||||
column: 2,
|
||||
id: 'clock-a',
|
||||
kind: 'clock',
|
||||
page: 1,
|
||||
row: 0,
|
||||
settings: {},
|
||||
size: 'small',
|
||||
},
|
||||
],
|
||||
version: 1,
|
||||
})
|
||||
|
||||
expect(parsed.instances).toHaveLength(1)
|
||||
expect(parsed.instances[0]?.size).toBe('small')
|
||||
})
|
||||
|
||||
it('deletes a home page and preserves its widgets on remaining pages', () => {
|
||||
const layout = createDefaultWidgetLayout()
|
||||
const moved = moveWidget(layout, 'home-clock', 2, 1, 1)
|
||||
const deleted = deleteWidgetPage(moved, 2, 1)
|
||||
|
||||
expect(deleted).not.toBe(moved)
|
||||
expect(
|
||||
deleted.instances.find((instance) => instance.id === 'home-clock')?.page,
|
||||
).toBe(1)
|
||||
expect(deleted.instances).toHaveLength(moved.instances.length)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,357 @@
|
||||
import { WIDGET_REGISTRY_BY_KIND } from '@/config/widgets'
|
||||
import type {
|
||||
WidgetInstance,
|
||||
WidgetKind,
|
||||
WidgetLayout,
|
||||
WidgetSettings,
|
||||
WidgetSize,
|
||||
} from '@/types/widgets'
|
||||
|
||||
export const WIDGET_GRID_COLUMNS = 4
|
||||
export const WIDGET_HOME_ROWS = 5
|
||||
export const WIDGET_PAGE_ROWS = 10
|
||||
const MAX_WIDGET_HOME_PAGES = 5
|
||||
|
||||
export const WIDGET_SPANS: Record<
|
||||
WidgetSize,
|
||||
{ columns: number; rows: number }
|
||||
> = {
|
||||
small: { columns: 2, rows: 2 },
|
||||
medium: { columns: 4, rows: 2 },
|
||||
large: { columns: 4, rows: 4 },
|
||||
}
|
||||
|
||||
function rowsForPage(page: number): number {
|
||||
return page === 0 ? WIDGET_PAGE_ROWS : WIDGET_HOME_ROWS
|
||||
}
|
||||
|
||||
function overlaps(left: WidgetInstance, right: WidgetInstance): boolean {
|
||||
if (left.page !== right.page) return false
|
||||
const leftSpan = WIDGET_SPANS[left.size]
|
||||
const rightSpan = WIDGET_SPANS[right.size]
|
||||
return (
|
||||
left.column < right.column + rightSpan.columns &&
|
||||
left.column + leftSpan.columns > right.column &&
|
||||
left.row < right.row + rightSpan.rows &&
|
||||
left.row + leftSpan.rows > right.row
|
||||
)
|
||||
}
|
||||
|
||||
function fits(instance: WidgetInstance, placed: WidgetInstance[]): boolean {
|
||||
const span = WIDGET_SPANS[instance.size]
|
||||
return (
|
||||
instance.column >= 0 &&
|
||||
instance.row >= 0 &&
|
||||
instance.column + span.columns <= WIDGET_GRID_COLUMNS &&
|
||||
instance.row + span.rows <= rowsForPage(instance.page) &&
|
||||
!placed.some((candidate) => overlaps(instance, candidate))
|
||||
)
|
||||
}
|
||||
|
||||
function findPosition(
|
||||
instance: WidgetInstance,
|
||||
placed: WidgetInstance[],
|
||||
startPage: number,
|
||||
maximumPage = MAX_WIDGET_HOME_PAGES,
|
||||
): WidgetInstance | null {
|
||||
const lastPage = startPage === 0 ? 0 : maximumPage
|
||||
for (let page = startPage; page <= lastPage; page += 1) {
|
||||
const span = WIDGET_SPANS[instance.size]
|
||||
for (let row = 0; row <= rowsForPage(page) - span.rows; row += 1) {
|
||||
for (
|
||||
let column = 0;
|
||||
column <= WIDGET_GRID_COLUMNS - span.columns;
|
||||
column += 1
|
||||
) {
|
||||
const candidate = { ...instance, column, page, row }
|
||||
if (fits(candidate, placed)) return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function deleteWidgetPage(
|
||||
layout: WidgetLayout,
|
||||
page: number,
|
||||
maximumPage: number,
|
||||
): WidgetLayout {
|
||||
if (page < 1 || maximumPage < 1 || page > maximumPage + 1) return layout
|
||||
|
||||
const placed = layout.instances
|
||||
.filter((instance) => instance.page !== page)
|
||||
.map((instance) => ({
|
||||
...instance,
|
||||
page: instance.page > page ? instance.page - 1 : instance.page,
|
||||
}))
|
||||
const removed = layout.instances.filter((instance) => instance.page === page)
|
||||
for (const instance of removed) {
|
||||
const positioned = findPosition(
|
||||
{ ...instance, page: 1 },
|
||||
placed,
|
||||
1,
|
||||
maximumPage,
|
||||
)
|
||||
if (!positioned) return layout
|
||||
placed.push(positioned)
|
||||
}
|
||||
|
||||
return { instances: placed, version: 1 }
|
||||
}
|
||||
|
||||
function normalizeSettings(value: unknown): WidgetSettings {
|
||||
if (!value || typeof value !== 'object') return {}
|
||||
const source = value as WidgetSettings
|
||||
return {
|
||||
...(source.balanceSource === 'cash' || source.balanceSource === 'bank'
|
||||
? { balanceSource: source.balanceSource }
|
||||
: {}),
|
||||
...(Array.isArray(source.contactIds)
|
||||
? {
|
||||
contactIds: source.contactIds
|
||||
.filter((id): id is string => typeof id === 'string')
|
||||
.slice(0, 6),
|
||||
}
|
||||
: {}),
|
||||
...(typeof source.showDate === 'boolean'
|
||||
? { showDate: source.showDate }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function createDefaultWidgetLayout(): WidgetLayout {
|
||||
return {
|
||||
instances: [
|
||||
{
|
||||
column: 0,
|
||||
id: 'home-clock',
|
||||
kind: 'clock',
|
||||
page: 1,
|
||||
row: 0,
|
||||
settings: { showDate: true },
|
||||
size: 'small',
|
||||
},
|
||||
{
|
||||
column: 2,
|
||||
id: 'home-weather',
|
||||
kind: 'weather',
|
||||
page: 1,
|
||||
row: 0,
|
||||
settings: {},
|
||||
size: 'small',
|
||||
},
|
||||
{
|
||||
column: 0,
|
||||
id: 'home-music',
|
||||
kind: 'music',
|
||||
page: 1,
|
||||
row: 2,
|
||||
settings: {},
|
||||
size: 'medium',
|
||||
},
|
||||
{
|
||||
column: 0,
|
||||
id: 'page-date',
|
||||
kind: 'date',
|
||||
page: 0,
|
||||
row: 0,
|
||||
settings: {},
|
||||
size: 'medium',
|
||||
},
|
||||
{
|
||||
column: 0,
|
||||
id: 'page-wallet',
|
||||
kind: 'wallet',
|
||||
page: 0,
|
||||
row: 2,
|
||||
settings: { balanceSource: 'bank' },
|
||||
size: 'medium',
|
||||
},
|
||||
{
|
||||
column: 0,
|
||||
id: 'page-transactions',
|
||||
kind: 'transactions',
|
||||
page: 0,
|
||||
row: 4,
|
||||
settings: {},
|
||||
size: 'medium',
|
||||
},
|
||||
{
|
||||
column: 0,
|
||||
id: 'page-contacts',
|
||||
kind: 'contacts',
|
||||
page: 0,
|
||||
row: 6,
|
||||
settings: {},
|
||||
size: 'medium',
|
||||
},
|
||||
],
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
export function parseWidgetLayout(value: unknown): WidgetLayout {
|
||||
if (!value || typeof value !== 'object') return createDefaultWidgetLayout()
|
||||
const source = value as Partial<WidgetLayout>
|
||||
if (source.version !== 1 || !Array.isArray(source.instances))
|
||||
return createDefaultWidgetLayout()
|
||||
|
||||
const placed: WidgetInstance[] = []
|
||||
const ids = new Set<string>()
|
||||
for (const raw of source.instances) {
|
||||
if (!raw || typeof raw !== 'object') continue
|
||||
const candidate = raw as Partial<WidgetInstance>
|
||||
if (
|
||||
typeof candidate.id !== 'string' ||
|
||||
ids.has(candidate.id) ||
|
||||
typeof candidate.kind !== 'string' ||
|
||||
!WIDGET_REGISTRY_BY_KIND.has(candidate.kind as WidgetKind)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const definition = WIDGET_REGISTRY_BY_KIND.get(candidate.kind as WidgetKind)
|
||||
const size = definition?.supportedSizes.includes(
|
||||
candidate.size as WidgetSize,
|
||||
)
|
||||
? (candidate.size as WidgetSize)
|
||||
: definition?.defaultSize
|
||||
if (!size) continue
|
||||
const instance: WidgetInstance = {
|
||||
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)),
|
||||
row: Math.floor(Number(candidate.row) || 0),
|
||||
settings: normalizeSettings(candidate.settings),
|
||||
size,
|
||||
}
|
||||
const normalized = fits(instance, placed)
|
||||
? instance
|
||||
: findPosition(instance, placed, instance.page)
|
||||
if (!normalized) continue
|
||||
placed.push(normalized)
|
||||
ids.add(normalized.id)
|
||||
}
|
||||
return { instances: placed, version: 1 }
|
||||
}
|
||||
|
||||
export function addWidget(
|
||||
layout: WidgetLayout,
|
||||
kind: WidgetKind,
|
||||
size: WidgetSize,
|
||||
page: number,
|
||||
): WidgetLayout {
|
||||
const definition = WIDGET_REGISTRY_BY_KIND.get(kind)
|
||||
if (!definition?.supportedSizes.includes(size)) return layout
|
||||
const instance: WidgetInstance = {
|
||||
column: 0,
|
||||
id: `${kind}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
kind,
|
||||
page,
|
||||
row: 0,
|
||||
settings: {
|
||||
...(kind === 'clock' ? { showDate: true } : {}),
|
||||
...(kind === 'wallet' ? { balanceSource: 'bank' as const } : {}),
|
||||
},
|
||||
size,
|
||||
}
|
||||
const positioned = findPosition(instance, layout.instances, page)
|
||||
if (!positioned) return layout
|
||||
return { instances: [...layout.instances, positioned], version: 1 }
|
||||
}
|
||||
|
||||
export function removeWidget(layout: WidgetLayout, id: string): WidgetLayout {
|
||||
return {
|
||||
instances: layout.instances.filter((instance) => instance.id !== id),
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
export function moveWidget(
|
||||
layout: WidgetLayout,
|
||||
id: string,
|
||||
page: number,
|
||||
column: number,
|
||||
row: number,
|
||||
): WidgetLayout {
|
||||
const moving = layout.instances.find((instance) => instance.id === id)
|
||||
if (!moving) return layout
|
||||
const span = WIDGET_SPANS[moving.size]
|
||||
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)),
|
||||
}
|
||||
const placed: WidgetInstance[] = [requested]
|
||||
for (const instance of layout.instances) {
|
||||
if (instance.id === id) continue
|
||||
if (fits(instance, placed)) placed.push(instance)
|
||||
else {
|
||||
const repositioned = findPosition(instance, placed, instance.page)
|
||||
if (!repositioned) return layout
|
||||
placed.push(repositioned)
|
||||
}
|
||||
}
|
||||
return { instances: placed, version: 1 }
|
||||
}
|
||||
|
||||
export function resizeWidget(
|
||||
layout: WidgetLayout,
|
||||
id: string,
|
||||
size: WidgetSize,
|
||||
): WidgetLayout {
|
||||
const instance = layout.instances.find((candidate) => candidate.id === id)
|
||||
const definition = instance && WIDGET_REGISTRY_BY_KIND.get(instance.kind)
|
||||
if (!instance || !definition?.supportedSizes.includes(size)) return layout
|
||||
const resized = { ...instance, size }
|
||||
const remaining = layout.instances.filter((candidate) => candidate.id !== id)
|
||||
const positioned = findPosition(resized, remaining, instance.page)
|
||||
if (!positioned) return layout
|
||||
return {
|
||||
instances: [
|
||||
...remaining.map((candidate) => ({ ...candidate })),
|
||||
positioned,
|
||||
],
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
export function updateWidgetSettings(
|
||||
layout: WidgetLayout,
|
||||
id: string,
|
||||
settings: WidgetSettings,
|
||||
): WidgetLayout {
|
||||
return {
|
||||
instances: layout.instances.map((instance) =>
|
||||
instance.id === id
|
||||
? {
|
||||
...instance,
|
||||
settings: normalizeSettings({ ...instance.settings, ...settings }),
|
||||
}
|
||||
: instance,
|
||||
),
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
export function widgetOccupiedCells(
|
||||
instances: WidgetInstance[],
|
||||
page: number,
|
||||
): Set<number> {
|
||||
const cells = new Set<number>()
|
||||
for (const instance of instances) {
|
||||
if (instance.page !== page) continue
|
||||
const span = WIDGET_SPANS[instance.size]
|
||||
for (let row = instance.row; row < instance.row + span.rows; row += 1) {
|
||||
for (
|
||||
let column = instance.column;
|
||||
column < instance.column + span.columns;
|
||||
column += 1
|
||||
) {
|
||||
cells.add(row * WIDGET_GRID_COLUMNS + column)
|
||||
}
|
||||
}
|
||||
}
|
||||
return cells
|
||||
}
|
||||
Reference in New Issue
Block a user