diff --git a/packages/desktop-client/src/components/mobile/transactions/FocusableAmountInput.tsx b/packages/desktop-client/src/components/mobile/transactions/FocusableAmountInput.tsx index 1dd1cc93a2a..3725306a8fc 100644 --- a/packages/desktop-client/src/components/mobile/transactions/FocusableAmountInput.tsx +++ b/packages/desktop-client/src/components/mobile/transactions/FocusableAmountInput.tsx @@ -11,8 +11,14 @@ import React, { import { css } from '@emotion/css'; +import { + evalArithmetic, + hasArithmeticOperator, + lastIndexOfArithmeticOperator, +} from 'loot-core/shared/arithmetic'; import { amountToCurrency, + amountToInteger, appendDecimals, currencyToAmount, } from 'loot-core/src/shared/util'; @@ -75,6 +81,10 @@ const AmountInput = memo(function AmountInput({ setValue(initialValue); }, [initialValue]); + useEffect(() => { + keyboardRef.current?.setInput(text); + }, [text]); + const onKeyUp: HTMLProps['onKeyUp'] = e => { if (e.key === 'Backspace' && text === '') { setEditing(true); @@ -87,7 +97,10 @@ const AmountInput = memo(function AmountInput({ }; const applyText = () => { - const parsed = currencyToAmount(text) || 0; + const parsed = hasArithmeticOperator(text) + ? evalArithmetic(text) + : currencyToAmount(text) || 0; + const newValue = editing ? parsed : value; setValue(Math.abs(newValue)); @@ -115,11 +128,44 @@ const AmountInput = memo(function AmountInput({ }; const onChangeText = (text: string) => { - text = appendDecimals(text, String(hideFraction) === 'true'); + console.log('text', text); + + const lastOperatorIndex = lastIndexOfArithmeticOperator(text); + if (lastOperatorIndex > 0) { + // This will evaluate the expression whenever an operator is added + // so only one operation will be displayed at a given time + const isOperatorAtEnd = lastOperatorIndex === text.length - 1; + if (isOperatorAtEnd) { + const lastOperator = text[lastOperatorIndex]; + const charIndexPriorToLastOperator = lastOperatorIndex - 1; + const charPriorToLastOperator = + text.length > 0 ? text[charIndexPriorToLastOperator] : ''; + + if ( + charPriorToLastOperator && + hasArithmeticOperator(charPriorToLastOperator) + ) { + // Clicked on another operator while there is still an operator + // Replace previous operator with the new one + // TODO: Fix why clicking the same operator duplicates it + text = `${text.slice(0, charIndexPriorToLastOperator)}${lastOperator}`; + } else { + // Evaluate the left side of the expression whenever an operator is added + const left = text.slice(0, lastOperatorIndex); + const leftEvaluated = evalArithmetic(left); + const leftEvaluatedWithDecimal = appendDecimals( + String(amountToInteger(leftEvaluated)), + String(hideFraction) === 'true', + ); + text = `${leftEvaluatedWithDecimal}${lastOperator}`; + } + } + } else { + text = appendDecimals(text, String(hideFraction) === 'true'); + } setEditing(true); setText(text); props.onChangeValue?.(text); - keyboardRef.current?.setInput(text); }; const input = ( @@ -172,6 +218,7 @@ const AmountInput = memo(function AmountInput({ keyboardRef={(r: AmountKeyboardRef) => (keyboardRef.current = r)} onChange={onChangeText} onBlur={onBlur} + onEnter={onUpdate} /> )} diff --git a/packages/desktop-client/src/components/util/AmountKeyboard.tsx b/packages/desktop-client/src/components/util/AmountKeyboard.tsx index ba3ffa2cb93..e17822a30f8 100644 --- a/packages/desktop-client/src/components/util/AmountKeyboard.tsx +++ b/packages/desktop-client/src/components/util/AmountKeyboard.tsx @@ -17,6 +17,7 @@ export type AmountKeyboardRef = SimpleKeyboard; type AmountKeyboardProps = ComponentPropsWithoutRef & { onBlur?: FocusEventHandler; + onEnter?: (text: string) => void; }; export function AmountKeyboard(props: AmountKeyboardProps) { @@ -50,10 +51,18 @@ export function AmountKeyboard(props: AmountKeyboardProps) { }, }, // eslint-disable-next-line rulesdir/typography - '& [data-skbtn="+"], & [data-skbtn="-"], & [data-skbtn="×"], & [data-skbtn="÷"], & [data-skbtn="{bksp}"]': + '& [data-skbtn="+"], & [data-skbtn="-"], & [data-skbtn="×"], & [data-skbtn="÷"]': { backgroundColor: theme.keyboardButtonSecondaryBackground, }, + // eslint-disable-next-line rulesdir/typography + '& [data-skbtn="{bksp}"], & [data-skbtn="{clear}"]': { + backgroundColor: theme.keyboardButtonSecondaryBackground, + }, + // eslint-disable-next-line rulesdir/typography + '& [data-skbtn="{enter}"]': { + backgroundColor: theme.buttonPrimaryBackground, + }, }), props.theme, ]); @@ -81,25 +90,38 @@ export function AmountKeyboard(props: AmountKeyboardProps) { }} > { keyboardRef.current = r; props.keyboardRef?.(r); }} + onKeyPress={key => { + if (key === '{clear}') { + props.onChange?.(''); + } else if (key === '{enter}') { + props.onEnter?.(keyboardRef.current?.getInput() || ''); + } + props.onKeyPress?.(key); + }} theme={layoutClassName} /> diff --git a/packages/loot-core/src/shared/arithmetic.ts b/packages/loot-core/src/shared/arithmetic.ts index 0d595d1b770..d217ee2dd5a 100644 --- a/packages/loot-core/src/shared/arithmetic.ts +++ b/packages/loot-core/src/shared/arithmetic.ts @@ -1,17 +1,29 @@ // @ts-strict-ignore import { currencyToAmount } from './util'; -function fail(state, msg) { +// These operators go from high to low order of precedence +const operators = ['^', '/', '÷', '*', '×', '-', '+'] as const; +type ArithmeticOp = (typeof operators)[number]; + +type ArithmeticAst = + | number + | { op: ArithmeticOp; left: ArithmeticAst; right: ArithmeticAst }; + +type ArithmeticState = { str: string; index: number }; + +const parseOperator = makeOperatorParser(...operators); + +function fail(state: ArithmeticState, msg: string) { throw new Error( msg + ': ' + JSON.stringify(state.str.slice(state.index, 10)), ); } -function char(state) { +function char(state: ArithmeticState): string { return state.str[state.index]; } -function next(state) { +function next(state: ArithmeticState): string { if (state.index >= state.str.length) { return null; } @@ -21,7 +33,7 @@ function next(state) { return ch; } -function nextOperator(state, op) { +function nextOperator(state: ArithmeticState, op: ArithmeticOp) { if (char(state) === op) { next(state); return true; @@ -30,7 +42,7 @@ function nextOperator(state, op) { return false; } -function parsePrimary(state) { +function parsePrimary(state: ArithmeticState): number { // We only support numbers const isNegative = char(state) === '-'; if (isNegative) { @@ -50,7 +62,7 @@ function parsePrimary(state) { return isNegative ? -number : number; } -function parseParens(state) { +function parseParens(state: ArithmeticState): ArithmeticAst { if (char(state) === '(') { next(state); const expr = parseOperator(state); @@ -66,7 +78,7 @@ function parseParens(state) { return parsePrimary(state); } -function makeOperatorParser(...ops) { +function makeOperatorParser(...ops: ArithmeticOp[]) { return ops.reduce((prevParser, op) => { return state => { let node = prevParser(state); @@ -78,15 +90,15 @@ function makeOperatorParser(...ops) { }, parseParens); } -// These operators go from high to low order of precedence -const parseOperator = makeOperatorParser('^', '/', '*', '-', '+'); - -function parse(expression: string) { - const state = { str: expression.replace(/\s/g, ''), index: 0 }; +function parse(expression: string): ArithmeticAst { + const state: ArithmeticState = { + str: expression.replace(/\s/g, ''), + index: 0, + }; return parseOperator(state); } -function evaluate(ast): number { +function evaluate(ast: ArithmeticAst): number { if (typeof ast === 'number') { return ast; } @@ -99,8 +111,10 @@ function evaluate(ast): number { case '-': return evaluate(left) - evaluate(right); case '*': + case '×': return evaluate(left) * evaluate(right); case '/': + case '÷': return evaluate(left) / evaluate(right); case '^': return Math.pow(evaluate(left), evaluate(right)); @@ -112,7 +126,7 @@ function evaluate(ast): number { export function evalArithmetic( expression: string, defaultValue: number = null, -) { +): number { // An empty expression always evals to the default if (expression === '') { return defaultValue; @@ -129,3 +143,14 @@ export function evalArithmetic( // Never return NaN return isNaN(result) ? defaultValue : result; } + +export function hasArithmeticOperator(expression: string): boolean { + return operators.some(op => expression.includes(op)); +} + +export function lastIndexOfArithmeticOperator(expression: string): number { + return operators.reduce((max, op) => { + const index = expression.lastIndexOf(op); + return index > max ? index : max; + }, -1); +}