diff --git a/src/components/base/NumberInputBase.ts b/src/components/base/NumberInputBase.ts
new file mode 100644
index 00000000..c7e6ea79
--- /dev/null
+++ b/src/components/base/NumberInputBase.ts
@@ -0,0 +1,123 @@
+import { watch } from 'vue';
+
+import { useI18n } from '@/locales/helpers.ts';
+import { type CommonNumberInputProps, useCommonNumberInputBase } from '@/components/base/CommonNumberInputBase.ts';
+
+import { isNumber, replaceAll, removeAll } from '@/lib/common.ts';
+
+export interface NumberInputProps extends CommonNumberInputProps {
+ minValue?: number;
+ maxValue?: number;
+ maxDecimalCount?: number;
+}
+
+export interface NumberInputEmits {
+ (e: 'update:modelValue', value: number): void;
+}
+
+export function useNumberInputBase(props: NumberInputProps, emit: NumberInputEmits) {
+ const {
+ getCurrentDecimalSeparator,
+ getCurrentDigitGroupingSymbol
+ } = useI18n();
+
+ const {
+ currentValue,
+ onKeyUpDown,
+ onPaste
+ } = useCommonNumberInputBase(props, props.maxDecimalCount ?? -1, getFormattedValue(props.modelValue), parseNumber, getFormattedValue, getValidFormattedValue);
+
+ function parseNumber(value: string): number {
+ if (!value) {
+ return 0;
+ }
+
+ const decimalSeparator = getCurrentDecimalSeparator();
+
+ let finalValue = '';
+ for (let i = 0; i < value.length; i++) {
+ if (!('0' <= value[i] && value[i] <= '9') && value[i] !== '-' && value[i] !== decimalSeparator) {
+ break;
+ }
+
+ finalValue += value[i];
+ }
+
+ if (decimalSeparator !== '.') {
+ finalValue = replaceAll(finalValue, decimalSeparator, '.');
+ }
+
+ return parseFloat(finalValue);
+ }
+
+ function getValidFormattedValue(value: number, textualValue: string): string {
+ if (isNumber(props.minValue) && value < props.minValue) {
+ return getFormattedValue(props.minValue);
+ }
+
+ if (isNumber(props.maxValue) && value > props.maxValue) {
+ return getFormattedValue(props.maxValue);
+ }
+
+ const decimalSeparator = getCurrentDecimalSeparator();
+ const digitGroupingSymbol = getCurrentDigitGroupingSymbol();
+ return replaceAll(removeAll(textualValue, digitGroupingSymbol), '.', decimalSeparator);
+ }
+
+ function getFormattedValue(value: number): string {
+ if (!Number.isNaN(value) && Number.isFinite(value)) {
+ const decimalSeparator = getCurrentDecimalSeparator();
+
+ if (isNumber(props.maxDecimalCount) && props.maxDecimalCount >= 0) {
+ return replaceAll(value.toFixed(props.maxDecimalCount), '.', decimalSeparator);
+ } else {
+ return replaceAll(value.toString(), '.', decimalSeparator);
+ }
+ }
+
+ return '0';
+ }
+
+ watch(() => props.modelValue, (newValue) => {
+ const numericCurrentValue = parseNumber(currentValue.value);
+
+ if (newValue !== numericCurrentValue) {
+ const newStringValue = getFormattedValue(newValue);
+
+ if (!(newStringValue === '0' && currentValue.value === '')) {
+ currentValue.value = newStringValue;
+ }
+ }
+ });
+
+ watch(currentValue, (newValue) => {
+ let finalValue = '';
+
+ if (newValue) {
+ const decimalSeparator = getCurrentDecimalSeparator();
+
+ for (let i = 0; i < newValue.length; i++) {
+ if (!('0' <= newValue[i] && newValue[i] <= '9') && newValue[i] !== '-' && newValue[i] !== decimalSeparator) {
+ break;
+ }
+
+ finalValue += newValue[i];
+ }
+ }
+
+ if (finalValue !== newValue) {
+ currentValue.value = finalValue;
+ } else {
+ const value: number = parseNumber(finalValue);
+ emit('update:modelValue', value);
+ }
+ });
+
+ return {
+ // states
+ currentValue,
+ // functions
+ onKeyUpDown,
+ onPaste
+ }
+}
diff --git a/src/components/desktop/NumberInput.vue b/src/components/desktop/NumberInput.vue
new file mode 100644
index 00000000..1abc4e13
--- /dev/null
+++ b/src/components/desktop/NumberInput.vue
@@ -0,0 +1,35 @@
+
+
+
+
+
+
diff --git a/src/components/mobile/ListNumberInput.vue b/src/components/mobile/ListNumberInput.vue
new file mode 100644
index 00000000..baab66de
--- /dev/null
+++ b/src/components/mobile/ListNumberInput.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
diff --git a/src/consts/exchange_rate.ts b/src/consts/exchange_rate.ts
new file mode 100644
index 00000000..f2dd6db7
--- /dev/null
+++ b/src/consts/exchange_rate.ts
@@ -0,0 +1,2 @@
+export const USER_CUSTOM_EXCHANGE_RATE_MIN_VALUE: number = 0;
+export const USER_CUSTOM_EXCHANGE_RATE_MAX_VALUE: number = 999999999.9999;
diff --git a/src/desktop-main.ts b/src/desktop-main.ts
index c511b461..086157f5 100644
--- a/src/desktop-main.ts
+++ b/src/desktop-main.ts
@@ -77,6 +77,7 @@ import MapView from '@/components/common/MapView.vue';
import ItemIcon from '@/components/desktop/ItemIcon.vue';
import BtnVerticalGroup from '@/components/desktop/BtnVerticalGroup.vue';
+import NumberInput from '@/components/desktop/NumberInput.vue';
import AmountInput from '@/components/desktop/AmountInput.vue';
import LanguageSelect from '@/components/desktop/LanguageSelect.vue';
import LanguageSelectButton from '@/components/desktop/LanguageSelectButton.vue';
@@ -453,6 +454,7 @@ app.component('MapView', MapView);
app.component('ItemIcon', ItemIcon);
app.component('BtnVerticalGroup', BtnVerticalGroup);
+app.component('NumberInput', NumberInput);
app.component('AmountInput', AmountInput);
app.component('LanguageSelect', LanguageSelect);
app.component('LanguageSelectButton', LanguageSelectButton);
diff --git a/src/mobile-main.ts b/src/mobile-main.ts
index a1e0588e..1bcb73d2 100644
--- a/src/mobile-main.ts
+++ b/src/mobile-main.ts
@@ -95,6 +95,7 @@ import DateSelectionSheet from '@/components/mobile/DateSelectionSheet.vue';
import DateRangeSelectionSheet from '@/components/mobile/DateRangeSelectionSheet.vue';
import MonthSelectionSheet from '@/components/mobile/MonthSelectionSheet.vue';
import MonthRangeSelectionSheet from '@/components/mobile/MonthRangeSelectionSheet.vue';
+import ListNumberInput from '@/components/mobile/ListNumberInput.vue';
import ListItemSelectionSheet from '@/components/mobile/ListItemSelectionSheet.vue';
import ListItemSelectionPopup from '@/components/mobile/ListItemSelectionPopup.vue';
import TwoColumnListItemSelectionSheet from '@/components/mobile/TwoColumnListItemSelectionSheet.vue';
@@ -181,6 +182,7 @@ app.component('DateSelectionSheet', DateSelectionSheet);
app.component('DateRangeSelectionSheet', DateRangeSelectionSheet);
app.component('MonthSelectionSheet', MonthSelectionSheet);
app.component('MonthRangeSelectionSheet', MonthRangeSelectionSheet);
+app.component('ListNumberInput', ListNumberInput);
app.component('ListItemSelectionSheet', ListItemSelectionSheet);
app.component('ListItemSelectionPopup', ListItemSelectionPopup);
app.component('TwoColumnListItemSelectionSheet', TwoColumnListItemSelectionSheet);
diff --git a/src/views/desktop/exchangerates/list/dialogs/UpdateDialog.vue b/src/views/desktop/exchangerates/list/dialogs/UpdateDialog.vue
index d785e713..8a1d39ed 100644
--- a/src/views/desktop/exchangerates/list/dialogs/UpdateDialog.vue
+++ b/src/views/desktop/exchangerates/list/dialogs/UpdateDialog.vue
@@ -11,11 +11,13 @@
-
@@ -28,11 +30,13 @@
-
@@ -68,6 +72,11 @@ import { useI18n } from '@/locales/helpers.ts';
import { useUserStore } from '@/stores/user.ts';
import { useExchangeRatesStore } from '@/stores/exchangeRates.ts';
+import {
+ USER_CUSTOM_EXCHANGE_RATE_MAX_VALUE,
+ USER_CUSTOM_EXCHANGE_RATE_MIN_VALUE
+} from '@/consts/exchange_rate.ts';
+
import {
mdiSwapVertical
} from '@mdi/js';
diff --git a/src/views/mobile/exchangerates/UpdatePage.vue b/src/views/mobile/exchangerates/UpdatePage.vue
index 87140fbc..dd417dc4 100644
--- a/src/views/mobile/exchangerates/UpdatePage.vue
+++ b/src/views/mobile/exchangerates/UpdatePage.vue
@@ -10,13 +10,17 @@
-
+
+
+
-
+
+
+
();