mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
ENH - rebuild calculator with scientific history
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<CalculatorOperator, string> = {
|
||||
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,
|
||||
|
||||
@@ -1,25 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { Calculator, Delete, History, Trash2, X } from 'lucide-vue-next'
|
||||
|
||||
import { useCalculatorStore } from '@/stores/calculator'
|
||||
import {
|
||||
useCalculatorStore,
|
||||
type CalculatorHistoryEntry,
|
||||
} from '@/stores/calculator'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { SkySheet } from '@/ui'
|
||||
import type {
|
||||
CalculatorOperator,
|
||||
CalculatorUnaryOperation,
|
||||
} from '@/utils/calculator'
|
||||
|
||||
type CalculatorKey = {
|
||||
label: string
|
||||
action: () => void
|
||||
kind?: 'operator' | 'utility'
|
||||
operator?: CalculatorOperator
|
||||
disabled?: () => boolean
|
||||
}
|
||||
|
||||
const calculator = useCalculatorStore()
|
||||
const phone = usePhoneStore()
|
||||
const calculation = computed(() => {
|
||||
if (!calculator.calculation) return ''
|
||||
return calculator.waitingForOperand
|
||||
? calculator.calculation
|
||||
: `${calculator.calculation} ${calculator.display}`
|
||||
})
|
||||
const keys: Array<{ label: string; action: () => void; kind?: string }> = [
|
||||
{ label: 'AC', action: calculator.clear, kind: 'utility' },
|
||||
{ label: '+/−', action: calculator.toggleSign, kind: 'utility' },
|
||||
const historyOpened = ref(false)
|
||||
const editingHistory = ref(false)
|
||||
const scientificOpened = ref(true)
|
||||
const resultDisplay = ref<HTMLElement | null>(null)
|
||||
const expressionDisplay = ref<HTMLElement | null>(null)
|
||||
|
||||
const expression = computed(() =>
|
||||
calculator.calculation
|
||||
.replace(/\s*=\s*$/, '')
|
||||
.split(' ')
|
||||
.join(''),
|
||||
)
|
||||
|
||||
function formatDisplayValue(value: string): string {
|
||||
if (value === 'Error') return value
|
||||
|
||||
const exponentIndex = value.search(/e/i)
|
||||
const mantissa = exponentIndex === -1 ? value : value.slice(0, exponentIndex)
|
||||
const exponent = exponentIndex === -1 ? '' : value.slice(exponentIndex)
|
||||
const negative = mantissa.startsWith('-')
|
||||
const unsigned = negative ? mantissa.slice(1) : mantissa
|
||||
const [integer, fraction] = unsigned.split('.')
|
||||
const groupedInteger = integer.replace(/\B(?=(\d{3})+(?!\d))/g, '.')
|
||||
|
||||
return `${negative ? '−' : ''}${groupedInteger}${fraction ? `,${fraction}` : ''}${exponent}`
|
||||
}
|
||||
|
||||
const scientificKeys = computed<CalculatorKey[]>(() => [
|
||||
{ label: '(', action: calculator.openParenthesis },
|
||||
{
|
||||
label: ')',
|
||||
action: calculator.closeParenthesis,
|
||||
disabled: () => calculator.parentFrames.length === 0,
|
||||
},
|
||||
{
|
||||
label: 'mc',
|
||||
action: calculator.memoryClear,
|
||||
disabled: () => calculator.memory === 0,
|
||||
},
|
||||
{ label: 'm+', action: calculator.memoryAdd },
|
||||
{ label: 'm−', action: calculator.memorySubtract },
|
||||
{
|
||||
label: 'mr',
|
||||
action: calculator.memoryRecall,
|
||||
disabled: () => calculator.memory === 0,
|
||||
},
|
||||
{ label: '2ⁿᵈ', action: calculator.toggleSecond },
|
||||
{
|
||||
label: calculator.second ? '√x' : 'x²',
|
||||
action: () => calculator.unary(calculator.second ? 'sqrt' : 'square'),
|
||||
},
|
||||
{
|
||||
label: calculator.second ? '∛x' : 'x³',
|
||||
action: () => calculator.unary(calculator.second ? 'cbrt' : 'cube'),
|
||||
},
|
||||
{
|
||||
label: 'xʸ',
|
||||
action: () => calculator.chooseOperator('power'),
|
||||
operator: 'power',
|
||||
},
|
||||
{
|
||||
label: 'eˣ',
|
||||
action: () => calculator.unary('exp'),
|
||||
},
|
||||
{
|
||||
label: '10ˣ',
|
||||
action: () => calculator.unary('tenPower'),
|
||||
},
|
||||
{
|
||||
label: '¹/x',
|
||||
action: () => calculator.unary('reciprocal'),
|
||||
},
|
||||
{ label: '²√x', action: () => calculator.unary('sqrt') },
|
||||
{ label: '³√x', action: () => calculator.unary('cbrt') },
|
||||
{
|
||||
label: 'ʸ√x',
|
||||
action: () => calculator.chooseOperator('root'),
|
||||
operator: 'root',
|
||||
},
|
||||
{ label: 'ln', action: () => calculator.unary('ln') },
|
||||
{ label: 'log₁₀', action: () => calculator.unary('log10') },
|
||||
{ label: 'x!', action: () => calculator.unary('factorial') },
|
||||
{
|
||||
label: calculator.second ? 'sin⁻¹' : 'sin',
|
||||
action: () =>
|
||||
calculator.unary(
|
||||
calculator.second ? 'asin' : ('sin' as CalculatorUnaryOperation),
|
||||
),
|
||||
},
|
||||
{
|
||||
label: calculator.second ? 'cos⁻¹' : 'cos',
|
||||
action: () =>
|
||||
calculator.unary(
|
||||
calculator.second ? 'acos' : ('cos' as CalculatorUnaryOperation),
|
||||
),
|
||||
},
|
||||
{
|
||||
label: calculator.second ? 'tan⁻¹' : 'tan',
|
||||
action: () =>
|
||||
calculator.unary(
|
||||
calculator.second ? 'atan' : ('tan' as CalculatorUnaryOperation),
|
||||
),
|
||||
},
|
||||
{ label: 'e', action: () => calculator.constant(Math.E) },
|
||||
{ label: 'EE', action: () => calculator.chooseOperator('power') },
|
||||
{ label: 'Rand', action: calculator.random },
|
||||
{ label: 'sinh', action: () => calculator.unary('sinh') },
|
||||
{ label: 'cosh', action: () => calculator.unary('cosh') },
|
||||
{ label: 'tanh', action: () => calculator.unary('tanh') },
|
||||
{ label: 'π', action: () => calculator.constant(Math.PI) },
|
||||
{
|
||||
label: calculator.angleUnit === 'radians' ? 'Deg' : 'Rad',
|
||||
action: calculator.toggleAngleUnit,
|
||||
},
|
||||
])
|
||||
|
||||
const basicKeys: CalculatorKey[] = [
|
||||
{ label: 'delete', action: calculator.backspace, kind: 'utility' },
|
||||
{ label: 'C', action: calculator.clear, kind: 'utility' },
|
||||
{ label: '%', action: calculator.percent, kind: 'utility' },
|
||||
{
|
||||
label: '÷',
|
||||
action: () => calculator.chooseOperator('divide'),
|
||||
kind: 'operator',
|
||||
operator: 'divide',
|
||||
},
|
||||
...['7', '8', '9'].map((value) => ({
|
||||
label: value,
|
||||
@@ -29,6 +157,7 @@ const keys: Array<{ label: string; action: () => void; kind?: string }> = [
|
||||
label: '×',
|
||||
action: () => calculator.chooseOperator('multiply'),
|
||||
kind: 'operator',
|
||||
operator: 'multiply',
|
||||
},
|
||||
...['4', '5', '6'].map((value) => ({
|
||||
label: value,
|
||||
@@ -38,6 +167,7 @@ const keys: Array<{ label: string; action: () => void; kind?: string }> = [
|
||||
label: '−',
|
||||
action: () => calculator.chooseOperator('subtract'),
|
||||
kind: 'operator',
|
||||
operator: 'subtract',
|
||||
},
|
||||
...['1', '2', '3'].map((value) => ({
|
||||
label: value,
|
||||
@@ -47,34 +177,532 @@ const keys: Array<{ label: string; action: () => void; kind?: string }> = [
|
||||
label: '+',
|
||||
action: () => calculator.chooseOperator('add'),
|
||||
kind: 'operator',
|
||||
operator: 'add',
|
||||
},
|
||||
{ label: '0', action: () => calculator.digit('0'), kind: 'zero' },
|
||||
{ label: '.', action: calculator.decimal },
|
||||
{ label: '+/−', action: calculator.toggleSign },
|
||||
{ label: '0', action: () => calculator.digit('0') },
|
||||
{ label: ',', action: calculator.decimal },
|
||||
{ label: '=', action: calculator.equals, kind: 'operator' },
|
||||
]
|
||||
|
||||
const historyGroups = computed(() => {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const yesterday = today.getTime() - 86_400_000
|
||||
const groups: Array<{ label: string; entries: CalculatorHistoryEntry[] }> = []
|
||||
const todayEntries = calculator.history.filter(
|
||||
(entry) => entry.createdAt >= today.getTime(),
|
||||
)
|
||||
const yesterdayEntries = calculator.history.filter(
|
||||
(entry) =>
|
||||
entry.createdAt >= yesterday && entry.createdAt < today.getTime(),
|
||||
)
|
||||
const olderEntries = calculator.history.filter(
|
||||
(entry) => entry.createdAt < yesterday,
|
||||
)
|
||||
if (todayEntries.length)
|
||||
groups.push({
|
||||
label: phone.t('Apps.calculator.today'),
|
||||
entries: todayEntries,
|
||||
})
|
||||
if (yesterdayEntries.length)
|
||||
groups.push({
|
||||
label: phone.t('Apps.calculator.yesterday'),
|
||||
entries: yesterdayEntries,
|
||||
})
|
||||
if (olderEntries.length)
|
||||
groups.push({
|
||||
label: phone.t('Apps.calculator.earlier'),
|
||||
entries: olderEntries,
|
||||
})
|
||||
return groups
|
||||
})
|
||||
|
||||
function scrollDisplay(event: WheelEvent): void {
|
||||
const display = event.currentTarget as HTMLElement
|
||||
if (display.scrollWidth <= display.clientWidth) return
|
||||
event.preventDefault()
|
||||
display.scrollLeft +=
|
||||
Math.abs(event.deltaX) > Math.abs(event.deltaY)
|
||||
? event.deltaX
|
||||
: event.deltaY
|
||||
}
|
||||
|
||||
function useHistoryEntry(entry: CalculatorHistoryEntry): void {
|
||||
if (editingHistory.value) {
|
||||
calculator.removeHistory(entry.id)
|
||||
return
|
||||
}
|
||||
calculator.useHistory(entry)
|
||||
historyOpened.value = false
|
||||
}
|
||||
|
||||
watch([() => calculator.display, expression], async () => {
|
||||
await nextTick()
|
||||
for (const display of [resultDisplay.value, expressionDisplay.value]) {
|
||||
if (display) display.scrollLeft = display.scrollWidth
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
class="native-app calculator-app"
|
||||
:class="{ 'calculator-app--scientific': scientificOpened }"
|
||||
:aria-label="phone.t('Apps.calculator.name')"
|
||||
>
|
||||
<div class="calculator-display" aria-live="polite">
|
||||
<div class="calculator-result">{{ calculator.display }}</div>
|
||||
<div v-if="calculation" class="calculator-calculation">
|
||||
{{ calculation }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="calculator-pad">
|
||||
<header class="calculator-toolbar">
|
||||
<button
|
||||
v-for="key in keys"
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.calculator.history')"
|
||||
@click="historyOpened = true"
|
||||
>
|
||||
<History :size="24" :stroke-width="1.8" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.calculator.changeMode')"
|
||||
@click="scientificOpened = !scientificOpened"
|
||||
>
|
||||
<Calculator :size="23" :stroke-width="1.8" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="calculator-display" aria-live="polite">
|
||||
<div
|
||||
v-if="expression"
|
||||
ref="expressionDisplay"
|
||||
class="calculator-expression"
|
||||
@wheel="scrollDisplay"
|
||||
>
|
||||
{{ expression }}
|
||||
</div>
|
||||
<div ref="resultDisplay" class="calculator-result" @wheel="scrollDisplay">
|
||||
{{ formatDisplayValue(calculator.display) }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="scientificOpened" class="calculator-angle">
|
||||
{{ calculator.angleUnit === 'radians' ? 'Rad' : 'Deg' }}
|
||||
</div>
|
||||
|
||||
<section
|
||||
v-if="scientificOpened"
|
||||
class="calculator-scientific-pad"
|
||||
:aria-label="phone.t('Apps.calculator.scientificFunctions')"
|
||||
>
|
||||
<button
|
||||
v-for="key in scientificKeys"
|
||||
:key="key.label"
|
||||
type="button"
|
||||
:class="key.kind"
|
||||
class="calculator-key calculator-key--scientific"
|
||||
:class="{
|
||||
'calculator-key--selected':
|
||||
(key.operator === calculator.pendingOperator &&
|
||||
calculator.waitingForOperand) ||
|
||||
(key.label === '2ⁿᵈ' && calculator.second),
|
||||
}"
|
||||
:disabled="key.disabled?.()"
|
||||
@click="key.action"
|
||||
>
|
||||
{{ key.label }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="calculator-basic-pad">
|
||||
<button
|
||||
v-for="key in basicKeys"
|
||||
:key="key.label"
|
||||
type="button"
|
||||
class="calculator-key calculator-key--basic"
|
||||
:class="[
|
||||
key.kind && `calculator-key--${key.kind}`,
|
||||
{
|
||||
'calculator-key--selected':
|
||||
key.operator === calculator.pendingOperator &&
|
||||
calculator.waitingForOperand,
|
||||
},
|
||||
]"
|
||||
@click="key.action"
|
||||
>
|
||||
<Delete v-if="key.label === 'delete'" :size="23" />
|
||||
<span v-else>{{ key.label }}</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<SkySheet
|
||||
class="calculator-history-sheet"
|
||||
:opened="historyOpened"
|
||||
:aria-label="phone.t('Apps.calculator.history')"
|
||||
@backdropclick="historyOpened = false"
|
||||
@escape="historyOpened = false"
|
||||
>
|
||||
<section class="calculator-history">
|
||||
<div class="calculator-history__handle"></div>
|
||||
<header>
|
||||
<button
|
||||
type="button"
|
||||
class="calculator-history__edit"
|
||||
@click="editingHistory = !editingHistory"
|
||||
>
|
||||
{{
|
||||
phone.t(
|
||||
editingHistory
|
||||
? 'Apps.calculator.done'
|
||||
: 'Apps.calculator.edit',
|
||||
)
|
||||
}}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="calculator-history__close"
|
||||
:aria-label="phone.t('Apps.calculator.closeHistory')"
|
||||
@click="historyOpened = false"
|
||||
>
|
||||
<X :size="23" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="historyGroups.length" class="calculator-history__scroll">
|
||||
<section v-for="group in historyGroups" :key="group.label">
|
||||
<h2>{{ group.label }}</h2>
|
||||
<button
|
||||
v-for="entry in group.entries"
|
||||
:key="entry.id"
|
||||
type="button"
|
||||
class="calculator-history__entry"
|
||||
@click="useHistoryEntry(entry)"
|
||||
>
|
||||
<Trash2 v-if="editingHistory" :size="18" />
|
||||
<span>
|
||||
<small>{{ entry.expression.split(' ').join('') }}</small>
|
||||
<strong>{{ formatDisplayValue(entry.result) }}</strong>
|
||||
</span>
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
<div v-else class="calculator-history__empty">
|
||||
<History :size="34" />
|
||||
<strong>{{ phone.t('Apps.calculator.noHistory') }}</strong>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="editingHistory && calculator.history.length"
|
||||
type="button"
|
||||
class="calculator-history__clear"
|
||||
@click="calculator.clearHistory"
|
||||
>
|
||||
{{ phone.t('Apps.calculator.clearHistory') }}
|
||||
</button>
|
||||
</section>
|
||||
</SkySheet>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.calculator-app {
|
||||
--calculator-orange: #ff9500;
|
||||
padding: calc(var(--sky-safe-area-top) + 5px) 15px
|
||||
calc(var(--sky-safe-area-bottom) + 4px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.calculator-toolbar {
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.calculator-toolbar button,
|
||||
.calculator-history__close {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 1px solid rgb(255 255 255 / 10%);
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(145deg, #202022, #101011);
|
||||
box-shadow: inset 0 1px rgb(255 255 255 / 9%);
|
||||
color: #f5f5f7;
|
||||
}
|
||||
|
||||
.calculator-display {
|
||||
min-height: 132px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calculator-expression,
|
||||
.calculator-result {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.calculator-expression::-webkit-scrollbar,
|
||||
.calculator-result::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.calculator-expression {
|
||||
color: #8e8e93;
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.calculator-result {
|
||||
font-size: 54px;
|
||||
font-weight: 280;
|
||||
letter-spacing: -2px;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.calculator-angle {
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #dedee2;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.calculator-scientific-pad,
|
||||
.calculator-basic-pad {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.calculator-scientific-pad {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.calculator-basic-pad {
|
||||
flex: 1;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.calculator-key {
|
||||
min-width: 0;
|
||||
border: 1px solid rgb(255 255 255 / 10%);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(145deg, #29292b, #1a1a1c);
|
||||
box-shadow: inset 0 1px rgb(255 255 255 / 7%);
|
||||
color: #f5f5f7;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 130ms ease,
|
||||
filter 150ms ease,
|
||||
border-color 150ms ease;
|
||||
}
|
||||
|
||||
.calculator-key--scientific {
|
||||
min-height: 38px;
|
||||
border-radius: 19px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.calculator-key--basic {
|
||||
min-height: 45px;
|
||||
border-radius: 23px;
|
||||
font-size: 25px;
|
||||
font-weight: 390;
|
||||
}
|
||||
|
||||
.calculator-key--utility {
|
||||
background: linear-gradient(145deg, #79797c, #58585b);
|
||||
}
|
||||
|
||||
.calculator-key--operator {
|
||||
border-color: #ffb23b;
|
||||
background: linear-gradient(180deg, #ffa20c, var(--calculator-orange));
|
||||
box-shadow: inset 0 1px rgb(255 255 255 / 28%);
|
||||
}
|
||||
|
||||
.calculator-key--selected {
|
||||
background: #f5f5f7;
|
||||
color: var(--calculator-orange);
|
||||
}
|
||||
|
||||
.calculator-key:disabled {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.calculator-key:hover:not(:disabled),
|
||||
.calculator-toolbar button:hover,
|
||||
.calculator-history button:hover {
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.calculator-key:active:not(:disabled),
|
||||
.calculator-toolbar button:active,
|
||||
.calculator-history button:active {
|
||||
transform: scale(0.96);
|
||||
filter: brightness(0.88);
|
||||
}
|
||||
|
||||
.calculator-key:focus-visible,
|
||||
.calculator-toolbar button:focus-visible,
|
||||
.calculator-history button:focus-visible {
|
||||
outline: 2px solid #ffd08a;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.calculator-app:not(.calculator-app--scientific) .calculator-display {
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
.calculator-app:not(.calculator-app--scientific) .calculator-basic-pad {
|
||||
gap: 11px 14px;
|
||||
}
|
||||
|
||||
.calculator-app:not(.calculator-app--scientific) .calculator-key--basic {
|
||||
min-height: 62px;
|
||||
border-radius: 31px;
|
||||
font-size: 29px;
|
||||
}
|
||||
|
||||
:deep(.calculator-history-sheet .sky-overlay-backdrop) {
|
||||
background: rgb(0 0 0 / 60%);
|
||||
}
|
||||
|
||||
:deep(.calculator-history-sheet .sky-sheet__panel) {
|
||||
height: 72%;
|
||||
max-height: 72%;
|
||||
overflow: hidden;
|
||||
border-color: rgb(255 255 255 / 15%);
|
||||
border-radius: 31px 31px 0 0;
|
||||
background: rgb(25 25 27 / 97%);
|
||||
color: #f5f5f7;
|
||||
box-shadow: 0 -10px 40px rgb(0 0 0 / 48%);
|
||||
}
|
||||
|
||||
.calculator-history {
|
||||
height: 100%;
|
||||
padding: 8px 18px calc(var(--sky-safe-area-bottom) + 8px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.calculator-history__handle {
|
||||
width: 40px;
|
||||
height: 5px;
|
||||
margin: 0 auto 8px;
|
||||
border-radius: 999px;
|
||||
background: #77777b;
|
||||
}
|
||||
|
||||
.calculator-history > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.calculator-history__edit {
|
||||
min-width: 106px;
|
||||
height: 42px;
|
||||
border: 0;
|
||||
border-radius: 21px;
|
||||
background: #262628;
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.calculator-history__scroll {
|
||||
min-height: 0;
|
||||
margin-top: 18px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.calculator-history__scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.calculator-history__scroll section + section {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.calculator-history h2 {
|
||||
margin: 0;
|
||||
padding: 0 2px 10px;
|
||||
border-bottom: 1px solid rgb(255 255 255 / 16%);
|
||||
color: #a5a5aa;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.calculator-history__entry {
|
||||
width: 100%;
|
||||
min-height: 66px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgb(255 255 255 / 14%);
|
||||
padding: 10px 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.calculator-history__entry span,
|
||||
.calculator-history__entry small,
|
||||
.calculator-history__entry strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.calculator-history__entry small {
|
||||
margin-bottom: 4px;
|
||||
color: #a5a5aa;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.calculator-history__entry strong {
|
||||
font-size: 18px;
|
||||
font-weight: 450;
|
||||
}
|
||||
|
||||
.calculator-history__entry > svg {
|
||||
color: #ff453a;
|
||||
}
|
||||
|
||||
.calculator-history__empty {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: 10px;
|
||||
color: #8e8e93;
|
||||
}
|
||||
|
||||
.calculator-history__clear {
|
||||
min-height: 44px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #ff453a;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.calculator-key,
|
||||
.calculator-toolbar button,
|
||||
.calculator-history button {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user