diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 7c389cd..2ee9fd5 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -3699,64 +3699,6 @@ button { background: #1c1c1e; color: #a0a0a7; } -.calculator-app { - padding: 85px 19px 29px; - display: flex; - flex-direction: column; -} -.calculator-display { - height: 145px; - display: flex; - flex-direction: column; - align-items: flex-end; - justify-content: flex-end; - padding: 0 8px 12px; - overflow: hidden; - font-size: 65px; - font-weight: 250; - white-space: nowrap; -} -.calculator-result { - max-width: 100%; - overflow: hidden; -} -.calculator-calculation { - max-width: 100%; - overflow: hidden; - color: #8e8e93; - font-size: 20px; - font-weight: 400; - letter-spacing: 0; - line-height: 1.2; - text-overflow: ellipsis; -} -.calculator-pad { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 11px; -} -.calculator-pad button { - aspect-ratio: 1; - border: 0; - border-radius: 50%; - background: #333336; - color: white; - font-size: 29px; -} -.calculator-pad button.utility { - background: #a5a5aa; - color: #000; -} -.calculator-pad button.operator { - background: #ff9f0a; -} -.calculator-pad button.zero { - grid-column: span 2; - aspect-ratio: auto; - border-radius: 50px; - text-align: left; - padding-left: 27px; -} .clock-content { min-height: 0; flex: 1; @@ -4107,23 +4049,6 @@ button { font-size: 13px; } /* Reference-accurate app surfaces. These remain scoped to the phone resource. */ -.calculator-app { - padding: 54px 16px 27px; -} -.calculator-display { - height: 226px; - padding: 0 5px 4px; - font-size: 78px; - letter-spacing: -5px; -} -.calculator-pad { - gap: 11px 14px; -} -.calculator-pad button { - font-size: 28px; - font-weight: 400; -} - .reference-tabbar { position: absolute; z-index: 6; diff --git a/frontend/src/stores/calculator.ts b/frontend/src/stores/calculator.ts index 4708b25..e4495ef 100644 --- a/frontend/src/stores/calculator.ts +++ b/frontend/src/stores/calculator.ts @@ -1,25 +1,67 @@ import { defineStore } from 'pinia' import { + applyCalculatorUnary, calculatorPercent, chooseCalculatorOperator, clearCalculator, + formatCalculatorNumber, inputDecimal, inputDigit, resolveCalculator, toggleCalculatorSign, type CalculatorOperator, type CalculatorState, + type CalculatorUnaryOperation, } from '@/utils/calculator' +export type CalculatorHistoryEntry = { + id: string + expression: string + result: string + createdAt: number +} + +type CalculatorParentFrame = Pick< + CalculatorState, + 'accumulator' | 'calculation' | 'pendingOperator' | 'waitingForOperand' +> + +const HISTORY_KEY = 'sky_phone_calculator_history_v1' + +function loadHistory(): CalculatorHistoryEntry[] { + if (typeof localStorage === 'undefined') return [] + const stored = localStorage.getItem(HISTORY_KEY) + if (!stored) return [] + try { + const parsed = JSON.parse(stored) as CalculatorHistoryEntry[] + return Array.isArray(parsed) ? parsed.slice(0, 50) : [] + } catch { + console.warn('[Calculator] Ignored invalid persisted history.') + return [] + } +} + export const useCalculatorStore = defineStore('calculator', { - state: (): CalculatorState => clearCalculator(), + state: () => ({ + ...clearCalculator(), + angleUnit: 'radians' as 'degrees' | 'radians', + history: loadHistory(), + memory: 0, + parentFrames: [] as CalculatorParentFrame[], + second: false, + }), actions: { + backspace(): void { + if (this.error || this.waitingForOperand) return + this.display = this.display.length > 1 ? this.display.slice(0, -1) : '0' + }, chooseOperator(operator: CalculatorOperator): void { Object.assign(this, chooseCalculatorOperator(this.$state, operator)) }, clear(): void { Object.assign(this, clearCalculator()) + this.parentFrames = [] }, decimal(): void { Object.assign(this, inputDecimal(this.$state)) @@ -28,7 +70,18 @@ export const useCalculatorStore = defineStore('calculator', { Object.assign(this, inputDigit(this.$state, value)) }, equals(): void { - Object.assign(this, resolveCalculator(this.$state)) + const expression = this.calculation + const next = resolveCalculator(this.$state) + Object.assign(this, next) + if (!expression || next.error || next.calculation === expression) return + this.history.unshift({ + id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + expression: next.calculation.replace(/\s*=\s*$/, ''), + result: next.display, + createdAt: Date.now(), + }) + this.history = this.history.slice(0, 50) + this.persistHistory() }, percent(): void { Object.assign(this, calculatorPercent(this.$state)) @@ -36,5 +89,99 @@ export const useCalculatorStore = defineStore('calculator', { toggleSign(): void { Object.assign(this, toggleCalculatorSign(this.$state)) }, + unary(operation: CalculatorUnaryOperation): void { + const result = applyCalculatorUnary( + Number(this.display), + operation, + this.angleUnit, + ) + if (result === null) { + Object.assign(this, { + ...clearCalculator(), + display: 'Error', + error: true, + }) + return + } + this.display = formatCalculatorNumber(result) + this.waitingForOperand = true + }, + constant(value: number): void { + this.display = formatCalculatorNumber(value) + this.error = false + this.waitingForOperand = true + }, + random(): void { + this.display = formatCalculatorNumber(Math.random()) + this.error = false + this.waitingForOperand = true + }, + memoryClear(): void { + this.memory = 0 + }, + memoryAdd(): void { + this.memory += Number(this.display) || 0 + }, + memorySubtract(): void { + this.memory -= Number(this.display) || 0 + }, + memoryRecall(): void { + this.constant(this.memory) + }, + openParenthesis(): void { + this.parentFrames.push({ + accumulator: this.accumulator, + calculation: this.calculation, + pendingOperator: this.pendingOperator, + waitingForOperand: this.waitingForOperand, + }) + Object.assign(this, clearCalculator()) + }, + closeParenthesis(): void { + const frame = this.parentFrames.pop() + if (!frame) return + const inner = this.pendingOperator + ? resolveCalculator(this.$state) + : { ...this.$state } + const innerExpression = (inner.calculation || inner.display).replace( + /\s*=\s*$/, + '', + ) + Object.assign(this, { + ...frame, + display: inner.display, + calculation: frame.calculation + ? `${frame.calculation} (${innerExpression})` + : `(${innerExpression})`, + error: inner.error, + waitingForOperand: false, + }) + }, + toggleAngleUnit(): void { + this.angleUnit = this.angleUnit === 'radians' ? 'degrees' : 'radians' + }, + toggleSecond(): void { + this.second = !this.second + }, + removeHistory(id: string): void { + this.history = this.history.filter((entry) => entry.id !== id) + this.persistHistory() + }, + clearHistory(): void { + this.history = [] + this.persistHistory() + }, + useHistory(entry: CalculatorHistoryEntry): void { + Object.assign(this, { + ...clearCalculator(), + display: entry.result, + calculation: entry.expression, + waitingForOperand: true, + }) + }, + persistHistory(): void { + if (typeof localStorage === 'undefined') return + localStorage.setItem(HISTORY_KEY, JSON.stringify(this.history)) + }, }, }) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index c4a9b34..7a54938 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -2403,7 +2403,20 @@ const defaultLocales: LocaleTree = { default: 'The housing request failed.', }, }, - calculator: { name: 'Calculator' }, + calculator: { + name: 'Calculator', + history: 'Calculation history', + changeMode: 'Change calculator mode', + scientificFunctions: 'Scientific functions', + edit: 'Edit', + done: 'Done', + closeHistory: 'Close history', + today: 'Today', + yesterday: 'Yesterday', + earlier: 'Earlier', + noHistory: 'No calculations yet', + clearHistory: 'Clear History', + }, snake: { name: 'Snake', backToMenu: 'Back to game menu', diff --git a/frontend/src/utils/calculator.test.ts b/frontend/src/utils/calculator.test.ts index 7640bb1..9ce4bbf 100644 --- a/frontend/src/utils/calculator.test.ts +++ b/frontend/src/utils/calculator.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + applyCalculatorUnary, calculate, chooseCalculatorOperator, clearCalculator, @@ -14,6 +15,14 @@ describe('calculator', () => { expect(calculate(8, 2, 'multiply')).toBe(16) expect(calculate(8, 2, 'divide')).toBe(4) }) + it('calculates scientific unary and binary operations', () => { + expect(calculate(2, 8, 'power')).toBe(256) + expect(calculate(3, 27, 'root')).toBe(3) + expect(applyCalculatorUnary(5, 'factorial')).toBe(120) + expect(applyCalculatorUnary(90, 'sin', 'degrees')).toBeCloseTo(1) + expect(applyCalculatorUnary(9, 'sqrt')).toBe(3) + expect(applyCalculatorUnary(-1, 'sqrt')).toBeNull() + }) it('chains operations and handles division by zero', () => { let state = inputDigit(clearCalculator(), '8') state = chooseCalculatorOperator(state, 'add') diff --git a/frontend/src/utils/calculator.ts b/frontend/src/utils/calculator.ts index d9a1935..7b0eed5 100644 --- a/frontend/src/utils/calculator.ts +++ b/frontend/src/utils/calculator.ts @@ -1,4 +1,31 @@ -export type CalculatorOperator = 'add' | 'subtract' | 'multiply' | 'divide' +export type CalculatorOperator = + | 'add' + | 'subtract' + | 'multiply' + | 'divide' + | 'power' + | 'root' + +export type CalculatorUnaryOperation = + | 'reciprocal' + | 'square' + | 'cube' + | 'exp' + | 'tenPower' + | 'sqrt' + | 'cbrt' + | 'ln' + | 'log10' + | 'factorial' + | 'sin' + | 'cos' + | 'tan' + | 'asin' + | 'acos' + | 'atan' + | 'sinh' + | 'cosh' + | 'tanh' export type CalculatorState = { accumulator: number | null @@ -23,9 +50,11 @@ const OPERATOR_SYMBOLS: Record = { subtract: '−', multiply: '×', divide: '÷', + power: '^', + root: 'ʸ√', } -function formatNumber(value: number): string { +export function formatCalculatorNumber(value: number): string { if (!Number.isFinite(value)) return 'Error' const rounded = Number(value.toPrecision(12)) const rendered = String(rounded) @@ -40,8 +69,53 @@ export function calculate( 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 + if (operator === 'divide') return right === 0 ? null : left / right + if (operator === 'power') return left ** right + if (left === 0) return null + return right < 0 && left % 2 === 0 + ? null + : Math.sign(right) * Math.abs(right) ** (1 / left) +} + +export function applyCalculatorUnary( + value: number, + operation: CalculatorUnaryOperation, + angleUnit: 'degrees' | 'radians' = 'radians', +): number | null { + const angle = angleUnit === 'degrees' ? (value * Math.PI) / 180 : value + let result: number + if (operation === 'reciprocal') return value === 0 ? null : 1 / value + if (operation === 'square') result = value ** 2 + else if (operation === 'cube') result = value ** 3 + else if (operation === 'exp') result = Math.exp(value) + else if (operation === 'tenPower') result = 10 ** value + else if (operation === 'sqrt') + result = value < 0 ? Number.NaN : Math.sqrt(value) + else if (operation === 'cbrt') result = Math.cbrt(value) + else if (operation === 'ln') + result = value <= 0 ? Number.NaN : Math.log(value) + else if (operation === 'log10') + result = value <= 0 ? Number.NaN : Math.log10(value) + else if (operation === 'factorial') { + if (value < 0 || !Number.isInteger(value) || value > 170) return null + result = 1 + for (let factor = 2; factor <= value; factor += 1) result *= factor + } else if (operation === 'sin') result = Math.sin(angle) + else if (operation === 'cos') result = Math.cos(angle) + else if (operation === 'tan') result = Math.tan(angle) + else if (operation === 'asin') { + result = Math.asin(value) + if (angleUnit === 'degrees') result = (result * 180) / Math.PI + } else if (operation === 'acos') { + result = Math.acos(value) + if (angleUnit === 'degrees') result = (result * 180) / Math.PI + } else if (operation === 'atan') { + result = Math.atan(value) + if (angleUnit === 'degrees') result = (result * 180) / Math.PI + } else if (operation === 'sinh') result = Math.sinh(value) + else if (operation === 'cosh') result = Math.cosh(value) + else result = Math.tanh(value) + return Number.isFinite(result) ? result : null } export function clearCalculator(): CalculatorState { @@ -94,7 +168,10 @@ export function toggleCalculatorSign(state: CalculatorState): CalculatorState { export function calculatorPercent(state: CalculatorState): CalculatorState { if (state.error) return state - return { ...state, display: formatNumber(Number(state.display) / 100) } + return { + ...state, + display: formatCalculatorNumber(Number(state.display) / 100), + } } export function chooseCalculatorOperator( @@ -132,7 +209,7 @@ export function chooseCalculatorOperator( return { accumulator, calculation, - display: formatNumber(accumulator), + display: formatCalculatorNumber(accumulator), error: false, pendingOperator: operator, waitingForOperand: true, @@ -152,7 +229,7 @@ export function resolveCalculator(state: CalculatorState): CalculatorState { return { accumulator: null, calculation: `${state.calculation} ${state.display} =`, - display: formatNumber(result), + display: formatCalculatorNumber(result), error: false, pendingOperator: null, waitingForOperand: true, diff --git a/frontend/src/views/apps/CalculatorApp.vue b/frontend/src/views/apps/CalculatorApp.vue index 8ea1651..143600f 100644 --- a/frontend/src/views/apps/CalculatorApp.vue +++ b/frontend/src/views/apps/CalculatorApp.vue @@ -1,25 +1,153 @@ + + diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 2ed4577..4b83b21 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -1141,7 +1141,11 @@ Locales["en"] = { device_locked = "Unlock the phone to manage your homes.", request_failed = "The housing request failed.", default = "The housing request failed.", }, }, - calculator = { name = "Calculator" }, + calculator = { + name = "Calculator", history = "Calculation history", changeMode = "Change calculator mode", + scientificFunctions = "Scientific functions", edit = "Edit", done = "Done", closeHistory = "Close history", + today = "Today", yesterday = "Yesterday", earlier = "Earlier", noHistory = "No calculations yet", clearHistory = "Clear History", + }, snake = { name = "Snake", backToMenu = "Back to game menu", board = "Snake game board", controls = "Direction controls",