mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
ADD - homescreen and apps
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
calculate,
|
||||
chooseCalculatorOperator,
|
||||
clearCalculator,
|
||||
inputDigit,
|
||||
resolveCalculator,
|
||||
} from './calculator'
|
||||
|
||||
describe('calculator', () => {
|
||||
it('calculates all four operations', () => {
|
||||
expect(calculate(8, 2, 'add')).toBe(10)
|
||||
expect(calculate(8, 2, 'subtract')).toBe(6)
|
||||
expect(calculate(8, 2, 'multiply')).toBe(16)
|
||||
expect(calculate(8, 2, 'divide')).toBe(4)
|
||||
})
|
||||
it('chains operations and handles division by zero', () => {
|
||||
let state = inputDigit(clearCalculator(), '8')
|
||||
state = chooseCalculatorOperator(state, 'add')
|
||||
state = inputDigit(state, '2')
|
||||
state = chooseCalculatorOperator(state, 'multiply')
|
||||
expect(state.display).toBe('10')
|
||||
state = inputDigit(state, '3')
|
||||
expect(resolveCalculator(state).display).toBe('30')
|
||||
expect(calculate(4, 0, 'divide')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
export type CalculatorOperator = 'add' | 'subtract' | 'multiply' | 'divide'
|
||||
|
||||
export type CalculatorState = {
|
||||
accumulator: number | null
|
||||
display: string
|
||||
error: boolean
|
||||
pendingOperator: CalculatorOperator | null
|
||||
waitingForOperand: boolean
|
||||
}
|
||||
|
||||
export const INITIAL_CALCULATOR_STATE: CalculatorState = {
|
||||
accumulator: null,
|
||||
display: '0',
|
||||
error: false,
|
||||
pendingOperator: null,
|
||||
waitingForOperand: false,
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
if (!Number.isFinite(value)) return 'Error'
|
||||
const rounded = Number(value.toPrecision(12))
|
||||
const rendered = String(rounded)
|
||||
return rendered.length <= 12 ? rendered : rounded.toExponential(6)
|
||||
}
|
||||
|
||||
export function calculate(
|
||||
left: number,
|
||||
right: number,
|
||||
operator: CalculatorOperator,
|
||||
): number | null {
|
||||
if (operator === 'add') return left + right
|
||||
if (operator === 'subtract') return left - right
|
||||
if (operator === 'multiply') return left * right
|
||||
if (right === 0) return null
|
||||
return left / right
|
||||
}
|
||||
|
||||
export function clearCalculator(): CalculatorState {
|
||||
return { ...INITIAL_CALCULATOR_STATE }
|
||||
}
|
||||
|
||||
export function inputDigit(
|
||||
state: CalculatorState,
|
||||
digit: string,
|
||||
): CalculatorState {
|
||||
if (!/^\d$/.test(digit)) return state
|
||||
if (state.error || state.waitingForOperand) {
|
||||
return { ...state, display: digit, error: false, waitingForOperand: false }
|
||||
}
|
||||
if (state.display === '0') return { ...state, display: digit }
|
||||
if (state.display.replace('-', '').replace('.', '').length >= 10) return state
|
||||
return { ...state, display: `${state.display}${digit}` }
|
||||
}
|
||||
|
||||
export function inputDecimal(state: CalculatorState): CalculatorState {
|
||||
if (state.error || state.waitingForOperand) {
|
||||
return { ...state, display: '0.', error: false, waitingForOperand: false }
|
||||
}
|
||||
return state.display.includes('.')
|
||||
? state
|
||||
: { ...state, display: `${state.display}.` }
|
||||
}
|
||||
|
||||
export function toggleCalculatorSign(state: CalculatorState): CalculatorState {
|
||||
if (state.error || state.display === '0') return state
|
||||
return {
|
||||
...state,
|
||||
display: state.display.startsWith('-')
|
||||
? state.display.slice(1)
|
||||
: `-${state.display}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function calculatorPercent(state: CalculatorState): CalculatorState {
|
||||
if (state.error) return state
|
||||
return { ...state, display: formatNumber(Number(state.display) / 100) }
|
||||
}
|
||||
|
||||
export function chooseCalculatorOperator(
|
||||
state: CalculatorState,
|
||||
operator: CalculatorOperator,
|
||||
): CalculatorState {
|
||||
if (state.error) return clearCalculator()
|
||||
const input = Number(state.display)
|
||||
let accumulator = state.accumulator
|
||||
|
||||
if (
|
||||
accumulator !== null &&
|
||||
state.pendingOperator &&
|
||||
!state.waitingForOperand
|
||||
) {
|
||||
const result = calculate(accumulator, input, state.pendingOperator)
|
||||
if (result === null)
|
||||
return { ...clearCalculator(), display: 'Error', error: true }
|
||||
accumulator = result
|
||||
} else if (accumulator === null) {
|
||||
accumulator = input
|
||||
}
|
||||
|
||||
return {
|
||||
accumulator,
|
||||
display: formatNumber(accumulator),
|
||||
error: false,
|
||||
pendingOperator: operator,
|
||||
waitingForOperand: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCalculator(state: CalculatorState): CalculatorState {
|
||||
if (state.error || state.accumulator === null || !state.pendingOperator)
|
||||
return state
|
||||
const result = calculate(
|
||||
state.accumulator,
|
||||
Number(state.display),
|
||||
state.pendingOperator,
|
||||
)
|
||||
if (result === null)
|
||||
return { ...clearCalculator(), display: 'Error', error: true }
|
||||
return {
|
||||
accumulator: null,
|
||||
display: formatNumber(result),
|
||||
error: false,
|
||||
pendingOperator: null,
|
||||
waitingForOperand: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { elapsedMilliseconds, remainingMilliseconds } from './clock'
|
||||
describe('timestamp clocks', () => {
|
||||
it('derives elapsed and remaining time without an interval state', () => {
|
||||
expect(elapsedMilliseconds(500, 1000, 2500)).toBe(2000)
|
||||
expect(remainingMilliseconds(5000, 1000, 2500)).toBe(3500)
|
||||
expect(remainingMilliseconds(100, 1000, 2500)).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
export function elapsedMilliseconds(
|
||||
accumulated: number,
|
||||
startedAt: number | null,
|
||||
now: number,
|
||||
): number {
|
||||
return Math.max(0, accumulated + (startedAt === null ? 0 : now - startedAt))
|
||||
}
|
||||
|
||||
export function remainingMilliseconds(
|
||||
remainingAtStart: number,
|
||||
startedAt: number | null,
|
||||
now: number,
|
||||
): number {
|
||||
return Math.max(
|
||||
0,
|
||||
remainingAtStart - (startedAt === null ? 0 : now - startedAt),
|
||||
)
|
||||
}
|
||||
|
||||
export function formatStopwatch(milliseconds: number): string {
|
||||
const totalCentiseconds = Math.floor(milliseconds / 10)
|
||||
const minutes = Math.floor(totalCentiseconds / 6000)
|
||||
const seconds = Math.floor((totalCentiseconds % 6000) / 100)
|
||||
const centiseconds = totalCentiseconds % 100
|
||||
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(centiseconds).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function formatTimer(milliseconds: number): string {
|
||||
const totalSeconds = Math.ceil(milliseconds / 1000)
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { clampPage } 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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
export const SPRINGBOARD_PAGE_COUNT = 3
|
||||
|
||||
export function clampPage(
|
||||
page: number,
|
||||
pageCount = SPRINGBOARD_PAGE_COUNT,
|
||||
): number {
|
||||
if (!Number.isFinite(page) || pageCount <= 0) return 0
|
||||
return Math.min(pageCount - 1, Math.max(0, Math.round(page)))
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DEFAULT_PHONE_PREFERENCES, parsePhonePreferences } from './preferences'
|
||||
describe('preferences', () => {
|
||||
it('falls back for malformed and obsolete records', () => {
|
||||
expect(parsePhonePreferences('{')).toEqual(DEFAULT_PHONE_PREFERENCES)
|
||||
expect(parsePhonePreferences('{"version":2}')).toEqual(
|
||||
DEFAULT_PHONE_PREFERENCES,
|
||||
)
|
||||
})
|
||||
it('keeps valid harmless preferences', () => {
|
||||
const value = parsePhonePreferences(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
settings: { wifi: false, wallpaper: 'ember' },
|
||||
}),
|
||||
)
|
||||
expect(value.settings.wifi).toBe(false)
|
||||
expect(value.settings.wallpaper).toBe('ember')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
export const PHONE_PREFERENCES_KEY = 'sky_phone.preferences.v1'
|
||||
|
||||
export type WallpaperId = 'midnight' | 'aurora' | 'ember'
|
||||
|
||||
export type PhonePreferencesV1 = {
|
||||
settings: {
|
||||
airplaneMode: boolean
|
||||
bluetooth: boolean
|
||||
cellular: boolean
|
||||
personalHotspot: boolean
|
||||
vpn: boolean
|
||||
wallpaper: WallpaperId
|
||||
wifi: boolean
|
||||
}
|
||||
version: 1
|
||||
}
|
||||
|
||||
export const DEFAULT_PHONE_PREFERENCES: PhonePreferencesV1 = {
|
||||
settings: {
|
||||
airplaneMode: false,
|
||||
bluetooth: true,
|
||||
cellular: true,
|
||||
personalHotspot: false,
|
||||
vpn: false,
|
||||
wallpaper: 'midnight',
|
||||
wifi: true,
|
||||
},
|
||||
version: 1,
|
||||
}
|
||||
|
||||
export const WALLPAPER_IDS: WallpaperId[] = ['midnight', 'aurora', 'ember']
|
||||
|
||||
export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
|
||||
if (!raw) return structuredClone(DEFAULT_PHONE_PREFERENCES)
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<PhonePreferencesV1>
|
||||
const settings = parsed.settings
|
||||
if (parsed.version !== 1 || !settings || typeof settings !== 'object') {
|
||||
return structuredClone(DEFAULT_PHONE_PREFERENCES)
|
||||
}
|
||||
|
||||
const defaults = DEFAULT_PHONE_PREFERENCES.settings
|
||||
return {
|
||||
settings: {
|
||||
airplaneMode:
|
||||
typeof settings.airplaneMode === 'boolean'
|
||||
? settings.airplaneMode
|
||||
: defaults.airplaneMode,
|
||||
bluetooth:
|
||||
typeof settings.bluetooth === 'boolean'
|
||||
? settings.bluetooth
|
||||
: defaults.bluetooth,
|
||||
cellular:
|
||||
typeof settings.cellular === 'boolean'
|
||||
? settings.cellular
|
||||
: defaults.cellular,
|
||||
personalHotspot:
|
||||
typeof settings.personalHotspot === 'boolean'
|
||||
? settings.personalHotspot
|
||||
: defaults.personalHotspot,
|
||||
vpn: typeof settings.vpn === 'boolean' ? settings.vpn : defaults.vpn,
|
||||
wallpaper: WALLPAPER_IDS.includes(settings.wallpaper as WallpaperId)
|
||||
? (settings.wallpaper as WallpaperId)
|
||||
: defaults.wallpaper,
|
||||
wifi:
|
||||
typeof settings.wifi === 'boolean' ? settings.wifi : defaults.wifi,
|
||||
},
|
||||
version: 1,
|
||||
}
|
||||
} catch {
|
||||
return structuredClone(DEFAULT_PHONE_PREFERENCES)
|
||||
}
|
||||
}
|
||||
|
||||
export function readPhonePreferences(): PhonePreferencesV1 {
|
||||
return parsePhonePreferences(
|
||||
window.localStorage.getItem(PHONE_PREFERENCES_KEY),
|
||||
)
|
||||
}
|
||||
|
||||
export function writePhonePreferences(preferences: PhonePreferencesV1): void {
|
||||
window.localStorage.setItem(
|
||||
PHONE_PREFERENCES_KEY,
|
||||
JSON.stringify(preferences),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user