mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-09 11:42:54 -05:00
Compare commits
9 Commits
claude/ana
...
mobile-cal
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1156efc6f | ||
|
|
dfd34d6c9a | ||
|
|
6d7cf52fee | ||
|
|
2d23a09f03 | ||
|
|
1fcba8571a | ||
|
|
db09dd7b6f | ||
|
|
dfdf17c4d2 | ||
|
|
ad7d90228e | ||
|
|
204d4e9394 |
@@ -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)',
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
135
packages/desktop-client/src/components/util/AmountKeyboard.tsx
Normal file
135
packages/desktop-client/src/components/util/AmountKeyboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
11
yarn.lock
11
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user