Compare commits

...

9 Commits

Author SHA1 Message Date
autofix-ci[bot]
e1156efc6f [autofix.ci] apply automated fixes 2025-08-12 15:52:46 +00:00
Joel Jeremy Marquez
dfd34d6c9a Fix typecheck errors 2025-08-12 08:49:56 -07:00
Joel Jeremy Marquez
6d7cf52fee Updates 2025-08-07 11:57:58 -07:00
Joel Jeremy Marquez
2d23a09f03 Updates 2025-08-07 08:11:48 -07:00
Joel Jeremy Marquez
1fcba8571a Styling 2025-08-07 08:11:47 -07:00
Joel Jeremy Marquez
db09dd7b6f Adjust font 2025-08-07 08:11:47 -07:00
Joel Jeremy Marquez
dfdf17c4d2 Fix math multiply and divide button styles 2025-08-07 08:11:47 -07:00
Joel Jeremy Marquez
ad7d90228e Change symbols 2025-08-07 08:11:47 -07:00
Joel Jeremy Marquez
204d4e9394 Mobile calculator keyboard 2025-08-07 08:11:47 -07:00
10 changed files with 293 additions and 19 deletions

View File

@@ -201,4 +201,11 @@ export const theme = {
tooltipBackground: 'var(--color-tooltipBackground)',
tooltipBorder: 'var(--color-tooltipBorder)',
calendarCellBackground: 'var(--color-calendarCellBackground)',
keyboardBackground: 'var(--color-keyboardBackground)',
keyboardBorder: 'var(--color-keyboardBorder)',
keyboardText: 'var(--color-keyboardText)',
keyboardButtonBackground: 'var(--color-keyboardButtonBackground)',
keyboardButtonShadow: 'var(--color-keyboardButtonShadow)',
keyboardButtonSecondaryBackground:
'var(--color-keyboardButtonSecondaryBackground)',
};

View File

@@ -65,6 +65,7 @@
"react-modal": "3.16.3",
"react-redux": "^9.2.0",
"react-router": "7.6.2",
"react-simple-keyboard": "^3.8.106",
"react-simple-pull-to-refresh": "^1.3.3",
"react-spring": "^10.0.0",
"react-stately": "^3.37.0",

View File

@@ -15,14 +15,24 @@ import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { css } from '@emotion/css';
import {
evalArithmetic,
hasArithmeticOperator,
lastIndexOfArithmeticOperator,
} from 'loot-core/shared/arithmetic';
import {
amountToCurrency,
amountToInteger,
appendDecimals,
currencyToAmount,
reapplyThousandSeparators,
} from 'loot-core/shared/util';
import { makeAmountFullStyle } from '@desktop-client/components/budget/util';
import {
AmountKeyboard,
type AmountKeyboardRef,
} from '@desktop-client/components/util/AmountKeyboard';
import { useMergedRefs } from '@desktop-client/hooks/useMergedRefs';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
@@ -50,7 +60,7 @@ const AmountInput = memo(function AmountInput({
const [text, setText] = useState('');
const [value, setValue] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const [hideFraction] = useSyncedPref('hideFraction');
const [hideFractionPref] = useSyncedPref('hideFraction');
const mergedInputRef = useMergedRefs<HTMLInputElement>(
props.inputRef,
@@ -58,6 +68,7 @@ const AmountInput = memo(function AmountInput({
);
const initialValue = Math.abs(props.value);
const keyboardRef = useRef<AmountKeyboardRef | null>(null);
useEffect(() => {
if (focused) {
@@ -83,7 +94,11 @@ 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));
@@ -114,10 +129,52 @@ const AmountInput = memo(function AmountInput({
};
const onChangeText = (text: string) => {
text = reapplyThousandSeparators(text);
text = appendDecimals(text, String(hideFraction) === 'true');
const hideFraction = String(hideFractionPref) === 'true';
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
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) ?? 0;
const leftEvaluatedWithDecimal = appendDecimals(
reapplyThousandSeparators(String(amountToInteger(leftEvaluated))),
hideFraction,
);
text = `${leftEvaluatedWithDecimal}${lastOperator}`;
}
} else {
// Append decimals to the right side of the expression
const left = text.slice(0, lastOperatorIndex);
const right = text.slice(lastOperatorIndex + 1);
const lastOperator = text[lastOperatorIndex];
const rightWithDecimal = appendDecimals(
reapplyThousandSeparators(right),
hideFraction,
);
text = `${left}${lastOperator}${rightWithDecimal}`;
}
} else {
text = appendDecimals(reapplyThousandSeparators(text), hideFraction);
}
setEditing(true);
setText(text);
keyboardRef.current?.setInput(text);
props.onChangeValue?.(text);
};
@@ -126,11 +183,17 @@ const AmountInput = memo(function AmountInput({
type="text"
ref={mergedInputRef}
value={text}
inputMode="decimal"
inputMode="none"
autoCapitalize="none"
onChange={e => onChangeText(e.target.value)}
onFocus={onFocus}
onBlur={onBlur}
onBlur={e => {
// Do not blur when clicking on the keyboard elements
if (keyboardRef.current?.keyboardDOM.contains(e.relatedTarget)) {
return;
}
onBlur(e);
}}
onKeyUp={onKeyUp}
data-testid="amount-input"
style={{ flex: 1, textAlign: 'center', position: 'absolute' }}
@@ -160,6 +223,14 @@ const AmountInput = memo(function AmountInput({
>
{editing ? text : amountToCurrency(value)}
</Text>
{focused && (
<AmountKeyboard
keyboardRef={(r: AmountKeyboardRef) => (keyboardRef.current = r)}
onChange={onChangeText}
onBlur={onBlur}
onEnter={onUpdate}
/>
)}
</View>
);
});

View File

@@ -0,0 +1,135 @@
import React, { type FocusEventHandler, useCallback, useRef } from 'react';
import {
default as Keyboard,
type KeyboardReactInterface,
type SimpleKeyboard,
} from 'react-simple-keyboard';
import { styles } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { css, cx } from '@emotion/css';
export type AmountKeyboardRef = SimpleKeyboard;
type AmountKeyboardProps = KeyboardReactInterface['options'] & {
onBlur?: FocusEventHandler<HTMLDivElement>;
onEnter?: (text: string) => void;
};
export function AmountKeyboard(props: AmountKeyboardProps) {
const layoutClassName = cx([
css({
'& .hg-row': {
display: 'flex',
},
'& .hg-button': {
...styles.noTapHighlight,
...styles.largeText,
fontWeight: 500,
display: 'flex',
height: '60px',
flex: 1,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 16,
cursor: 'pointer',
backgroundColor: theme.keyboardButtonBackground,
color: theme.keyboardText,
padding: '5px 10px',
margin: 2,
borderWidth: 0,
outline: 0,
boxSizing: 'border-box',
':active': {
transform: 'translateY(1px)',
boxShadow: `0 1px 4px 0 ${theme.keyboardButtonShadow}`,
transition: 'none',
},
},
// eslint-disable-next-line actual/typography
'& [data-skbtn="+"], & [data-skbtn="-"], & [data-skbtn="*"], & [data-skbtn="/"]':
{
backgroundColor: theme.keyboardButtonSecondaryBackground,
},
// eslint-disable-next-line actual/typography
'& [data-skbtn="{bksp}"], & [data-skbtn="{clear}"]': {
backgroundColor: theme.keyboardButtonSecondaryBackground,
},
// eslint-disable-next-line actual/typography
'& [data-skbtn="{enter}"]': {
backgroundColor: theme.buttonPrimaryBackground,
},
}),
props.theme,
]);
const keyboardRef = useRef<SimpleKeyboard | null>(null);
const keyboardRefProp = props.keyboardRef;
const mergedRef = useCallback(
(r: SimpleKeyboard) => {
keyboardRef.current = r;
keyboardRefProp?.(r);
},
[keyboardRefProp],
);
return (
<View
style={{
position: 'fixed',
left: 0,
bottom: 0,
right: 0,
zIndex: 999,
backgroundColor: theme.keyboardBackground,
borderTop: `1px solid ${theme.keyboardBorder}`,
padding: 5,
}}
onBlur={e => {
if (keyboardRef.current?.keyboardDOM.contains(e.relatedTarget)) {
return;
}
props.onBlur?.(e);
}}
>
<Keyboard
layout={{
default: [
'+ 1 2 3',
'- 4 5 6',
'* 7 8 9',
'/ {clear} 0 {bksp}',
'{space} , . {enter}',
],
}}
display={{
'{bksp}': '⌫',
'{enter}': '↵',
'{space}': '␣',
'{clear}': 'C',
'*': '×',
'/': '÷',
}}
useButtonTag
autoUseTouchEvents
physicalKeyboardHighlight
disableButtonHold
debug
{...props}
keyboardRef={mergedRef}
onKeyPress={(key, e) => {
if (key === '{clear}') {
props.onChange?.('', e);
} else if (key === '{enter}') {
props.onEnter?.(keyboardRef.current?.getInput() || '');
}
props.onKeyPress?.(key, e);
}}
theme={layoutClassName}
/>
</View>
);
}

View File

@@ -217,3 +217,9 @@ export const tooltipBackground = colorPalette.navy800;
export const tooltipBorder = colorPalette.navy700;
export const calendarCellBackground = colorPalette.navy900;
export const keyboardBackground = colorPalette.navy900;
export const keyboardBorder = colorPalette.navy600;
export const keyboardText = buttonNormalText;
export const keyboardButtonBackground = buttonNormalBackground;
export const keyboardButtonShadow = 'rgba(0, 0, 0, 0.4)';
export const keyboardButtonSecondaryBackground = colorPalette.navy500;

View File

@@ -217,3 +217,9 @@ export const tooltipBackground = colorPalette.navy50;
export const tooltipBorder = colorPalette.navy150;
export const calendarCellBackground = colorPalette.navy100;
export const keyboardBackground = colorPalette.navy100;
export const keyboardBorder = colorPalette.navy150;
export const keyboardText = buttonNormalText;
export const keyboardButtonBackground = buttonNormalBackground;
export const keyboardButtonShadow = 'rgba(0, 0, 0, 0.2)';
export const keyboardButtonSecondaryBackground = colorPalette.navy200;

View File

@@ -219,3 +219,9 @@ export const tooltipBackground = colorPalette.white;
export const tooltipBorder = colorPalette.navy150;
export const calendarCellBackground = colorPalette.navy100;
export const keyboardBackground = colorPalette.navy100;
export const keyboardBorder = colorPalette.navy150;
export const keyboardText = buttonNormalText;
export const keyboardButtonBackground = buttonNormalBackground;
export const keyboardButtonShadow = 'rgba(0, 0, 0, 0.2)';
export const keyboardButtonSecondaryBackground = colorPalette.navy200;

View File

@@ -219,3 +219,9 @@ export const tooltipBackground = colorPalette.gray800;
export const tooltipBorder = colorPalette.gray600;
export const calendarCellBackground = colorPalette.navy900;
export const keyboardBackground = colorPalette.gray900;
export const keyboardBorder = colorPalette.gray600;
export const keyboardText = buttonNormalText;
export const keyboardButtonBackground = buttonNormalBackground;
export const keyboardButtonShadow = 'rgba(0, 0, 0, 0.4)';
export const keyboardButtonSecondaryBackground = colorPalette.gray400;

View File

@@ -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));
@@ -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);
}

View File

@@ -185,6 +185,7 @@ __metadata:
react-modal: "npm:3.16.3"
react-redux: "npm:^9.2.0"
react-router: "npm:7.6.2"
react-simple-keyboard: "npm:^3.8.106"
react-simple-pull-to-refresh: "npm:^1.3.3"
react-spring: "npm:^10.0.0"
react-stately: "npm:^3.37.0"
@@ -17080,6 +17081,16 @@ __metadata:
languageName: node
linkType: hard
"react-simple-keyboard@npm:^3.8.106":
version: 3.8.106
resolution: "react-simple-keyboard@npm:3.8.106"
peerDependencies:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10/69b75d1de7fc604c2fe8b614765a0e2f7d8a61dfbb5ad7e969531ba0fcf3ab0f2e92abc9e08528fa3d190d381b531c8a1d42f929173e882d5ce3fd3441713885
languageName: node
linkType: hard
"react-simple-pull-to-refresh@npm:^1.3.3":
version: 1.3.3
resolution: "react-simple-pull-to-refresh@npm:1.3.3"