diff --git a/app/add.tsx b/app/add.tsx index 6ece3ac..16c0988 100644 --- a/app/add.tsx +++ b/app/add.tsx @@ -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; @@ -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')); diff --git a/app/edit.tsx b/app/edit.tsx index a2e52d4..1cb785d 100644 --- a/app/edit.tsx +++ b/app/edit.tsx @@ -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'; @@ -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, @@ -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'); + // 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')); @@ -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')); diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 42de55c..82b554c 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -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", @@ -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", diff --git a/i18n/locales/ja.json b/i18n/locales/ja.json index 6c71c8a..ba1bb55 100644 --- a/i18n/locales/ja.json +++ b/i18n/locales/ja.json @@ -266,6 +266,7 @@ "error_title": "入力エラー", "error_required": "サービス名と料金は必須です", "error_invalid_amount": "料金は0以上の数値で入力してください", + "error_amount_too_large": "料金が大きすぎて正確に保存できません。より小さい値を入力してください", "service_name": "サービス名", "service_name_placeholder": "例: Netflix", "plan_name": "プラン名", @@ -288,7 +289,11 @@ "title": "編集", "submit": "保存", "error_failed": "更新に失敗しました", - "not_found": "サブスクリプションが見つかりません" + "not_found": "サブスクリプションが見つかりません", + "discard_title": "変更を破棄しますか?", + "discard_message": "保存されていない変更があります。破棄してもよろしいですか?", + "discard_confirm": "破棄", + "discard_keep": "編集を続ける" }, "detail": { "edit_button": "編集", diff --git a/lib/api.ts b/lib/api.ts index 1685073..bd88300 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -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; @@ -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. @@ -543,20 +544,20 @@ export const uploadApi = { }); uploadUri = converted.uri; filename = filename.replace(/\.[^.]+$/, '.jpg'); - ext = 'jpg'; } - const mimeTypes: Record = { - 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', diff --git a/lib/currency.ts b/lib/currency.ts index b307ad3..23110ce 100644 --- a/lib/currency.ts +++ b/lib/currency.ts @@ -23,14 +23,47 @@ const REGION_TO_CURRENCY: Record = { 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; diff --git a/package.json b/package.json index 2613255..2342d6d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76e57a8..3a7c287 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: expo-constants: specifier: ~57.0.3 version: 57.0.3(expo@57.0.2)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)) + expo-file-system: + specifier: ~57.0.0 + version: 57.0.0(expo@57.0.2)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)) expo-font: specifier: ~57.0.0 version: 57.0.0(expo@57.0.2)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) @@ -5689,7 +5692,9 @@ snapshots: metro-runtime: 0.84.4 transitivePeerDependencies: - '@babel/core' + - bufferutil - supports-color + - utf-8-validate '@react-native/normalize-colors@0.74.89': {}