Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion app/add.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
} from '../lib/subscriptionIcon';
const { width: SCREEN_WIDTH } = Dimensions.get('window');

import { CURRENCIES, parseAmountInput } from '../lib/currency';
import { CURRENCIES, isAmountInputAboveMax, parseAmountInput } from '../lib/currency';

const ICON_PICKER_WIDTH = Math.min(SCREEN_WIDTH - 32, 360);
const ICON_PICKER_GAP = 10;
Expand Down Expand Up @@ -58,6 +58,10 @@ export default function AddSubscriptionModal() {
Alert.alert(t('subscription_form.error_title'), t('subscription_form.error_required'));
return;
}
if (isAmountInputAboveMax(amount)) {
Alert.alert(t('subscription_form.error_title'), t('subscription_form.error_amount_too_large'));
return;
}
const parsedAmount = parseAmountInput(amount);
if (parsedAmount === null) {
Alert.alert(t('subscription_form.error_title'), t('subscription_form.error_invalid_amount'));
Expand Down
54 changes: 51 additions & 3 deletions app/edit.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { View, Text, TextInput, Pressable, useColorScheme, KeyboardAvoidingView, ScrollView, Platform, Alert, ActivityIndicator, Image, Modal, Dimensions } from 'react-native';
import { Stack, useRouter, useLocalSearchParams } from 'expo-router';
import { Stack, useRouter, useLocalSearchParams, useNavigation } from 'expo-router';
import { Ionicons, FontAwesome5 } from '@expo/vector-icons';
import { useSubscriptionStore } from '../store/useSubscriptionStore';
import { useAddFormStore } from '../store/useAddFormStore';
Expand All @@ -11,7 +11,7 @@ import { setCropHandler } from '../lib/imageCropStore';
import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker';
import { useTranslation } from 'react-i18next';
import { usePaymentMethodStore } from '../store/usePaymentMethodStore';
import { CURRENCIES, CurrencyId, parseAmountInput } from '../lib/currency';
import { CURRENCIES, CurrencyId, isAmountInputAboveMax, parseAmountInput } from '../lib/currency';
import {
isSubscriptionPresetIconValue,
parseSubscriptionPresetIconValue,
Expand Down Expand Up @@ -75,11 +75,58 @@ function EditSubscriptionForm({ subscription }: { subscription: Subscription })
setCurrency((subscription.currency as CurrencyId) || 'JPY');
}, [subscription, setBillingCycle, setPaymentMethod, setCurrency]);

const navigation = useNavigation();
const initialNextPaymentTimestamp = useMemo(
() => new Date(subscription.next_payment_date).getTime(),
[subscription.next_payment_date],
);
const isDirty =
serviceName !== subscription.service_name ||
planName !== (subscription.plan_name || '') ||
amount !== String(subscription.amount) ||
memo !== (subscription.memo || '') ||
iconUri !== (subscription.icon_url ?? null) ||
nextPaymentDate.getTime() !== initialNextPaymentTimestamp ||
billingCycle !== subscription.billing_cycle ||
paymentMethod !== subscription.payment_method ||
currency !== ((subscription.currency as CurrencyId) || 'JPY');
Comment thread
darui3018823 marked this conversation as resolved.
// Refs so the beforeRemove listener sees current values without
// re-subscribing on every keystroke.
const isDirtyRef = useRef(isDirty);
const skipDirtyGuardRef = useRef(false);
useEffect(() => {
isDirtyRef.current = isDirty;
}, [isDirty]);

// Confirm before the screen is removed with unsaved edits — covers the
// × button, the Android back button, and the modal swipe-down gesture.
useEffect(() => {
return navigation.addListener('beforeRemove', (e) => {
if (skipDirtyGuardRef.current || !isDirtyRef.current) return;
e.preventDefault();
Alert.alert(t('edit.discard_title'), t('edit.discard_message'), [
{ text: t('edit.discard_keep'), style: 'cancel' },
{
text: t('edit.discard_confirm'),
style: 'destructive',
onPress: () => {
skipDirtyGuardRef.current = true;
navigation.dispatch(e.data.action);
},
},
]);
});
}, [navigation, t]);

const handleSave = async () => {
if (!serviceName || !amount) {
Alert.alert(t('subscription_form.error_title'), t('subscription_form.error_required'));
return;
}
if (isAmountInputAboveMax(amount)) {
Alert.alert(t('subscription_form.error_title'), t('subscription_form.error_amount_too_large'));
return;
}
const parsedAmount = parseAmountInput(amount);
if (parsedAmount === null) {
Alert.alert(t('subscription_form.error_title'), t('subscription_form.error_invalid_amount'));
Expand Down Expand Up @@ -109,6 +156,7 @@ function EditSubscriptionForm({ subscription }: { subscription: Subscription })
icon_url: iconUrl,
memo: memo.trim() ? memo.trim() : null,
});
skipDirtyGuardRef.current = true;
router.back();
} catch (error: any) {
Alert.alert(t('common.error'), error.message || t('edit.error_failed'));
Expand Down
7 changes: 6 additions & 1 deletion i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@
"error_title": "Input Error",
"error_required": "Service name and amount are required.",
"error_invalid_amount": "Amount must be a number of 0 or more.",
"error_amount_too_large": "Amount is too large to be stored accurately. Please enter a smaller value.",
"service_name": "Service Name",
"service_name_placeholder": "e.g. Netflix",
"plan_name": "Plan Name",
Expand All @@ -288,7 +289,11 @@
"title": "Edit",
"submit": "Save",
"error_failed": "Failed to update.",
"not_found": "Subscription not found."
"not_found": "Subscription not found.",
"discard_title": "Discard changes?",
"discard_message": "You have unsaved changes. Are you sure you want to discard them?",
"discard_confirm": "Discard",
"discard_keep": "Keep Editing"
},
"detail": {
"edit_button": "Edit",
Expand Down
7 changes: 6 additions & 1 deletion i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@
"error_title": "入力エラー",
"error_required": "サービス名と料金は必須です",
"error_invalid_amount": "料金は0以上の数値で入力してください",
"error_amount_too_large": "料金が大きすぎて正確に保存できません。より小さい値を入力してください",
"service_name": "サービス名",
"service_name_placeholder": "例: Netflix",
"plan_name": "プラン名",
Expand All @@ -288,7 +289,11 @@
"title": "編集",
"submit": "保存",
"error_failed": "更新に失敗しました",
"not_found": "サブスクリプションが見つかりません"
"not_found": "サブスクリプションが見つかりません",
"discard_title": "変更を破棄しますか?",
"discard_message": "保存されていない変更があります。破棄してもよろしいですか?",
"discard_confirm": "破棄",
"discard_keep": "編集を続ける"
},
"detail": {
"edit_button": "編集",
Expand Down
27 changes: 14 additions & 13 deletions lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as SecureStore from 'expo-secure-store';
import { Platform, Alert } from 'react-native';
import Constants from 'expo-constants';
import { manipulateAsync, SaveFormat } from 'expo-image-manipulator';
import { File as FileSystemFile } from 'expo-file-system';

const PRODUCTION_URL = 'https://subscription-manager.daruks.com';
const DEV_PORT = 8084;
Expand Down Expand Up @@ -532,7 +533,7 @@ export const uploadApi = {
const token = await getToken();
let uploadUri = uri;
let filename = uri.split('/').pop()?.split('?')[0] || 'icon.jpg';
let ext = (filename.split('.').pop() || 'jpg').toLowerCase();
const ext = (filename.split('.').pop() || 'jpg').toLowerCase();
// The server only accepts png/jpg/jpeg/gif/webp. iOS photos are often
// HEIC/HEIF, so convert those to JPEG before uploading instead of
// letting the server reject them with 415.
Expand All @@ -543,20 +544,20 @@ export const uploadApi = {
});
uploadUri = converted.uri;
filename = filename.replace(/\.[^.]+$/, '.jpg');
ext = 'jpg';
}
const mimeTypes: Record<string, string> = {
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
};
const mimeType = mimeTypes[ext] || 'image/jpeg';
const formData = new FormData();
formData.append('file', {
uri: uploadUri,
name: filename,
type: mimeType,
} as any);
if (Platform.OS === 'web') {
// No file:// paths on web — the picker hands out blob:/data:
// URIs that fetch can read back into a Blob.
const blob = await (await fetch(uploadUri)).blob();
formData.append('file', blob, filename);
} else {
// Expo's WinterCG fetch rejects React Native's legacy
// { uri, name, type } parts with "Unsupported FormDataPart
// implementation" — it only accepts strings, Blobs, or objects
// exposing bytes(), like expo-file-system's File.
formData.append('file', new FileSystemFile(uploadUri) as unknown as Blob);
}

const response = await fetch(`${API_BASE_URL}/upload/icon`, {
method: 'POST',
Expand Down
41 changes: 37 additions & 4 deletions lib/currency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,47 @@ const REGION_TO_CURRENCY: Record<string, CurrencyId> = {
FI: 'EUR', IE: 'EUR', GR: 'EUR', LU: 'EUR',
};

// The largest amount that can survive the round trip through IEEE 754
// doubles (JS numbers / JSON). Anything above this is silently rounded
// (e.g. 1145141919364364810 becomes …800), so inputs beyond it must be
// rejected instead of stored corrupted.
export const MAX_AMOUNT = Number.MAX_SAFE_INTEGER;

function normalizeAmountInput(input: string): string {
return input
.replace(/[0-9.]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xfee0))
.replace(/[,,]/g, '')
.trim();
}

export function isAmountInputAboveMax(input: string): boolean {
const normalized = normalizeAmountInput(input);
if (!normalized) return false;

const value = Number(normalized);
if (!Number.isFinite(value) || value < 0) return false;
if (value > MAX_AMOUNT) return true;

const decimalMatch = normalized.match(/^\+?(\d+)(?:\.(\d*))?$/);
if (!decimalMatch) return false;

const integerPart = decimalMatch[1].replace(/^0+(?=\d)/, '');
const maxInteger = String(MAX_AMOUNT);
if (integerPart.length !== maxInteger.length) {
return integerPart.length > maxInteger.length;
}
if (integerPart !== maxInteger) {
return integerPart > maxInteger;
}

return /[1-9]/.test(decimalMatch[2] ?? '');
}

// Parse a user-entered amount string. Accepts full-width digits and
// thousand separators (e.g. "1,000"). Returns null when the input is
// empty or not a valid non-negative finite number.
export function parseAmountInput(input: string): number | null {
const normalized = input
.replace(/[0-9.]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xfee0))
.replace(/[,,]/g, '')
.trim();
const normalized = normalizeAmountInput(input);
if (!normalized) return null;
const value = Number(normalized);
if (!Number.isFinite(value) || value < 0) return null;
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"expo-auth-session": "^57.0.1",
"expo-blur": "^57.0.0",
"expo-constants": "~57.0.3",
"expo-file-system": "~57.0.0",
"expo-font": "~57.0.0",
"expo-haptics": "~57.0.0",
"expo-image": "~57.0.0",
Expand Down
5 changes: 5 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading